<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>CSS on Tarragon</title><link>https://tarrragon.github.io/blog/tags/css/</link><description>Recent content in CSS on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Sun, 26 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/css/index.xml" rel="self" type="application/rss+xml"/><item><title>在外部組件上加客製功能：以邊界為中心的方法選擇</title><link>https://tarrragon.github.io/blog/report/external-component-customization/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/external-component-customization/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>客製外部組件的穩定性與「離組件邊界多近」成正比。&lt;/strong> 組件作者維護組件內部的一致性；客製貼著組件的對外介面（CLI 參數、CSS reset 邊界、CSS layer）做，組件升級不會打到客製。深入組件內部（注入子節點、改 source 行為）的客製則仰賴組件的內部實作，作者不保證下個版本還相容。&lt;/p>
&lt;p>這條原則對應到 Pagefind 整合時的三個具體情境，本文逐一拆解。&lt;/p>
&lt;hr>
&lt;h2 id="邊界概念為什麼用邊界看外部組件">邊界概念：為什麼用「邊界」看外部組件&lt;/h2>
&lt;h3 id="這類問題的本質商業邏輯">這類問題的本質（商業邏輯）&lt;/h3>
&lt;p>組件 = 一組對外契約 + 內部實作。對外契約是作者保證的東西（CLI 參數、CSS class name、CSS variable hooks），內部實作是達成契約的手段、可能在版本之間變動。&lt;/p>
&lt;p>客製貼著對外契約做，等於跟作者站在同一邊；客製動到內部實作，等於跟作者每個版本對抗。&lt;/p>
&lt;p>「邊界」這個概念把組件分成「契約面」與「實作面」 — 客製選位置時，依離邊界遠近排序：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>客製位置&lt;/th>
 &lt;th>跨越時的依賴&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>組件對外介面&lt;/td>
 &lt;td>介面契約（穩定）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>組件 DOM 邊界外&lt;/td>
 &lt;td>邊界元素的 class / id 名稱（半穩定）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>組件 DOM 邊界內&lt;/td>
 &lt;td>內部 DOM 結構（隨框架渲染週期變動）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>組件內部邏輯&lt;/td>
 &lt;td>source code（升級時必衝突）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="這次任務涉及的邊界case">這次任務涉及的邊界（CASE）&lt;/h3>
&lt;p>Pagefind 提供三條值得辨識的邊界：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>邊界名稱&lt;/th>
 &lt;th>介面形式&lt;/th>
 &lt;th>範圍&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>索引邊界&lt;/td>
 &lt;td>&lt;code>--root-selector&lt;/code> 參數&lt;/td>
 &lt;td>「組件看到什麼資料」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>重置邊界&lt;/td>
 &lt;td>&lt;code>.pagefind-ui--reset&lt;/code> class&lt;/td>
 &lt;td>對 UA 樣式的覆寫範圍&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>specificity 邊界&lt;/td>
 &lt;td>svelte hash class（雙寫到 30）&lt;/td>
 &lt;td>組件 CSS 的權重起點&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每條邊界各有對應的客製介面 — 緊貼這些介面比繞道進內部安全得多。&lt;/p>
&lt;hr>
&lt;h2 id="索引邊界用-root-selector-限縮搜尋範圍">索引邊界：用 root-selector 限縮搜尋範圍&lt;/h2>
&lt;p>&lt;strong>核心定義&lt;/strong>：Pagefind 的索引流程透過 &lt;code>--root-selector&lt;/code> 參數控制「哪些 HTML 進索引」。這是組件預期的客製介面。&lt;/p>
&lt;h3 id="這次的觀察">這次的觀察&lt;/h3>
&lt;p>theme 的 site header 包含 &lt;code>&amp;lt;h1&amp;gt;{{ .Site.Title }}&amp;lt;/h1&amp;gt;&lt;/code>，每一頁 DOM 上的第一個 h1 都是站名「Tarragon」。Pagefind 預設取頁面第一個 h1 當搜尋結果 title — 結果所有結果都顯示「Tarragon」。&lt;/p>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>第一個 h1 是站名而非文章名 — 索引「看到」的範圍跟我們以為的不同。要修的不是 theme（影響其他頁面），是 Pagefind 的 input 範圍。&lt;/p>
&lt;h3 id="執行">執行&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">npx -y pagefind --site public --root-selector main&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>&amp;lt;main&amp;gt;&lt;/code> 內才包含文章 h1；site header 在 &lt;code>&amp;lt;body&amp;gt;&lt;/code> 直接子節點、不在 &lt;code>&amp;lt;main&amp;gt;&lt;/code> 內，自然被排除。&lt;/p>
&lt;p>&lt;strong>完成標準&lt;/strong>：每一筆結果的 title 顯示文章 h1，而非站名。&lt;/p>
&lt;hr>
&lt;h2 id="重置邊界在落腳處重建-ua-樣式">重置邊界：在落腳處重建 UA 樣式&lt;/h2>
&lt;p>&lt;strong>核心定義&lt;/strong>：CSS reset 的有效範圍與 ancestor class 綁定。元素換位置就換 reset context — 這是 CSS 的基本機制，不是 bug。&lt;/p>
&lt;h3 id="這次的觀察-1">這次的觀察&lt;/h3>
&lt;p>Filter UI（&lt;code>.pagefind-ui__filter-panel&lt;/code>，本質是 &lt;code>&amp;lt;fieldset&amp;gt;&lt;/code>）需要從 &lt;code>.pagefind-ui&lt;/code> 搬到外部 aside（左側 sidebar）。搬完後 fieldset 的瀏覽器預設邊框冒出來。&lt;/p>
&lt;h3 id="判讀-1">判讀&lt;/h3>
&lt;p>&lt;code>.pagefind-ui--reset&lt;/code> 用 &lt;code>all: unset&lt;/code> 重置所有後代元素的 UA 樣式。fieldset 搬出 &lt;code>.pagefind-ui&lt;/code> 後，這個 ancestor 不在了，UA 樣式（包括 fieldset 預設邊框）回到原樣。&lt;/p>
&lt;h3 id="執行-1">執行&lt;/h3>
&lt;p>在 fieldset 落腳處（aside 內）重新關掉 UA 樣式：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">search-filter-slot&lt;/span> &lt;span class="nt">fieldset&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">border&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">padding&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="k">margin&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>完成標準&lt;/strong>：fieldset 在新位置看起來跟在 &lt;code>.pagefind-ui&lt;/code> 內一樣 — 沒有多餘邊框。&lt;/p>
&lt;hr>
&lt;h2 id="specificity-邊界跳出線性比較戰場">specificity 邊界：跳出線性比較戰場&lt;/h2>
&lt;p>&lt;strong>核心定義&lt;/strong>：當組件透過 hash class 把 specificity 拉高到一般客製寫法蓋不過時，邊界落在 CSS 樣式分層機制 — 不在個別 selector 數字。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>客製外部組件的穩定性與「離組件邊界多近」成正比。</strong> 組件作者維護組件內部的一致性；客製貼著組件的對外介面（CLI 參數、CSS reset 邊界、CSS layer）做，組件升級不會打到客製。深入組件內部（注入子節點、改 source 行為）的客製則仰賴組件的內部實作，作者不保證下個版本還相容。</p>
<p>這條原則對應到 Pagefind 整合時的三個具體情境，本文逐一拆解。</p>
<hr>
<h2 id="邊界概念為什麼用邊界看外部組件">邊界概念：為什麼用「邊界」看外部組件</h2>
<h3 id="這類問題的本質商業邏輯">這類問題的本質（商業邏輯）</h3>
<p>組件 = 一組對外契約 + 內部實作。對外契約是作者保證的東西（CLI 參數、CSS class name、CSS variable hooks），內部實作是達成契約的手段、可能在版本之間變動。</p>
<p>客製貼著對外契約做，等於跟作者站在同一邊；客製動到內部實作，等於跟作者每個版本對抗。</p>
<p>「邊界」這個概念把組件分成「契約面」與「實作面」 — 客製選位置時，依離邊界遠近排序：</p>
<table>
  <thead>
      <tr>
          <th>客製位置</th>
          <th>跨越時的依賴</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>組件對外介面</td>
          <td>介面契約（穩定）</td>
      </tr>
      <tr>
          <td>組件 DOM 邊界外</td>
          <td>邊界元素的 class / id 名稱（半穩定）</td>
      </tr>
      <tr>
          <td>組件 DOM 邊界內</td>
          <td>內部 DOM 結構（隨框架渲染週期變動）</td>
      </tr>
      <tr>
          <td>組件內部邏輯</td>
          <td>source code（升級時必衝突）</td>
      </tr>
  </tbody>
</table>
<h3 id="這次任務涉及的邊界case">這次任務涉及的邊界（CASE）</h3>
<p>Pagefind 提供三條值得辨識的邊界：</p>
<table>
  <thead>
      <tr>
          <th>邊界名稱</th>
          <th>介面形式</th>
          <th>範圍</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>索引邊界</td>
          <td><code>--root-selector</code> 參數</td>
          <td>「組件看到什麼資料」</td>
      </tr>
      <tr>
          <td>重置邊界</td>
          <td><code>.pagefind-ui--reset</code> class</td>
          <td>對 UA 樣式的覆寫範圍</td>
      </tr>
      <tr>
          <td>specificity 邊界</td>
          <td>svelte hash class（雙寫到 30）</td>
          <td>組件 CSS 的權重起點</td>
      </tr>
  </tbody>
</table>
<p>每條邊界各有對應的客製介面 — 緊貼這些介面比繞道進內部安全得多。</p>
<hr>
<h2 id="索引邊界用-root-selector-限縮搜尋範圍">索引邊界：用 root-selector 限縮搜尋範圍</h2>
<p><strong>核心定義</strong>：Pagefind 的索引流程透過 <code>--root-selector</code> 參數控制「哪些 HTML 進索引」。這是組件預期的客製介面。</p>
<h3 id="這次的觀察">這次的觀察</h3>
<p>theme 的 site header 包含 <code>&lt;h1&gt;{{ .Site.Title }}&lt;/h1&gt;</code>，每一頁 DOM 上的第一個 h1 都是站名「Tarragon」。Pagefind 預設取頁面第一個 h1 當搜尋結果 title — 結果所有結果都顯示「Tarragon」。</p>
<h3 id="判讀">判讀</h3>
<p>第一個 h1 是站名而非文章名 — 索引「看到」的範圍跟我們以為的不同。要修的不是 theme（影響其他頁面），是 Pagefind 的 input 範圍。</p>
<h3 id="執行">執行</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">npx -y pagefind --site public --root-selector main</span></span></code></pre></div><p><code>&lt;main&gt;</code> 內才包含文章 h1；site header 在 <code>&lt;body&gt;</code> 直接子節點、不在 <code>&lt;main&gt;</code> 內，自然被排除。</p>
<p><strong>完成標準</strong>：每一筆結果的 title 顯示文章 h1，而非站名。</p>
<hr>
<h2 id="重置邊界在落腳處重建-ua-樣式">重置邊界：在落腳處重建 UA 樣式</h2>
<p><strong>核心定義</strong>：CSS reset 的有效範圍與 ancestor class 綁定。元素換位置就換 reset context — 這是 CSS 的基本機制，不是 bug。</p>
<h3 id="這次的觀察-1">這次的觀察</h3>
<p>Filter UI（<code>.pagefind-ui__filter-panel</code>，本質是 <code>&lt;fieldset&gt;</code>）需要從 <code>.pagefind-ui</code> 搬到外部 aside（左側 sidebar）。搬完後 fieldset 的瀏覽器預設邊框冒出來。</p>
<h3 id="判讀-1">判讀</h3>
<p><code>.pagefind-ui--reset</code> 用 <code>all: unset</code> 重置所有後代元素的 UA 樣式。fieldset 搬出 <code>.pagefind-ui</code> 後，這個 ancestor 不在了，UA 樣式（包括 fieldset 預設邊框）回到原樣。</p>
<h3 id="執行-1">執行</h3>
<p>在 fieldset 落腳處（aside 內）重新關掉 UA 樣式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">search-filter-slot</span> <span class="nt">fieldset</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">border</span><span class="p">:</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">padding</span><span class="p">:</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">margin</span><span class="p">:</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><strong>完成標準</strong>：fieldset 在新位置看起來跟在 <code>.pagefind-ui</code> 內一樣 — 沒有多餘邊框。</p>
<hr>
<h2 id="specificity-邊界跳出線性比較戰場">specificity 邊界：跳出線性比較戰場</h2>
<p><strong>核心定義</strong>：當組件透過 hash class 把 specificity 拉高到一般客製寫法蓋不過時，邊界落在 CSS 樣式分層機制 — 不在個別 selector 數字。</p>
<h3 id="這次的觀察-2">這次的觀察</h3>
<p>Pagefind 透過 svelte 把 class name 加 hash 重複寫進 selector（<code>.x.svelte-y.svelte-y</code>），把 specificity 從 10 提升到 30。一般客製 CSS specificity 10-20、蓋不過去。</p>
<h3 id="判讀-2">判讀</h3>
<p>Specificity 是線性數字比較 — 跟組件作者比 specificity 是無贏的軍備競賽。要真正解這類覆寫戰、需要跳出「線性比較」這個維度本身。</p>
<h3 id="執行-2">執行</h3>
<p>具體做法（<code>@import url(...) layer(...)</code>）與升級兼容性、其他外部組件的 layer 策略，由 <a href="../css-layers-over-specificity/">#24 CSS Layers 取代 specificity 戰</a> 完整展開。在邊界辨識上、本篇要記住的是：<strong>遇到 specificity 30+ 的覆寫戰、不要往 <code>!important</code> / <code>.x.x</code> 雙寫的方向加碼、改去看 layer 維度</strong>。</p>
<p><strong>完成標準</strong>：所有原本需要 <code>!important</code> 或 <code>.x.x</code> 雙寫的覆寫，可以用單純的 class selector 寫。</p>
<hr>
<h2 id="客製深度的內在屬性比較">客製深度的內在屬性比較</h2>
<p>選擇客製位置時，用三個<strong>內在屬性</strong>比較 — 不用「實作要多久」這類時間維度：</p>
<table>
  <thead>
      <tr>
          <th>位置</th>
          <th>依賴前提</th>
          <th>升級風險</th>
          <th>可逆性</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>組件對外介面</td>
          <td>介面 stable across 版本</td>
          <td>低 — 介面是公開契約</td>
          <td>高 — 改一個參數即可還原</td>
      </tr>
      <tr>
          <td>DOM 邊界外（class hooks）</td>
          <td>邊界元素 class / id 穩定</td>
          <td>中 — class 改名才打破</td>
          <td>中 — 改選擇器即可</td>
      </tr>
      <tr>
          <td>DOM 邊界內</td>
          <td>內部 DOM 結構穩定</td>
          <td>高 — 框架重繪可能即時打破</td>
          <td>低 — 客製跟內部結構深度耦合</td>
      </tr>
      <tr>
          <td>內部邏輯（fork / patch）</td>
          <td>整個組件 source</td>
          <td>最高 — 升級必衝突</td>
          <td>最低 — 重新 merge</td>
      </tr>
  </tbody>
</table>
<p><strong>選擇順序</strong>：先試最外層；不夠用再往內推一層。每往內一層、依賴前提增加、升級風險上升、可逆性下降。</p>
<hr>
<h2 id="設計取捨客製位置的層次選擇">設計取捨：客製位置的層次選擇</h2>
<p>四種做法、各自機會成本不同。預設選最外層（A）、不夠用才往內推一層。</p>
<blockquote>
<p>本篇是 <a href="../external-component-collaboration-layers/">#45 跟外部組件合作的層次</a> 抽象原則在「客製位置選擇」這個面向的應用。</p></blockquote>
<h3 id="a組件公開介面層最佳">A：組件公開介面層（最佳）</h3>
<ul>
<li><strong>機制</strong>：用組件提供的 CLI 參數、props、CSS variable hook、event API 客製</li>
<li><strong>選 A 的理由</strong>：作者保證跨版本相容、客製跟組件升級無關</li>
<li><strong>適合</strong>：能被介面覆蓋的客製需求（多數常見場景）</li>
<li><strong>代價</strong>：受介面範圍限制、超出範圍只能往內推</li>
</ul>
<h3 id="b鄰接層class-hooksreset-邊界這個專案的次選">B：鄰接層（class hooks、reset 邊界）（這個專案的次選）</h3>
<ul>
<li><strong>機制</strong>：用組件邊界元素的 class / id / data attribute 寫客製 CSS、注意 CSS reset 邊界</li>
<li><strong>跟 A 的取捨</strong>：B 介面外但仍貼著組件邊界、A 在介面內；B 在 class 名穩定時 OK、改名就壞</li>
<li><strong>B 比 A 好的情境</strong>：組件未提供對應介面、但有穩定的 class 邊界可掛勾</li>
</ul>
<h3 id="c邊界內-dom-操作">C：邊界內 DOM 操作</h3>
<ul>
<li><strong>機制</strong>：JS 改組件內部 DOM 結構 / 節點屬性 / 注入元素</li>
<li><strong>跟 A/B 的取捨</strong>：C 直接操控、跟 framework reconciliation 競爭；風險顯著上升</li>
<li><strong>C 才合理的情境</strong>：A/B 都無法達成、且能容忍每次升級重做（<a href="../component-boundary-and-js-impact/">#13</a> 的安全規則必看）</li>
</ul>
<h3 id="dfork--patch-組件-source">D：Fork / patch 組件 source</h3>
<ul>
<li><strong>機制</strong>：fork 整個組件、改 source code、自家維護版本</li>
<li><strong>成本特別高的原因</strong>：每次升級都要 merge upstream、客製永久綁在 fork</li>
<li><strong>D 才合理的情境</strong>：客製需求超過所有其他選項、且願意承擔 fork 維護成本</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<p>下次遇到組件整合任務、看到這些訊號就走「找邊界」的路：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>對應邊界類型</th>
          <th>第一個該嘗試的動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>組件抓到的資料超過你想要的範圍</td>
          <td>索引邊界</td>
          <td>找組件的 <code>root</code> / <code>scope</code> / <code>selector</code> 參數</td>
      </tr>
      <tr>
          <td>同一段 CSS 在不同位置表現不一致</td>
          <td>重置邊界</td>
          <td>比對兩個位置的 ancestor class、看 reset 是否還在</td>
      </tr>
      <tr>
          <td><code>!important</code> 寫了還是沒蓋過</td>
          <td>specificity 邊界</td>
          <td>看組件 CSS 是否用 hash class 提升 specificity；考慮 CSS Layers</td>
      </tr>
      <tr>
          <td>組件渲染後客製 UI 消失</td>
          <td>DOM 結構邊界</td>
          <td>把客製 UI 搬出組件、用 CSS 控制視覺位置</td>
      </tr>
      <tr>
          <td>組件升級後客製失效</td>
          <td>內部邏輯邊界</td>
          <td>把客製重寫到組件介面層</td>
      </tr>
  </tbody>
</table>
<p><strong>使用順序</strong>：訊號出現 → 對應邊界 → 嘗試該邊界提供的介面 → 介面不夠用、才考慮往內推一層。</p>
]]></content:encoded></item><item><title>跨 viewport 雙模式 UI 的物理空間預算</title><link>https://tarrragon.github.io/blog/report/viewport-dual-mode-spatial-budget/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/viewport-dual-mode-spatial-budget/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>雙模式 UI 的 breakpoint 是「物理空間預算的結果」、不是「設計選擇」。&lt;/strong> 把每個元件的固有尺寸與 gap 加總、得到「兩種模式各自能存活的最小 viewport」 — breakpoint 從這個數字往上取一個安全餘裕。憑感覺取（768、1024）會在中間過渡區看到元件擠壓、消失或溢出。&lt;/p>
&lt;blockquote>
&lt;p>本篇焦點：&lt;strong>breakpoint 數字怎麼推算&lt;/strong>。&lt;/p>
&lt;ul>
&lt;li>&lt;strong>stateful UI 怎麼跨兩個 slot 共用同一份節點&lt;/strong>由 &lt;a href="../pattern-cross-slot-node-relocation/">#54 跨 slot 同節點搬遷 pattern&lt;/a> 處理（兩議題機制不同：本篇是數字計算、#54 是 state 一致性）&lt;/li>
&lt;/ul>&lt;/blockquote>
&lt;hr>
&lt;h2 id="breakpoint-從元件預算推算">breakpoint 從元件預算推算&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>Responsive UI 的本質是「同一份內容、在不同寬度有不同的呈現方式」。每種模式（mobile / tablet / desktop）對應一個 layout、每個 layout 有自己的最小可生存寬度 — 這是元件尺寸與 gap 的加總、不是任意選擇。&lt;/p>
&lt;p>當 breakpoint 取得比實際所需大、模式切換點與「真正放得下」的點不一致、使用者在過渡區看到擠壓、溢出、或元件神秘消失。&lt;/p>
&lt;h3 id="預算推算的步驟">預算推算的步驟&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>步驟&lt;/th>
 &lt;th>動作&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1&lt;/td>
 &lt;td>列出該模式下所有可見元件的固有寬度&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2&lt;/td>
 &lt;td>列出元件之間的 gap 與 container 的 padding&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3&lt;/td>
 &lt;td>加總得到「該模式所需的最小 viewport」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>4&lt;/td>
 &lt;td>breakpoint = 最小 viewport + 安全餘裕（避免邊界情況閃爍）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="這次任務的實際預算">這次任務的實際預算&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>搜尋頁的 desktop layout 包含：&lt;/p>
&lt;ul>
&lt;li>main 內容欄寬度 = 70ch ≈ 720px（theme 預設）&lt;/li>
&lt;li>filter sidebar 寬度 = 400px（自訂）&lt;/li>
&lt;li>main 與 filter 之間 gap = 32px&lt;/li>
&lt;li>body 左右 padding 各 64px = 128px&lt;/li>
&lt;/ul>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>把 filter 放在 main 左外側、main 維持置中時、所需最小 viewport：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">最小 viewport ≈ main + 2 × (filter + gap) + body padding
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> = 720 + 2 × (400 + 32) + 128
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> = 1712px&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>但這是「左右對稱都放下」的條件。若允許 filter 溢出 body padding（仍在 viewport 內可見）、條件放寬為：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">最小 viewport ≈ main + filter + gap + body padding
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> = 720 + 400 + 32 + 128
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> = 1280px&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>選 1280 為下限、加餘裕後取 1400px。&lt;/p>
&lt;h3 id="執行">執行&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">@&lt;/span>&lt;span class="k">media&lt;/span> &lt;span class="o">(&lt;/span>&lt;span class="nt">min-width&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nt">1400px&lt;/span>&lt;span class="o">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="nc">search-filter-slot&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">display&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">block&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c">/* 桌面寬模式 */&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>下方寬度時 filter 顯示在 pagefind 原生位置（drawer 內、結果上方）、由 pagefind 自己處理。&lt;/p>
&lt;hr>
&lt;h2 id="設計取捨餘裕的取法">設計取捨：餘裕的取法&lt;/h2>
&lt;p>預算算出最小 viewport 後、breakpoint 加多少餘裕？四種做法：&lt;/p>
&lt;h3 id="a加-10-餘裕這個專案的預設">A：加 ~10% 餘裕（這個專案的預設）&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>機制&lt;/strong>：1280 + 120 ≈ 1400&lt;/li>
&lt;li>&lt;strong>選 A 的理由&lt;/strong>：避免邊界情況閃爍（使用者視窗剛好在 1280 像素時、輕微 resize 會反覆觸發切換）&lt;/li>
&lt;li>&lt;strong>適合&lt;/strong>：一般情境&lt;/li>
&lt;li>&lt;strong>代價&lt;/strong>：1280-1400 區間其實能放下、但 CSS 仍走 mobile 模式&lt;/li>
&lt;/ul>
&lt;h3 id="b取整到常見裝置寬度">B：取整到常見裝置寬度&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>機制&lt;/strong>：1280 → 1366（MacBook Pro 寬度）或 1440（外接螢幕常見）&lt;/li>
&lt;li>&lt;strong>跟 A 的取捨&lt;/strong>：B 對齊裝置生態、A 對齊計算結果；B 對「設計給特定裝置的網站」較合理&lt;/li>
&lt;li>&lt;strong>B 比 A 好的情境&lt;/strong>：使用者群明確（公司內部工具、特定裝置網站）&lt;/li>
&lt;/ul>
&lt;h3 id="c完全等於最小值無餘裕">C：完全等於最小值（無餘裕）&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>機制&lt;/strong>：breakpoint = 1280&lt;/li>
&lt;li>&lt;strong>跟 A 的取捨&lt;/strong>：C 把所有可放下的視窗都納入 desktop 模式、A 留空間給邊界閃爍&lt;/li>
&lt;li>&lt;strong>C 才合理的情境&lt;/strong>：實作上有 debounce 處理閃爍、且裝置寬度集中在某個值附近&lt;/li>
&lt;/ul>
&lt;h3 id="d用-container-query-取代-viewport-breakpoint">D：用 container query 取代 viewport breakpoint&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>機制&lt;/strong>：CSS Container Queries — 元件根據父容器寬度切換、跟 viewport 解耦&lt;/li>
&lt;li>&lt;strong>跟 A 的取捨&lt;/strong>：D 更精準（容器寬度才是元件真正可用的空間）、A 簡單（viewport 是全局觀念）&lt;/li>
&lt;li>&lt;strong>D 比 A 好的情境&lt;/strong>：元件可能放在不同寬度的容器內（CMS 系統、可嵌入元件）&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="不該套用物理空間預算的情境">不該套用「物理空間預算」的情境&lt;/h2>
&lt;p>預算法有適用邊界：&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>雙模式 UI 的 breakpoint 是「物理空間預算的結果」、不是「設計選擇」。</strong> 把每個元件的固有尺寸與 gap 加總、得到「兩種模式各自能存活的最小 viewport」 — breakpoint 從這個數字往上取一個安全餘裕。憑感覺取（768、1024）會在中間過渡區看到元件擠壓、消失或溢出。</p>
<blockquote>
<p>本篇焦點：<strong>breakpoint 數字怎麼推算</strong>。</p>
<ul>
<li><strong>stateful UI 怎麼跨兩個 slot 共用同一份節點</strong>由 <a href="../pattern-cross-slot-node-relocation/">#54 跨 slot 同節點搬遷 pattern</a> 處理（兩議題機制不同：本篇是數字計算、#54 是 state 一致性）</li>
</ul></blockquote>
<hr>
<h2 id="breakpoint-從元件預算推算">breakpoint 從元件預算推算</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>Responsive UI 的本質是「同一份內容、在不同寬度有不同的呈現方式」。每種模式（mobile / tablet / desktop）對應一個 layout、每個 layout 有自己的最小可生存寬度 — 這是元件尺寸與 gap 的加總、不是任意選擇。</p>
<p>當 breakpoint 取得比實際所需大、模式切換點與「真正放得下」的點不一致、使用者在過渡區看到擠壓、溢出、或元件神秘消失。</p>
<h3 id="預算推算的步驟">預算推算的步驟</h3>
<table>
  <thead>
      <tr>
          <th>步驟</th>
          <th>動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>列出該模式下所有可見元件的固有寬度</td>
      </tr>
      <tr>
          <td>2</td>
          <td>列出元件之間的 gap 與 container 的 padding</td>
      </tr>
      <tr>
          <td>3</td>
          <td>加總得到「該模式所需的最小 viewport」</td>
      </tr>
      <tr>
          <td>4</td>
          <td>breakpoint = 最小 viewport + 安全餘裕（避免邊界情況閃爍）</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="這次任務的實際預算">這次任務的實際預算</h2>
<h3 id="觀察">觀察</h3>
<p>搜尋頁的 desktop layout 包含：</p>
<ul>
<li>main 內容欄寬度 = 70ch ≈ 720px（theme 預設）</li>
<li>filter sidebar 寬度 = 400px（自訂）</li>
<li>main 與 filter 之間 gap = 32px</li>
<li>body 左右 padding 各 64px = 128px</li>
</ul>
<h3 id="判讀">判讀</h3>
<p>把 filter 放在 main 左外側、main 維持置中時、所需最小 viewport：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">最小 viewport ≈ main + 2 × (filter + gap) + body padding
</span></span><span class="line"><span class="ln">2</span><span class="cl">             = 720 + 2 × (400 + 32) + 128
</span></span><span class="line"><span class="ln">3</span><span class="cl">             = 1712px</span></span></code></pre></div><p>但這是「左右對稱都放下」的條件。若允許 filter 溢出 body padding（仍在 viewport 內可見）、條件放寬為：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">最小 viewport ≈ main + filter + gap + body padding
</span></span><span class="line"><span class="ln">2</span><span class="cl">             = 720 + 400 + 32 + 128
</span></span><span class="line"><span class="ln">3</span><span class="cl">             = 1280px</span></span></code></pre></div><p>選 1280 為下限、加餘裕後取 1400px。</p>
<h3 id="執行">執行</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">@</span><span class="k">media</span> <span class="o">(</span><span class="nt">min-width</span><span class="o">:</span> <span class="nt">1400px</span><span class="o">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="p">.</span><span class="nc">search-filter-slot</span> <span class="p">{</span> <span class="k">display</span><span class="p">:</span> <span class="kc">block</span><span class="p">;</span> <span class="c">/* 桌面寬模式 */</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>下方寬度時 filter 顯示在 pagefind 原生位置（drawer 內、結果上方）、由 pagefind 自己處理。</p>
<hr>
<h2 id="設計取捨餘裕的取法">設計取捨：餘裕的取法</h2>
<p>預算算出最小 viewport 後、breakpoint 加多少餘裕？四種做法：</p>
<h3 id="a加-10-餘裕這個專案的預設">A：加 ~10% 餘裕（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：1280 + 120 ≈ 1400</li>
<li><strong>選 A 的理由</strong>：避免邊界情況閃爍（使用者視窗剛好在 1280 像素時、輕微 resize 會反覆觸發切換）</li>
<li><strong>適合</strong>：一般情境</li>
<li><strong>代價</strong>：1280-1400 區間其實能放下、但 CSS 仍走 mobile 模式</li>
</ul>
<h3 id="b取整到常見裝置寬度">B：取整到常見裝置寬度</h3>
<ul>
<li><strong>機制</strong>：1280 → 1366（MacBook Pro 寬度）或 1440（外接螢幕常見）</li>
<li><strong>跟 A 的取捨</strong>：B 對齊裝置生態、A 對齊計算結果；B 對「設計給特定裝置的網站」較合理</li>
<li><strong>B 比 A 好的情境</strong>：使用者群明確（公司內部工具、特定裝置網站）</li>
</ul>
<h3 id="c完全等於最小值無餘裕">C：完全等於最小值（無餘裕）</h3>
<ul>
<li><strong>機制</strong>：breakpoint = 1280</li>
<li><strong>跟 A 的取捨</strong>：C 把所有可放下的視窗都納入 desktop 模式、A 留空間給邊界閃爍</li>
<li><strong>C 才合理的情境</strong>：實作上有 debounce 處理閃爍、且裝置寬度集中在某個值附近</li>
</ul>
<h3 id="d用-container-query-取代-viewport-breakpoint">D：用 container query 取代 viewport breakpoint</h3>
<ul>
<li><strong>機制</strong>：CSS Container Queries — 元件根據父容器寬度切換、跟 viewport 解耦</li>
<li><strong>跟 A 的取捨</strong>：D 更精準（容器寬度才是元件真正可用的空間）、A 簡單（viewport 是全局觀念）</li>
<li><strong>D 比 A 好的情境</strong>：元件可能放在不同寬度的容器內（CMS 系統、可嵌入元件）</li>
</ul>
<hr>
<h2 id="不該套用物理空間預算的情境">不該套用「物理空間預算」的情境</h2>
<p>預算法有適用邊界：</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼不套用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>內容靜態、不依設計尺寸（純文字段落）</td>
          <td>內容自然 reflow、不需要 breakpoint</td>
      </tr>
      <tr>
          <td>流體 layout（純 % / fr 單位）</td>
          <td>元件自動撐滿可用空間、無「最小寬度」概念</td>
      </tr>
      <tr>
          <td>完全 mobile-first 設計</td>
          <td>沒有「desktop 模式」這個分支</td>
      </tr>
      <tr>
          <td>元件尺寸 runtime 才知道</td>
          <td>用 ResizeObserver 動態調整、不是 breakpoint</td>
      </tr>
  </tbody>
</table>
<p><strong>核心判準</strong>：兩種模式之間有「明確的視覺結構切換」嗎？是 → 用預算法；否 → 用流體 layout 或動態量測。</p>
<hr>
<h2 id="跟其他原則的關係">跟其他原則的關係</h2>
<table>
  <thead>
      <tr>
          <th>抽象層原則</th>
          <th>關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="../single-source-of-truth/">#44 SSoT</a></td>
          <td>breakpoint 數字是 fact、CSS 變數住址唯一才能集中管理</td>
      </tr>
      <tr>
          <td><a href="../measurement-completeness/">#7 量測值缺一不可</a></td>
          <td>預算的每個分量都要有明確來源（寫死 / 量測）、不能估算</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>可能的根因</th>
          <th>第一個該檢查的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>中間寬度時 UI 元件擠壓或重疊</td>
          <td>breakpoint 比實際所需小</td>
          <td>算物理空間預算、確認 breakpoint 對應的最小 viewport</td>
      </tr>
      <tr>
          <td>元件在某些寬度下消失但 CSS <code>display</code> 是 <code>block</code></td>
          <td>元件被 absolute 定位推出 viewport</td>
          <td>檢查 absolute 元件相對 viewport 的座標、是否為負</td>
      </tr>
      <tr>
          <td>Breakpoint 取常見值（768 / 1024）就壞</td>
          <td>那些值跟你的元件尺寸無關</td>
          <td>重算預算、不要用「常見」值</td>
      </tr>
      <tr>
          <td>Resize 過 breakpoint 時 layout 跳動</td>
          <td>沒加餘裕、邊界震盪</td>
          <td>加 10% 餘裕避開閃爍區</td>
      </tr>
  </tbody>
</table>
<p><strong>順序</strong>：看到擠壓或消失先量空間、不要立刻調 breakpoint 數字。數字背後有計算才不會反覆試錯。</p>
]]></content:encoded></item><item><title>視覺對齊用單一真實來源</title><link>https://tarrragon.github.io/blog/report/visual-alignment-single-source-of-truth/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/visual-alignment-single-source-of-truth/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>多個元素要對齊，每個元素的尺寸都需要「來源明確的數字」 — 寫死的 token 或 runtime 量測寫回變數，二選一不要混搭。&lt;/strong> 任何一個值含糊（猜的、估的、依字型自然渲染的），整條對齊基準就靠不住、修一處要找十處跟著改。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼對齊需要單一來源">為什麼對齊需要單一來源&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>對齊問題本質是&lt;strong>線性方程組&lt;/strong>：每個參與對齊的元素貢獻一個未知數，要解出對齊的 padding / margin / position 等變數，所有未知數都要有確定值。任一個未知數憑感覺給，整組無解。&lt;/p>
&lt;p>CSS 變數提供「一處定義、多處引用」的單一來源 — 改 token 只動一個值、所有引用點自動跟上。&lt;/p>
&lt;h3 id="兩種值來源各用對應的定義方法">兩種值來源、各用對應的定義方法&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>值的性質&lt;/th>
 &lt;th>確定方式&lt;/th>
 &lt;th>例子&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>設計可決定的固定值&lt;/td>
 &lt;td>CSS 變數寫死&lt;/td>
 &lt;td>H1 height、icon size&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Runtime 依字型 / 內容變動&lt;/td>
 &lt;td>ResizeObserver 量測寫回 CSS 變數&lt;/td>
 &lt;td>多行文字區塊高度、圖片自適應高度&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>混搭的後果：寫死值跟實際渲染不一致時，對齊只在某些字型 / 螢幕 / 瀏覽器下成立、其他情境壞掉、且難以重現。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的實際應用">這次任務的實際應用&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>搜尋頁有四處要共用同一組視覺 token：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>元素&lt;/th>
 &lt;th>為什麼需要這個值&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>H1「搜尋」&lt;/td>
 &lt;td>自身 height&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Pagefind search input&lt;/td>
 &lt;td>自身 height&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Filter sidebar &lt;code>padding-top&lt;/code>&lt;/td>
 &lt;td>對齊到 results 頂端&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Drawer &lt;code>margin-top&lt;/code>&lt;/td>
 &lt;td>為 scope UI 讓出空間&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>H1 與 input 的 height 是設計可決定的固定值 — 用 CSS 變數寫死。Scope UI 的 height 受字型 / 換行影響、不可預測 — 用 ResizeObserver 量測寫回。&lt;/p>
&lt;h3 id="執行css-變數定義">執行：CSS 變數定義&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nt">body&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nc">page-search&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nv">--search-title-h&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">64&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c">/* 設計決定 */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nv">--search-form-h&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">68&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c">/* pagefind input 64 + border 4，鎖定 scale=1.0 */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nv">--search-gap&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">20&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c">/* drawer margin-top */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="c">/* --search-scope-h 由 JS 量測寫入 :root */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="執行js-量測寫回">執行：JS 量測寫回&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">syncScopeHeight&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">h&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">scopeEl&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">offsetHeight&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="mi">56&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">documentElement&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">style&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setProperty&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;--search-scope-h&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">h&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s1">&amp;#39;px&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="nx">syncScopeHeight&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="k">new&lt;/span> &lt;span class="nx">ResizeObserver&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">syncScopeHeight&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">observe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">scopeEl&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="執行多處引用">執行：多處引用&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">search-title&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">height&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nf">var&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="n">search&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">title&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">h&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">search-shell&lt;/span> &lt;span class="p">.&lt;/span>&lt;span class="nc">pagefind-ui__drawer&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">margin-top&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">calc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nf">var&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="n">search&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">scope&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">h&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">8&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">search-filter-slot&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="k">padding-top&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">calc&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nf">var&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="n">search&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">title&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">h&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nf">var&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="n">search&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">form&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">h&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="o">+&lt;/span> &lt;span class="nf">var&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="n">search&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">scope&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">h&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">8&lt;/span>&lt;span class="kt">px&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nf">var&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="n">search&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">gap&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="對齊問題的兩種失敗模式">對齊問題的兩種失敗模式&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>失敗模式&lt;/th>
 &lt;th>表現&lt;/th>
 &lt;th>根因&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>寫死值與實際渲染不一致&lt;/td>
 &lt;td>字型變動或 scale 改變後對不齊&lt;/td>
 &lt;td>沒控制渲染參數（如 pagefind scale）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>用估算值代替量測&lt;/td>
 &lt;td>邊界情境（短/長文字、特殊字型）壞掉&lt;/td>
 &lt;td>把不可預測的值當固定值處理&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>兩者共通的修法是：&lt;strong>確認每個值的性質、按性質選來源&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="鎖定渲染參數讓寫死值生效">鎖定渲染參數讓寫死值生效&lt;/h2>
&lt;p>當值「理論上可預測」但需要強制條件時，鎖定渲染參數。&lt;/p>
&lt;p>&lt;strong>核心定義&lt;/strong>：Pagefind input 高度 = &lt;code>64px × --pagefind-ui-scale&lt;/code>。把 scale 設成 1.0、input 自然渲染為 64px、加 border 4px 共 68px、剛好等於我們的 &lt;code>--search-form-h&lt;/code>。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">search-shell&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nv">--pagefind-ui-scale&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mf">1.0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>把組件提供的 scale 變數拉進自家設計系統 — 組件配合我們、不是我們配合組件。&lt;/p>
&lt;hr>
&lt;h2 id="設計取捨對齊基準的值來源策略">設計取捨：對齊基準的值來源策略&lt;/h2>
&lt;p>四種做法、各自機會成本不同。這個專案選 A（CSS 變數寫死 + var 引用）當固定值預設、B（ResizeObserver 量測寫回變數）當 runtime 值預設、其他做法是反模式。&lt;/p>
&lt;blockquote>
&lt;p>本篇是 &lt;a href="../single-source-of-truth/">#44 SSoT&lt;/a> 抽象原則在「對齊基準」這個面向的應用。&lt;/p>&lt;/blockquote>
&lt;h3 id="acss-變數寫死--var-引用這個專案對固定值的預設">A：CSS 變數寫死 + var() 引用（這個專案對固定值的預設）&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>機制&lt;/strong>：&lt;code>body.page-search { --search-title-h: 64px }&lt;/code>、其他地方用 &lt;code>var(--search-title-h)&lt;/code> 引用&lt;/li>
&lt;li>&lt;strong>選 A 的理由&lt;/strong>：定義住址唯一、改 token 自動跟上、無 runtime 開銷&lt;/li>
&lt;li>&lt;strong>適合&lt;/strong>：設計可決定的固定值（H1 height、icon size、間距）&lt;/li>
&lt;li>&lt;strong>代價&lt;/strong>：值不能跟 runtime 內容變動 — 字型大幅變化時 layout 可能不適配&lt;/li>
&lt;/ul>
&lt;h3 id="bresizeobserver-量測寫回變數這個專案對-runtime-值的預設">B：ResizeObserver 量測寫回變數（這個專案對 runtime 值的預設）&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>機制&lt;/strong>：JS 量測元素實際渲染高度、&lt;code>setProperty('--search-scope-h', ...)&lt;/code> 寫回變數&lt;/li>
&lt;li>&lt;strong>跟 A 的取捨&lt;/strong>：B 自動跟著實際渲染走、A 假設渲染條件穩定；B 多 JS 一層、A 純 CSS&lt;/li>
&lt;li>&lt;strong>B 比 A 好的情境&lt;/strong>：值受字型 / 換行 / 內容動態影響、無法 build time 預測&lt;/li>
&lt;/ul>
&lt;h3 id="c複製-magic-number-在多處">C：複製 magic number 在多處&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>機制&lt;/strong>：&lt;code>padding-top: 152px&lt;/code> 在多個 selector 直接寫死&lt;/li>
&lt;li>&lt;strong>跟 A 的取捨&lt;/strong>：C 寫法直接、不用變數系統；但改一處要找全部、漏改一個就壞&lt;/li>
&lt;li>&lt;strong>C 是反模式&lt;/strong>：magic number 是「未來 debug 的潛在炸彈」（&lt;a href="../single-source-of-truth/">#44 SSoT&lt;/a>） — 改一處要找全部、漏改一個就壞&lt;/li>
&lt;/ul>
&lt;h3 id="d估算值不寫變數不量測">D：估算值（不寫變數、不量測）&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>機制&lt;/strong>：執行者依感覺寫「padding-top: 152px、應該對齊」&lt;/li>
&lt;li>&lt;strong>成本特別高的原因&lt;/strong>：估值對 = 巧合、估值錯 = 看起來對但 +/- 幾 px、後者更糟（錯誤被視覺接受、不會被發現）&lt;/li>
&lt;li>&lt;strong>D 是反模式&lt;/strong>：任何寫死值都該標明來源（fact / 鎖定條件 / 量測）— 估值對 = 巧合、估值錯 = 看起來對但實際 +/- 幾 px、ship 後在邊界情境暴露&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>可能的根因&lt;/th>
 &lt;th>第一個該檢查的事&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>改一個 CSS 數字、要去 N 個地方跟著改&lt;/td>
 &lt;td>缺少單一來源&lt;/td>
 &lt;td>找出複製的 magic number、提成 CSS 變數&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>設計稿對齊但實作對不齊&lt;/td>
 &lt;td>把可變值當固定值&lt;/td>
 &lt;td>量測該元素的真實 height、決定改用 ResizeObserver&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>字型變動或 dark mode 後對齊壞掉&lt;/td>
 &lt;td>寫死值依賴某個沒鎖定的渲染參數&lt;/td>
 &lt;td>找出該渲染參數、用 CSS 變數鎖定&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>對齊「大部分時候對」、邊界 case 壞掉&lt;/td>
 &lt;td>沒處理動態高度&lt;/td>
 &lt;td>把該值用 ResizeObserver 量測寫回&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>核心原則&lt;/strong>：對齊不是視覺問題，是「每個參與元素是否有確定尺寸」的問題。任何一個值不確定、整組對齊就脆弱。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>多個元素要對齊，每個元素的尺寸都需要「來源明確的數字」 — 寫死的 token 或 runtime 量測寫回變數，二選一不要混搭。</strong> 任何一個值含糊（猜的、估的、依字型自然渲染的），整條對齊基準就靠不住、修一處要找十處跟著改。</p>
<hr>
<h2 id="為什麼對齊需要單一來源">為什麼對齊需要單一來源</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>對齊問題本質是<strong>線性方程組</strong>：每個參與對齊的元素貢獻一個未知數，要解出對齊的 padding / margin / position 等變數，所有未知數都要有確定值。任一個未知數憑感覺給，整組無解。</p>
<p>CSS 變數提供「一處定義、多處引用」的單一來源 — 改 token 只動一個值、所有引用點自動跟上。</p>
<h3 id="兩種值來源各用對應的定義方法">兩種值來源、各用對應的定義方法</h3>
<table>
  <thead>
      <tr>
          <th>值的性質</th>
          <th>確定方式</th>
          <th>例子</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>設計可決定的固定值</td>
          <td>CSS 變數寫死</td>
          <td>H1 height、icon size</td>
      </tr>
      <tr>
          <td>Runtime 依字型 / 內容變動</td>
          <td>ResizeObserver 量測寫回 CSS 變數</td>
          <td>多行文字區塊高度、圖片自適應高度</td>
      </tr>
  </tbody>
</table>
<p>混搭的後果：寫死值跟實際渲染不一致時，對齊只在某些字型 / 螢幕 / 瀏覽器下成立、其他情境壞掉、且難以重現。</p>
<hr>
<h2 id="這次任務的實際應用">這次任務的實際應用</h2>
<h3 id="觀察">觀察</h3>
<p>搜尋頁有四處要共用同一組視覺 token：</p>
<table>
  <thead>
      <tr>
          <th>元素</th>
          <th>為什麼需要這個值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>H1「搜尋」</td>
          <td>自身 height</td>
      </tr>
      <tr>
          <td>Pagefind search input</td>
          <td>自身 height</td>
      </tr>
      <tr>
          <td>Filter sidebar <code>padding-top</code></td>
          <td>對齊到 results 頂端</td>
      </tr>
      <tr>
          <td>Drawer <code>margin-top</code></td>
          <td>為 scope UI 讓出空間</td>
      </tr>
  </tbody>
</table>
<h3 id="判讀">判讀</h3>
<p>H1 與 input 的 height 是設計可決定的固定值 — 用 CSS 變數寫死。Scope UI 的 height 受字型 / 換行影響、不可預測 — 用 ResizeObserver 量測寫回。</p>
<h3 id="執行css-變數定義">執行：CSS 變數定義</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="nt">body</span><span class="p">.</span><span class="nc">page-search</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nv">--search-title-h</span><span class="p">:</span> <span class="mi">64</span><span class="kt">px</span><span class="p">;</span>     <span class="c">/* 設計決定 */</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nv">--search-form-h</span><span class="p">:</span> <span class="mi">68</span><span class="kt">px</span><span class="p">;</span>      <span class="c">/* pagefind input 64 + border 4，鎖定 scale=1.0 */</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nv">--search-gap</span><span class="p">:</span> <span class="mi">20</span><span class="kt">px</span><span class="p">;</span>         <span class="c">/* drawer margin-top */</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="c">/* --search-scope-h 由 JS 量測寫入 :root */</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="執行js-量測寫回">執行：JS 量測寫回</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">syncScopeHeight</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">var</span> <span class="nx">h</span> <span class="o">=</span> <span class="nx">scopeEl</span><span class="p">.</span><span class="nx">offsetHeight</span> <span class="o">||</span> <span class="mi">56</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nb">document</span><span class="p">.</span><span class="nx">documentElement</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="s1">&#39;--search-scope-h&#39;</span><span class="p">,</span> <span class="nx">h</span> <span class="o">+</span> <span class="s1">&#39;px&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nx">syncScopeHeight</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">new</span> <span class="nx">ResizeObserver</span><span class="p">(</span><span class="nx">syncScopeHeight</span><span class="p">).</span><span class="nx">observe</span><span class="p">(</span><span class="nx">scopeEl</span><span class="p">);</span></span></span></code></pre></div><h3 id="執行多處引用">執行：多處引用</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">search-title</span> <span class="p">{</span> <span class="k">height</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">search</span><span class="o">-</span><span class="n">title</span><span class="o">-</span><span class="n">h</span><span class="p">);</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">search-shell</span> <span class="p">.</span><span class="nc">pagefind-ui__drawer</span> <span class="p">{</span> <span class="k">margin-top</span><span class="p">:</span> <span class="nb">calc</span><span class="p">(</span><span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">search</span><span class="o">-</span><span class="n">scope</span><span class="o">-</span><span class="n">h</span><span class="p">)</span> <span class="o">+</span> <span class="mi">8</span><span class="kt">px</span><span class="p">);</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">.</span><span class="nc">search-filter-slot</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">padding-top</span><span class="p">:</span> <span class="nb">calc</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">search</span><span class="o">-</span><span class="n">title</span><span class="o">-</span><span class="n">h</span><span class="p">)</span> <span class="o">+</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">search</span><span class="o">-</span><span class="n">form</span><span class="o">-</span><span class="n">h</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="o">+</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">search</span><span class="o">-</span><span class="n">scope</span><span class="o">-</span><span class="n">h</span><span class="p">)</span> <span class="o">+</span> <span class="mi">8</span><span class="kt">px</span> <span class="o">+</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">search</span><span class="o">-</span><span class="n">gap</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="p">);</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><hr>
<h2 id="對齊問題的兩種失敗模式">對齊問題的兩種失敗模式</h2>
<table>
  <thead>
      <tr>
          <th>失敗模式</th>
          <th>表現</th>
          <th>根因</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>寫死值與實際渲染不一致</td>
          <td>字型變動或 scale 改變後對不齊</td>
          <td>沒控制渲染參數（如 pagefind scale）</td>
      </tr>
      <tr>
          <td>用估算值代替量測</td>
          <td>邊界情境（短/長文字、特殊字型）壞掉</td>
          <td>把不可預測的值當固定值處理</td>
      </tr>
  </tbody>
</table>
<p>兩者共通的修法是：<strong>確認每個值的性質、按性質選來源</strong>。</p>
<hr>
<h2 id="鎖定渲染參數讓寫死值生效">鎖定渲染參數讓寫死值生效</h2>
<p>當值「理論上可預測」但需要強制條件時，鎖定渲染參數。</p>
<p><strong>核心定義</strong>：Pagefind input 高度 = <code>64px × --pagefind-ui-scale</code>。把 scale 設成 1.0、input 自然渲染為 64px、加 border 4px 共 68px、剛好等於我們的 <code>--search-form-h</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">search-shell</span> <span class="p">{</span> <span class="nv">--pagefind-ui-scale</span><span class="p">:</span> <span class="mf">1.0</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><p>把組件提供的 scale 變數拉進自家設計系統 — 組件配合我們、不是我們配合組件。</p>
<hr>
<h2 id="設計取捨對齊基準的值來源策略">設計取捨：對齊基準的值來源策略</h2>
<p>四種做法、各自機會成本不同。這個專案選 A（CSS 變數寫死 + var 引用）當固定值預設、B（ResizeObserver 量測寫回變數）當 runtime 值預設、其他做法是反模式。</p>
<blockquote>
<p>本篇是 <a href="../single-source-of-truth/">#44 SSoT</a> 抽象原則在「對齊基準」這個面向的應用。</p></blockquote>
<h3 id="acss-變數寫死--var-引用這個專案對固定值的預設">A：CSS 變數寫死 + var() 引用（這個專案對固定值的預設）</h3>
<ul>
<li><strong>機制</strong>：<code>body.page-search { --search-title-h: 64px }</code>、其他地方用 <code>var(--search-title-h)</code> 引用</li>
<li><strong>選 A 的理由</strong>：定義住址唯一、改 token 自動跟上、無 runtime 開銷</li>
<li><strong>適合</strong>：設計可決定的固定值（H1 height、icon size、間距）</li>
<li><strong>代價</strong>：值不能跟 runtime 內容變動 — 字型大幅變化時 layout 可能不適配</li>
</ul>
<h3 id="bresizeobserver-量測寫回變數這個專案對-runtime-值的預設">B：ResizeObserver 量測寫回變數（這個專案對 runtime 值的預設）</h3>
<ul>
<li><strong>機制</strong>：JS 量測元素實際渲染高度、<code>setProperty('--search-scope-h', ...)</code> 寫回變數</li>
<li><strong>跟 A 的取捨</strong>：B 自動跟著實際渲染走、A 假設渲染條件穩定；B 多 JS 一層、A 純 CSS</li>
<li><strong>B 比 A 好的情境</strong>：值受字型 / 換行 / 內容動態影響、無法 build time 預測</li>
</ul>
<h3 id="c複製-magic-number-在多處">C：複製 magic number 在多處</h3>
<ul>
<li><strong>機制</strong>：<code>padding-top: 152px</code> 在多個 selector 直接寫死</li>
<li><strong>跟 A 的取捨</strong>：C 寫法直接、不用變數系統；但改一處要找全部、漏改一個就壞</li>
<li><strong>C 是反模式</strong>：magic number 是「未來 debug 的潛在炸彈」（<a href="../single-source-of-truth/">#44 SSoT</a>） — 改一處要找全部、漏改一個就壞</li>
</ul>
<h3 id="d估算值不寫變數不量測">D：估算值（不寫變數、不量測）</h3>
<ul>
<li><strong>機制</strong>：執行者依感覺寫「padding-top: 152px、應該對齊」</li>
<li><strong>成本特別高的原因</strong>：估值對 = 巧合、估值錯 = 看起來對但 +/- 幾 px、後者更糟（錯誤被視覺接受、不會被發現）</li>
<li><strong>D 是反模式</strong>：任何寫死值都該標明來源（fact / 鎖定條件 / 量測）— 估值對 = 巧合、估值錯 = 看起來對但實際 +/- 幾 px、ship 後在邊界情境暴露</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>可能的根因</th>
          <th>第一個該檢查的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>改一個 CSS 數字、要去 N 個地方跟著改</td>
          <td>缺少單一來源</td>
          <td>找出複製的 magic number、提成 CSS 變數</td>
      </tr>
      <tr>
          <td>設計稿對齊但實作對不齊</td>
          <td>把可變值當固定值</td>
          <td>量測該元素的真實 height、決定改用 ResizeObserver</td>
      </tr>
      <tr>
          <td>字型變動或 dark mode 後對齊壞掉</td>
          <td>寫死值依賴某個沒鎖定的渲染參數</td>
          <td>找出該渲染參數、用 CSS 變數鎖定</td>
      </tr>
      <tr>
          <td>對齊「大部分時候對」、邊界 case 壞掉</td>
          <td>沒處理動態高度</td>
          <td>把該值用 ResizeObserver 量測寫回</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：對齊不是視覺問題，是「每個參與元素是否有確定尺寸」的問題。任何一個值不確定、整組對齊就脆弱。</p>
]]></content:encoded></item><item><title>拓樸理解先行於 CSS 規則</title><link>https://tarrragon.github.io/blog/report/dom-topology-before-css/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/dom-topology-before-css/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>CSS 是基於 DOM tree 的規則系統 — 不知道 tree 的真實結構，寫的 CSS 規則無法生效。&lt;/strong> 看 class name 的命名規則（如 &lt;code>__form&lt;/code>、&lt;code>__drawer&lt;/code> 看起來像 sibling）容易推錯層級；寫 CSS 之前用工具直接讀 live DOM tree、確認哪些是 grid item、哪些是 grid item 內部的子元素。&lt;/p>
&lt;hr>
&lt;h2 id="層級必須從-live-dom-讀">層級必須從 live DOM 讀&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>CSS class name 是「用途標記」、不是「結構描述」。&lt;code>.parent__child&lt;/code> 這種 BEM 風格在很多框架裡只是作者方便辨認用途，跟元素之間的 DOM parent-child 關係無對應。&lt;/p>
&lt;p>當作者在 wrapper 裡又加一層 wrapper，class name 不一定改 — 同一個 class name 在不同框架版本可能對應不同的 DOM 巢狀。&lt;/p>
&lt;p>唯一能確定 DOM 層級的方法是&lt;strong>讀 live DOM&lt;/strong>。&lt;/p>
&lt;h3 id="看-dom-的工具選擇">看 DOM 的工具選擇&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>工具&lt;/th>
 &lt;th>適用情境&lt;/th>
 &lt;th>限制&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>瀏覽器 DevTools Elements 面板&lt;/td>
 &lt;td>手動探索、單次確認&lt;/td>
 &lt;td>截圖溝通慢、不能寫成測試&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>playwright browser_evaluate&lt;/code>&lt;/td>
 &lt;td>程式化讀 parent chain、computed style、bounding rect&lt;/td>
 &lt;td>需要 server 跑著&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>框架原始碼（svelte template、JSX）&lt;/td>
 &lt;td>確認靜態 DOM 結構&lt;/td>
 &lt;td>動態渲染情境看不到&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>優先用 playwright — 同一段 query 可以重複跑、結果可以寫進測試。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的拓樸誤判">這次任務的拓樸誤判&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>要把 search scope UI 放在「搜尋輸入框與結果之間」。基於 class name 推測 DOM 結構：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">.pagefind-ui
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">├── .pagefind-ui__form ← 搜尋輸入框
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">└── .pagefind-ui__drawer ← 結果（與 filter）&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Class name &lt;code>__form&lt;/code> 與 &lt;code>__drawer&lt;/code> 都用 &lt;code>__&lt;/code> 前綴、並列在 &lt;code>.pagefind-ui&lt;/code> 下、看起來是 sibling。&lt;/p>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>依此假設寫 CSS Grid：把 &lt;code>.pagefind-ui&lt;/code> 設為 grid、用 &lt;code>display: contents&lt;/code> 串接、把 form 放 row 2、scope 放 row 3、drawer 放 row 4。&lt;/p>
&lt;p>實際渲染後：scope 跑到頁尾。&lt;/p>
&lt;p>用 &lt;code>playwright browser_evaluate&lt;/code> 讀 live DOM tree：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">drawer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__drawer&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="kd">let&lt;/span> &lt;span class="nx">parents&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[],&lt;/span> &lt;span class="nx">el&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">drawer&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">el&lt;/span> &lt;span class="o">!==&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">body&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">parents&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">push&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">tagName&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s1">&amp;#39;.&amp;#39;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">className&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">el&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">parentElement&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>結果：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">DIV.pagefind-ui__drawer
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">FORM.pagefind-ui__form ← drawer 在 form 內！
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">DIV.pagefind-ui
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">DIV#search&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>drawer 是 form 的 child、不是 sibling。我們的 grid 規則把 form（含 drawer 全部結果）放在 row 2、scope 放 row 3 — scope 自然跑到所有結果之後。&lt;/p>
&lt;h3 id="執行">執行&lt;/h3>
&lt;p>確認 DOM 後改用「scope absolute 浮在 form 上、drawer 用 margin-top 讓位」的策略 — 不再嘗試把 form 與 drawer 拆到不同 grid row。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>CSS 是基於 DOM tree 的規則系統 — 不知道 tree 的真實結構，寫的 CSS 規則無法生效。</strong> 看 class name 的命名規則（如 <code>__form</code>、<code>__drawer</code> 看起來像 sibling）容易推錯層級；寫 CSS 之前用工具直接讀 live DOM tree、確認哪些是 grid item、哪些是 grid item 內部的子元素。</p>
<hr>
<h2 id="層級必須從-live-dom-讀">層級必須從 live DOM 讀</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>CSS class name 是「用途標記」、不是「結構描述」。<code>.parent__child</code> 這種 BEM 風格在很多框架裡只是作者方便辨認用途，跟元素之間的 DOM parent-child 關係無對應。</p>
<p>當作者在 wrapper 裡又加一層 wrapper，class name 不一定改 — 同一個 class name 在不同框架版本可能對應不同的 DOM 巢狀。</p>
<p>唯一能確定 DOM 層級的方法是<strong>讀 live DOM</strong>。</p>
<h3 id="看-dom-的工具選擇">看 DOM 的工具選擇</h3>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>適用情境</th>
          <th>限制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>瀏覽器 DevTools Elements 面板</td>
          <td>手動探索、單次確認</td>
          <td>截圖溝通慢、不能寫成測試</td>
      </tr>
      <tr>
          <td><code>playwright browser_evaluate</code></td>
          <td>程式化讀 parent chain、computed style、bounding rect</td>
          <td>需要 server 跑著</td>
      </tr>
      <tr>
          <td>框架原始碼（svelte template、JSX）</td>
          <td>確認靜態 DOM 結構</td>
          <td>動態渲染情境看不到</td>
      </tr>
  </tbody>
</table>
<p>優先用 playwright — 同一段 query 可以重複跑、結果可以寫進測試。</p>
<hr>
<h2 id="這次任務的拓樸誤判">這次任務的拓樸誤判</h2>
<h3 id="觀察">觀察</h3>
<p>要把 search scope UI 放在「搜尋輸入框與結果之間」。基於 class name 推測 DOM 結構：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">.pagefind-ui
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── .pagefind-ui__form        ← 搜尋輸入框
</span></span><span class="line"><span class="ln">3</span><span class="cl">└── .pagefind-ui__drawer      ← 結果（與 filter）</span></span></code></pre></div><p>Class name <code>__form</code> 與 <code>__drawer</code> 都用 <code>__</code> 前綴、並列在 <code>.pagefind-ui</code> 下、看起來是 sibling。</p>
<h3 id="判讀">判讀</h3>
<p>依此假設寫 CSS Grid：把 <code>.pagefind-ui</code> 設為 grid、用 <code>display: contents</code> 串接、把 form 放 row 2、scope 放 row 3、drawer 放 row 4。</p>
<p>實際渲染後：scope 跑到頁尾。</p>
<p>用 <code>playwright browser_evaluate</code> 讀 live DOM tree：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">const</span> <span class="nx">drawer</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__drawer&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">let</span> <span class="nx">parents</span> <span class="o">=</span> <span class="p">[],</span> <span class="nx">el</span> <span class="o">=</span> <span class="nx">drawer</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">while</span> <span class="p">(</span><span class="nx">el</span> <span class="o">&amp;&amp;</span> <span class="nx">el</span> <span class="o">!==</span> <span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">parents</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="nx">el</span><span class="p">.</span><span class="nx">tagName</span> <span class="o">+</span> <span class="s1">&#39;.&#39;</span> <span class="o">+</span> <span class="nx">el</span><span class="p">.</span><span class="nx">className</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nx">el</span> <span class="o">=</span> <span class="nx">el</span><span class="p">.</span><span class="nx">parentElement</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>結果：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">DIV.pagefind-ui__drawer
</span></span><span class="line"><span class="ln">2</span><span class="cl">FORM.pagefind-ui__form        ← drawer 在 form 內！
</span></span><span class="line"><span class="ln">3</span><span class="cl">DIV.pagefind-ui
</span></span><span class="line"><span class="ln">4</span><span class="cl">DIV#search</span></span></code></pre></div><p>drawer 是 form 的 child、不是 sibling。我們的 grid 規則把 form（含 drawer 全部結果）放在 row 2、scope 放 row 3 — scope 自然跑到所有結果之後。</p>
<h3 id="執行">執行</h3>
<p>確認 DOM 後改用「scope absolute 浮在 form 上、drawer 用 margin-top 讓位」的策略 — 不再嘗試把 form 與 drawer 拆到不同 grid row。</p>
<hr>
<h2 id="內在屬性比較拓樸推理的可靠性">內在屬性比較：拓樸推理的可靠性</h2>
<table>
  <thead>
      <tr>
          <th>推理來源</th>
          <th>可靠性</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Live DOM（playwright / DevTools）</td>
          <td>最高 — 反映實際渲染</td>
          <td>Debug、整合外部組件</td>
      </tr>
      <tr>
          <td>框架 source / template</td>
          <td>高 — 靜態結構</td>
          <td>自家組件、可讀的 source</td>
      </tr>
      <tr>
          <td>Class name 命名規則</td>
          <td>低 — 命名是慣例、不是契約</td>
          <td>僅參考、不依賴</td>
      </tr>
      <tr>
          <td>視覺截圖推測</td>
          <td>最低 — 看不到 DOM 包裹層</td>
          <td>不應作為唯一依據</td>
      </tr>
  </tbody>
</table>
<p>選擇順序：<strong>Live DOM &gt; source &gt; 命名 &gt; 視覺</strong>。Class name 與視覺只能形成假設、必須用前兩者驗證。</p>
<hr>
<h2 id="display-contents-的拓樸限制">display: contents 的拓樸限制</h2>
<p>當決定用 <code>display: contents</code> 串接讓子元素參與外層 grid，必須注意：<strong>contents 只能讓直接子節點上去、不能跨越多層 box</strong>。</p>
<p>例：要讓 form 內的 drawer 參與 search-shell 的 grid，需要 form 也設 <code>display: contents</code>。但 form 設 contents 後：</p>
<ul>
<li>form 自己的 box 消失</li>
<li>依賴 form 為 offset parent 的子元素（如 absolute 定位的 clear button）失去定位基準</li>
<li>form 的 <code>::before</code> / <code>::after</code> 偽元素可能不渲染</li>
</ul>
<p><strong>display: contents 適用條件</strong>：中間層 box 沒有自己的視覺責任（背景、邊框、定位、尺寸） — 否則拆開後視覺破壞。</p>
<hr>
<h2 id="設計取捨拓樸理解的方法">設計取捨：拓樸理解的方法</h2>
<p>四種做法、各自機會成本不同。這個專案選 A（讀 live DOM）當預設、其他做法在特定情境合理。</p>
<h3 id="a讀-live-domplaywright--devtools這個專案的預設">A：讀 live DOM（playwright / DevTools）（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：用 <code>playwright browser_evaluate</code> 讀 ancestor chain、computed style、bounding rect；或 DevTools Elements 面板手動探索</li>
<li><strong>選 A 的理由</strong>：反映實際渲染結果、跨 framework 都對、可寫成測試</li>
<li><strong>適合</strong>：debug、整合外部組件、寫第一版 CSS 之前</li>
<li><strong>代價</strong>：需要 server 跑著（可用 hugo dev / static server）</li>
</ul>
<h3 id="b讀框架-source--template">B：讀框架 source / template</h3>
<ul>
<li><strong>機制</strong>：直接看 svelte / react component 的 template</li>
<li><strong>跟 A 的取捨</strong>：B 看靜態結構、A 看 runtime 結構；B 對自家組件夠用、對動態渲染（runtime wrapper / portal）會漏</li>
<li><strong>B 比 A 好的情境</strong>：自家組件、template 跟 DOM 1:1 對應、不需要 runtime 確認</li>
</ul>
<h3 id="c用-class-name-命名規則推測">C：用 class name 命名規則推測</h3>
<ul>
<li><strong>機制</strong>：看 <code>.parent__child</code> 推測 DOM 巢狀</li>
<li><strong>跟 A 的取捨</strong>：C 完全不需要工具、A 需要 server；但 C 命名是慣例不是契約、容易錯</li>
<li><strong>C 才合理的情境</strong>：初步假設、必須用 A/B 驗證後才能寫 CSS — 不應作為唯一依據</li>
</ul>
<h3 id="d視覺截圖推測">D：視覺截圖推測</h3>
<ul>
<li><strong>機制</strong>：看截圖猜 DOM 結構</li>
<li><strong>成本特別高的原因</strong>：截圖看不到 wrapper、看不到 display: contents 等不可視結構</li>
<li><strong>D 是反模式</strong>：視覺上看起來相同的 DOM 可能完全不同 — 截圖驗收的盲區會在規則寫了不生效時才被發現、debug 成本指數放大</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>可能的根因</th>
          <th>第一個該嘗試的動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>寫好的 CSS 規則完全沒生效</td>
          <td>元素根本不在預期的 DOM 位置</td>
          <td>用 playwright <code>browser_evaluate</code> 讀 ancestor chain</td>
      </tr>
      <tr>
          <td>Grid / flex 排序與預期不符</td>
          <td>子元素不是直接 grid item</td>
          <td>確認 grid container 的 direct children</td>
      </tr>
      <tr>
          <td>設了 <code>display: contents</code> 後某些定位元素跑掉</td>
          <td>那層 box 是 absolute 元素的 offset parent</td>
          <td>把該層 box 留下、找其他方式達成 layout</td>
      </tr>
      <tr>
          <td>框架重繪後 layout 完全變了</td>
          <td>框架增加了 wrapper 元素</td>
          <td>重新讀 live DOM、更新 CSS 假設</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：CSS 行為與預期不符 ≥ 1 次，先回去看 DOM tree、不要繼續調 CSS 規則。先看才不會試錯。</p>
]]></content:encoded></item><item><title>客製 UI 留 framework 邊界外、用 CSS 控制視覺位置</title><link>https://tarrragon.github.io/blog/report/coexisting-with-framework-managed-dom/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/coexisting-with-framework-managed-dom/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>客製 UI 留在 framework 管轄的 DOM 邊界外、用 CSS（absolute、margin spacer、grid）達成想要的視覺位置。&lt;/strong> 注入 framework 子樹的客製元素會被 reconciliation 清掉、跟渲染週期競爭、行為不可預測。邊界外的客製跟 framework 解耦、命運由我們自己決定。&lt;/p>
&lt;blockquote>
&lt;p>本篇焦點：客製 UI 該放哪。&lt;strong>framework 元件本身需要動（搬節點、改順序、改 attribute）的安全規則&lt;/strong>由 &lt;a href="../component-boundary-and-js-impact/">#13 JS 操作 framework 元件：邊界辨識與安全規則&lt;/a> 處理。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="為什麼-framework-管轄區會清外來節點">為什麼 framework 管轄區會清外來節點&lt;/h2>
&lt;h3 id="reconciliation-機制">Reconciliation 機制&lt;/h3>
&lt;p>Svelte / React 等框架透過「component tree → DOM tree」的 reconciliation 機制保持 UI 與 state 同步。框架在 patch 時：&lt;/p>
&lt;ol>
&lt;li>比對 component tree 的當前狀態與目標狀態&lt;/li>
&lt;li>計算 DOM 需要的最小變動&lt;/li>
&lt;li>套用變動到實際 DOM&lt;/li>
&lt;/ol>
&lt;p>關鍵是步驟 2：&lt;strong>框架只認得自己 create 的節點&lt;/strong>。外來節點（我們手動 appendChild 進去的）不在它的 component tree 裡、被視為「該節點不該存在」、清掉。&lt;/p>
&lt;p>這不是 bug、是 reconciliation 的正常行為 — 框架要保證 DOM 跟 component state 一致、外來節點屬於不一致的部分。&lt;/p>
&lt;h3 id="外來節點的命運是不可預測的">外來節點的命運是不可預測的&lt;/h3>
&lt;p>不同框架 / 不同 reconciliation 策略對外來節點的處理：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>框架&lt;/th>
 &lt;th>外來節點命運&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Svelte&lt;/td>
 &lt;td>多數情境清掉、視 patch 點而定&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>React&lt;/td>
 &lt;td>通常清掉（Virtual DOM diff 時）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Vue&lt;/td>
 &lt;td>通常清掉、但 v-pre 包裹可保留&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Web Components&lt;/td>
 &lt;td>由 component 內部邏輯決定&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>「不可預測」本身就是問題&lt;/strong> — 即使某次測試沒清、下次升級或 patch 時可能清。設計時不該依賴未明確保證的行為。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的具體情境">這次任務的具體情境&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>要把搜尋範圍切換 UI（scope radio group）放在「pagefind 搜尋輸入框與結果之間」 — 視覺上希望它就在 form 與 drawer 中間。&lt;/p>
&lt;p>第一次嘗試：JS 把 scope element 用 &lt;code>form.insertAdjacentElement('afterend', scopeEl)&lt;/code> 注入 &lt;code>.pagefind-ui&lt;/code> 內部。&lt;/p>
&lt;p>結果：使用者打字後 scope 消失。&lt;/p>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>Pagefind 用 svelte 構建 UI、reactivity 監聽 search query 變動。Query 改變時 svelte 會 patch &lt;code>.pagefind-ui&lt;/code> 的子樹 — 我們注入的 scope 不是 svelte 認得的節點、被視為差異清掉。&lt;/p>
&lt;h3 id="執行邊界外--css-控制位置">執行：邊界外 + CSS 控制位置&lt;/h3>
&lt;p>策略改為「scope 留在 &lt;code>.search-shell&lt;/code> 裡（framework 邊界外）、用 CSS absolute 浮在 form 上」：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-html" data-lang="html">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;search-shell&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">h1&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>...&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">h1&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;search-scope&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>...&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span> &lt;span class="c">&amp;lt;!-- 邊界外、永不被清 --&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;search&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>...&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span> &lt;span class="c">&amp;lt;!-- pagefind 進來這裡 --&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">search-shell&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">position&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">relative&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">search-scope&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">position&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">absolute&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="k">top&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">calc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nf">var&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="n">search&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">title&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">h&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nf">var&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="n">search&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">form&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">h&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">4&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="c">/* ... */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">search-shell&lt;/span> &lt;span class="p">.&lt;/span>&lt;span class="nc">pagefind-ui__drawer&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> &lt;span class="k">margin-top&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">calc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nf">var&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="n">search&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">scope&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">h&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">8&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="c">/* 為 scope 讓位 */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>scope 不在 svelte 管轄區、永遠不被清；視覺位置靠 absolute + drawer 的 margin-top 共同決定。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>客製 UI 留在 framework 管轄的 DOM 邊界外、用 CSS（absolute、margin spacer、grid）達成想要的視覺位置。</strong> 注入 framework 子樹的客製元素會被 reconciliation 清掉、跟渲染週期競爭、行為不可預測。邊界外的客製跟 framework 解耦、命運由我們自己決定。</p>
<blockquote>
<p>本篇焦點：客製 UI 該放哪。<strong>framework 元件本身需要動（搬節點、改順序、改 attribute）的安全規則</strong>由 <a href="../component-boundary-and-js-impact/">#13 JS 操作 framework 元件：邊界辨識與安全規則</a> 處理。</p></blockquote>
<hr>
<h2 id="為什麼-framework-管轄區會清外來節點">為什麼 framework 管轄區會清外來節點</h2>
<h3 id="reconciliation-機制">Reconciliation 機制</h3>
<p>Svelte / React 等框架透過「component tree → DOM tree」的 reconciliation 機制保持 UI 與 state 同步。框架在 patch 時：</p>
<ol>
<li>比對 component tree 的當前狀態與目標狀態</li>
<li>計算 DOM 需要的最小變動</li>
<li>套用變動到實際 DOM</li>
</ol>
<p>關鍵是步驟 2：<strong>框架只認得自己 create 的節點</strong>。外來節點（我們手動 appendChild 進去的）不在它的 component tree 裡、被視為「該節點不該存在」、清掉。</p>
<p>這不是 bug、是 reconciliation 的正常行為 — 框架要保證 DOM 跟 component state 一致、外來節點屬於不一致的部分。</p>
<h3 id="外來節點的命運是不可預測的">外來節點的命運是不可預測的</h3>
<p>不同框架 / 不同 reconciliation 策略對外來節點的處理：</p>
<table>
  <thead>
      <tr>
          <th>框架</th>
          <th>外來節點命運</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Svelte</td>
          <td>多數情境清掉、視 patch 點而定</td>
      </tr>
      <tr>
          <td>React</td>
          <td>通常清掉（Virtual DOM diff 時）</td>
      </tr>
      <tr>
          <td>Vue</td>
          <td>通常清掉、但 v-pre 包裹可保留</td>
      </tr>
      <tr>
          <td>Web Components</td>
          <td>由 component 內部邏輯決定</td>
      </tr>
  </tbody>
</table>
<p><strong>「不可預測」本身就是問題</strong> — 即使某次測試沒清、下次升級或 patch 時可能清。設計時不該依賴未明確保證的行為。</p>
<hr>
<h2 id="這次任務的具體情境">這次任務的具體情境</h2>
<h3 id="觀察">觀察</h3>
<p>要把搜尋範圍切換 UI（scope radio group）放在「pagefind 搜尋輸入框與結果之間」 — 視覺上希望它就在 form 與 drawer 中間。</p>
<p>第一次嘗試：JS 把 scope element 用 <code>form.insertAdjacentElement('afterend', scopeEl)</code> 注入 <code>.pagefind-ui</code> 內部。</p>
<p>結果：使用者打字後 scope 消失。</p>
<h3 id="判讀">判讀</h3>
<p>Pagefind 用 svelte 構建 UI、reactivity 監聽 search query 變動。Query 改變時 svelte 會 patch <code>.pagefind-ui</code> 的子樹 — 我們注入的 scope 不是 svelte 認得的節點、被視為差異清掉。</p>
<h3 id="執行邊界外--css-控制位置">執行：邊界外 + CSS 控制位置</h3>
<p>策略改為「scope 留在 <code>.search-shell</code> 裡（framework 邊界外）、用 CSS absolute 浮在 form 上」：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;search-shell&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="p">&lt;</span><span class="nt">h1</span><span class="p">&gt;</span>...<span class="p">&lt;/</span><span class="nt">h1</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;search-scope&#34;</span><span class="p">&gt;</span>...<span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>      <span class="c">&lt;!-- 邊界外、永不被清 --&gt;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;search&#34;</span><span class="p">&gt;</span>...<span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>               <span class="c">&lt;!-- pagefind 進來這裡 --&gt;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">search-shell</span> <span class="p">{</span> <span class="k">position</span><span class="p">:</span> <span class="kc">relative</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">search-scope</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">position</span><span class="p">:</span> <span class="kc">absolute</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">top</span><span class="p">:</span> <span class="nb">calc</span><span class="p">(</span><span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">search</span><span class="o">-</span><span class="n">title</span><span class="o">-</span><span class="n">h</span><span class="p">)</span> <span class="o">+</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">search</span><span class="o">-</span><span class="n">form</span><span class="o">-</span><span class="n">h</span><span class="p">)</span> <span class="o">+</span> <span class="mi">4</span><span class="kt">px</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="c">/* ... */</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">.</span><span class="nc">search-shell</span> <span class="p">.</span><span class="nc">pagefind-ui__drawer</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="k">margin-top</span><span class="p">:</span> <span class="nb">calc</span><span class="p">(</span><span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">search</span><span class="o">-</span><span class="n">scope</span><span class="o">-</span><span class="n">h</span><span class="p">)</span> <span class="o">+</span> <span class="mi">8</span><span class="kt">px</span><span class="p">);</span>  <span class="c">/* 為 scope 讓位 */</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>scope 不在 svelte 管轄區、永遠不被清；視覺位置靠 absolute + drawer 的 margin-top 共同決定。</p>
<hr>
<h2 id="css-達成視覺位置的設計工具">CSS 達成視覺位置的設計工具</h2>
<h3 id="工具-1absolute--容器-relative">工具 1：Absolute + 容器 relative</h3>
<p>把客製 UI 設 <code>position: absolute</code>、容器設 <code>position: relative</code> 當定位基準。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">search-shell</span> <span class="p">{</span> <span class="k">position</span><span class="p">:</span> <span class="kc">relative</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">search-custom</span> <span class="p">{</span> <span class="k">position</span><span class="p">:</span> <span class="kc">absolute</span><span class="p">;</span> <span class="k">top</span><span class="p">:</span> <span class="o">...</span><span class="p">;</span> <span class="k">left</span><span class="p">:</span> <span class="o">...</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><p>客製 UI 跟 framework 元素脫離 layout flow、各自獨立。</p>
<h3 id="工具-2margin-spacer-推開-framework-元素">工具 2：Margin spacer 推開 framework 元素</h3>
<p>要在 framework 元素之間插入空間放客製 UI、改 framework 元素的 margin / padding 推出空間：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">framework-element</span> <span class="p">{</span> <span class="k">margin-top</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">custom</span><span class="o">-</span><span class="n">height</span><span class="p">);</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">custom-ui</span> <span class="p">{</span> <span class="k">position</span><span class="p">:</span> <span class="kc">absolute</span><span class="p">;</span> <span class="k">top</span><span class="p">:</span> <span class="mi">0</span><span class="p">;</span> <span class="k">height</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">custom</span><span class="o">-</span><span class="n">height</span><span class="p">);</span> <span class="p">}</span></span></span></code></pre></div><p>framework 元素留出空間、客製 UI 浮在空間上。</p>
<h3 id="工具-3grid-容器讓-framework-元件當-grid-item">工具 3：Grid 容器讓 framework 元件當 grid item</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">search-shell</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">display</span><span class="p">:</span> <span class="k">grid</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">grid-template-rows</span><span class="p">:</span> <span class="kc">auto</span> <span class="kc">auto</span> <span class="mi">1</span><span class="n">fr</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">.</span><span class="nc">search-shell</span> <span class="o">&gt;</span> <span class="p">.</span><span class="nc">search-scope</span> <span class="p">{</span> <span class="k">grid-row</span><span class="p">:</span> <span class="mi">2</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">.</span><span class="nc">search-shell</span> <span class="o">&gt;</span> <span class="p">#</span><span class="nn">search</span> <span class="p">{</span> <span class="k">grid-row</span><span class="p">:</span> <span class="mi">3</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><p>把 framework 元件當 grid 的一個 item — grid 控制 layout、framework 不知道有 grid 在外層、繼續管它的子樹。</p>
<h3 id="工具-4用-css-variables-共享尺寸">工具 4：用 CSS variables 共享尺寸</h3>
<p>framework 元素的尺寸需要參考客製 UI 時、用 CSS variable 傳遞：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">:</span><span class="nd">root</span> <span class="p">{</span> <span class="nv">--custom-height</span><span class="p">:</span> <span class="mi">60</span><span class="kt">px</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">framework-element</span> <span class="p">{</span> <span class="k">margin-top</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">custom</span><span class="o">-</span><span class="n">height</span><span class="p">);</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">.</span><span class="nc">custom-ui</span> <span class="p">{</span> <span class="k">height</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">custom</span><span class="o">-</span><span class="n">height</span><span class="p">);</span> <span class="p">}</span></span></span></code></pre></div><p>或用 ResizeObserver 量測寫回 variable（<a href="../runtime-measurement-unification/">#27 runtime 量測模式統一</a>）。</p>
<hr>
<h2 id="設計取捨客製-ui-的位置選擇">設計取捨：客製 UI 的位置選擇</h2>
<p>四種做法、各自機會成本不同。這個專案選 A（邊界外 + CSS）當預設、其他做法在特定情境合理。</p>
<h3 id="aframework-邊界外--css-視覺定位這個專案的預設">A：framework 邊界外 + CSS 視覺定位（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：客製 UI 放在 framework 元件的 sibling 位置、用 CSS absolute / grid / margin spacer 達成視覺位置</li>
<li><strong>選 A 的理由</strong>：跟 framework reconciliation 完全解耦、命運由自己決定、升級不影響</li>
<li><strong>適合</strong>：絕大多數需要在 framework UI 旁 / 上 / 下加客製內容的情境</li>
<li><strong>代價</strong>：CSS 定位邏輯比 DOM 巢狀複雜、需要正確處理 stacking context / z-index</li>
</ul>
<h3 id="bframework-邊界外--js-量測位置">B：framework 邊界外 + JS 量測位置</h3>
<ul>
<li><strong>機制</strong>：用 ResizeObserver 量 framework 元素的 bounding rect、JS 算出客製 UI 該擺哪</li>
<li><strong>跟 A 的取捨</strong>：A 用 CSS 表達靜態關係、B 處理 runtime 才知道的尺寸；B 多一層 JS、但能達成 CSS 表達不出的精確定位</li>
<li><strong>B 比 A 好的情境</strong>：客製 UI 位置依賴 framework 元件的 runtime 尺寸（內容換行、字型變化）</li>
</ul>
<h3 id="cframework-邊界內注入">C：framework 邊界內注入</h3>
<ul>
<li><strong>機制</strong>：JS 把客製 element 直接 appendChild 到 framework 子樹內</li>
<li><strong>跟 A 的取捨</strong>：C 看似省事（少一層 wrapper）、實際把客製命運綁在 framework reconciliation 上</li>
<li><strong>C 才合理的情境</strong>：該 framework 子樹確認「不會被 reconcile」（極罕見、需要讀框架 source 確認）</li>
<li><strong>代價</strong>：客製可能在任何 patch 時消失、需要 MutationObserver 補打、跟渲染週期賽跑</li>
</ul>
<h3 id="dfork-framework-source">D：Fork framework source</h3>
<ul>
<li><strong>機制</strong>：fork 整個 framework、改 reconciliation 行為讓它認得我們的客製</li>
<li><strong>成本特別高的原因</strong>：每次升級都要重新 merge、客製永久綁在 fork 版本</li>
<li><strong>D 才合理的情境</strong>：framework 已停止維護、且客製需求超過所有其他選項</li>
</ul>
<hr>
<h2 id="不該套用邊界外的情境">不該套用「邊界外」的情境</h2>
<p>A 是預設、但不是萬靈丹：</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼不適合 A</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>客製內容必須在 framework 元件的內部視覺脈絡內（共享 inline flow）</td>
          <td>Absolute 跳出 flow、達不到 inline 的視覺效果</td>
      </tr>
      <tr>
          <td>Framework 元件本身就是要客製化（改 row、改 cell）</td>
          <td>動的是 framework 本身、不是「在旁邊加東西」</td>
      </tr>
      <tr>
          <td>Framework 提供了官方擴展介面（slot、render prop）</td>
          <td>用官方介面更穩、不需要邊界外 hack</td>
      </tr>
      <tr>
          <td>客製需要訪問 framework 的內部 state</td>
          <td>邊界外的客製跟內部 state 隔離、訪問成本高</td>
      </tr>
  </tbody>
</table>
<p><strong>核心判準</strong>：客製是「在 framework 旁邊加東西」還是「改 framework 本身」？前者用本策略、後者另想辦法。</p>
<hr>
<h2 id="跟其他原則的關係">跟其他原則的關係</h2>
<table>
  <thead>
      <tr>
          <th>抽象層原則</th>
          <th>關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="../external-component-collaboration-layers/">#45 跟外部組件合作的層次</a></td>
          <td>「邊界外 + CSS」是「不要挖 framework 內部」的具體應用 — 客製貼著外部介面（DOM sibling）做、不挖內部</td>
      </tr>
      <tr>
          <td><a href="../two-occurrence-threshold/">#42 2 次門檻</a></td>
          <td>第 1 次注入失敗（被清掉）= 第 2 次該換策略到邊界外、不該繼續嘗試「換種方式注入」</td>
      </tr>
      <tr>
          <td><a href="../component-boundary-and-js-impact/">#13 JS 操作 framework 元件：邊界辨識與安全規則</a></td>
          <td>互補關係 — 本篇處理「客製 UI 該放哪」、#13 處理「framework 元件本身要動時怎麼動」</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該怎麼處理</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>注入 framework DOM 的元素在使用者互動後消失</td>
          <td>把該元素搬出 framework 邊界、用 CSS 控制視覺位置</td>
      </tr>
      <tr>
          <td>客製 UI 在 framework 更新後 attribute 被 revert</td>
          <td>客製 UI 不該在 framework 內、wrapper 在外、attribute 套 wrapper</td>
      </tr>
      <tr>
          <td>看不出哪些 DOM 是 framework 管的</td>
          <td>讀 framework 的 mount root、從那裡往內都是管轄區</td>
      </tr>
      <tr>
          <td>Stacking context 衝突、z-index 失靈</td>
          <td>確認 absolute 的 containing block 是預期的 relative parent</td>
      </tr>
      <tr>
          <td>Framework 元件位置不固定、客製 UI 對不齊</td>
          <td>用 ResizeObserver 量 framework 元素、寫回 CSS variable</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：客製 UI 的存活壽命 = 「離 framework 管轄區多遠」。最遠 = 永遠不被清；注入內部 = 隨時可能消失。預設選邊界外、不要為了「省一層 wrapper」進入 framework 領地。</p>
]]></content:encoded></item><item><title>置中元件與絕對定位元件並存：用疊層而非排擠</title><link>https://tarrragon.github.io/blog/report/centered-and-positioned-coexistence/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/centered-and-positioned-coexistence/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>置中元件與指定位置元件共存，正確做法是讓兩者位於不同 layout 層。&lt;/strong> Layout 流負責「以內容驅動的尺寸與置中」；絕對定位負責「貼在 layout 流之上的固定位置元件」。兩者用疊層共存、不互相排擠。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼排擠式做法行不通">為什麼排擠式做法行不通&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>排擠式：把兩個元件放進同一個 grid / flex container、各佔一個欄位。問題在於「容器寬度有限、加一個欄位就壓縮另一個」 — 中央欄想置中時，加 sidebar 後整個 layout 重新分佈、中央欄被推到非置中位置。&lt;/p>
&lt;p>疊層式：sidebar 用 &lt;code>position: absolute&lt;/code> 從 layout 流跳出 — 中央欄看不到 sidebar、繼續按自己的規則置中。&lt;/p>
&lt;h3 id="兩種共存模式比較">兩種共存模式比較&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>模式&lt;/th>
 &lt;th>中央欄置中&lt;/th>
 &lt;th>維護成本&lt;/th>
 &lt;th>適用情境&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>排擠式（同 layout 流）&lt;/td>
 &lt;td>受 sidebar 影響、需要重算&lt;/td>
 &lt;td>低 — 純 CSS&lt;/td>
 &lt;td>兩個元件都要自然撐開&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>疊層式（absolute）&lt;/td>
 &lt;td>不受影響、永遠按 viewport 置中&lt;/td>
 &lt;td>中 — absolute 需要定位基準&lt;/td>
 &lt;td>中央欄要嚴格置中、sidebar 是「附加」&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>選擇疊層式的時機：&lt;strong>中央欄的位置是設計重點、sidebar 是補充&lt;/strong>。本案搜尋頁的 main 是內容主體、filter 是輔助 — 適合疊層。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的實際做法">這次任務的實際做法&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>搜尋頁 desktop layout：&lt;/p>
&lt;ul>
&lt;li>&lt;code>&amp;lt;main&amp;gt;&lt;/code> 寬度 70ch、theme 預設 &lt;code>margin-inline: auto&lt;/code> 置中&lt;/li>
&lt;li>想加 filter sidebar 在 main 左外側、寬度 400px&lt;/li>
&lt;/ul>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>把 filter 放進 main 的 grid（變成 main 的子 column），main 內容會被推到右半邊、不再置中。&lt;/p>
&lt;p>讓 filter 用 &lt;code>position: absolute&lt;/code> 相對 main 定位 — main 完全不知道 filter 存在、繼續置中。filter 在 main 外側「貼著」main 的左邊緣。&lt;/p>
&lt;h3 id="執行">執行&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="nt">body&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nc">page-search&lt;/span> &lt;span class="nt">main&lt;/span>&lt;span class="p">#&lt;/span>&lt;span class="nn">main-content&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">position&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">relative&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c">/* filter 的 offset parent */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="c">/* main 的 max-width: 70ch 與 margin-inline: auto 由 theme 提供，不動 */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">search-filter-slot&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">position&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">absolute&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">top&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">right&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">calc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">100&lt;/span>&lt;span class="kt">%&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">2&lt;/span>&lt;span class="kt">rem&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="c">/* main 的左外側、間距 2rem */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">width&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">400&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>right: calc(100% + 2rem)&lt;/code> 的含義：filter 的右邊緣 = main 左邊緣 - 2rem。filter 從這個 anchor 往左展開 400px。&lt;/p>
&lt;p>main 永遠按 viewport 置中、filter 永遠貼在 main 左外側。&lt;/p>
&lt;hr>
&lt;h2 id="疊層共存的三個關鍵要素">疊層共存的三個關鍵要素&lt;/h2>
&lt;h3 id="1-offset-parent-的選擇">1. Offset parent 的選擇&lt;/h3>
&lt;p>絕對定位元件的座標相對於「最近的 positioned ancestor」。要讓 sidebar 跟著 main 一起移動（不要跟 viewport 走），就把 main 設為 &lt;code>position: relative&lt;/code> 當作 offset parent。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nt">body&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nc">page-search&lt;/span> &lt;span class="nt">main&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">position&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">relative&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">search-filter-slot&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">position&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">absolute&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c">/* 相對 main */&lt;/span> &lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="2-anchor-點的選擇">2. Anchor 點的選擇&lt;/h3>
&lt;p>&lt;code>right: calc(100% + 2rem)&lt;/code>、&lt;code>left: -432px&lt;/code>、&lt;code>right: 100%; margin-right: 2rem&lt;/code> 三種寫法視覺上等價。選擇可讀性最高的 — 通常是 &lt;code>right: calc(100% + 2rem)&lt;/code>，意義最直接（「我的右緣 = parent 寬度 + 2rem 之外」）。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>置中元件與指定位置元件共存，正確做法是讓兩者位於不同 layout 層。</strong> Layout 流負責「以內容驅動的尺寸與置中」；絕對定位負責「貼在 layout 流之上的固定位置元件」。兩者用疊層共存、不互相排擠。</p>
<hr>
<h2 id="為什麼排擠式做法行不通">為什麼排擠式做法行不通</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>排擠式：把兩個元件放進同一個 grid / flex container、各佔一個欄位。問題在於「容器寬度有限、加一個欄位就壓縮另一個」 — 中央欄想置中時，加 sidebar 後整個 layout 重新分佈、中央欄被推到非置中位置。</p>
<p>疊層式：sidebar 用 <code>position: absolute</code> 從 layout 流跳出 — 中央欄看不到 sidebar、繼續按自己的規則置中。</p>
<h3 id="兩種共存模式比較">兩種共存模式比較</h3>
<table>
  <thead>
      <tr>
          <th>模式</th>
          <th>中央欄置中</th>
          <th>維護成本</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>排擠式（同 layout 流）</td>
          <td>受 sidebar 影響、需要重算</td>
          <td>低 — 純 CSS</td>
          <td>兩個元件都要自然撐開</td>
      </tr>
      <tr>
          <td>疊層式（absolute）</td>
          <td>不受影響、永遠按 viewport 置中</td>
          <td>中 — absolute 需要定位基準</td>
          <td>中央欄要嚴格置中、sidebar 是「附加」</td>
      </tr>
  </tbody>
</table>
<p>選擇疊層式的時機：<strong>中央欄的位置是設計重點、sidebar 是補充</strong>。本案搜尋頁的 main 是內容主體、filter 是輔助 — 適合疊層。</p>
<hr>
<h2 id="這次任務的實際做法">這次任務的實際做法</h2>
<h3 id="觀察">觀察</h3>
<p>搜尋頁 desktop layout：</p>
<ul>
<li><code>&lt;main&gt;</code> 寬度 70ch、theme 預設 <code>margin-inline: auto</code> 置中</li>
<li>想加 filter sidebar 在 main 左外側、寬度 400px</li>
</ul>
<h3 id="判讀">判讀</h3>
<p>把 filter 放進 main 的 grid（變成 main 的子 column），main 內容會被推到右半邊、不再置中。</p>
<p>讓 filter 用 <code>position: absolute</code> 相對 main 定位 — main 完全不知道 filter 存在、繼續置中。filter 在 main 外側「貼著」main 的左邊緣。</p>
<h3 id="執行">執行</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">body</span><span class="p">.</span><span class="nc">page-search</span> <span class="nt">main</span><span class="p">#</span><span class="nn">main-content</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="k">position</span><span class="p">:</span> <span class="kc">relative</span><span class="p">;</span>   <span class="c">/* filter 的 offset parent */</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="c">/* main 的 max-width: 70ch 與 margin-inline: auto 由 theme 提供，不動 */</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">.</span><span class="nc">search-filter-slot</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="k">position</span><span class="p">:</span> <span class="kc">absolute</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="k">top</span><span class="p">:</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="k">right</span><span class="p">:</span> <span class="nb">calc</span><span class="p">(</span><span class="mi">100</span><span class="kt">%</span> <span class="o">+</span> <span class="mi">2</span><span class="kt">rem</span><span class="p">);</span>   <span class="c">/* main 的左外側、間距 2rem */</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="k">width</span><span class="p">:</span> <span class="mi">400</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>right: calc(100% + 2rem)</code> 的含義：filter 的右邊緣 = main 左邊緣 - 2rem。filter 從這個 anchor 往左展開 400px。</p>
<p>main 永遠按 viewport 置中、filter 永遠貼在 main 左外側。</p>
<hr>
<h2 id="疊層共存的三個關鍵要素">疊層共存的三個關鍵要素</h2>
<h3 id="1-offset-parent-的選擇">1. Offset parent 的選擇</h3>
<p>絕對定位元件的座標相對於「最近的 positioned ancestor」。要讓 sidebar 跟著 main 一起移動（不要跟 viewport 走），就把 main 設為 <code>position: relative</code> 當作 offset parent。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="nt">body</span><span class="p">.</span><span class="nc">page-search</span> <span class="nt">main</span> <span class="p">{</span> <span class="k">position</span><span class="p">:</span> <span class="kc">relative</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">search-filter-slot</span> <span class="p">{</span> <span class="k">position</span><span class="p">:</span> <span class="kc">absolute</span><span class="p">;</span> <span class="c">/* 相對 main */</span> <span class="p">}</span></span></span></code></pre></div><h3 id="2-anchor-點的選擇">2. Anchor 點的選擇</h3>
<p><code>right: calc(100% + 2rem)</code>、<code>left: -432px</code>、<code>right: 100%; margin-right: 2rem</code> 三種寫法視覺上等價。選擇可讀性最高的 — 通常是 <code>right: calc(100% + 2rem)</code>，意義最直接（「我的右緣 = parent 寬度 + 2rem 之外」）。</p>
<h3 id="3-物理空間檢查">3. 物理空間檢查</h3>
<p>絕對定位不檢查 viewport 邊界 — sidebar 可能被推到 viewport 外。需要在 breakpoint 確認「viewport 夠寬時才顯示 sidebar」：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">search-filter-slot</span> <span class="p">{</span> <span class="k">display</span><span class="p">:</span> <span class="kc">none</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">@</span><span class="k">media</span> <span class="o">(</span><span class="nt">min-width</span><span class="o">:</span> <span class="nt">1400px</span><span class="o">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">.</span><span class="nc">search-filter-slot</span> <span class="p">{</span> <span class="k">display</span><span class="p">:</span> <span class="kc">block</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>物理空間預算的細節參見〈跨 viewport 雙模式 UI 的物理空間預算〉。</p>
<hr>
<h2 id="內在屬性比較兩種共存做法">內在屬性比較：兩種共存做法</h2>
<table>
  <thead>
      <tr>
          <th>屬性</th>
          <th>排擠式（grid / flex）</th>
          <th>疊層式（absolute）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>中央欄位置</td>
          <td>隨 sidebar 寬度變動</td>
          <td>不受影響、嚴格置中</td>
      </tr>
      <tr>
          <td>Sidebar 寬度限制</td>
          <td>來自 grid container</td>
          <td>來自 viewport（需 breakpoint 控制）</td>
      </tr>
      <tr>
          <td>Layout 重算成本</td>
          <td>改 sidebar 寬度時 main 跟著動</td>
          <td>main 永遠不動</td>
      </tr>
      <tr>
          <td>適用情境</td>
          <td>兩個元件都要自然撐開</td>
          <td>中央嚴格置中、sidebar 附加</td>
      </tr>
  </tbody>
</table>
<p>選擇順序：<strong>先確認中央欄的位置要求</strong>。要嚴格置中 → 疊層式；可隨 sidebar 浮動 → 排擠式。</p>
<hr>
<h2 id="設計取捨兩元件共存的-layout-策略">設計取捨：兩元件共存的 layout 策略</h2>
<p>四種做法、各自機會成本不同。這個專案選 A（疊層 absolute）當預設、其他做法在特定情境合理。</p>
<h3 id="a疊層式中央-layout-flow--附加-absolute這個專案的預設">A：疊層式（中央 layout flow + 附加 absolute）（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：嚴格置中的元件留 layout flow、附加元件 <code>position: absolute</code> 跳出 flow</li>
<li><strong>選 A 的理由</strong>：中央位置不受附加元件影響、改附加元件不需重算中央</li>
<li><strong>適合</strong>：中央嚴格置中 + sidebar 是「附加」的場景（搜尋頁、文章閱讀頁）</li>
<li><strong>代價</strong>：absolute 需要明確 offset parent、要做物理空間檢查避免溢出</li>
</ul>
<h3 id="b排擠式同-layout-flowgrid--flex">B：排擠式（同 layout flow、grid / flex）</h3>
<ul>
<li><strong>機制</strong>：把兩元件放進同一 grid / flex container、各佔一個欄位</li>
<li><strong>跟 A 的取捨</strong>：B 兩元件都自然撐開、A 中央嚴格置中；B 改一邊另一邊跟著動、A 完全解耦</li>
<li><strong>B 比 A 好的情境</strong>：兩元件都是內容主體、需要互相適應寬度（dashboard 多面板）</li>
</ul>
<h3 id="cfixed相對-viewport">C：Fixed（相對 viewport）</h3>
<ul>
<li><strong>機制</strong>：附加元件 <code>position: fixed</code>、永遠在 viewport 同位置</li>
<li><strong>跟 A 的取捨</strong>：C 永遠可見（隨 scroll 不動）、A 跟內容一起 scroll；C 適合 always-on UI</li>
<li><strong>C 比 A 好的情境</strong>：浮動操作鈕、頂部 nav、使用者隨時要 access 的元件</li>
</ul>
<h3 id="d重新設計-layout取消共存需求">D：重新設計 layout（取消共存需求）</h3>
<ul>
<li><strong>機制</strong>：把附加元件搬到完全不同位置（footer、modal、抽屜）</li>
<li><strong>跟 A/B/C 的取捨</strong>：D 完全避開共存問題、A/B/C 解決共存</li>
<li><strong>D 比 A 好的情境</strong>：附加元件使用頻率低（offset 很大價值低）— 例如 settings 改放 modal 內</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>可能的根因</th>
          <th>第一個該嘗試的動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>加了 sidebar 後中央內容不再置中</td>
          <td>用了排擠式、中央欄被推</td>
          <td>改用 absolute、main 設 <code>position: relative</code></td>
      </tr>
      <tr>
          <td>Absolute sidebar 跟著 viewport 跑、不貼 main</td>
          <td>沒設 offset parent</td>
          <td>給 main 加 <code>position: relative</code></td>
      </tr>
      <tr>
          <td>Sidebar 在窄 viewport 溢出畫面</td>
          <td>沒做物理空間檢查</td>
          <td>加 breakpoint、寬度不夠時 <code>display: none</code></td>
      </tr>
      <tr>
          <td>改 sidebar 寬度時要回頭調 main 樣式</td>
          <td>排擠式造成 layout 耦合</td>
          <td>改用疊層、main 永遠不需要因 sidebar 改</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：兩個元件的視覺關係用疊層描述、不用排擠描述。疊層 = 兩層獨立 = 改一邊不影響另一邊。</p>
]]></content:encoded></item><item><title>排版精度的工具選擇：CSS-only vs JS-assisted</title><link>https://tarrragon.github.io/blog/report/css-only-vs-js-assisted/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/css-only-vs-js-assisted/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>CSS 處理 build-time 可決定的 layout、JS 處理 runtime 才知道的尺寸與 stateful DOM 移動。&lt;/strong> 邊界誤判：硬要 CSS 解決 runtime 問題會反覆試錯；硬要 JS 解決 layout 問題會跟 framework 渲染競爭。&lt;/p>
&lt;p>選擇問題簡化為兩問：&lt;/p>
&lt;ol>
&lt;li>這個值在 build time 能定下來嗎？能 → CSS；不能 → JS 量測寫回 CSS 變數。&lt;/li>
&lt;li>這個 DOM 變動是 framework 管的嗎？是 → 不要動；不是 → JS 可動。&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="為什麼分工是必要的">為什麼分工是必要的&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>CSS 的設計假設是「規則在 build time 決定、瀏覽器渲染時應用」。CSS 沒有 reactive 機制 — 沒辦法「等元素渲染完才知道高度然後對齊」。&lt;/p>
&lt;p>JS 的設計假設是「runtime 可以讀寫 DOM 與 style」。JS 可以在元件渲染後量測尺寸、可以隨 viewport 變動 reparent 節點。&lt;/p>
&lt;p>&lt;strong>用錯工具不只「不太優雅」、是直接做不到&lt;/strong>。要 CSS 解決動態尺寸只能寫 magic number（猜的）；要 JS 解決靜態 layout 寫了一堆 imperative 代碼還可能跟 framework 衝突。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的工具分配">這次任務的工具分配&lt;/h2>
&lt;h3 id="css-處理的部分">CSS 處理的部分&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>任務&lt;/th>
 &lt;th>CSS 寫法&lt;/th>
 &lt;th>為什麼用 CSS&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>H1 / search input 的固定高度&lt;/td>
 &lt;td>&lt;code>height: 64px&lt;/code> 寫死&lt;/td>
 &lt;td>Build time 可決定的設計 token&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>搜尋頁主欄置中、breakpoint 切換&lt;/td>
 &lt;td>&lt;code>@media (min-width: 1400px)&lt;/code>&lt;/td>
 &lt;td>純宣告式 layout&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Filter sidebar absolute 定位&lt;/td>
 &lt;td>&lt;code>position: absolute; right: calc(100% + 2rem)&lt;/code>&lt;/td>
 &lt;td>靜態定位關係&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Drawer 留出 scope 空間&lt;/td>
 &lt;td>&lt;code>margin-top: calc(var(--search-scope-h) + 8px)&lt;/code>&lt;/td>
 &lt;td>引用變數的 calc&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="js-處理的部分">JS 處理的部分&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>任務&lt;/th>
 &lt;th>JS 寫法&lt;/th>
 &lt;th>為什麼用 JS&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>量測 scope 高度寫回 CSS 變數&lt;/td>
 &lt;td>ResizeObserver&lt;/td>
 &lt;td>Runtime 才知道（字型、換行）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Filter sidebar 切換到 mobile drawer&lt;/td>
 &lt;td>matchMedia + appendChild&lt;/td>
 &lt;td>跨 viewport 的 stateful DOM 移動&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Scope filter（regex 比對標題 / 內文）&lt;/td>
 &lt;td>event listener + setProperty&lt;/td>
 &lt;td>純 runtime 邏輯、無 build time 解&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Scope UI 寫死值與量測值的橋&lt;/td>
 &lt;td>&lt;code>style.setProperty('--search-scope-h', ...)&lt;/code>&lt;/td>
 &lt;td>JS 寫回讓 CSS 用&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="兩問判斷法">兩問判斷法&lt;/h2>
&lt;h3 id="問-1這個值在-build-time-能定下來嗎">問 1：這個值在 build time 能定下來嗎&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>值&lt;/th>
 &lt;th>Build time 知道嗎&lt;/th>
 &lt;th>工具&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>設計 token（spacing、typography scale）&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>CSS 變數寫死&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>元件固定尺寸（icon size、button height）&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>CSS height / width&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>響應式 breakpoint&lt;/td>
 &lt;td>是（設計決定）&lt;/td>
 &lt;td>&lt;code>@media&lt;/code> query&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>動態文字塊高度（受字型 / 換行）&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>JS ResizeObserver&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>元件位置（隨 viewport 變化）&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>JS getBoundingClientRect&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>知道 → CSS 解。不知道 → JS 量測寫回 CSS 變數、CSS 從變數計算。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>CSS 處理 build-time 可決定的 layout、JS 處理 runtime 才知道的尺寸與 stateful DOM 移動。</strong> 邊界誤判：硬要 CSS 解決 runtime 問題會反覆試錯；硬要 JS 解決 layout 問題會跟 framework 渲染競爭。</p>
<p>選擇問題簡化為兩問：</p>
<ol>
<li>這個值在 build time 能定下來嗎？能 → CSS；不能 → JS 量測寫回 CSS 變數。</li>
<li>這個 DOM 變動是 framework 管的嗎？是 → 不要動；不是 → JS 可動。</li>
</ol>
<hr>
<h2 id="為什麼分工是必要的">為什麼分工是必要的</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>CSS 的設計假設是「規則在 build time 決定、瀏覽器渲染時應用」。CSS 沒有 reactive 機制 — 沒辦法「等元素渲染完才知道高度然後對齊」。</p>
<p>JS 的設計假設是「runtime 可以讀寫 DOM 與 style」。JS 可以在元件渲染後量測尺寸、可以隨 viewport 變動 reparent 節點。</p>
<p><strong>用錯工具不只「不太優雅」、是直接做不到</strong>。要 CSS 解決動態尺寸只能寫 magic number（猜的）；要 JS 解決靜態 layout 寫了一堆 imperative 代碼還可能跟 framework 衝突。</p>
<hr>
<h2 id="這次任務的工具分配">這次任務的工具分配</h2>
<h3 id="css-處理的部分">CSS 處理的部分</h3>
<table>
  <thead>
      <tr>
          <th>任務</th>
          <th>CSS 寫法</th>
          <th>為什麼用 CSS</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>H1 / search input 的固定高度</td>
          <td><code>height: 64px</code> 寫死</td>
          <td>Build time 可決定的設計 token</td>
      </tr>
      <tr>
          <td>搜尋頁主欄置中、breakpoint 切換</td>
          <td><code>@media (min-width: 1400px)</code></td>
          <td>純宣告式 layout</td>
      </tr>
      <tr>
          <td>Filter sidebar absolute 定位</td>
          <td><code>position: absolute; right: calc(100% + 2rem)</code></td>
          <td>靜態定位關係</td>
      </tr>
      <tr>
          <td>Drawer 留出 scope 空間</td>
          <td><code>margin-top: calc(var(--search-scope-h) + 8px)</code></td>
          <td>引用變數的 calc</td>
      </tr>
  </tbody>
</table>
<h3 id="js-處理的部分">JS 處理的部分</h3>
<table>
  <thead>
      <tr>
          <th>任務</th>
          <th>JS 寫法</th>
          <th>為什麼用 JS</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>量測 scope 高度寫回 CSS 變數</td>
          <td>ResizeObserver</td>
          <td>Runtime 才知道（字型、換行）</td>
      </tr>
      <tr>
          <td>Filter sidebar 切換到 mobile drawer</td>
          <td>matchMedia + appendChild</td>
          <td>跨 viewport 的 stateful DOM 移動</td>
      </tr>
      <tr>
          <td>Scope filter（regex 比對標題 / 內文）</td>
          <td>event listener + setProperty</td>
          <td>純 runtime 邏輯、無 build time 解</td>
      </tr>
      <tr>
          <td>Scope UI 寫死值與量測值的橋</td>
          <td><code>style.setProperty('--search-scope-h', ...)</code></td>
          <td>JS 寫回讓 CSS 用</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="兩問判斷法">兩問判斷法</h2>
<h3 id="問-1這個值在-build-time-能定下來嗎">問 1：這個值在 build time 能定下來嗎</h3>
<table>
  <thead>
      <tr>
          <th>值</th>
          <th>Build time 知道嗎</th>
          <th>工具</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>設計 token（spacing、typography scale）</td>
          <td>是</td>
          <td>CSS 變數寫死</td>
      </tr>
      <tr>
          <td>元件固定尺寸（icon size、button height）</td>
          <td>是</td>
          <td>CSS height / width</td>
      </tr>
      <tr>
          <td>響應式 breakpoint</td>
          <td>是（設計決定）</td>
          <td><code>@media</code> query</td>
      </tr>
      <tr>
          <td>動態文字塊高度（受字型 / 換行）</td>
          <td>否</td>
          <td>JS ResizeObserver</td>
      </tr>
      <tr>
          <td>元件位置（隨 viewport 變化）</td>
          <td>否</td>
          <td>JS getBoundingClientRect</td>
      </tr>
  </tbody>
</table>
<p>知道 → CSS 解。不知道 → JS 量測寫回 CSS 變數、CSS 從變數計算。</p>
<h3 id="問-2這個-dom-變動是-framework-管的嗎">問 2：這個 DOM 變動是 framework 管的嗎</h3>
<table>
  <thead>
      <tr>
          <th>變動</th>
          <th>Framework 管</th>
          <th>工具</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>自家 DOM 內元素加 / 移 / 改</td>
          <td>否</td>
          <td>JS 自由動</td>
      </tr>
      <tr>
          <td>Framework 元素的整節點 reparent</td>
          <td>不管內部</td>
          <td>JS 可搬</td>
      </tr>
      <tr>
          <td>Framework 元素內部的子節點</td>
          <td>是</td>
          <td>不要動</td>
      </tr>
      <tr>
          <td>Framework 元素的 attribute</td>
          <td>視 framework 而定</td>
          <td>通常不要動</td>
      </tr>
  </tbody>
</table>
<p>是 → 不要動，用 CSS 視覺解。不是 → JS 可動。</p>
<hr>
<h2 id="內在屬性比較兩種工具的特性">內在屬性比較：兩種工具的特性</h2>
<table>
  <thead>
      <tr>
          <th>屬性</th>
          <th>CSS-only</th>
          <th>JS-assisted</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>知識成本</td>
          <td>低（語言簡單）</td>
          <td>中（需要 DOM API）</td>
      </tr>
      <tr>
          <td>執行時機</td>
          <td>渲染前 / 樣式重新計算</td>
          <td>DOMContentLoaded 後 / 事件觸發</td>
      </tr>
      <tr>
          <td>是否阻塞首次渲染</td>
          <td>是（CSS 是 render-blocking）</td>
          <td>否（async）</td>
      </tr>
      <tr>
          <td>Framework 衝突風險</td>
          <td>無</td>
          <td>有（若動到 framework 管的 DOM）</td>
      </tr>
      <tr>
          <td>可維護性</td>
          <td>高（純 declarative）</td>
          <td>中（imperative）</td>
      </tr>
      <tr>
          <td>跨瀏覽器一致性</td>
          <td>高（CSS 標準清楚）</td>
          <td>中（API 差異）</td>
      </tr>
  </tbody>
</table>
<p>優先 CSS — declarative、無 framework 衝突、首次渲染就生效。JS 補 CSS 做不到的部分。</p>
<hr>
<h2 id="邊界誤判的兩種失敗">邊界誤判的兩種失敗</h2>
<h3 id="css-解-runtime-問題">CSS 解 runtime 問題</h3>
<p>例：用 CSS magic number 寫死 scope-h（猜 56px），實際渲染 73.5px、對齊壞掉。</p>
<p>修法：認知到「scope-h 是 runtime 才能知道的值」、改用 ResizeObserver 量測寫回 CSS 變數。</p>
<h3 id="js-解-framework-managed-layout">JS 解 framework-managed layout</h3>
<p>例：用 JS <code>appendChild</code> 把 scope UI 注入 <code>.pagefind-ui</code> 內、Svelte 重繪時清掉。</p>
<p>修法：認知到「<code>.pagefind-ui</code> 是 framework 邊界內」、改用 CSS absolute 把 scope 浮在外部。</p>
<hr>
<h2 id="設計取捨css--js-工具分配策略">設計取捨：CSS / JS 工具分配策略</h2>
<p>四種做法、各自機會成本不同。這個專案以 A（CSS-first、JS 透過變數補）為主、其他做法在特定情境合理。</p>
<h3 id="acss-firstjs-透過-css-變數補-runtime-值這個專案的預設">A：CSS-first、JS 透過 CSS 變數補 runtime 值（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：CSS 處理 build time 可決定的 layout、JS 量測 runtime 值寫回 CSS 變數、CSS 從變數計算</li>
<li><strong>選 A 的理由</strong>：CSS 是 declarative 不阻塞首次渲染、跟 framework 不衝突；變數是 JS-CSS 介面、單向資料流</li>
<li><strong>適合</strong>：絕大多數客製情境</li>
<li><strong>代價</strong>：需要明確分辨「值能在 build time 定下來嗎」、JS 寫法要是「寫回變數」而不是「直接改 inline style」</li>
</ul>
<h3 id="b純-css-only無-js">B：純 CSS-only（無 JS）</h3>
<ul>
<li><strong>機制</strong>：純宣告式、不寫 JS</li>
<li><strong>跟 A 的取捨</strong>：B 完全無 JS 維護成本、A 多一層 JS；但 B 對 runtime 才知道的尺寸（內容動態）束手無策</li>
<li><strong>B 比 A 好的情境</strong>：所有值都能 build time 確定、layout 完全靜態</li>
</ul>
<h3 id="cjs-imperative-layout每次重算">C：JS-imperative layout（每次重算）</h3>
<ul>
<li><strong>機制</strong>：JS 監聽變動、每次重新計算所有元素位置、寫 inline style</li>
<li><strong>成本特別高的原因</strong>：imperative 代碼難維護、首次渲染慢（要等 JS 跑）、跟 framework 渲染衝突</li>
<li><strong>C 才合理的情境</strong>：CSS 表達不出的複雜 layout（極罕見、現代 CSS 已涵蓋大多數）</li>
</ul>
<h3 id="dcss-magic-number-估算-runtime-值">D：CSS magic number 估算 runtime 值</h3>
<ul>
<li><strong>機制</strong>：CSS 內寫死「應該差不多」的值（不量測）</li>
<li><strong>跟 A 的取捨</strong>：D 看似省 JS、實際對齊在邊界情境（字型 / theme）必壞</li>
<li><strong>D 是反模式</strong>：「runtime 才知道的值」用 CSS 估算是錯誤工具選擇 — 對齊在邊界情境（字型 / theme）必壞</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>工具誤用方向</th>
          <th>修正動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CSS 寫了 magic number、改字型後對不齊</td>
          <td>用 CSS 解 runtime 問題</td>
          <td>量測該值、改 ResizeObserver 寫回變數</td>
      </tr>
      <tr>
          <td>JS 寫了 100+ 行做 layout</td>
          <td>用 JS 解靜態 layout 問題</td>
          <td>退回 CSS、用 grid / flex / absolute 達成</td>
      </tr>
      <tr>
          <td>JS 改 framework DOM 後，framework 更新就失效</td>
          <td>JS 動到 framework 管的領域</td>
          <td>改用 CSS 視覺定位、不動 framework DOM</td>
      </tr>
      <tr>
          <td>Inline style 散落多處難 debug</td>
          <td>JS 直接寫 style 而非透過變數</td>
          <td>重構成「JS 寫 CSS 變數、CSS 從變數計算」</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：選工具不是品味問題、是「值能不能在 build time 定下來」「DOM 是不是我管」兩個技術問題的答案。問清楚再選。</p>
]]></content:encoded></item><item><title>CSS Layers 取代 specificity 戰</title><link>https://tarrragon.github.io/blog/report/css-layers-over-specificity/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/css-layers-over-specificity/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>CSS Layers 把樣式覆寫從「線性 specificity 數字戰」改成「分組權重順序」。&lt;/strong> 把外部組件 CSS &lt;code>@import&lt;/code> 進一個 layer、自家 CSS 留在 unlayered，自家規則自動贏 — 不論個別 selector specificity 數值。一次設定、所有 &lt;code>!important&lt;/code> 與 &lt;code>.x.x&lt;/code> 雙寫 hack 可以拿掉。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼-specificity-戰沒有贏家">為什麼 specificity 戰沒有贏家&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>CSS specificity 是線性數字比較。組件作者用 &lt;code>.x.svelte-yyy.svelte-yyy&lt;/code> 雙寫 specificity 30 → 自家用 &lt;code>.search-shell .x&lt;/code> specificity 20 蓋不過 → 加 &lt;code>.x.x&lt;/code> 雙寫到 30 → 還是看 source order → 加 &lt;code>!important&lt;/code> → 跟其他 important 對撞 → 寫死多層 fallback。&lt;/p>
&lt;p>每加一層覆寫成本累積、未來 debug 越來越難。每個 &lt;code>!important&lt;/code> 都是一個 future debugging burden、&lt;code>!important&lt;/code> 之間沒有層級可言。&lt;/p>
&lt;h3 id="css-layers-的解法">CSS Layers 的解法&lt;/h3>
&lt;p>CSS &lt;code>@layer&lt;/code> 提供「分組權重」 — unlayered CSS &amp;gt; layered CSS（layer 越早宣告越低權）、跟 selector specificity 無關：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">unlayered { ... } ← 最高權
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">@layer high { ... }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">@layer medium { ... }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">@layer low { ... } ← 最低權&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>把組件 CSS 整包丟進某個 layer、自家 CSS 留 unlayered、自家規則自動贏所有組件規則 — &lt;strong>不論 specificity&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的覆寫戰場">這次任務的覆寫戰場&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>現在 &lt;code>search.html&lt;/code> 內為了蓋過 pagefind specificity 30 的寫法：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">pagefind-ui__filter-block&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">border-bottom&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span> &lt;span class="cp">!important&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">pagefind-ui__filter-panel&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">display&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">none&lt;/span> &lt;span class="cp">!important&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">search-filter-slot&lt;/span> &lt;span class="nt">fieldset&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">border&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="k">padding&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="k">margin&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每條都靠 &lt;code>!important&lt;/code> 或 source order 取勝。可維護性低。&lt;/p>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>把 pagefind-ui.css 用 &lt;code>@import&lt;/code> 包進 layer：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">@&lt;/span>&lt;span class="k">import&lt;/span> &lt;span class="nt">url&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="s2">&amp;#34;/blog/pagefind/pagefind-ui.css&amp;#34;&lt;/span>&lt;span class="o">)&lt;/span> &lt;span class="nt">layer&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nt">pagefind&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>自家 CSS 不加 layer 宣告、留 unlayered。自家規則優先級自動高於 layer(pagefind)。&lt;/p>
&lt;h3 id="執行refactor-步驟">執行：refactor 步驟&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c">/* search.html / assets/search.css */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="c">/* 把 pagefind 的整包 CSS 包進 layer */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="p">@&lt;/span>&lt;span class="k">import&lt;/span> &lt;span class="nt">url&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="s2">&amp;#34;/blog/pagefind/pagefind-ui.css&amp;#34;&lt;/span>&lt;span class="o">)&lt;/span> &lt;span class="nt">layer&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nt">pagefind&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c">/* 自家 CSS 留 unlayered、自動贏 */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">pagefind-ui__filter-block&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">border-bottom&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c">/* 不需要 !important */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">pagefind-ui__filter-panel&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">display&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">none&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c">/* 不需要 !important */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="p">@&lt;/span>&lt;span class="k">media&lt;/span> &lt;span class="o">(&lt;/span>&lt;span class="nt">min-width&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nt">1400px&lt;/span>&lt;span class="o">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="nc">pagefind-ui__filter-panel&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">display&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">none&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>原本的 &lt;code>&amp;lt;link href=&amp;quot;...pagefind-ui.css&amp;quot; rel=&amp;quot;stylesheet&amp;quot;&amp;gt;&lt;/code> 改成上方 &lt;code>@import&lt;/code> 寫法、確保 import 在自家 CSS 之前發生（layered CSS 不會阻擋 unlayered CSS 的優先級）。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>CSS Layers 把樣式覆寫從「線性 specificity 數字戰」改成「分組權重順序」。</strong> 把外部組件 CSS <code>@import</code> 進一個 layer、自家 CSS 留在 unlayered，自家規則自動贏 — 不論個別 selector specificity 數值。一次設定、所有 <code>!important</code> 與 <code>.x.x</code> 雙寫 hack 可以拿掉。</p>
<hr>
<h2 id="為什麼-specificity-戰沒有贏家">為什麼 specificity 戰沒有贏家</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>CSS specificity 是線性數字比較。組件作者用 <code>.x.svelte-yyy.svelte-yyy</code> 雙寫 specificity 30 → 自家用 <code>.search-shell .x</code> specificity 20 蓋不過 → 加 <code>.x.x</code> 雙寫到 30 → 還是看 source order → 加 <code>!important</code> → 跟其他 important 對撞 → 寫死多層 fallback。</p>
<p>每加一層覆寫成本累積、未來 debug 越來越難。每個 <code>!important</code> 都是一個 future debugging burden、<code>!important</code> 之間沒有層級可言。</p>
<h3 id="css-layers-的解法">CSS Layers 的解法</h3>
<p>CSS <code>@layer</code> 提供「分組權重」 — unlayered CSS &gt; layered CSS（layer 越早宣告越低權）、跟 selector specificity 無關：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">unlayered { ... }              ← 最高權
</span></span><span class="line"><span class="ln">2</span><span class="cl">@layer high { ... }
</span></span><span class="line"><span class="ln">3</span><span class="cl">@layer medium { ... }
</span></span><span class="line"><span class="ln">4</span><span class="cl">@layer low { ... }             ← 最低權</span></span></code></pre></div><p>把組件 CSS 整包丟進某個 layer、自家 CSS 留 unlayered、自家規則自動贏所有組件規則 — <strong>不論 specificity</strong>。</p>
<hr>
<h2 id="這次任務的覆寫戰場">這次任務的覆寫戰場</h2>
<h3 id="觀察">觀察</h3>
<p>現在 <code>search.html</code> 內為了蓋過 pagefind specificity 30 的寫法：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">pagefind-ui__filter-block</span> <span class="p">{</span> <span class="k">border-bottom</span><span class="p">:</span> <span class="mi">0</span> <span class="cp">!important</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">pagefind-ui__filter-panel</span> <span class="p">{</span> <span class="k">display</span><span class="p">:</span> <span class="kc">none</span> <span class="cp">!important</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">.</span><span class="nc">search-filter-slot</span> <span class="nt">fieldset</span> <span class="p">{</span> <span class="k">border</span><span class="p">:</span> <span class="mi">0</span><span class="p">;</span> <span class="k">padding</span><span class="p">:</span> <span class="mi">0</span><span class="p">;</span> <span class="k">margin</span><span class="p">:</span> <span class="mi">0</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><p>每條都靠 <code>!important</code> 或 source order 取勝。可維護性低。</p>
<h3 id="判讀">判讀</h3>
<p>把 pagefind-ui.css 用 <code>@import</code> 包進 layer：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">@</span><span class="k">import</span> <span class="nt">url</span><span class="o">(</span><span class="s2">&#34;/blog/pagefind/pagefind-ui.css&#34;</span><span class="o">)</span> <span class="nt">layer</span><span class="o">(</span><span class="nt">pagefind</span><span class="o">)</span><span class="p">;</span></span></span></code></pre></div><p>自家 CSS 不加 layer 宣告、留 unlayered。自家規則優先級自動高於 layer(pagefind)。</p>
<h3 id="執行refactor-步驟">執行：refactor 步驟</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c">/* search.html / assets/search.css */</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c">/* 把 pagefind 的整包 CSS 包進 layer */</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="p">@</span><span class="k">import</span> <span class="nt">url</span><span class="o">(</span><span class="s2">&#34;/blog/pagefind/pagefind-ui.css&#34;</span><span class="o">)</span> <span class="nt">layer</span><span class="o">(</span><span class="nt">pagefind</span><span class="o">)</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c">/* 自家 CSS 留 unlayered、自動贏 */</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">.</span><span class="nc">pagefind-ui__filter-block</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="k">border-bottom</span><span class="p">:</span> <span class="mi">0</span><span class="p">;</span>            <span class="c">/* 不需要 !important */</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">.</span><span class="nc">pagefind-ui__filter-panel</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="k">display</span><span class="p">:</span> <span class="kc">none</span><span class="p">;</span>               <span class="c">/* 不需要 !important */</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">@</span><span class="k">media</span> <span class="o">(</span><span class="nt">min-width</span><span class="o">:</span> <span class="nt">1400px</span><span class="o">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="p">.</span><span class="nc">pagefind-ui__filter-panel</span> <span class="p">{</span> <span class="k">display</span><span class="p">:</span> <span class="kc">none</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>原本的 <code>&lt;link href=&quot;...pagefind-ui.css&quot; rel=&quot;stylesheet&quot;&gt;</code> 改成上方 <code>@import</code> 寫法、確保 import 在自家 CSS 之前發生（layered CSS 不會阻擋 unlayered CSS 的優先級）。</p>
<hr>
<h2 id="內在屬性比較四種-specificity-應對">內在屬性比較：四種 specificity 應對</h2>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>維護成本</th>
          <th>可讀性</th>
          <th>升級兼容性</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>!important</code> 對抗</td>
          <td>高 — 每加一條未來 debug 成本上升</td>
          <td>低 — 不知為什麼要 important</td>
          <td>中 — 組件變更可能讓 important 用錯</td>
      </tr>
      <tr>
          <td>雙寫 class（<code>.x.x</code>）</td>
          <td>中 — selector 看起來奇怪</td>
          <td>低 — 維護者不知為什麼</td>
          <td>中 — 組件改 class 名就失效</td>
      </tr>
      <tr>
          <td>Inline style + setProperty important</td>
          <td>高 — 散落在 JS 各處</td>
          <td>最低 — 不在 CSS 找不到</td>
          <td>低 — JS 規則容易被 framework 重繪打破</td>
      </tr>
      <tr>
          <td>CSS Layers</td>
          <td>低 — 一次設定、規則簡單</td>
          <td>高 — 結構化分層</td>
          <td>高 — 跟組件升級無關</td>
      </tr>
  </tbody>
</table>
<p><strong>Layers 的所有指標都最佳</strong>。其他三種是 Layers 之前的 workaround、現在沒理由繼續用。</p>
<hr>
<h2 id="layers-的進階用法">Layers 的進階用法</h2>
<h3 id="多個外部組件分別-layer">多個外部組件分別 layer</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">@</span><span class="k">import</span> <span class="nt">url</span><span class="o">(</span><span class="s2">&#34;vendor-a.css&#34;</span><span class="o">)</span> <span class="nt">layer</span><span class="o">(</span><span class="nt">vendor-a</span><span class="o">)</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">@</span><span class="k">import</span> <span class="nt">url</span><span class="o">(</span><span class="s2">&#34;vendor-b.css&#34;</span><span class="o">)</span> <span class="nt">layer</span><span class="o">(</span><span class="nt">vendor-b</span><span class="o">)</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">@</span><span class="k">layer</span> <span class="nt">vendor-a</span><span class="o">,</span> <span class="nt">vendor-b</span><span class="p">;</span>   <span class="c">/* 後宣告的優先 */</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c">/* 自家 unlayered */</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">.</span><span class="nc">my-overrides</span> <span class="p">{</span> <span class="err">...</span> <span class="p">}</span></span></span></code></pre></div><p><code>@layer name1, name2;</code> 顯式宣告 layer 順序、後宣告的權重高。</p>
<h3 id="自家-css-也分層">自家 CSS 也分層</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="p">@</span><span class="k">layer</span> <span class="nt">base</span><span class="o">,</span> <span class="nt">components</span><span class="o">,</span> <span class="nt">utilities</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="p">@</span><span class="k">layer</span> <span class="nt">base</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nt">body</span> <span class="p">{</span> <span class="k">font-family</span><span class="p">:</span> <span class="o">...</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">@</span><span class="k">layer</span> <span class="nt">components</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="p">.</span><span class="nc">button</span> <span class="p">{</span> <span class="k">padding</span><span class="p">:</span> <span class="o">...</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="p">@</span><span class="k">layer</span> <span class="nt">utilities</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="p">.</span><span class="nc">text-center</span> <span class="p">{</span> <span class="k">text-align</span><span class="p">:</span> <span class="kc">center</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>自家 CSS 內部也分層、避免 utilities 被 components 蓋過。</p>
<h3 id="跟-unlayered-並存">跟 unlayered 並存</h3>
<p>不是所有自家 CSS 都要分 layer。<strong>最高優先的自家規則留 unlayered、其他規則可以分層</strong>。</p>
<hr>
<h2 id="瀏覽器支援">瀏覽器支援</h2>
<p>CSS Layers 在所有主流瀏覽器（Chrome 99+、Firefox 97+、Safari 15.4+）支援、2022 年起。當前（2026）所有現代瀏覽器都支援。</p>
<p>對舊瀏覽器降級：不支援 <code>@layer</code> 的瀏覽器會把整個 <code>@layer { ... }</code> block 當作 invalid 跳過 — 自家 unlayered 規則仍然適用、效果一樣（但 vendor CSS 完全失效）。實務上不需要擔心。</p>
<hr>
<h2 id="設計取捨覆寫外部組件-css-的策略">設計取捨：覆寫外部組件 CSS 的策略</h2>
<p>四種做法、各自機會成本不同。這個專案選 A（CSS Layers）當預設、其他做法在特定情境合理。</p>
<h3 id="acss-layers這個專案的預設">A：CSS Layers（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：<code>@import url(...) layer(vendor)</code> 把外部 CSS 包進低權層、自家 unlayered CSS 自動贏</li>
<li><strong>選 A 的理由</strong>：跨組件升級穩定、規則簡單、<code>!important</code> 完全不需要、跳出 specificity 線性比較戰場</li>
<li><strong>適合</strong>：所有現代瀏覽器（Chrome 99+ / Firefox 97+ / Safari 15.4+）的客製情境</li>
<li><strong>代價</strong>：需要重新引入 vendor CSS（從 <code>&lt;link&gt;</code> 改 <code>@import</code>）</li>
</ul>
<h3 id="b雙寫-class-提升-specificity">B：雙寫 class 提升 specificity</h3>
<ul>
<li><strong>機制</strong>：<code>.pagefind-ui__filter-block.pagefind-ui__filter-block</code> 寫兩次提升 specificity 從 10 到 20</li>
<li><strong>跟 A 的取捨</strong>：B 不需要改 vendor CSS 引入方式、A 需要；但 B 跟組件 specificity 競賽（組件作者改 hash 寫法就壞）、A 跳出競賽</li>
<li><strong>B 是反模式</strong>：跟組件 specificity 競賽（組件作者改 hash 寫法就壞） — 唯一例外是 vendor CSS 不能用 <code>@import</code> 引入（極罕見的 build pipeline 限制）</li>
</ul>
<h3 id="cimportant-對抗">C：<code>!important</code> 對抗</h3>
<ul>
<li><strong>機制</strong>：每條覆寫加 <code>!important</code>、用 importance 取勝</li>
<li><strong>跟 A 的取捨</strong>：C 短期有效、長期 important 之間沒層級可言；多個 important 對撞時 debug 困難</li>
<li><strong>C 才合理的情境</strong>：CSS Layers 不支援的舊瀏覽器（&lt; 2022 的版本）、且確認沒其他 important 對撞</li>
</ul>
<h3 id="dinline-style--setpropertyimportant">D：Inline style + <code>setProperty('important')</code></h3>
<ul>
<li><strong>機制</strong>：JS 用 <code>el.style.setProperty('display', 'none', 'important')</code></li>
<li><strong>成本特別高的原因</strong>：規則散落在 JS 各處、devtools 看不出意圖、跟 framework 重繪競爭</li>
<li><strong>D 才合理的情境</strong>：動態值（runtime 算的位置 / 尺寸）必須用 inline 表達 — 但即使這樣、也建議用 class toggle + CSS 變數（<a href="../class-toggle-over-important/">#28</a>）取代</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>Refactor 動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>為了蓋過組件規則寫了 <code>!important</code></td>
          <td>評估改用 CSS Layers</td>
      </tr>
      <tr>
          <td>Selector 寫成 <code>.x.x</code> 雙寫只為了 specificity</td>
          <td>評估改用 CSS Layers</td>
      </tr>
      <tr>
          <td>覆寫邏輯散落在多個檔案 / inline style</td>
          <td>集中到一份 CSS、用 layers 分層</td>
      </tr>
      <tr>
          <td>組件升級後覆寫失效</td>
          <td>用 layers 隔離、跟組件 specificity 變動脫鉤</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：跟組件 CSS 競爭 specificity 是不必要的戰爭。Layers 提供更高層的權重機制、把覆寫簡化成「自家 vs 別人」的二元決定。</p>
]]></content:encoded></item><item><title>CSS 變數定義位置統一</title><link>https://tarrragon.github.io/blog/report/css-variable-single-location/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/css-variable-single-location/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>CSS 變數的定義位置只能有一處。&lt;/strong> 一次定義在離 root 最近的合適 selector（&lt;code>:root&lt;/code> 或頁面層級的 body class），其他地方只用 &lt;code>var()&lt;/code> 引用、不重複宣告。改 token 只動一處、所有引用點自動跟上。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼定義位置要單一">為什麼定義位置要單一&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>CSS 變數的價值是「單一來源、多處引用」。把定義散在多個 selector：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">:&lt;/span>&lt;span class="nd">root&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nv">--search-title-h&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">64&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">search-shell&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nv">--pagefind-ui-scale&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mf">1.0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="nt">body&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nc">page-search&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nv">--search-form-h&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">68&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每個變數的「真相」分散在不同位置 — 改一個 token 要先 grep 找到定義位置、可能漏改。&lt;/p>
&lt;p>更嚴重：同名變數在不同 selector 重複定義時、值依 cascade 順序決定 — 維護者不易看出哪個值生效。&lt;/p>
&lt;h3 id="統一定義的位置選擇">統一定義的位置選擇&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>位置&lt;/th>
 &lt;th>適用情境&lt;/th>
 &lt;th>影響範圍&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>:root&lt;/code>&lt;/td>
 &lt;td>全站適用的 design token&lt;/td>
 &lt;td>全站&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>body.page-X&lt;/code>&lt;/td>
 &lt;td>特定頁面類型適用&lt;/td>
 &lt;td>該類型頁面&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>.component-name&lt;/code>&lt;/td>
 &lt;td>特定 component 內適用&lt;/td>
 &lt;td>該 component 子樹&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>選擇原則：&lt;strong>定義在「跟使用範圍最匹配的最高層級」selector&lt;/strong>。全站用 &lt;code>:root&lt;/code>、頁面類型用 body class、組件內用組件 class。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的散落問題">這次任務的散落問題&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>&lt;code>search.html&lt;/code> 內 CSS 變數定義散在三處：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="nt">body&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nc">page-search&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nv">--search-title-h&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">64&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nv">--search-form-h&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">68&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nv">--search-gap&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">20&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="p">:&lt;/span>&lt;span class="nd">root&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nv">--search-scope-h&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">60&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c">/* JS 量測會覆寫 */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">search-shell&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nv">--pagefind-ui-scale&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mf">1.0&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>三處定義 — 雖然各有理由（body 範圍、JS 寫入點、cascade 給 pagefind），但維護者要知道「改 search-form-h 在哪改」需要全文 grep。&lt;/p>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>整理後集中在 &lt;code>body.page-search&lt;/code>（搜尋頁的 root selector）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="nt">body&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nc">page-search&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="c">/* 設計 token：寫死值 */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nv">--search-title-h&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">64&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nv">--search-form-h&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">68&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nv">--search-gap&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">20&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="c">/* JS 量測寫入 fallback：JS 會用 setProperty 覆寫到 :root */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nv">--search-scope-h&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">60&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="c">/* 給 pagefind cascade 的 scale */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nv">--pagefind-ui-scale&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mf">1.0&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>一個 selector 看到所有 search 相關 token、cascade 到子樹生效。&lt;/p>
&lt;h3 id="執行">執行&lt;/h3>
&lt;p>JS 量測寫入 scope-h 時、寫到 &lt;code>body.page-search&lt;/code> 而非 &lt;code>:root&lt;/code>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">syncScopeHeight&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">h&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">scopeEl&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">offsetHeight&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="mi">56&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">body&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">style&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setProperty&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;--search-scope-h&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">h&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s1">&amp;#39;px&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>寫到 body.style 直接覆蓋 body.page-search 的 fallback 值。Cascade 到所有後代生效。&lt;/p>
&lt;hr>
&lt;h2 id="變數命名與分類">變數命名與分類&lt;/h2>
&lt;h3 id="命名前綴標明範圍">命名前綴標明範圍&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>前綴&lt;/th>
 &lt;th>範圍&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>--token-*&lt;/code> 或無前綴&lt;/td>
 &lt;td>全站設計 token（顏色、字型）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>--page-search-*&lt;/code>&lt;/td>
 &lt;td>搜尋頁專用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>--pagefind-ui-*&lt;/code>&lt;/td>
 &lt;td>Pagefind 提供的 hook（不是我們命名、是組件預期）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>前綴讓維護者一眼看出變數的「歸屬」、不會誤改別處變數。&lt;/p>
&lt;h3 id="分類定義">分類定義&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="nt">body&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nc">page-search&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="c">/* === 對齊 token === */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nv">--search-title-h&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">64&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nv">--search-form-h&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">68&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nv">--search-gap&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">20&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nv">--search-scope-h&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">60&lt;/span>&lt;span class="kt">px&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c">/* JS 寫入 */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="c">/* === 響應式 breakpoint === */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="c">/* (CSS 變數無法用在 @media query、breakpoint 寫死在 query 內) */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="c">/* === 對組件的 hook === */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nv">--pagefind-ui-scale&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mf">1.0&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>分類註解讓維護者知道「我要改哪類 token」、找對位置。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>CSS 變數的定義位置只能有一處。</strong> 一次定義在離 root 最近的合適 selector（<code>:root</code> 或頁面層級的 body class），其他地方只用 <code>var()</code> 引用、不重複宣告。改 token 只動一處、所有引用點自動跟上。</p>
<hr>
<h2 id="為什麼定義位置要單一">為什麼定義位置要單一</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>CSS 變數的價值是「單一來源、多處引用」。把定義散在多個 selector：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">:</span><span class="nd">root</span>           <span class="p">{</span> <span class="nv">--search-title-h</span><span class="p">:</span> <span class="mi">64</span><span class="kt">px</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">search-shell</span>   <span class="p">{</span> <span class="nv">--pagefind-ui-scale</span><span class="p">:</span> <span class="mf">1.0</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nt">body</span><span class="p">.</span><span class="nc">page-search</span> <span class="p">{</span> <span class="nv">--search-form-h</span><span class="p">:</span> <span class="mi">68</span><span class="kt">px</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><p>每個變數的「真相」分散在不同位置 — 改一個 token 要先 grep 找到定義位置、可能漏改。</p>
<p>更嚴重：同名變數在不同 selector 重複定義時、值依 cascade 順序決定 — 維護者不易看出哪個值生效。</p>
<h3 id="統一定義的位置選擇">統一定義的位置選擇</h3>
<table>
  <thead>
      <tr>
          <th>位置</th>
          <th>適用情境</th>
          <th>影響範圍</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>:root</code></td>
          <td>全站適用的 design token</td>
          <td>全站</td>
      </tr>
      <tr>
          <td><code>body.page-X</code></td>
          <td>特定頁面類型適用</td>
          <td>該類型頁面</td>
      </tr>
      <tr>
          <td><code>.component-name</code></td>
          <td>特定 component 內適用</td>
          <td>該 component 子樹</td>
      </tr>
  </tbody>
</table>
<p>選擇原則：<strong>定義在「跟使用範圍最匹配的最高層級」selector</strong>。全站用 <code>:root</code>、頁面類型用 body class、組件內用組件 class。</p>
<hr>
<h2 id="這次任務的散落問題">這次任務的散落問題</h2>
<h3 id="觀察">觀察</h3>
<p><code>search.html</code> 內 CSS 變數定義散在三處：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">body</span><span class="p">.</span><span class="nc">page-search</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nv">--search-title-h</span><span class="p">:</span> <span class="mi">64</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nv">--search-form-h</span><span class="p">:</span> <span class="mi">68</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nv">--search-gap</span><span class="p">:</span> <span class="mi">20</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">:</span><span class="nd">root</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="nv">--search-scope-h</span><span class="p">:</span> <span class="mi">60</span><span class="kt">px</span><span class="p">;</span>   <span class="c">/* JS 量測會覆寫 */</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">.</span><span class="nc">search-shell</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="nv">--pagefind-ui-scale</span><span class="p">:</span> <span class="mf">1.0</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>三處定義 — 雖然各有理由（body 範圍、JS 寫入點、cascade 給 pagefind），但維護者要知道「改 search-form-h 在哪改」需要全文 grep。</p>
<h3 id="判讀">判讀</h3>
<p>整理後集中在 <code>body.page-search</code>（搜尋頁的 root selector）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">body</span><span class="p">.</span><span class="nc">page-search</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="c">/* 設計 token：寫死值 */</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nv">--search-title-h</span><span class="p">:</span> <span class="mi">64</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nv">--search-form-h</span><span class="p">:</span> <span class="mi">68</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="nv">--search-gap</span><span class="p">:</span> <span class="mi">20</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="c">/* JS 量測寫入 fallback：JS 會用 setProperty 覆寫到 :root */</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="nv">--search-scope-h</span><span class="p">:</span> <span class="mi">60</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="c">/* 給 pagefind cascade 的 scale */</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="nv">--pagefind-ui-scale</span><span class="p">:</span> <span class="mf">1.0</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>一個 selector 看到所有 search 相關 token、cascade 到子樹生效。</p>
<h3 id="執行">執行</h3>
<p>JS 量測寫入 scope-h 時、寫到 <code>body.page-search</code> 而非 <code>:root</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">syncScopeHeight</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">var</span> <span class="nx">h</span> <span class="o">=</span> <span class="nx">scopeEl</span><span class="p">.</span><span class="nx">offsetHeight</span> <span class="o">||</span> <span class="mi">56</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="s1">&#39;--search-scope-h&#39;</span><span class="p">,</span> <span class="nx">h</span> <span class="o">+</span> <span class="s1">&#39;px&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>寫到 body.style 直接覆蓋 body.page-search 的 fallback 值。Cascade 到所有後代生效。</p>
<hr>
<h2 id="變數命名與分類">變數命名與分類</h2>
<h3 id="命名前綴標明範圍">命名前綴標明範圍</h3>
<table>
  <thead>
      <tr>
          <th>前綴</th>
          <th>範圍</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>--token-*</code> 或無前綴</td>
          <td>全站設計 token（顏色、字型）</td>
      </tr>
      <tr>
          <td><code>--page-search-*</code></td>
          <td>搜尋頁專用</td>
      </tr>
      <tr>
          <td><code>--pagefind-ui-*</code></td>
          <td>Pagefind 提供的 hook（不是我們命名、是組件預期）</td>
      </tr>
  </tbody>
</table>
<p>前綴讓維護者一眼看出變數的「歸屬」、不會誤改別處變數。</p>
<h3 id="分類定義">分類定義</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">body</span><span class="p">.</span><span class="nc">page-search</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="c">/* === 對齊 token === */</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nv">--search-title-h</span><span class="p">:</span> <span class="mi">64</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nv">--search-form-h</span><span class="p">:</span> <span class="mi">68</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="nv">--search-gap</span><span class="p">:</span> <span class="mi">20</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="nv">--search-scope-h</span><span class="p">:</span> <span class="mi">60</span><span class="kt">px</span><span class="p">;</span>     <span class="c">/* JS 寫入 */</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="c">/* === 響應式 breakpoint === */</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="c">/* (CSS 變數無法用在 @media query、breakpoint 寫死在 query 內) */</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="c">/* === 對組件的 hook === */</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="nv">--pagefind-ui-scale</span><span class="p">:</span> <span class="mf">1.0</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>分類註解讓維護者知道「我要改哪類 token」、找對位置。</p>
<hr>
<h2 id="內在屬性比較四種變數定義方式">內在屬性比較：四種變數定義方式</h2>
<table>
  <thead>
      <tr>
          <th>方式</th>
          <th>維護成本</th>
          <th>可見性</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>散在多個 selector 定義</td>
          <td>高 — grep 找定義</td>
          <td>低 — 不知哪個生效</td>
      </tr>
      <tr>
          <td>集中在一個 selector</td>
          <td>低 — 改一處</td>
          <td>高 — 全部變數一覽</td>
      </tr>
      <tr>
          <td>集中 + 分類註解</td>
          <td>低</td>
          <td>最高 — 結構化</td>
      </tr>
      <tr>
          <td>集中 + JS 寫入用同一 selector</td>
          <td>低</td>
          <td>最高 + JS 動態同步</td>
      </tr>
  </tbody>
</table>
<p>優先選「集中 + 分類 + JS 寫入同 selector」。</p>
<hr>
<h2 id="變數的-fallback-策略">變數的 fallback 策略</h2>
<blockquote>
<p><strong>責任邊界</strong>：本節只談「fallback 值寫在哪個 selector」、屬於定義位置議題。「該不該用 runtime 量測」這個更上層的策略選擇由 <a href="../runtime-measurement-unification/">#27 runtime 量測模式統一</a> 處理 — 那邊主張「全寫死 vs 全量測、不要混搭」。</p></blockquote>
<p>JS 量測寫入的變數、CSS 應該有 fallback 值供 JS 還沒跑完時用：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="nt">body</span><span class="p">.</span><span class="nc">page-search</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nv">--search-scope-h</span><span class="p">:</span> <span class="mi">60</span><span class="kt">px</span><span class="p">;</span>   <span class="c">/* fallback、JS 會覆寫 */</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">.</span><span class="nc">search-shell</span> <span class="p">.</span><span class="nc">pagefind-ui__drawer</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="k">margin-top</span><span class="p">:</span> <span class="nb">calc</span><span class="p">(</span><span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">search</span><span class="o">-</span><span class="n">scope</span><span class="o">-</span><span class="n">h</span><span class="p">)</span> <span class="o">+</span> <span class="mi">8</span><span class="kt">px</span><span class="p">);</span>  <span class="c">/* JS 跑完前用 60px */</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>或用 <code>var()</code> 第二參數：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="nt">margin-top</span><span class="o">:</span> <span class="nt">calc</span><span class="o">(</span><span class="nt">var</span><span class="o">(</span><span class="nt">--search-scope-h</span><span class="o">,</span> <span class="nt">60px</span><span class="o">)</span> <span class="o">+</span> <span class="nt">8px</span><span class="o">);</span></span></span></code></pre></div><p>兩種寫法效果相近 — 第一種讓 token 集中在 body.page-search 內、推薦使用。</p>
<hr>
<h2 id="設計取捨css-變數定義位置策略">設計取捨：CSS 變數定義位置策略</h2>
<p>四種做法、各自機會成本不同。這個專案選 A（集中在使用範圍的最高層）當預設、其他做法在特定情境合理。</p>
<blockquote>
<p>本篇是 <a href="../single-source-of-truth/">#44 SSoT</a> 抽象原則在「CSS 變數定義位置」這個面向的應用。</p></blockquote>
<h3 id="a集中在跟使用範圍最匹配的最高層selector這個專案的預設">A：集中在「跟使用範圍最匹配的最高層」selector（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：全站 token 在 <code>:root</code>、頁面 token 在 <code>body.page-X</code>、組件 token 在 <code>.component</code>、JS 寫入也用同 selector</li>
<li><strong>選 A 的理由</strong>：定義住址唯一、改 token 自動跟上、cascade 範圍跟使用範圍一致</li>
<li><strong>適合</strong>：絕大多數 design token 系統</li>
<li><strong>代價</strong>：要先想清楚每個變數的「使用範圍」、不能無腦丟一處</li>
</ul>
<h3 id="b所有變數都丟-root">B：所有變數都丟 <code>:root</code></h3>
<ul>
<li><strong>機制</strong>：不分使用範圍、全部 <code>:root</code></li>
<li><strong>跟 A 的取捨</strong>：B 簡單一致、A 按範圍分；但 B 不在乎 scope、可能跟其他組件變數命名衝突、且 cascade 範圍過大</li>
<li><strong>B 比 A 好的情境</strong>：純 design system token（顏色 / 字型）、確實全站適用</li>
</ul>
<h3 id="c散在多個-selector-各自定義">C：散在多個 selector 各自定義</h3>
<ul>
<li><strong>機制</strong>：每個 component 各自定義需要的變數</li>
<li><strong>跟 A 的取捨</strong>：C 元件自包含、A 集中管理；但 C 同名 token 散落多處、cascade 順序決定值、改一處可能漏其他</li>
<li><strong>C 才合理的情境</strong>：完全獨立的元件、不共用任何 token（罕見）</li>
</ul>
<h3 id="d每處引用點都重複定義">D：每處引用點都重複定義</h3>
<ul>
<li><strong>機制</strong>：用 var 引用前都重新宣告一次</li>
<li><strong>D 是反模式</strong>：徹底違反 SSoT、改 token 要 grep 找全、必漏改 — 重複定義是 magic number 散落的另一種形式</li>
<li><strong>看起來吸引人的原因</strong>：每處就地寫值最快、不用想 token 該定義在哪</li>
<li><strong>實際發生的代價</strong>：未來改值時掃不到全部、UI 出現「有的地方變、有的沒變」的怪 bug</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>Refactor 動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>同名變數在多個 selector 定義</td>
          <td>集中到一個 selector、移除其他</td>
      </tr>
      <tr>
          <td>改一個 token 要 grep 找定義位置</td>
          <td>集中 + 分類註解</td>
      </tr>
      <tr>
          <td>Token 命名沒前綴、跟其他組件變數混</td>
          <td>加範圍前綴（<code>--page-X-*</code>）</td>
      </tr>
      <tr>
          <td>JS 寫入變數的位置跟 CSS 定義不同</td>
          <td>對齊到同一 selector</td>
      </tr>
      <tr>
          <td>變數值在 cascade 中被另一處覆蓋</td>
          <td>找出兩處、決定哪一處保留</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：CSS 變數是設計 token 系統的基礎、定義位置就是 token 的「住址」。住址一個就好、不要一物多址。</p>
<p>「就地寫個值」是便利（不用找 token 位置）、「集中定義 + 引用」是對齊 — 同 <a href="../single-source-of-truth/">#44 SSOT</a> 跟 <a href="../ease-of-writing-vs-intent-alignment/">#67 便利 vs 對齊反相關</a>。</p>
]]></content:encoded></item><item><title>以 class toggle 取代 inline `display: none !important`</title><link>https://tarrragon.github.io/blog/report/class-toggle-over-important/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/class-toggle-over-important/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>JS 改 DOM 元素的視覺狀態、用 class toggle、不用 inline style.setProperty important hack。&lt;/strong> Class toggle 的好處：CSS 規則集中可讀、devtools 看到語意化的 class 名而非神秘 inline style、未來改視覺只動 CSS 不動 JS。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼-class-toggle-比-inline-style-好">為什麼 class toggle 比 inline style 好&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>兩種方式都能達成「JS 控制視覺」、差別在「視覺規則的家在哪」：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>方式&lt;/th>
 &lt;th>視覺規則住址&lt;/th>
 &lt;th>維護成本&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>el.style.display = 'none'&lt;/code>&lt;/td>
 &lt;td>散在 JS 各處&lt;/td>
 &lt;td>高 — 改視覺要找 JS、不在 CSS&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>el.classList.toggle('is-hidden')&lt;/code> + &lt;code>.is-hidden { display: none }&lt;/code>&lt;/td>
 &lt;td>集中在 CSS&lt;/td>
 &lt;td>低 — 改視覺改 CSS&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>集中在 CSS = 設計系統的單一來源、devtools Element 面板看 class 知道狀態、code review 容易理解。&lt;/p>
&lt;p>&lt;code>!important&lt;/code> 的需求消失：只要 CSS Layers 把 vendor CSS 包進低權層、自家 unlayered CSS 自然贏、不需要 important。&lt;/p>
&lt;h3 id="何時-inline-style-是必要的">何時 inline style 是必要的&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>情境&lt;/th>
 &lt;th>inline style 必要&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>動態值（隨 runtime 計算）&lt;/td>
 &lt;td>是 — 如 &lt;code>el.style.top = scrollY + 'px'&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>動畫起點 / 終點&lt;/td>
 &lt;td>是 — &lt;code>el.style.transform = ...&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>切換 boolean 狀態（顯示/隱藏）&lt;/td>
 &lt;td>否 — 用 class toggle&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>套用設計系統一致樣式&lt;/td>
 &lt;td>否 — 用 class toggle&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>「狀態切換」是 class toggle 的場景、不是 inline style 的場景。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的重構機會">這次任務的重構機會&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>Scope filter 用：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">items&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">show&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">scope&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="s1">&amp;#39;title&amp;#39;&lt;/span> &lt;span class="o">?&lt;/span> &lt;span class="nx">re&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">title&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">:&lt;/span> &lt;span class="nx">re&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">excerpt&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">show&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">style&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">removeProperty&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;display&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">style&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setProperty&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;display&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;none&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;important&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>setProperty important&lt;/code> 是為了壓過 Svelte 重繪可能 reset 的 inline style。CSS Layers 之後、important 不再必要。&lt;/p>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>改用 class toggle + layered CSS：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c">/* assets/search.css，unlayered（pagefind 在 layer(pagefind) 內） */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">pagefind-ui__result&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nc">is-scope-filtered&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">display&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">none&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">items&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">function&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">show&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">scope&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="s1">&amp;#39;title&amp;#39;&lt;/span> &lt;span class="o">?&lt;/span> &lt;span class="nx">re&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">title&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">:&lt;/span> &lt;span class="nx">re&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">excerpt&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">classList&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">toggle&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;is-scope-filtered&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">!&lt;/span>&lt;span class="nx">show&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>更乾淨：&lt;/p>
&lt;ul>
&lt;li>CSS 規則集中&lt;/li>
&lt;li>DevTools Element 面板看到 &lt;code>.is-scope-filtered&lt;/code> 就知道為什麼隱藏&lt;/li>
&lt;li>JS 邏輯簡化（&lt;code>classList.toggle&lt;/code> 一行解兩種狀態）&lt;/li>
&lt;li>不需要 &lt;code>!important&lt;/code>&lt;/li>
&lt;/ul>
&lt;h3 id="執行-prerequisite">執行 prerequisite&lt;/h3>
&lt;p>要這個 refactor 生效、先做 #24（CSS Layers）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">@&lt;/span>&lt;span class="k">import&lt;/span> &lt;span class="nt">url&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="s2">&amp;#34;/blog/pagefind/pagefind-ui.css&amp;#34;&lt;/span>&lt;span class="o">)&lt;/span> &lt;span class="nt">layer&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nt">pagefind&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c">/* unlayered，自動勝過 pagefind 的所有 specificity */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">pagefind-ui__result&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nc">is-scope-filtered&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">display&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">none&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>否則 layered 的 pagefind CSS 可能用 specificity 30 蓋過 &lt;code>.is-scope-filtered&lt;/code>（specificity 20）。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>JS 改 DOM 元素的視覺狀態、用 class toggle、不用 inline style.setProperty important hack。</strong> Class toggle 的好處：CSS 規則集中可讀、devtools 看到語意化的 class 名而非神秘 inline style、未來改視覺只動 CSS 不動 JS。</p>
<hr>
<h2 id="為什麼-class-toggle-比-inline-style-好">為什麼 class toggle 比 inline style 好</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>兩種方式都能達成「JS 控制視覺」、差別在「視覺規則的家在哪」：</p>
<table>
  <thead>
      <tr>
          <th>方式</th>
          <th>視覺規則住址</th>
          <th>維護成本</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>el.style.display = 'none'</code></td>
          <td>散在 JS 各處</td>
          <td>高 — 改視覺要找 JS、不在 CSS</td>
      </tr>
      <tr>
          <td><code>el.classList.toggle('is-hidden')</code> + <code>.is-hidden { display: none }</code></td>
          <td>集中在 CSS</td>
          <td>低 — 改視覺改 CSS</td>
      </tr>
  </tbody>
</table>
<p>集中在 CSS = 設計系統的單一來源、devtools Element 面板看 class 知道狀態、code review 容易理解。</p>
<p><code>!important</code> 的需求消失：只要 CSS Layers 把 vendor CSS 包進低權層、自家 unlayered CSS 自然贏、不需要 important。</p>
<h3 id="何時-inline-style-是必要的">何時 inline style 是必要的</h3>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>inline style 必要</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>動態值（隨 runtime 計算）</td>
          <td>是 — 如 <code>el.style.top = scrollY + 'px'</code></td>
      </tr>
      <tr>
          <td>動畫起點 / 終點</td>
          <td>是 — <code>el.style.transform = ...</code></td>
      </tr>
      <tr>
          <td>切換 boolean 狀態（顯示/隱藏）</td>
          <td>否 — 用 class toggle</td>
      </tr>
      <tr>
          <td>套用設計系統一致樣式</td>
          <td>否 — 用 class toggle</td>
      </tr>
  </tbody>
</table>
<p>「狀態切換」是 class toggle 的場景、不是 inline style 的場景。</p>
<hr>
<h2 id="這次任務的重構機會">這次任務的重構機會</h2>
<h3 id="觀察">觀察</h3>
<p>Scope filter 用：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">items</span><span class="p">.</span><span class="nx">forEach</span><span class="p">(</span><span class="kd">function</span> <span class="p">(</span><span class="nx">el</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">var</span> <span class="nx">show</span> <span class="o">=</span> <span class="nx">scope</span> <span class="o">===</span> <span class="s1">&#39;title&#39;</span> <span class="o">?</span> <span class="nx">re</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">title</span><span class="p">)</span> <span class="o">:</span> <span class="nx">re</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">excerpt</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">show</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">el</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">removeProperty</span><span class="p">(</span><span class="s1">&#39;display&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">el</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="s1">&#39;display&#39;</span><span class="p">,</span> <span class="s1">&#39;none&#39;</span><span class="p">,</span> <span class="s1">&#39;important&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p><code>setProperty important</code> 是為了壓過 Svelte 重繪可能 reset 的 inline style。CSS Layers 之後、important 不再必要。</p>
<h3 id="判讀">判讀</h3>
<p>改用 class toggle + layered CSS：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="c">/* assets/search.css，unlayered（pagefind 在 layer(pagefind) 內） */</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">pagefind-ui__result</span><span class="p">.</span><span class="nc">is-scope-filtered</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">display</span><span class="p">:</span> <span class="kc">none</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">items</span><span class="p">.</span><span class="nx">forEach</span><span class="p">(</span><span class="kd">function</span> <span class="p">(</span><span class="nx">el</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">var</span> <span class="nx">show</span> <span class="o">=</span> <span class="nx">scope</span> <span class="o">===</span> <span class="s1">&#39;title&#39;</span> <span class="o">?</span> <span class="nx">re</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">title</span><span class="p">)</span> <span class="o">:</span> <span class="nx">re</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">excerpt</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">el</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nx">toggle</span><span class="p">(</span><span class="s1">&#39;is-scope-filtered&#39;</span><span class="p">,</span> <span class="o">!</span><span class="nx">show</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>更乾淨：</p>
<ul>
<li>CSS 規則集中</li>
<li>DevTools Element 面板看到 <code>.is-scope-filtered</code> 就知道為什麼隱藏</li>
<li>JS 邏輯簡化（<code>classList.toggle</code> 一行解兩種狀態）</li>
<li>不需要 <code>!important</code></li>
</ul>
<h3 id="執行-prerequisite">執行 prerequisite</h3>
<p>要這個 refactor 生效、先做 #24（CSS Layers）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">@</span><span class="k">import</span> <span class="nt">url</span><span class="o">(</span><span class="s2">&#34;/blog/pagefind/pagefind-ui.css&#34;</span><span class="o">)</span> <span class="nt">layer</span><span class="o">(</span><span class="nt">pagefind</span><span class="o">)</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c">/* unlayered，自動勝過 pagefind 的所有 specificity */</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">.</span><span class="nc">pagefind-ui__result</span><span class="p">.</span><span class="nc">is-scope-filtered</span> <span class="p">{</span> <span class="k">display</span><span class="p">:</span> <span class="kc">none</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><p>否則 layered 的 pagefind CSS 可能用 specificity 30 蓋過 <code>.is-scope-filtered</code>（specificity 20）。</p>
<hr>
<h2 id="內在屬性比較四種-js-控制視覺方式">內在屬性比較：四種 JS 控制視覺方式</h2>
<table>
  <thead>
      <tr>
          <th>方式</th>
          <th>維護成本</th>
          <th>DevTools 可讀性</th>
          <th>Important 需求</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>el.style.display = 'none'</code></td>
          <td>中 — 規則散在 JS</td>
          <td>中 — 看到 inline style</td>
          <td>否</td>
      </tr>
      <tr>
          <td><code>el.style.setProperty('display','none','important')</code></td>
          <td>高 — important 散在 JS</td>
          <td>中</td>
          <td>是 — 跟 framework 競爭</td>
      </tr>
      <tr>
          <td><code>el.classList.toggle('is-hidden')</code> + CSS</td>
          <td>低 — 規則在 CSS</td>
          <td>高 — 看 class 知狀態</td>
          <td>否（CSS Layers 環境下）</td>
      </tr>
      <tr>
          <td><code>el.dataset.state = 'hidden'</code> + <code>[data-state=hidden]</code> CSS</td>
          <td>低 — 規則在 CSS</td>
          <td>高 — attribute 也表達狀態</td>
          <td>否</td>
      </tr>
  </tbody>
</table>
<p>優先選 class toggle（或 dataset） — CSS-first、可讀、可維護。</p>
<hr>
<h2 id="class-toggle-的命名慣例">Class toggle 的命名慣例</h2>
<h3 id="用-is-x--has-x-表狀態">用 <code>is-X</code> / <code>has-X</code> 表狀態</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">is-scope-filtered</span> <span class="p">{</span> <span class="k">display</span><span class="p">:</span> <span class="kc">none</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">is-loading</span> <span class="p">{</span> <span class="k">opacity</span><span class="p">:</span> <span class="mf">0.5</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">.</span><span class="nc">has-error</span> <span class="p">{</span> <span class="k">border-color</span><span class="p">:</span> <span class="kc">red</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><p><code>is-</code> / <code>has-</code> 前綴讓「狀態 class」跟「結構 class」（如 <code>.search-shell</code>）視覺區分、code review 一眼看出哪些是動態狀態。</p>
<h3 id="用-bem-modifier">用 BEM modifier</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">search-result--filtered</span> <span class="p">{</span> <span class="k">display</span><span class="p">:</span> <span class="kc">none</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><p>BEM 風格也可以、看專案 convention。重點是有規律、不要混雜。</p>
<hr>
<h2 id="devtools-可讀性的具體差異">DevTools 可讀性的具體差異</h2>
<h3 id="inline-style-視角">Inline style 視角</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;pagefind-ui__result svelte-j9e30&#34;</span> <span class="na">style</span><span class="o">=</span><span class="s">&#34;display: none !important;&#34;</span><span class="p">&gt;</span></span></span></code></pre></div><p>DevTools 顯示「inline style 設了 important」 — 但不知道為什麼。要 grep JS 找出哪段邏輯設的。</p>
<h3 id="class-toggle-視角">Class toggle 視角</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;pagefind-ui__result svelte-j9e30 is-scope-filtered&#34;</span><span class="p">&gt;</span></span></span></code></pre></div><p>DevTools 顯示「有 <code>.is-scope-filtered</code> class」 — class 名本身解釋為什麼隱藏。CSS 面板也直接顯示對應規則。</p>
<hr>
<h2 id="設計取捨js-控制視覺狀態的策略">設計取捨：JS 控制視覺狀態的策略</h2>
<p>四種做法、各自機會成本不同。這個專案選 A（class toggle）當預設、其他做法在特定情境合理。</p>
<h3 id="aclass-toggle--css-規則這個專案的預設">A：Class toggle + CSS 規則（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：<code>el.classList.toggle('is-scope-filtered')</code>、CSS 內定義 <code>.is-scope-filtered { display: none }</code></li>
<li><strong>選 A 的理由</strong>：CSS 規則集中、devtools 看 class 知狀態、改視覺只動 CSS、配 CSS Layers 不需 <code>!important</code></li>
<li><strong>適合</strong>：布林狀態切換（顯示 / 隱藏 / 啟用 / 停用）</li>
<li><strong>代價</strong>：需要在 CSS 預先定義 class 規則（多一份 CSS）</li>
</ul>
<h3 id="binline-stylex--">B：Inline <code>style.X = ...</code></h3>
<ul>
<li><strong>機制</strong>：<code>el.style.display = 'none'</code></li>
<li><strong>跟 A 的取捨</strong>：B 一行 JS、A 需要 CSS 規則；但 B 規則散在 JS 各處、devtools 看到 <code>display: none</code> inline 不知道為什麼</li>
<li><strong>B 比 A 好的情境</strong>：runtime 計算的動態值（<code>el.style.top = scrollY + 'px'</code>）— 這類值無法預先寫進 CSS</li>
</ul>
<h3 id="cinline--setpropertyimportant">C：Inline + <code>setProperty('important')</code></h3>
<ul>
<li><strong>機制</strong>：<code>el.style.setProperty('display', 'none', 'important')</code></li>
<li><strong>跟 A/B 的取捨</strong>：C 比 B 多 important、為了壓過 framework 重繪 reset 的 inline；但 C 進入 <code>!important</code> 戰、未來新 important 對撞 debug 困難</li>
<li><strong>C 才合理的情境</strong>：framework 強制 reset 自家 inline style、且不能用 layered CSS（極罕見）</li>
<li><strong>更好的解</strong>：用 <a href="../css-layers-over-specificity/">#24 CSS Layers</a> 解 specificity 戰、本卡片 A 即可</li>
</ul>
<h3 id="ddataset-attribute--css-attribute-selector">D：Dataset attribute + CSS attribute selector</h3>
<ul>
<li><strong>機制</strong>：<code>el.dataset.state = 'hidden'</code>、CSS <code>[data-state=&quot;hidden&quot;] { display: none }</code></li>
<li><strong>跟 A 的取捨</strong>：D 用 attribute 表狀態、A 用 class；D 在「狀態值多種」時更合適（例如 <code>data-state=&quot;loading|ready|error&quot;</code>）</li>
<li><strong>D 比 A 好的情境</strong>：狀態有多個值（不只 boolean）、需要 CSS attribute selector 表達多分支</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>Refactor 動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>JS 用 <code>style.setProperty(..., 'important')</code></td>
          <td>改用 class toggle、用 CSS Layers 解決 specificity</td>
      </tr>
      <tr>
          <td><code>el.style.display = 'none'</code> 散落多處</td>
          <td>集中為 <code>.is-X</code> class、JS 只 toggle</td>
      </tr>
      <tr>
          <td>DevTools 看到 inline style 不知為什麼</td>
          <td>改用語意化 class、devtools 看 class 自帶解釋</td>
      </tr>
      <tr>
          <td>視覺改動要改 JS（不是 CSS）</td>
          <td>Refactor 為 class toggle、視覺改動只動 CSS</td>
      </tr>
      <tr>
          <td>改視覺需要對抗 framework reset</td>
          <td>用 CSS Layers 把 framework 規則降層、自家規則不需 important</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：CSS 是視覺規則的家、JS 控制狀態 - 兩者透過 class toggle 介面共處、不互相侵入。</p>
<p>Inline style + !important 是「立刻生效」的便利、class toggle 是「樣式留 CSS」的對齊 — 這個反相關的更高層原則見 <a href="../ease-of-writing-vs-intent-alignment/">#67 寫作便利度跟意圖對齊反相關</a>。</p>
]]></content:encoded></item><item><title>Layout reflow / repaint 的可量化評估</title><link>https://tarrragon.github.io/blog/report/layout-reflow-measurement/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/layout-reflow-measurement/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>Reflow 與 repaint 的成本差兩個數量級、用 Performance 面板可以量化判斷哪個發生。&lt;/strong> 開發時不需要「全部避開 reflow」、要做的是「知道哪些操作觸發 reflow、規模放大時哪些值得優化」。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼要量化不憑感覺">為什麼要量化、不憑感覺&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>瀏覽器渲染管線分階段：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>階段&lt;/th>
 &lt;th>觸發條件&lt;/th>
 &lt;th>相對成本&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Style recalc&lt;/td>
 &lt;td>CSS 規則變動、class toggle&lt;/td>
 &lt;td>低&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Layout (reflow)&lt;/td>
 &lt;td>影響元素尺寸 / 位置的 CSS 改變&lt;/td>
 &lt;td>高（要重算所有受影響元素）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Paint (repaint)&lt;/td>
 &lt;td>顏色 / 背景變動但位置不變&lt;/td>
 &lt;td>中&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Composite&lt;/td>
 &lt;td>transform / opacity 等 GPU 加速屬性&lt;/td>
 &lt;td>最低&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>不同操作落在不同階段。「改 width」觸發 reflow、「改 transform」只到 composite。差距 ~10-100x。&lt;/p>
&lt;p>但這不代表要「永遠用 transform」 — 多數場景 reflow 成本可以接受、過度避免反而讓 layout 變脆。&lt;/p>
&lt;h3 id="量化的工具">量化的工具&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>工具&lt;/th>
 &lt;th>看什麼&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Chrome DevTools Performance&lt;/td>
 &lt;td>整段操作的 reflow / paint / composite 時間&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Performance API（&lt;code>performance.measure&lt;/code>）&lt;/td>
 &lt;td>程式化量自家函式&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Layout shift (Web Vitals CLS)&lt;/td>
 &lt;td>視覺上的 layout 跳動&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>優先用 DevTools Performance 量、有具體數字後再決定是否優化。&lt;/p>
&lt;hr>
&lt;h2 id="搜尋頁的具體風險點">搜尋頁的具體風險點&lt;/h2>
&lt;h3 id="風險-1filter-slot-跨-viewport-切換">風險 1：Filter slot 跨 viewport 切換&lt;/h3>
&lt;p>&lt;strong>位置&lt;/strong>：matchMedia callback 內 &lt;code>slot.appendChild(filter)&lt;/code> / &lt;code>drawer.insertBefore(filter, ...)&lt;/code>。&lt;/p>
&lt;p>&lt;strong>判讀&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>整個 filter 子樹移動 = layout 重算（filter 的新位置、原位置元素重排）&lt;/li>
&lt;li>同時 main 區域與 sidebar 區域的尺寸都重算&lt;/li>
&lt;li>一次性發生、不持續觸發&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>症狀&lt;/strong>：使用者拖動視窗寬度跨過 1400px 時、瞬間卡頓 1-2 frame。&lt;/p>
&lt;p>&lt;strong>第一個該查的&lt;/strong>：DevTools Performance 錄下 resize 跨過 breakpoint 的瞬間、看 Layout 區塊有多大。&amp;lt; 16ms = OK；&amp;gt; 16ms 考慮 debounce matchMedia callback。&lt;/p>
&lt;h3 id="風險-2css-變數寫入">風險 2：CSS 變數寫入&lt;/h3>
&lt;p>&lt;strong>位置&lt;/strong>：&lt;code>document.body.style.setProperty('--search-scope-h', ...)&lt;/code>。&lt;/p>
&lt;p>&lt;strong>判讀&lt;/strong>：寫 CSS 變數不一定觸發 reflow — 看哪些規則用了這個變數、那些規則影響哪些元素。&lt;/p>
&lt;ul>
&lt;li>&lt;code>--search-scope-h&lt;/code> 用於 drawer 的 &lt;code>margin-top&lt;/code> → drawer 位置變動 → reflow&lt;/li>
&lt;li>&lt;code>--search-scope-h&lt;/code> 用於 filter slot 的 &lt;code>padding-top&lt;/code> → filter slot 高度變動 → reflow&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>症狀&lt;/strong>：scope 大小變動時、drawer 與 filter slot 同時重排、可能看到輕微跳動。&lt;/p>
&lt;p>&lt;strong>第一個該查的&lt;/strong>：DevTools Performance 錄一次 scope 變大的事件、看 Layout 區塊。多數場景 &amp;lt; 5ms、可忽略。&lt;/p>
&lt;h3 id="風險-3absolute-定位的重算">風險 3：Absolute 定位的重算&lt;/h3>
&lt;p>&lt;strong>位置&lt;/strong>：&lt;code>.search-filter-slot { position: absolute; ... }&lt;/code>。&lt;/p>
&lt;p>&lt;strong>判讀&lt;/strong>：Absolute 元素跟一般 flow 元素分離、自己不影響 sibling 的 layout、但仍受自身 position / size 變動影響。Filter 改 top 觸發自身 reflow、不影響 main。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>Reflow 與 repaint 的成本差兩個數量級、用 Performance 面板可以量化判斷哪個發生。</strong> 開發時不需要「全部避開 reflow」、要做的是「知道哪些操作觸發 reflow、規模放大時哪些值得優化」。</p>
<hr>
<h2 id="為什麼要量化不憑感覺">為什麼要量化、不憑感覺</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>瀏覽器渲染管線分階段：</p>
<table>
  <thead>
      <tr>
          <th>階段</th>
          <th>觸發條件</th>
          <th>相對成本</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Style recalc</td>
          <td>CSS 規則變動、class toggle</td>
          <td>低</td>
      </tr>
      <tr>
          <td>Layout (reflow)</td>
          <td>影響元素尺寸 / 位置的 CSS 改變</td>
          <td>高（要重算所有受影響元素）</td>
      </tr>
      <tr>
          <td>Paint (repaint)</td>
          <td>顏色 / 背景變動但位置不變</td>
          <td>中</td>
      </tr>
      <tr>
          <td>Composite</td>
          <td>transform / opacity 等 GPU 加速屬性</td>
          <td>最低</td>
      </tr>
  </tbody>
</table>
<p>不同操作落在不同階段。「改 width」觸發 reflow、「改 transform」只到 composite。差距 ~10-100x。</p>
<p>但這不代表要「永遠用 transform」 — 多數場景 reflow 成本可以接受、過度避免反而讓 layout 變脆。</p>
<h3 id="量化的工具">量化的工具</h3>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>看什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Chrome DevTools Performance</td>
          <td>整段操作的 reflow / paint / composite 時間</td>
      </tr>
      <tr>
          <td>Performance API（<code>performance.measure</code>）</td>
          <td>程式化量自家函式</td>
      </tr>
      <tr>
          <td>Layout shift (Web Vitals CLS)</td>
          <td>視覺上的 layout 跳動</td>
      </tr>
  </tbody>
</table>
<p>優先用 DevTools Performance 量、有具體數字後再決定是否優化。</p>
<hr>
<h2 id="搜尋頁的具體風險點">搜尋頁的具體風險點</h2>
<h3 id="風險-1filter-slot-跨-viewport-切換">風險 1：Filter slot 跨 viewport 切換</h3>
<p><strong>位置</strong>：matchMedia callback 內 <code>slot.appendChild(filter)</code> / <code>drawer.insertBefore(filter, ...)</code>。</p>
<p><strong>判讀</strong>：</p>
<ul>
<li>整個 filter 子樹移動 = layout 重算（filter 的新位置、原位置元素重排）</li>
<li>同時 main 區域與 sidebar 區域的尺寸都重算</li>
<li>一次性發生、不持續觸發</li>
</ul>
<p><strong>症狀</strong>：使用者拖動視窗寬度跨過 1400px 時、瞬間卡頓 1-2 frame。</p>
<p><strong>第一個該查的</strong>：DevTools Performance 錄下 resize 跨過 breakpoint 的瞬間、看 Layout 區塊有多大。&lt; 16ms = OK；&gt; 16ms 考慮 debounce matchMedia callback。</p>
<h3 id="風險-2css-變數寫入">風險 2：CSS 變數寫入</h3>
<p><strong>位置</strong>：<code>document.body.style.setProperty('--search-scope-h', ...)</code>。</p>
<p><strong>判讀</strong>：寫 CSS 變數不一定觸發 reflow — 看哪些規則用了這個變數、那些規則影響哪些元素。</p>
<ul>
<li><code>--search-scope-h</code> 用於 drawer 的 <code>margin-top</code> → drawer 位置變動 → reflow</li>
<li><code>--search-scope-h</code> 用於 filter slot 的 <code>padding-top</code> → filter slot 高度變動 → reflow</li>
</ul>
<p><strong>症狀</strong>：scope 大小變動時、drawer 與 filter slot 同時重排、可能看到輕微跳動。</p>
<p><strong>第一個該查的</strong>：DevTools Performance 錄一次 scope 變大的事件、看 Layout 區塊。多數場景 &lt; 5ms、可忽略。</p>
<h3 id="風險-3absolute-定位的重算">風險 3：Absolute 定位的重算</h3>
<p><strong>位置</strong>：<code>.search-filter-slot { position: absolute; ... }</code>。</p>
<p><strong>判讀</strong>：Absolute 元素跟一般 flow 元素分離、自己不影響 sibling 的 layout、但仍受自身 position / size 變動影響。Filter 改 top 觸發自身 reflow、不影響 main。</p>
<p><strong>症狀</strong>：filter slot 的 padding-top 變動（隨 scope-h）— 只影響 filter 自身高度。</p>
<p><strong>第一個該查的</strong>：DevTools Performance 看 filter padding 變動時的 layout 範圍 — 應該只到 filter 內部、不擴散到 main / footer。若擴散表示有意外的 stacking context 影響。</p>
<h3 id="風險-4js-連續操作-dom">風險 4：JS 連續操作 DOM</h3>
<p><strong>位置</strong>：<code>reorderFilters()</code> 用 <code>appendChild</code> 多次調整順序。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">desiredOrder</span><span class="p">.</span><span class="nx">forEach</span><span class="p">(</span><span class="kd">function</span> <span class="p">(</span><span class="nx">k</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">byKey</span><span class="p">[</span><span class="nx">k</span><span class="p">])</span> <span class="nx">filter</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">byKey</span><span class="p">[</span><span class="nx">k</span><span class="p">]);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p><strong>判讀</strong>：</p>
<ul>
<li>多次 <code>appendChild</code> 可能觸發多次 layout</li>
<li>但 browser 通常會合併同步 DOM 變動到一次 layout（natural batching）</li>
<li>真正會「強制 layout」的是 DOM 寫入後馬上讀 layout 屬性（如 offsetHeight）</li>
</ul>
<p><strong>症狀</strong>：rare — reorder 一次只在 setup 時跑、影響很短。</p>
<p><strong>第一個該查的</strong>：若有這類「寫後立刻讀」的 pattern、用 <code>requestAnimationFrame</code> 把讀延後到下一幀、避免 forced sync layout。</p>
<hr>
<h2 id="內在屬性比較四種-layout-變動類型">內在屬性比較：四種 layout 變動類型</h2>
<table>
  <thead>
      <tr>
          <th>變動類型</th>
          <th>成本</th>
          <th>可控性</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Composite-only（transform / opacity）</td>
          <td>最低</td>
          <td>GPU 加速、&lt; 1ms</td>
      </tr>
      <tr>
          <td>Paint-only（顏色變動）</td>
          <td>低</td>
          <td>局部重繪</td>
      </tr>
      <tr>
          <td>Layout（尺寸 / 位置變動）</td>
          <td>中-高</td>
          <td>要算受影響的範圍</td>
      </tr>
      <tr>
          <td>Forced sync layout（DOM 寫後立刻讀）</td>
          <td>最高</td>
          <td>連續觸發是 perf killer</td>
      </tr>
  </tbody>
</table>
<p>選擇順序：<strong>有意識避免 forced sync layout</strong>、<strong>對動畫優先用 transform</strong>、<strong>一般 layout 變動量小不必特別避免</strong>。</p>
<hr>
<h2 id="預估成本的快速法則">預估成本的快速法則</h2>
<p>不要每個操作都用 DevTools 量、用快速法則先判斷：</p>
<table>
  <thead>
      <tr>
          <th>操作</th>
          <th>預估等級</th>
          <th>何時要量</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>改 class（class toggle）</td>
          <td>1ms 等級</td>
          <td>套用到大量元素時</td>
      </tr>
      <tr>
          <td>Append / remove 單一節點</td>
          <td>1-5ms</td>
          <td>大規模迭代時</td>
      </tr>
      <tr>
          <td>移動 DOM 子樹（reparent）</td>
          <td>5-20ms</td>
          <td>子樹大、頻繁觸發時</td>
      </tr>
      <tr>
          <td>改 CSS 變數（簡單 calc）</td>
          <td>1-5ms</td>
          <td>頻繁觸發時</td>
      </tr>
      <tr>
          <td>Forced sync layout</td>
          <td>5-50ms</td>
          <td>任何寫後立刻讀的 pattern 都該量</td>
      </tr>
  </tbody>
</table>
<p>預估超過 frame budget（16.67ms）才值得實際量、進一步優化。</p>
<hr>
<h2 id="設計取捨layout-操作的處理策略">設計取捨：layout 操作的處理策略</h2>
<p>四種做法、各自機會成本不同。這個專案選 A（量化評估再決定）當預設、其他做法在特定情境合理。</p>
<h3 id="a量化評估按規模決定優化與否這個專案的預設">A：量化評估、按規模決定優化與否（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：用 DevTools Performance 量每個 layout 操作的實際成本、超過 frame budget（16.67ms）才優化</li>
<li><strong>選 A 的理由</strong>：避免過度優化（多數 reflow 成本可接受）、又不漏真正貴的（forced sync layout）</li>
<li><strong>適合</strong>：所有效能盤點情境</li>
<li><strong>代價</strong>：需要學會用 DevTools Performance、對效能 dispute 要量</li>
</ul>
<h3 id="b全部用-transform--opacity-避免-reflow">B：全部用 transform / opacity 避免 reflow</h3>
<ul>
<li><strong>機制</strong>：所有動畫 / 變動都用 transform 或 opacity（GPU composite）</li>
<li><strong>跟 A 的取捨</strong>：B 預先避免 reflow、A 量化按需處理；但 B 寫出複雜的 transform / absolute 組合、layout 邏輯難維護</li>
<li><strong>B 比 A 好的情境</strong>：高頻動畫（每 frame 變動的旋轉 / 移動）— 確定觸發 layout 會卡</li>
</ul>
<h3 id="c完全避免-layout-操作">C：完全避免 layout 操作</h3>
<ul>
<li><strong>機制</strong>：把所有可能觸發 reflow 的操作都繞開</li>
<li><strong>跟 A/B 的取捨</strong>：C 過度反應、A/B 適度；C 寫法極受限、layout 表達力下降</li>
<li><strong>C 才合理的情境</strong>：純動畫場景（沒有 layout 需求）— 對一般 UI 不適用</li>
</ul>
<h3 id="d不量靠經驗判斷">D：不量、靠經驗判斷</h3>
<ul>
<li><strong>機制</strong>：依「我覺得這應該快」做決定</li>
<li><strong>成本特別高的原因</strong>：瀏覽器 / 設備 / 場景差異大、直覺不可靠；可能漏掉 forced sync layout 等真正貴的 pattern</li>
<li><strong>D 是反模式</strong>：效能 dispute 必須有數字 — 直覺判斷會漏掉 forced sync layout 等真正貴的 pattern、跨設備差異大</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該檢查的位置</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>使用者操作後輕微跳動或卡頓</td>
          <td>DevTools Performance 看 Layout 區塊</td>
      </tr>
      <tr>
          <td>動畫不順</td>
          <td>確認動畫屬性是 transform / opacity 而非 width / left</td>
      </tr>
      <tr>
          <td>Layout shift 警告</td>
          <td>找出觸發 layout 的元素、量穩定性</td>
      </tr>
      <tr>
          <td>Console 出現「Forced reflow」warning</td>
          <td>找寫後立刻讀的 DOM pattern</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：Reflow 是 layout 系統的正常運作、不是要消滅的敵人。盤點時量化看哪些值得優化、哪些可以接受。</p>
]]></content:encoded></item><item><title>視覺輔助：對比度、放大、字型 zoom 的 layout 適配</title><link>https://tarrragon.github.io/blog/report/visual-aids-contrast-zoom-responsive/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/visual-aids-contrast-zoom-responsive/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>視覺輔助使用者跟一般使用者「看到的不是同一個 UI」 — 對比度、放大倍率、字型尺寸調整都會把版面變形。&lt;/strong> 設計時先盤點「在這些變形下、UI 還能用嗎」、不需要等到使用者反映。WCAG 提供量化標準、可以在開發階段驗證。&lt;/p>
&lt;blockquote>
&lt;p>本篇焦點：&lt;strong>視覺呈現面的 a11y&lt;/strong>（對比 / 放大 / 字型 zoom）。&lt;/p>
&lt;ul>
&lt;li>&lt;strong>鍵盤使用者的 a11y&lt;/strong>（focus indicator / tab 順序）由 &lt;a href="../keyboard-accessibility/">#52 鍵盤可達性&lt;/a> 處理&lt;/li>
&lt;li>&lt;strong>行動 / motor 使用者的 a11y&lt;/strong>（hit target / 點擊精準度）由 &lt;a href="../motor-accessibility-hit-target/">#53 Motor 可達性&lt;/a> 處理&lt;/li>
&lt;/ul>&lt;/blockquote>
&lt;hr>
&lt;h2 id="為什麼視覺輔助需要獨立盤點">為什麼視覺輔助需要獨立盤點&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>視覺輔助使用者的需求多元：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>情境&lt;/th>
 &lt;th>需求&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>色弱（colour blindness）&lt;/td>
 &lt;td>不依賴顏色區分資訊&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>低對比敏感&lt;/td>
 &lt;td>文字 vs 背景對比足夠&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>低視力（low vision）&lt;/td>
 &lt;td>字大、可放大、layout 不破&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>老花、暫時視覺受限&lt;/td>
 &lt;td>字大、清楚的視覺層次&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每類觸發不同的 CSS 行為。一個 UI 在標準視窗看起來 OK、放大 200% 後可能：&lt;/p>
&lt;ul>
&lt;li>字超出容器&lt;/li>
&lt;li>Absolute 定位元件跑到視窗外&lt;/li>
&lt;li>對比度被覆蓋（dark mode / 高對比模式）&lt;/li>
&lt;/ul>
&lt;p>WCAG（Web Content Accessibility Guidelines）提供量化標準（對比度 AA ≥ 4.5:1、放大 200% 不橫向 scroll）— 可在開發階段測量。&lt;/p>
&lt;h3 id="視覺呈現的三維度">視覺呈現的三維度&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>變形方式&lt;/th>
 &lt;th>開發階段檢查方法&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>色彩&lt;/td>
 &lt;td>dark mode / 高對比模式 / 色弱模擬&lt;/td>
 &lt;td>DevTools Contrast Ratio + Emulate Vision Deficiencies&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>整體 zoom&lt;/td>
 &lt;td>瀏覽器 zoom 200% / OS 放大鏡&lt;/td>
 &lt;td>Cmd + 5 次、macOS Zoom 4x&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>字型 zoom&lt;/td>
 &lt;td>OS Display Scale（只放大字型不放大 box）&lt;/td>
 &lt;td>OS 設定 Larger Text&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>三維度獨立、要分開檢查 — 一維度過 ≠ 全部過。&lt;/p>
&lt;hr>
&lt;h2 id="風險點-1搜尋結果-highlight-對比度">風險點 1：搜尋結果 highlight 對比度&lt;/h2>
&lt;p>&lt;strong>位置&lt;/strong>：Pagefind 高亮命中關鍵字（黃底）。&lt;/p>
&lt;p>&lt;strong>判讀&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>預設 &lt;code>--pagefind-ui-tag&lt;/code> = &lt;code>#eeeeee&lt;/code>（淺灰）— 文字 &lt;code>#393939&lt;/code>（深灰）、對比 ~9:1、合格&lt;/li>
&lt;li>但搜尋頁 dark mode 下、theme 可能讓文字變淺色 — 對淺底要驗證&lt;/li>
&lt;li>色弱使用者看不出哪個字是 highlight（若僅靠顏色區分）&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>WCAG 標準&lt;/strong>：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>等級&lt;/th>
 &lt;th>對比度要求&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>AA 一般文字&lt;/td>
 &lt;td>≥ 4.5:1&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AA 大字（≥ 18pt 或 14pt bold）&lt;/td>
 &lt;td>≥ 3:1&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AAA 一般文字&lt;/td>
 &lt;td>≥ 7:1&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>第一個該查的&lt;/strong>：用 Chrome DevTools 的 Contrast Ratio 工具量 highlight 區域的「背景 vs 文字」對比。不足則覆寫 &lt;code>--pagefind-ui-tag&lt;/code> 變數。&lt;/p>
&lt;p>&lt;strong>雙重保險&lt;/strong>：除了顏色、加 underline 或 bold 區分 highlight — 色弱使用者不靠顏色也能辨識。&lt;/p>
&lt;hr>
&lt;h2 id="風險點-2absolute-定位元件在放大模式下跑到視窗外">風險點 2：Absolute 定位元件在放大模式下跑到視窗外&lt;/h2>
&lt;p>&lt;strong>位置&lt;/strong>：&lt;code>.search-filter-slot { position: absolute; right: calc(100% + 2rem); }&lt;/code>。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>視覺輔助使用者跟一般使用者「看到的不是同一個 UI」 — 對比度、放大倍率、字型尺寸調整都會把版面變形。</strong> 設計時先盤點「在這些變形下、UI 還能用嗎」、不需要等到使用者反映。WCAG 提供量化標準、可以在開發階段驗證。</p>
<blockquote>
<p>本篇焦點：<strong>視覺呈現面的 a11y</strong>（對比 / 放大 / 字型 zoom）。</p>
<ul>
<li><strong>鍵盤使用者的 a11y</strong>（focus indicator / tab 順序）由 <a href="../keyboard-accessibility/">#52 鍵盤可達性</a> 處理</li>
<li><strong>行動 / motor 使用者的 a11y</strong>（hit target / 點擊精準度）由 <a href="../motor-accessibility-hit-target/">#53 Motor 可達性</a> 處理</li>
</ul></blockquote>
<hr>
<h2 id="為什麼視覺輔助需要獨立盤點">為什麼視覺輔助需要獨立盤點</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>視覺輔助使用者的需求多元：</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>需求</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>色弱（colour blindness）</td>
          <td>不依賴顏色區分資訊</td>
      </tr>
      <tr>
          <td>低對比敏感</td>
          <td>文字 vs 背景對比足夠</td>
      </tr>
      <tr>
          <td>低視力（low vision）</td>
          <td>字大、可放大、layout 不破</td>
      </tr>
      <tr>
          <td>老花、暫時視覺受限</td>
          <td>字大、清楚的視覺層次</td>
      </tr>
  </tbody>
</table>
<p>每類觸發不同的 CSS 行為。一個 UI 在標準視窗看起來 OK、放大 200% 後可能：</p>
<ul>
<li>字超出容器</li>
<li>Absolute 定位元件跑到視窗外</li>
<li>對比度被覆蓋（dark mode / 高對比模式）</li>
</ul>
<p>WCAG（Web Content Accessibility Guidelines）提供量化標準（對比度 AA ≥ 4.5:1、放大 200% 不橫向 scroll）— 可在開發階段測量。</p>
<h3 id="視覺呈現的三維度">視覺呈現的三維度</h3>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>變形方式</th>
          <th>開發階段檢查方法</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>色彩</td>
          <td>dark mode / 高對比模式 / 色弱模擬</td>
          <td>DevTools Contrast Ratio + Emulate Vision Deficiencies</td>
      </tr>
      <tr>
          <td>整體 zoom</td>
          <td>瀏覽器 zoom 200% / OS 放大鏡</td>
          <td>Cmd + 5 次、macOS Zoom 4x</td>
      </tr>
      <tr>
          <td>字型 zoom</td>
          <td>OS Display Scale（只放大字型不放大 box）</td>
          <td>OS 設定 Larger Text</td>
      </tr>
  </tbody>
</table>
<p>三維度獨立、要分開檢查 — 一維度過 ≠ 全部過。</p>
<hr>
<h2 id="風險點-1搜尋結果-highlight-對比度">風險點 1：搜尋結果 highlight 對比度</h2>
<p><strong>位置</strong>：Pagefind 高亮命中關鍵字（黃底）。</p>
<p><strong>判讀</strong>：</p>
<ul>
<li>預設 <code>--pagefind-ui-tag</code> = <code>#eeeeee</code>（淺灰）— 文字 <code>#393939</code>（深灰）、對比 ~9:1、合格</li>
<li>但搜尋頁 dark mode 下、theme 可能讓文字變淺色 — 對淺底要驗證</li>
<li>色弱使用者看不出哪個字是 highlight（若僅靠顏色區分）</li>
</ul>
<p><strong>WCAG 標準</strong>：</p>
<table>
  <thead>
      <tr>
          <th>等級</th>
          <th>對比度要求</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>AA 一般文字</td>
          <td>≥ 4.5:1</td>
      </tr>
      <tr>
          <td>AA 大字（≥ 18pt 或 14pt bold）</td>
          <td>≥ 3:1</td>
      </tr>
      <tr>
          <td>AAA 一般文字</td>
          <td>≥ 7:1</td>
      </tr>
  </tbody>
</table>
<p><strong>第一個該查的</strong>：用 Chrome DevTools 的 Contrast Ratio 工具量 highlight 區域的「背景 vs 文字」對比。不足則覆寫 <code>--pagefind-ui-tag</code> 變數。</p>
<p><strong>雙重保險</strong>：除了顏色、加 underline 或 bold 區分 highlight — 色弱使用者不靠顏色也能辨識。</p>
<hr>
<h2 id="風險點-2absolute-定位元件在放大模式下跑到視窗外">風險點 2：Absolute 定位元件在放大模式下跑到視窗外</h2>
<p><strong>位置</strong>：<code>.search-filter-slot { position: absolute; right: calc(100% + 2rem); }</code>。</p>
<p><strong>判讀</strong>：</p>
<ul>
<li>Absolute 定位相對 main 計算</li>
<li>使用者用 OS 螢幕放大鏡（macOS Zoom）放大 4x 看 main 中央</li>
<li>main 仍在視窗範圍、但 absolute filter 在 main 左外側 — 放大 4x 後可能完全跑到視窗左邊看不見</li>
</ul>
<p><strong>症狀</strong>：低視力使用者用放大鏡時、不知道 filter 存在、無法操作。</p>
<p><strong>第一個該查的</strong>：用 macOS 的 Zoom 功能（System Settings &gt; Accessibility &gt; Zoom）放大 4x、看 filter 是否仍在可達範圍。</p>
<p><strong>修正方向</strong>：</p>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>機制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>放大模式 fallback 到 mobile layout</td>
          <td><code>@media</code> 偵測 prefers-reduced-motion / 高 zoom level</td>
      </tr>
      <tr>
          <td>Filter 移到頁面內 flow（不用 absolute）</td>
          <td>跟主要內容一起 reflow、不會跑外</td>
      </tr>
      <tr>
          <td>加 floating button「展開 filter」</td>
          <td>任何 zoom level 都可達</td>
      </tr>
  </tbody>
</table>
<p>詳細展開由 <a href="../minimum-necessary-scope-is-sanity-defense/">#43 最小必要範圍</a> + <a href="../runtime-measurement-unification/">#27 runtime 量測模式統一</a> 補充。</p>
<hr>
<h2 id="風險點-3字型放大-200-後-layout-破壞">風險點 3：字型放大 200% 後 layout 破壞</h2>
<p><strong>位置</strong>：所有寫死 px 高度的元素（H1、search input、filter slot padding）。</p>
<p><strong>判讀</strong>：</p>
<ul>
<li>使用者用瀏覽器 zoom（Cmd +）通常等比放大 — 字 + box 一起放大、layout 不破</li>
<li>但 OS Display Scale（macOS Display &gt; Larger Text）只放大字型不放大 box — 字撐爆寫死的 64px 高度</li>
</ul>
<p>當 H1 字撐到 80px、寫死 height: 64px 的 box — 字被裁切。</p>
<p><strong>症狀</strong>：低視力使用者開啟「文字放大」設定、UI 字被裁。</p>
<p><strong>第一個該查的</strong>：開瀏覽器 zoom 200%、看 layout 是否變橫向 scroll（破壞）或仍 reflow（OK）。</p>
<p><strong>WCAG 標準</strong>：1.4.4 Resize text — zoom 至 200% 時不需要橫向 scroll。</p>
<p><strong>修正方向</strong>：</p>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>機制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>用 <code>min-height</code> 取代 <code>height</code></td>
          <td>box 可隨字撐高、不裁切</td>
      </tr>
      <tr>
          <td>用 <code>em</code> / <code>rem</code> 取代 <code>px</code></td>
          <td>跟字型一起 scale</td>
      </tr>
      <tr>
          <td>用 ResizeObserver 量字型實際高度寫回變數</td>
          <td>跟 <a href="../runtime-measurement-unification/">#27 runtime 量測模式統一</a> 同框架</td>
      </tr>
  </tbody>
</table>
<p>預設用 <code>min-height</code> + 相對單位、特殊精準對齊才用 ResizeObserver。</p>
<hr>
<h2 id="設計取捨layout-適應字型放大">設計取捨：layout 適應字型放大</h2>
<p>當「對齊精度」與「字型放大相容性」衝突、四種做法：</p>
<h3 id="a用-min-height--相對單位這個專案的預設">A：用 <code>min-height</code> + 相對單位（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：<code>min-height: 4rem</code>、box 隨字撐高、用 <code>em</code> / <code>rem</code> 跟著 scale</li>
<li><strong>選 A 的理由</strong>：字型放大時 layout 自然 reflow、不裁切</li>
<li><strong>適合</strong>：絕大多數 UI 元素、不需要極精準對齊</li>
<li><strong>代價</strong>：對齊精度受字型 metrics 影響、難以做 pixel-perfect 對齊</li>
</ul>
<h3 id="b寫死-height--resizeobserver-量測補償">B：寫死 <code>height</code> + ResizeObserver 量測補償</h3>
<ul>
<li><strong>機制</strong>：<code>height: 64px</code>、用 ResizeObserver 量實際渲染高度寫回 CSS 變數、其他依賴此值的元素跟著調</li>
<li><strong>跟 A 的取捨</strong>：B 達到 pixel-perfect 對齊、A 信任 reflow；B 多一層 JS 量測、A 純 CSS</li>
<li><strong>B 比 A 好的情境</strong>：對齊精度是 UX 核心（搜尋頁的視覺對齊）、字型可預期</li>
</ul>
<h3 id="c寫死-height--不處理字型放大">C：寫死 <code>height</code> + 不處理字型放大</h3>
<ul>
<li><strong>機制</strong>：<code>height: 64px</code>、不管字型放大</li>
<li><strong>成本特別高的原因</strong>：字型放大時 UI 被裁切、低視力使用者無法用</li>
<li><strong>C 才合理的情境</strong>：UI 不會被字型放大影響（純圖示、無文字）</li>
</ul>
<h3 id="d用-clampmin-ideal-max-限制字型大小">D：用 <code>clamp(min, ideal, max)</code> 限制字型大小</h3>
<ul>
<li><strong>機制</strong>：字型 <code>font-size: clamp(0.875rem, 1rem, 1.125rem)</code>、限制使用者放大範圍</li>
<li><strong>跟 A/B/C 的取捨</strong>：D 主動限制字型放大範圍、違反 WCAG 1.4.4</li>
<li><strong>D 是反模式</strong>：違反 WCAG 1.4.4 — 強制限制字型放大是反 a11y、低視力使用者完全無法調整</li>
</ul>
<hr>
<h2 id="開發階段檢查清單">開發階段檢查清單</h2>
<p>每個視覺輔助項目對應一個檢查動作：</p>
<table>
  <thead>
      <tr>
          <th>檢查</th>
          <th>動作</th>
          <th>WCAG 等級</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>對比度</td>
          <td>DevTools Inspect Element &gt; Contrast Ratio 看每個文字區域</td>
          <td>AA 必要</td>
      </tr>
      <tr>
          <td>色彩可辨</td>
          <td>DevTools Rendering &gt; Emulate Vision Deficiencies</td>
          <td>AA 建議</td>
      </tr>
      <tr>
          <td>Zoom 200%</td>
          <td>瀏覽器 Cmd + 5 次、看是否仍可用、無橫向 scroll</td>
          <td>AA 必要</td>
      </tr>
      <tr>
          <td>OS 字型放大</td>
          <td>macOS Display &gt; Text Size &gt; 大、看 layout</td>
          <td>AA 建議</td>
      </tr>
      <tr>
          <td>螢幕放大鏡</td>
          <td>macOS Zoom 4x、看絕對定位元件是否在可達範圍</td>
          <td>AA 建議</td>
      </tr>
  </tbody>
</table>
<p>每個 ~30 秒、開發完成前跑一輪、抓常見問題。</p>
<hr>
<h2 id="設計取捨色彩區分策略">設計取捨：色彩區分策略</h2>
<p>當資訊需要區分（hit / miss、selected / unselected）、四種做法：</p>
<h3 id="a顏色--形狀--位置雙重區分這個專案的預設">A：顏色 + 形狀 / 位置雙重區分（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：highlight 用黃底 + bold；selected radio 用色彩 + ✓ 圖示</li>
<li><strong>選 A 的理由</strong>：色弱使用者不靠顏色仍能辨識</li>
<li><strong>適合</strong>：絕大多數需要區分資訊的場景</li>
<li><strong>代價</strong>：UI 多一層視覺裝飾</li>
</ul>
<h3 id="b純顏色區分">B：純顏色區分</h3>
<ul>
<li><strong>機制</strong>：紅色 = 錯、綠色 = 對</li>
<li><strong>跟 A 的取捨</strong>：B 視覺乾淨、A 對色弱友善；B 違反 WCAG 1.4.1 Use of Color</li>
<li><strong>B 是反模式</strong>：違反 WCAG 1.4.1 Use of Color（合規層） — 色弱使用者完全無法區分對 / 錯</li>
</ul>
<h3 id="c純形狀--位置區分無顏色">C：純形狀 / 位置區分（無顏色）</h3>
<ul>
<li><strong>機制</strong>：用 ✓ / ✗ / 位置區分、不靠顏色</li>
<li><strong>跟 A 的取捨</strong>：C 對色彩無關、A 對視力正常使用者更直覺</li>
<li><strong>C 比 A 好的情境</strong>：列印 / 黑白渲染環境</li>
</ul>
<h3 id="d使用者可自訂顏色">D：使用者可自訂顏色</h3>
<ul>
<li><strong>機制</strong>：透過 CSS variable 讓使用者覆寫色彩</li>
<li><strong>跟 A 的取捨</strong>：D 提供無限彈性、實作成本高</li>
<li><strong>D 才合理的情境</strong>：core a11y 工具（如 reading mode）</li>
</ul>
<hr>
<h2 id="跟其他原則的關係">跟其他原則的關係</h2>
<table>
  <thead>
      <tr>
          <th>抽象層原則</th>
          <th>關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="../minimum-necessary-scope-is-sanity-defense/">#43 最小必要範圍</a></td>
          <td>字型放大下 layout 適配是「不依賴特定渲染條件」的應用</td>
      </tr>
      <tr>
          <td><a href="../single-source-of-truth/">#44 SSoT</a></td>
          <td>CSS 變數提供主題切換、變數住址唯一才能正確覆寫色彩</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該檢查的位置</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>色弱使用者反映找不到資訊</td>
          <td>DevTools Contrast Ratio + Emulate Vision Deficiencies</td>
      </tr>
      <tr>
          <td>低視力使用者反映 UI 跑到視窗外</td>
          <td>用螢幕放大鏡放 4x 確認 absolute 元件位置</td>
      </tr>
      <tr>
          <td>字型放大後 UI 破</td>
          <td>用瀏覽器 zoom 200% 與 OS text size 雙測</td>
      </tr>
      <tr>
          <td>Dark mode 下文字看不清</td>
          <td>該主題的對比度未驗證、補測</td>
      </tr>
      <tr>
          <td>「色弱使用者反正不多」當不做的理由</td>
          <td>視覺輔助使用者通常不會反映、只默默離開 — 量化檢查不靠使用者通報</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：視覺輔助使用者用的是「同一份程式、不同的 viewport / colour / scale」。WCAG 提供量化標準、開發階段可測 — 等使用者反映晚了。</p>
]]></content:encoded></item><item><title>CSS / JS Boundary — CSS / JS 邊界與 specificity 處理</title><link>https://tarrragon.github.io/blog/skills/frontend-with-playwright/css-js-boundary/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/frontend-with-playwright/css-js-boundary/</guid><description>&lt;p>CSS 跟 JS 各自負責什麼、邊界由「值能不能 build-time 定下來」決定。&lt;code>!important&lt;/code> / inline style / specificity 戰是訊號、不是工具。&lt;/p>
&lt;p>適用：寫 / 改 CSS 規則、決定 styling 該放 CSS 還是 JS、跟 vendor CSS 共存、檔案組織。
不適用：純 logic JS（沒涉及 styling）。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>自包含聲明&lt;/strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋 CSS-only vs JS-assisted 判準、class toggle 模式、CSS layers、variable 單一位置、檔案拆分。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="何時參閱本文件">何時參閱本文件&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>該做的第一件事&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>不確定值該寫進 CSS 還是 JS&lt;/td>
 &lt;td>問「能 build-time 定下來嗎」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>即將寫 &lt;code>!important&lt;/code>&lt;/td>
 &lt;td>停 — 換 CSS layers 思路&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>即將寫 &lt;code>el.style.setProperty(..., 'important')&lt;/code>&lt;/td>
 &lt;td>停 — 換 class toggle&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Inline &lt;code>&amp;lt;style&amp;gt;&lt;/code> / &lt;code>&amp;lt;script&amp;gt;&lt;/code> 超過 30 行&lt;/td>
 &lt;td>拆出獨立檔案、讓 Hugo / build pipeline 處理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CSS variable 在 3 個地方定義&lt;/td>
 &lt;td>集中到單一定義位置、其他地方只引用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Vendor CSS 跟自家 CSS 打 specificity 戰&lt;/td>
 &lt;td>&lt;code>@layer&lt;/code> 包 vendor、自家 unlayered 自動贏&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Runtime 量測值跟 hardcoded 值在同一個對齊基準上混用&lt;/td>
 &lt;td>全選一邊、不要混搭&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="為什麼-css--js-邊界要清楚">為什麼 CSS / JS 邊界要清楚&lt;/h2>
&lt;p>樣式邏輯散落在 inline style + CSS file + JS setProperty + &lt;code>!important&lt;/code> 的後果：&lt;/p>
&lt;ol>
&lt;li>改一個顏色要 grep 三個地方、其中一個改不到&lt;/li>
&lt;li>DevTools 看不出「為什麼這個值在這裡」（inline style 沒 class hint、important 是核武）&lt;/li>
&lt;li>升級 vendor 後 specificity 戰失敗、自家規則失效&lt;/li>
&lt;/ol>
&lt;p>清楚的邊界 = &lt;strong>CSS 描述「在某狀態下長什麼樣」、JS 切換狀態（toggle class / 寫 var）&lt;/strong>。樣式定義集中在 CSS、JS 不直接操作 inline style。&lt;/p>
&lt;hr>
&lt;h2 id="邊界判準值能不能-build-time-定下來">邊界判準：值能不能 build-time 定下來&lt;/h2>
&lt;h3 id="css-only值能-build-time-定下來">CSS-only：值能 build-time 定下來&lt;/h3>
&lt;ul>
&lt;li>Design token（&lt;code>--brand-color&lt;/code>、&lt;code>--gap-base&lt;/code>）&lt;/li>
&lt;li>固定 breakpoint / aspect ratio&lt;/li>
&lt;li>元件預設尺寸&lt;/li>
&lt;li>跨狀態的視覺差異（&lt;code>.expanded&lt;/code>、&lt;code>.loading&lt;/code>）&lt;/li>
&lt;/ul>
&lt;p>寫成 CSS variable + class toggle、JS 只負責加減 class。&lt;/p>
&lt;h3 id="js-assisted必須-runtime-才能知道">JS-assisted：必須 runtime 才能知道&lt;/h3>
&lt;ul>
&lt;li>Form 高度（隨字型 / line-height 變動）&lt;/li>
&lt;li>Container 寬度（隨 viewport / sidebar 變動）&lt;/li>
&lt;li>Scroll position&lt;/li>
&lt;li>元素的 bounding rect&lt;/li>
&lt;/ul>
&lt;p>JS 量測後&lt;strong>寫回 CSS variable&lt;/strong>、CSS 仍然只讀變數：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">formHeight&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">form&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getBoundingClientRect&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nx">height&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">documentElement&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">style&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setProperty&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;--form-height&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="sb">`&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">formHeight&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">px`&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-css" data-lang="css">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">scope&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="k">top&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">calc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nf">var&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="n">form&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">height&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nf">var&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">--&lt;/span>&lt;span class="n">gap&lt;/span>&lt;span class="p">));&lt;/span> &lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>CSS 不知道值怎麼來的、只知道讀 var — 換 framework / 換量測方式時、CSS 不動。&lt;/p></description><content:encoded><![CDATA[<p>CSS 跟 JS 各自負責什麼、邊界由「值能不能 build-time 定下來」決定。<code>!important</code> / inline style / specificity 戰是訊號、不是工具。</p>
<p>適用：寫 / 改 CSS 規則、決定 styling 該放 CSS 還是 JS、跟 vendor CSS 共存、檔案組織。
不適用：純 logic JS（沒涉及 styling）。</p>
<blockquote>
<p><strong>自包含聲明</strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋 CSS-only vs JS-assisted 判準、class toggle 模式、CSS layers、variable 單一位置、檔案拆分。</p></blockquote>
<hr>
<h2 id="何時參閱本文件">何時參閱本文件</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的第一件事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>不確定值該寫進 CSS 還是 JS</td>
          <td>問「能 build-time 定下來嗎」</td>
      </tr>
      <tr>
          <td>即將寫 <code>!important</code></td>
          <td>停 — 換 CSS layers 思路</td>
      </tr>
      <tr>
          <td>即將寫 <code>el.style.setProperty(..., 'important')</code></td>
          <td>停 — 換 class toggle</td>
      </tr>
      <tr>
          <td>Inline <code>&lt;style&gt;</code> / <code>&lt;script&gt;</code> 超過 30 行</td>
          <td>拆出獨立檔案、讓 Hugo / build pipeline 處理</td>
      </tr>
      <tr>
          <td>CSS variable 在 3 個地方定義</td>
          <td>集中到單一定義位置、其他地方只引用</td>
      </tr>
      <tr>
          <td>Vendor CSS 跟自家 CSS 打 specificity 戰</td>
          <td><code>@layer</code> 包 vendor、自家 unlayered 自動贏</td>
      </tr>
      <tr>
          <td>Runtime 量測值跟 hardcoded 值在同一個對齊基準上混用</td>
          <td>全選一邊、不要混搭</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="為什麼-css--js-邊界要清楚">為什麼 CSS / JS 邊界要清楚</h2>
<p>樣式邏輯散落在 inline style + CSS file + JS setProperty + <code>!important</code> 的後果：</p>
<ol>
<li>改一個顏色要 grep 三個地方、其中一個改不到</li>
<li>DevTools 看不出「為什麼這個值在這裡」（inline style 沒 class hint、important 是核武）</li>
<li>升級 vendor 後 specificity 戰失敗、自家規則失效</li>
</ol>
<p>清楚的邊界 = <strong>CSS 描述「在某狀態下長什麼樣」、JS 切換狀態（toggle class / 寫 var）</strong>。樣式定義集中在 CSS、JS 不直接操作 inline style。</p>
<hr>
<h2 id="邊界判準值能不能-build-time-定下來">邊界判準：值能不能 build-time 定下來</h2>
<h3 id="css-only值能-build-time-定下來">CSS-only：值能 build-time 定下來</h3>
<ul>
<li>Design token（<code>--brand-color</code>、<code>--gap-base</code>）</li>
<li>固定 breakpoint / aspect ratio</li>
<li>元件預設尺寸</li>
<li>跨狀態的視覺差異（<code>.expanded</code>、<code>.loading</code>）</li>
</ul>
<p>寫成 CSS variable + class toggle、JS 只負責加減 class。</p>
<h3 id="js-assisted必須-runtime-才能知道">JS-assisted：必須 runtime 才能知道</h3>
<ul>
<li>Form 高度（隨字型 / line-height 變動）</li>
<li>Container 寬度（隨 viewport / sidebar 變動）</li>
<li>Scroll position</li>
<li>元素的 bounding rect</li>
</ul>
<p>JS 量測後<strong>寫回 CSS variable</strong>、CSS 仍然只讀變數：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">const</span> <span class="nx">formHeight</span> <span class="o">=</span> <span class="nx">form</span><span class="p">.</span><span class="nx">getBoundingClientRect</span><span class="p">().</span><span class="nx">height</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">document</span><span class="p">.</span><span class="nx">documentElement</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="s1">&#39;--form-height&#39;</span><span class="p">,</span> <span class="sb">`</span><span class="si">${</span><span class="nx">formHeight</span><span class="si">}</span><span class="sb">px`</span><span class="p">);</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">scope</span> <span class="p">{</span> <span class="k">top</span><span class="p">:</span> <span class="nb">calc</span><span class="p">(</span><span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">form</span><span class="o">-</span><span class="n">height</span><span class="p">)</span> <span class="o">+</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">gap</span><span class="p">));</span> <span class="p">}</span></span></span></code></pre></div><p>CSS 不知道值怎麼來的、只知道讀 var — 換 framework / 換量測方式時、CSS 不動。</p>
<hr>
<h2 id="模式-1class-toggle-取代-inline-style">模式 1：Class toggle 取代 inline style</h2>
<h3 id="反例">反例</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// JS 直接設 inline style + important
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kd">function</span> <span class="nx">showScope</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">scope</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="s1">&#39;display&#39;</span><span class="p">,</span> <span class="s1">&#39;block&#39;</span><span class="p">,</span> <span class="s1">&#39;important&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="kd">function</span> <span class="nx">hideScope</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nx">scope</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="s1">&#39;display&#39;</span><span class="p">,</span> <span class="s1">&#39;none&#39;</span><span class="p">,</span> <span class="s1">&#39;important&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>DevTools 看到 inline style + important、不知道為什麼、難 debug。</p>
<h3 id="對例">對例</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">setScope</span><span class="p">(</span><span class="nx">visible</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">scope</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nx">toggle</span><span class="p">(</span><span class="s1">&#39;is-visible&#39;</span><span class="p">,</span> <span class="nx">visible</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">scope</span> <span class="p">{</span> <span class="k">display</span><span class="p">:</span> <span class="kc">none</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">scope</span><span class="p">.</span><span class="nc">is-visible</span> <span class="p">{</span> <span class="k">display</span><span class="p">:</span> <span class="kc">block</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><p>樣式留在 CSS、JS 只 toggle state。改視覺只動 CSS、改 logic 只動 JS。</p>
<hr>
<h2 id="模式-2css-layers-取代-specificity-戰">模式 2：CSS Layers 取代 specificity 戰</h2>
<h3 id="反例-1">反例</h3>
<p>自家規則被 vendor 的 <code>.pagefind-ui .target</code> 蓋過、寫 <code>.parent .container .target</code> 加 specificity、再不行加 <code>!important</code>。</p>
<h3 id="對例-1">對例</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">@</span><span class="k">layer</span> <span class="nt">vendor</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="p">@</span><span class="k">import</span> <span class="nt">url</span><span class="o">(</span><span class="s1">&#39;vendor/pagefind.css&#39;</span><span class="o">)</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c">/* 自家規則 unlayered → 自動贏所有 layered 規則 */</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">.</span><span class="nc">target</span> <span class="p">{</span> <span class="k">color</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">brand</span><span class="p">);</span> <span class="p">}</span></span></span></code></pre></div><p><code>@layer vendor</code> 把 vendor CSS 放進低優先級的 layer、自家 unlayered 規則自動贏。再也不用打 specificity 戰。</p>
<p><code>@layer</code> 在 Chrome 99+ / Firefox 97+ / Safari 15.4+ 全部支援（2022+）。</p>
<hr>
<h2 id="模式-3css-variable-單一定義位置">模式 3：CSS Variable 單一定義位置</h2>
<h3 id="反例-2">反例</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">:</span><span class="nd">root</span> <span class="p">{</span> <span class="nv">--gap</span><span class="p">:</span> <span class="mi">16</span><span class="kt">px</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">results</span> <span class="p">{</span> <span class="nv">--gap</span><span class="p">:</span> <span class="mi">16</span><span class="kt">px</span><span class="p">;</span> <span class="k">padding</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">gap</span><span class="p">);</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">.</span><span class="nc">scope</span> <span class="p">{</span> <span class="nv">--gap</span><span class="p">:</span> <span class="mi">16</span><span class="kt">px</span><span class="p">;</span> <span class="k">margin-top</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">gap</span><span class="p">);</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c">/* 三處定義、改一個地方漏改 */</span></span></span></code></pre></div><h3 id="對例-2">對例</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">:</span><span class="nd">root</span> <span class="p">{</span> <span class="nv">--gap</span><span class="p">:</span> <span class="mi">16</span><span class="kt">px</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">results</span> <span class="p">{</span> <span class="k">padding</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">gap</span><span class="p">);</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">.</span><span class="nc">scope</span> <span class="p">{</span> <span class="k">margin-top</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">gap</span><span class="p">);</span> <span class="p">}</span></span></span></code></pre></div><p>定義集中 <code>:root</code>（global）、<code>.page-search</code>（page-scoped）、或 <code>.pagefind-ui</code>（component-scoped）— <strong>挑最窄能涵蓋所有用途的 selector</strong>。其他地方只引用、不重新定義。</p>
<p>JS 寫 variable 也寫到同個 selector：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">document</span><span class="p">.</span><span class="nx">documentElement</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="s1">&#39;--form-height&#39;</span><span class="p">,</span> <span class="s1">&#39;...&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">// 而不是 form.style.setProperty(...) 在 form 上設
</span></span></span></code></pre></div><hr>
<h2 id="模式-4inline-程式碼超過-30-行就拆檔">模式 4：Inline 程式碼超過 30 行就拆檔</h2>
<h3 id="反例-3">反例</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">style</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="p">.</span><span class="nc">scope</span> <span class="p">{</span> <span class="err">...</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">.</span><span class="nc">results</span> <span class="p">{</span> <span class="err">...</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="c">/* ... 50 行 */</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">&lt;/</span><span class="nt">style</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">&lt;</span><span class="nt">script</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="kd">function</span> <span class="nx">decorate</span><span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="cm">/* ... 80 行 */</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">&lt;/</span><span class="nt">script</span><span class="p">&gt;</span></span></span></code></pre></div><p>問題：沒 syntax highlight、沒 minify、沒 fingerprint cache-bust、改一行整個 HTML reload。</p>
<h3 id="對例-3">對例</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl">{{ $css := resources.Get &#34;css/search.css&#34; | minify | fingerprint }}
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;stylesheet&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;{{ $css.RelPermalink }}&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl">{{ $js := resources.Get &#34;js/search.js&#34; | minify | fingerprint }}
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">&lt;</span><span class="nt">script</span> <span class="na">src</span><span class="o">=</span><span class="s">&#34;{{ $js.RelPermalink }}&#34;</span> <span class="na">defer</span><span class="p">&gt;&lt;/</span><span class="nt">script</span><span class="p">&gt;</span></span></span></code></pre></div><p>獨立檔案 → IDE 支援、build pipeline 處理 minify / fingerprint、cache-bust 自動。</p>
<hr>
<h2 id="模式-5runtime-量測模式統一">模式 5：Runtime 量測模式統一</h2>
<p>對齊基準上的尺寸值要嘛全寫死、要嘛全量測、不要混搭。</p>
<h3 id="反例-4">反例</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="c">/* form 高度寫死、gap 寫死、scope 用 measured 值 */</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">scope</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">top</span><span class="p">:</span> <span class="nb">calc</span><span class="p">(</span><span class="mi">72</span><span class="kt">px</span> <span class="o">+</span> <span class="mi">16</span><span class="kt">px</span> <span class="o">+</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">scope</span><span class="o">-</span><span class="n">measured</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Form 高度其實會隨字型變動 → 70px 或 76px → scope 跑位。</p>
<h3 id="對例-a全寫死">對例 A：全寫死</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">form</span> <span class="p">{</span> <span class="k">height</span><span class="p">:</span> <span class="mi">72</span><span class="kt">px</span><span class="p">;</span> <span class="p">}</span>  <span class="c">/* 強制固定高度 */</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">scope</span> <span class="p">{</span> <span class="k">top</span><span class="p">:</span> <span class="nb">calc</span><span class="p">(</span><span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">form</span><span class="o">-</span><span class="n">h</span><span class="p">)</span> <span class="o">+</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">gap</span><span class="p">));</span> <span class="p">}</span></span></span></code></pre></div><p>Form 強制固定高度、所有變數都是已知。</p>
<h3 id="對例-b全量測">對例 B：全量測</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">recalc</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">const</span> <span class="nx">fH</span> <span class="o">=</span> <span class="nx">form</span><span class="p">.</span><span class="nx">getBoundingClientRect</span><span class="p">().</span><span class="nx">height</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kr">const</span> <span class="nx">gap</span> <span class="o">=</span> <span class="nb">parseFloat</span><span class="p">(</span><span class="nx">getComputedStyle</span><span class="p">(</span><span class="nx">form</span><span class="p">).</span><span class="nx">marginBottom</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nb">document</span><span class="p">.</span><span class="nx">documentElement</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="s1">&#39;--form-h&#39;</span><span class="p">,</span> <span class="sb">`</span><span class="si">${</span><span class="nx">fH</span><span class="si">}</span><span class="sb">px`</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nb">document</span><span class="p">.</span><span class="nx">documentElement</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="s1">&#39;--gap&#39;</span><span class="p">,</span> <span class="sb">`</span><span class="si">${</span><span class="nx">gap</span><span class="si">}</span><span class="sb">px`</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="k">new</span> <span class="nx">ResizeObserver</span><span class="p">(</span><span class="nx">recalc</span><span class="p">).</span><span class="nx">observe</span><span class="p">(</span><span class="nx">form</span><span class="p">);</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">scope</span> <span class="p">{</span> <span class="k">top</span><span class="p">:</span> <span class="nb">calc</span><span class="p">(</span><span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">form</span><span class="o">-</span><span class="n">h</span><span class="p">)</span> <span class="o">+</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">gap</span><span class="p">));</span> <span class="p">}</span></span></span></code></pre></div><p>全部 runtime 算、CSS 只讀變數。</p>
<hr>
<h2 id="wrong-vs-right-對照">Wrong vs Right 對照</h2>
<h3 id="範例-1搜尋框背景色客製">範例 1：搜尋框背景色客製</h3>
<p><strong>錯</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">input</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="s1">&#39;background&#39;</span><span class="p">,</span> <span class="s1">&#39;#fff&#39;</span><span class="p">,</span> <span class="s1">&#39;important&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">input</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">setProperty</span><span class="p">(</span><span class="s1">&#39;color&#39;</span><span class="p">,</span> <span class="s1">&#39;#000&#39;</span><span class="p">,</span> <span class="s1">&#39;important&#39;</span><span class="p">);</span></span></span></code></pre></div><p><strong>對</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">@</span><span class="k">layer</span> <span class="nt">vendor</span> <span class="p">{</span> <span class="p">@</span><span class="k">import</span> <span class="s1">&#39;pagefind.css&#39;</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">.</span><span class="nc">pagefind-ui__search-input</span> <span class="p">{</span> <span class="k">background</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">bg</span><span class="p">);</span> <span class="k">color</span><span class="p">:</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="kc">text</span><span class="p">);</span> <span class="p">}</span></span></span></code></pre></div><p>JS 不需要參與、純 CSS 解。</p>
<h3 id="範例-2跨-viewport-的-sidebar-切換">範例 2：跨 viewport 的 sidebar 切換</h3>
<p><strong>錯</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">window</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;resize&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nb">window</span><span class="p">.</span><span class="nx">innerWidth</span> <span class="o">&gt;=</span> <span class="mi">1400</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">sidebar</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">display</span> <span class="o">=</span> <span class="s1">&#39;block&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">sidebar</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">display</span> <span class="o">=</span> <span class="s1">&#39;none&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p><strong>對</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">sidebar</span> <span class="p">{</span> <span class="k">display</span><span class="p">:</span> <span class="kc">none</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">@</span><span class="k">media</span> <span class="o">(</span><span class="nt">min-width</span><span class="o">:</span> <span class="nt">1400px</span><span class="o">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">.</span><span class="nc">sidebar</span> <span class="p">{</span> <span class="k">display</span><span class="p">:</span> <span class="kc">block</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>值（1400）能 build-time 定下來 → CSS media query 直接寫、不需要 JS resize listener。</p>
<hr>
<h2 id="自檢清單dogfooding">自檢清單（dogfooding）</h2>
<p>寫樣式相關 code 前：</p>
<ul>
<li><input disabled="" type="checkbox"> 我有沒有問「這個值能不能 build-time 定下來」？</li>
<li><input disabled="" type="checkbox"> 我有沒有用 <code>!important</code> / inline <code>setProperty(..., 'important')</code>？（如果有 → 換成 class toggle）</li>
<li><input disabled="" type="checkbox"> 我有沒有跟 vendor CSS 打 specificity 戰？（如果有 → 用 <code>@layer</code>）</li>
<li><input disabled="" type="checkbox"> CSS variable 是不是只在一個地方定義？</li>
<li><input disabled="" type="checkbox"> Inline <code>&lt;style&gt;</code> / <code>&lt;script&gt;</code> 是不是 &lt; 30 行？（超過就拆檔）</li>
<li><input disabled="" type="checkbox"> Runtime 量測跟 hardcoded 值在同一個對齊基準上、是不是只用了一邊？</li>
</ul>
<hr>
<h2 id="延伸閱讀">延伸閱讀</h2>
<p>對應的事後檢討（在 <code>content/report/</code>）：</p>
<ul>
<li><a href="/blog/report/css-only-vs-js-assisted/" data-link-title="排版精度的工具選擇：CSS-only vs JS-assisted" data-link-desc="CSS 適合 build-time 可決定的 layout、JS 適合 runtime 才知道的尺寸與 DOM 移動。混淆兩者會讓 layout 跟 framework 渲染週期競爭。本文展開選擇規則。">css-only-vs-js-assisted</a> — 排版精度的工具選擇</li>
<li><a href="/blog/report/class-toggle-over-important/" data-link-title="以 class toggle 取代 inline `display: none !important`" data-link-desc="JS 用 `el.style.setProperty(&#39;display&#39;, &#39;none&#39;, &#39;important&#39;)` 是低層次 hack。在 CSS Layers 環境下、用語意化 class &#43; JS toggle 可以更乾淨、更易 debug。">class-toggle-over-important</a> — class toggle 取代 inline <code>display:none !important</code></li>
<li><a href="/blog/report/css-layers-over-specificity/" data-link-title="CSS Layers 取代 specificity 戰" data-link-desc="用 @import url(&#39;vendor.css&#39;) layer(vendor) 把外部組件 CSS 包進低權層、自家 CSS 留在 unlayered 自動贏 — 不論 specificity 數值。本文展開取代 !important 與雙寫的方法。">css-layers-over-specificity</a> — CSS Layers 取代 specificity 戰</li>
<li><a href="/blog/report/css-variable-single-location/" data-link-title="CSS 變數定義位置統一" data-link-desc="CSS 變數一次定義在離 root 最近的合適位置、其他地方只引用、不重複宣告。改 token 只動一處、避免散落多處難同步。">css-variable-single-location</a> — CSS 變數定義位置統一</li>
<li><a href="/blog/report/extract-css-js-files/" data-link-title="CSS / JS 拆出獨立檔案" data-link-desc="Hugo template 內 inline CSS / JS 超過 30 行就值得拆檔、走 resources pipeline。本文展開拆檔的理由、步驟、與得益。">extract-css-js-files</a> — CSS / JS 拆出獨立檔案</li>
<li><a href="/blog/report/runtime-measurement-unification/" data-link-title="runtime 量測模式統一" data-link-desc="對齊基準上的所有元素、要嘛全部寫死、要嘛全部用 ResizeObserver 量測 — 不要混搭。混搭時某些字型 / theme 變化會打破對齊、且難以重現。">runtime-measurement-unification</a> — runtime 量測模式統一</li>
</ul>
<hr>
<p><strong>Last Updated</strong>: 2026-04-26
<strong>Version</strong>: 0.1.0</p>
]]></content:encoded></item><item><title>DOM Topology First — 寫 CSS 前先確認 DOM 結構</title><link>https://tarrragon.github.io/blog/skills/frontend-with-playwright/dom-topology-first/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/frontend-with-playwright/dom-topology-first/</guid><description>&lt;p>寫 CSS 規則之前、先讀真實 DOM tree — class name 是約定、不是結構保證。Selector 設計從最精準起步、有證據再放寬。&lt;/p>
&lt;p>適用：寫 / 改 CSS 規則、設計 JS query selector、判斷是否該改 layout 結構。
不適用：純邏輯演算法（沒有 DOM）。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>自包含聲明&lt;/strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋 DOM 量測方法、selector 三維度設計、四種起點的取捨。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="何時參閱本文件">何時參閱本文件&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>該做的第一件事&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>即將寫 CSS 規則但只看過 class name、沒看過真實 DOM&lt;/td>
 &lt;td>playwright 量 ancestor chain&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Selector 命中超出預期的元素&lt;/td>
 &lt;td>把 selector 加上起點 + 範圍 + 過濾三維度&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>規則寫了但不生效&lt;/td>
 &lt;td>DevTools Computed → 看誰實際贏了&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Class name 含 &lt;code>__inner&lt;/code> &lt;code>__wrapper&lt;/code> 但不確定是直接子節點&lt;/td>
 &lt;td>playwright 讀 parent / child 關係&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>想用 &lt;code>document.querySelectorAll('.target')&lt;/code>&lt;/td>
 &lt;td>先評估「起點要不要從元件根」&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="為什麼-dom-topology-要先確認">為什麼 DOM topology 要先確認&lt;/h2>
&lt;p>CSS 行為由「規則 + DOM tree + 樣式繼承 + 框架渲染」四個變數共同決定。&lt;strong>靜態推理只能基於假設的 DOM tree&lt;/strong> — 假設錯了、推理就錯。&lt;/p>
&lt;p>Class name 是命名約定 — &lt;code>pagefind-ui__drawer&lt;/code> 看起來像 &lt;code>.pagefind-ui&lt;/code> 的 child，但實際可能是 &lt;code>pagefind-ui__form&lt;/code> 的 child。命名告訴你「這是 drawer」、不告訴你「在哪一層」。&lt;/p>
&lt;p>跳過 DOM 確認的代價：寫了 N 條 CSS 規則、推理為什麼不生效、加 specificity / &lt;code>!important&lt;/code> / &lt;code>display: contents&lt;/code> — 全部基於錯假設。&lt;/p>
&lt;hr>
&lt;h2 id="量-dom-的最小-query">量 DOM 的最小 query&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// ancestor chain
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kr">async&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">el&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.target&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="kd">let&lt;/span> &lt;span class="nx">chain&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[];&lt;/span> &lt;span class="kd">let&lt;/span> &lt;span class="nx">n&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">el&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">n&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">n&lt;/span> &lt;span class="o">!==&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">body&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">chain&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">push&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">tagName&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">.&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">className&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">n&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">parentElement&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">chain&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>返回值告訴你目標元素在 DOM tree 哪個位置、parent / sibling 是誰。寫 CSS 規則前 30 秒能省掉後續 30 分鐘推理。&lt;/p>
&lt;hr>
&lt;h2 id="selector-設計三維度">Selector 設計三維度&lt;/h2>
&lt;p>精準的 selector = &lt;strong>起點 + 範圍 + 過濾&lt;/strong> 三維度顯式設計、不是「能命中就好」。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>問題&lt;/th>
 &lt;th>答案類型&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>起點&lt;/td>
 &lt;td>從哪個 DOM 節點開始 query&lt;/td>
 &lt;td>document / 元件根 / 函式參數 / closest()&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>範圍&lt;/td>
 &lt;td>要找直接子節點還是子孫&lt;/td>
 &lt;td>&lt;code>&amp;gt;&lt;/code> 直接子 / &lt;code>&amp;gt; ... &amp;gt; ...&lt;/code> 多層 / 空格 子孫&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>過濾&lt;/td>
 &lt;td>要排除哪些元素 / 已處理的&lt;/td>
 &lt;td>&lt;code>:not()&lt;/code> / &lt;code>[data-processed]&lt;/code> / WeakMap 檢查&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="起點四選一依情境">起點四選一（依情境）&lt;/h2>
&lt;h3 id="起點-adocument-全文件-query">起點 A：Document 全文件 query&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.target&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>用&lt;/strong>：原型期、單例（整頁只一個）、跨元件邊界元素。
&lt;strong>不用&lt;/strong>：production 客製、可能多實例、效能敏感（大頁面）。&lt;/p></description><content:encoded><![CDATA[<p>寫 CSS 規則之前、先讀真實 DOM tree — class name 是約定、不是結構保證。Selector 設計從最精準起步、有證據再放寬。</p>
<p>適用：寫 / 改 CSS 規則、設計 JS query selector、判斷是否該改 layout 結構。
不適用：純邏輯演算法（沒有 DOM）。</p>
<blockquote>
<p><strong>自包含聲明</strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋 DOM 量測方法、selector 三維度設計、四種起點的取捨。</p></blockquote>
<hr>
<h2 id="何時參閱本文件">何時參閱本文件</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的第一件事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>即將寫 CSS 規則但只看過 class name、沒看過真實 DOM</td>
          <td>playwright 量 ancestor chain</td>
      </tr>
      <tr>
          <td>Selector 命中超出預期的元素</td>
          <td>把 selector 加上起點 + 範圍 + 過濾三維度</td>
      </tr>
      <tr>
          <td>規則寫了但不生效</td>
          <td>DevTools Computed → 看誰實際贏了</td>
      </tr>
      <tr>
          <td>Class name 含 <code>__inner</code> <code>__wrapper</code> 但不確定是直接子節點</td>
          <td>playwright 讀 parent / child 關係</td>
      </tr>
      <tr>
          <td>想用 <code>document.querySelectorAll('.target')</code></td>
          <td>先評估「起點要不要從元件根」</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="為什麼-dom-topology-要先確認">為什麼 DOM topology 要先確認</h2>
<p>CSS 行為由「規則 + DOM tree + 樣式繼承 + 框架渲染」四個變數共同決定。<strong>靜態推理只能基於假設的 DOM tree</strong> — 假設錯了、推理就錯。</p>
<p>Class name 是命名約定 — <code>pagefind-ui__drawer</code> 看起來像 <code>.pagefind-ui</code> 的 child，但實際可能是 <code>pagefind-ui__form</code> 的 child。命名告訴你「這是 drawer」、不告訴你「在哪一層」。</p>
<p>跳過 DOM 確認的代價：寫了 N 條 CSS 規則、推理為什麼不生效、加 specificity / <code>!important</code> / <code>display: contents</code> — 全部基於錯假設。</p>
<hr>
<h2 id="量-dom-的最小-query">量 DOM 的最小 query</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// ancestor chain
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kr">const</span> <span class="nx">el</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="kd">let</span> <span class="nx">chain</span> <span class="o">=</span> <span class="p">[];</span> <span class="kd">let</span> <span class="nx">n</span> <span class="o">=</span> <span class="nx">el</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="k">while</span> <span class="p">(</span><span class="nx">n</span> <span class="o">&amp;&amp;</span> <span class="nx">n</span> <span class="o">!==</span> <span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">chain</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="sb">`</span><span class="si">${</span><span class="nx">n</span><span class="p">.</span><span class="nx">tagName</span><span class="si">}</span><span class="sb">.</span><span class="si">${</span><span class="nx">n</span><span class="p">.</span><span class="nx">className</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">n</span> <span class="o">=</span> <span class="nx">n</span><span class="p">.</span><span class="nx">parentElement</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="k">return</span> <span class="nx">chain</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>返回值告訴你目標元素在 DOM tree 哪個位置、parent / sibling 是誰。寫 CSS 規則前 30 秒能省掉後續 30 分鐘推理。</p>
<hr>
<h2 id="selector-設計三維度">Selector 設計三維度</h2>
<p>精準的 selector = <strong>起點 + 範圍 + 過濾</strong> 三維度顯式設計、不是「能命中就好」。</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>問題</th>
          <th>答案類型</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>起點</td>
          <td>從哪個 DOM 節點開始 query</td>
          <td>document / 元件根 / 函式參數 / closest()</td>
      </tr>
      <tr>
          <td>範圍</td>
          <td>要找直接子節點還是子孫</td>
          <td><code>&gt;</code> 直接子 / <code>&gt; ... &gt; ...</code> 多層 / 空格 子孫</td>
      </tr>
      <tr>
          <td>過濾</td>
          <td>要排除哪些元素 / 已處理的</td>
          <td><code>:not()</code> / <code>[data-processed]</code> / WeakMap 檢查</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="起點四選一依情境">起點四選一（依情境）</h2>
<h3 id="起點-adocument-全文件-query">起點 A：Document 全文件 query</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">);</span></span></span></code></pre></div><p><strong>用</strong>：原型期、單例（整頁只一個）、跨元件邊界元素。
<strong>不用</strong>：production 客製、可能多實例、效能敏感（大頁面）。</p>
<h3 id="起點-b元件根變數-query">起點 B：元件根變數 query</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">const</span> <span class="nx">root</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">root</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">);</span>  <span class="c1">// 從 root 起
</span></span></span></code></pre></div><p><strong>用</strong>：production 客製、客製只該影響該元件、避免命中其他頁面同名元素。
<strong>不用</strong>：跨多元件邊界的 query。</p>
<h3 id="起點-c起點當函式參數">起點 C：起點當函式參數</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">decorate</span><span class="p">(</span><span class="nx">root</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">return</span> <span class="nx">root</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><strong>用</strong>：library / utility function、需要支援多實例、純函式設計。
<strong>不用</strong>：一次性腳本（多餘的抽象）。</p>
<h3 id="起點-dclosest-反向找根">起點 D：closest() 反向找根</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">button</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;click&#39;</span><span class="p">,</span> <span class="nx">e</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">const</span> <span class="nx">card</span> <span class="o">=</span> <span class="nx">e</span><span class="p">.</span><span class="nx">target</span><span class="p">.</span><span class="nx">closest</span><span class="p">(</span><span class="s1">&#39;.result-card&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">card</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nx">add</span><span class="p">(</span><span class="s1">&#39;expanded&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p><strong>用</strong>：動態 / 多實例元件、event delegation、不知道事件源在哪一層。
<strong>不用</strong>：靜態起點已知（用 B 或 C 更直接）。</p>
<hr>
<h2 id="範圍-還是空格">範圍：<code>&gt;</code> 還是空格</h2>
<table>
  <thead>
      <tr>
          <th>寫法</th>
          <th>意思</th>
          <th>風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>.parent &gt; .child</code></td>
          <td>直接子節點</td>
          <td>安全、嚴格</td>
      </tr>
      <tr>
          <td><code>.parent .child</code></td>
          <td>任意深度子孫</td>
          <td>命中 nested 結構的同類元素</td>
      </tr>
      <tr>
          <td><code>.parent &gt; * &gt; .x</code></td>
          <td>確切兩層</td>
          <td>嚴格、結構變動時要更新</td>
      </tr>
      <tr>
          <td><code>.parent .x:not(.y)</code></td>
          <td>子孫中排除某類</td>
          <td>還是子孫範圍、:not 是過濾不是限制範圍</td>
      </tr>
  </tbody>
</table>
<p>預設 <code>&gt;</code>、有證據（多層 nested 結構都該 match）才放寬到空格。</p>
<hr>
<h2 id="過濾idempotency-標記">過濾：idempotency 標記</h2>
<p>JS 處理元素時、避免重複處理。兩種做法：</p>
<h3 id="adom-attribute-標記">A：DOM attribute 標記</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">function</span> <span class="nx">decorate</span><span class="p">(</span><span class="nx">root</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">const</span> <span class="nx">targets</span> <span class="o">=</span> <span class="nx">root</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.target:not([data-decorated])&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">targets</span><span class="p">.</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="c1">// ... 處理
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span>    <span class="nx">el</span><span class="p">.</span><span class="nx">setAttribute</span><span class="p">(</span><span class="s1">&#39;data-decorated&#39;</span><span class="p">,</span> <span class="s1">&#39;&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><strong>用</strong>：production 預設、devtools 可見、跨 page reload 也保留（如果元素持久）。
<strong>不用</strong>：library 設計（不該污染使用者 DOM）。</p>
<h3 id="bweakmap-紀錄">B：WeakMap 紀錄</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">const</span> <span class="nx">decorated</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">WeakMap</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">function</span> <span class="nx">decorate</span><span class="p">(</span><span class="nx">root</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">root</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">decorated</span><span class="p">.</span><span class="nx">has</span><span class="p">(</span><span class="nx">el</span><span class="p">))</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="c1">// ... 處理
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span>    <span class="nx">decorated</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="nx">el</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><strong>用</strong>：library 設計、不污染 DOM、元素 GC 後紀錄自動清。
<strong>不用</strong>：跨頁面、需要 devtools debug、需要 CSS selector 過濾（CSS 看不到 WeakMap）。</p>
<hr>
<h2 id="wrong-vs-right-對照">Wrong vs Right 對照</h2>
<h3 id="範例-1寫-css-前沒看-dom">範例 1：寫 CSS 前沒看 DOM</h3>
<blockquote>
<p>任務：把 <code>pagefind-ui__drawer</code> 排到 <code>pagefind-ui__form</code> 下方</p></blockquote>
<p><strong>錯</strong>（基於 class 命名假設）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">pagefind-ui</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">display</span><span class="p">:</span> <span class="k">grid</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">grid-template-rows</span><span class="p">:</span> <span class="kc">auto</span> <span class="kc">auto</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">.</span><span class="nc">pagefind-ui__form</span> <span class="p">{</span> <span class="k">grid-row</span><span class="p">:</span> <span class="mi">1</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">.</span><span class="nc">pagefind-ui__drawer</span> <span class="p">{</span> <span class="k">grid-row</span><span class="p">:</span> <span class="mi">2</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><p>跑出來 drawer 跑到頁尾、grid-row 完全沒生效。</p>
<p><strong>對</strong>（先量 DOM）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">const</span> <span class="nx">drawer</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__drawer&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kd">let</span> <span class="nx">chain</span> <span class="o">=</span> <span class="p">[];</span> <span class="kd">let</span> <span class="nx">n</span> <span class="o">=</span> <span class="nx">drawer</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">while</span> <span class="p">(</span><span class="nx">n</span> <span class="o">&amp;&amp;</span> <span class="nx">n</span> <span class="o">!==</span> <span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">)</span> <span class="p">{</span> <span class="nx">chain</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="sb">`</span><span class="si">${</span><span class="nx">n</span><span class="p">.</span><span class="nx">tagName</span><span class="si">}</span><span class="sb">.</span><span class="si">${</span><span class="nx">n</span><span class="p">.</span><span class="nx">className</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span> <span class="nx">n</span> <span class="o">=</span> <span class="nx">n</span><span class="p">.</span><span class="nx">parentElement</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="k">return</span> <span class="nx">chain</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">// 返回：[DIV.pagefind-ui__drawer, FORM.pagefind-ui__form, DIV.pagefind-ui]
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1">// → drawer 是 form 的 child、不是 sibling
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1">// → grid-row 在 .pagefind-ui 上設、無法控制 form 的 child
</span></span></span></code></pre></div><p>→ 換方向：drawer 改 absolute、form 加 margin-bottom 留 spacer。</p>
<h3 id="範例-2selector-過寬命中無關元素">範例 2：selector 過寬命中無關元素</h3>
<p><strong>錯</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.title&#39;</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="nx">el</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nx">add</span><span class="p">(</span><span class="s1">&#39;search-title&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">// 命中 page header 的 .title、navbar 的 .title、結果卡的 .title — 全變色
</span></span></span></code></pre></div><p><strong>對</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">const</span> <span class="nx">root</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">root</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;:scope &gt; .results &gt; .result &gt; .title&#39;</span><span class="p">).</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="nx">el</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nx">add</span><span class="p">(</span><span class="s1">&#39;search-title&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">// 起點 = .pagefind-ui、範圍 = 確切三層、過濾 = 不需要（已精準）
</span></span></span></code></pre></div><hr>
<h2 id="自檢清單dogfooding">自檢清單（dogfooding）</h2>
<p>寫 CSS 規則或 JS query 前：</p>
<ul>
<li><input disabled="" type="checkbox"> 我有沒有量過真實 DOM tree（playwright <code>browser_evaluate</code> 或 DevTools）？</li>
<li><input disabled="" type="checkbox"> Selector 的「起點」明確嗎？是 document / 元件根 / 函式參數 / closest 哪一個？</li>
<li><input disabled="" type="checkbox"> Selector 的「範圍」明確嗎？是 <code>&gt;</code> 直接子還是空格子孫？</li>
<li><input disabled="" type="checkbox"> Selector 的「過濾」明確嗎？需要 idempotency 標記嗎？</li>
<li><input disabled="" type="checkbox"> 過寬的 selector（<code>document.querySelectorAll('*')</code>、<code>[class*=&quot;x&quot;]</code>）能不能換成更精準的？</li>
</ul>
<p>任一項打勾失敗 → 補上、再寫規則。</p>
<hr>
<h2 id="延伸閱讀">延伸閱讀</h2>
<p>對應的事後檢討（在 <code>content/report/</code>）：</p>
<ul>
<li><a href="/blog/report/dom-topology-before-css/" data-link-title="拓樸理解先行於 CSS 規則" data-link-desc="寫 CSS 之前看真實 DOM tree、不靠 class name 推測層級。本文以『drawer 在 form 內、不是 form 的 sibling』這個假設錯誤為例，展開『拓樸理解 → CSS 規則』的順序。">dom-topology-before-css</a> — 拓樸理解先行於 CSS 規則</li>
<li><a href="/blog/report/dom-selector-precision/" data-link-title="Selector 精準度：讓 query 只命中你想要的元素" data-link-desc="JS 的 DOM query 是 sanity 防線、不是優化選項。從『起點 / 範圍 / 過濾』三層收斂、避免誤命中、避免未來頁面結構變動讓 query 撈到不該撈的東西。本文是 selector 設計的完整指引。">dom-selector-precision</a> — Selector 精準度三維度</li>
<li><a href="/blog/report/pattern-document-query/" data-link-title="Pattern：Document 全文件 query" data-link-desc="`document.querySelector` 從整個頁面找元素 — 是探索期與一次性 script 的合理工具、不是 production 客製的預設。本文展開這個 pattern 的適用邊界。">pattern-document-query</a> / <a href="/blog/report/pattern-component-root/" data-link-title="Pattern：元件根變數 query" data-link-desc="把元件根 `var shell = document.querySelector(&#39;.shell&#39;)` 一次存變數、之後所有 query 從 shell 開始 — 是 production 客製的預設起點。本文展開這個 pattern 的設計細節與邊界。">pattern-component-root</a> / <a href="/blog/report/pattern-root-as-parameter/" data-link-title="Pattern：起點當函式參數" data-link-desc="把元件根當函式參數傳入 — `function setup(shell) { shell.querySelector(...) }`、外部呼叫 `forEach(setup)` 處理多實例。本文展開純函式設計與多實例支援的取捨。">pattern-root-as-parameter</a> / <a href="/blog/report/pattern-closest-lookup/" data-link-title="Pattern：closest 反向找根" data-link-desc="事件處理時用 `e.target.closest(&#39;.shell&#39;)` 從事件目標反向找元件根 — 適合動態元件、SPA 路由切換、事件委派場景。本文展開反向定位 pattern 的應用邊界。">pattern-closest-lookup</a> — 起點四選一 pattern 卡片</li>
<li><a href="/blog/report/pattern-attribute-idempotency-marker/" data-link-title="Pattern：DOM attribute idempotency 標記" data-link-desc="用 `:not([data-x])` 過濾 &#43; 處理後 `setAttribute(&#39;data-x&#39;, &#39;true&#39;)` 保證每元素只處理一次 — 是 production apply 函式的預設 idempotency 工具。本文展開命名、生命週期、跟 framework 共處的設計細節。">pattern-attribute-idempotency-marker</a> / <a href="/blog/report/pattern-weakmap-idempotency-record/" data-link-title="Pattern：WeakMap idempotency 紀錄" data-link-desc="用 `WeakMap` 紀錄已處理的元素 — 不污染 DOM、適合第三方 library、跟 framework 衝突場景。本文展開 GC 行為、debug 替代方案、跟 attribute 標記的取捨。">pattern-weakmap-idempotency-record</a> — Idempotency 兩選一</li>
</ul>
<hr>
<p><strong>Last Updated</strong>: 2026-04-26
<strong>Version</strong>: 0.1.0</p>
]]></content:encoded></item><item><title>Frontend with Playwright — SKILL 入口</title><link>https://tarrragon.github.io/blog/skills/frontend-with-playwright/skill/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/frontend-with-playwright/skill/</guid><description>&lt;p>框架無關的前端開發協議 + Playwright 驗證。原則適用於 vanilla HTML/CSS/JS、Vue、React、jQuery — 因為核心是「DOM / CSS / JS 三者的本質行為」加上「Playwright 用 live DOM 量測驗證」、不依賴特定框架的渲染機制。&lt;/p>
&lt;p>協議的核心命題：&lt;strong>先讀真實狀態、再寫規則；先量再改、不要靠假設&lt;/strong>。前端 bug 多半來自「寫 CSS 時假設的 DOM 結構與實際不符」、「JS 改完元素被 framework 還原」、「listener 觸發頻率失控」。Playwright 把這些假設變成可驗證的量測值。&lt;/p>
&lt;hr>
&lt;h2 id="core-pillars支柱">Core Pillars（支柱）&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>支柱&lt;/th>
 &lt;th>意義&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>Read Before Write&lt;/strong> 先讀真實狀態&lt;/td>
 &lt;td>寫 CSS 前用 playwright/DevTools 量真實 DOM；寫 JS 前確認 framework 邊界&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>CSS-First, JS-Augment&lt;/strong> CSS 為主、JS 補強&lt;/td>
 &lt;td>能 build-time 算的進 CSS、必須 runtime 量測的進 JS、邊界清楚不混搭&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Measure, Don&amp;rsquo;t Assume&lt;/strong> 量測、不要假設&lt;/td>
 &lt;td>Layout / 行為 / 互動三層、用 playwright &lt;code>browser_evaluate&lt;/code> 把假設變已知&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="principles原則速查">Principles（原則速查）&lt;/h2>
&lt;p>讀者在本區塊能完成大方向判斷；具體展開（步驟 / 範例）依下方「觸發路由」進對應 reference。&lt;/p>
&lt;h3 id="1-寫-css-前先確認-dom-topology">1. 寫 CSS 前先確認 DOM topology&lt;/h3>
&lt;p>Class name 是約定、不是結構保證。寫 CSS 規則之前、用 playwright &lt;code>browser_evaluate&lt;/code> 讀目標元素的 ancestor chain — 確認它在 DOM tree 的哪個位置、parent / sibling / 共用的 grid cell 是什麼。&lt;/p>
&lt;p>Selector 設計三維度：&lt;strong>起點（document / 元件根 / 函式參數 / closest）+ 範圍（直接子節點 / 子孫）+ 過濾（attribute / 已處理標記）&lt;/strong>。預設用最精準的、有證據再放寬。&lt;/p>
&lt;h3 id="2-css--js-的邊界由值能否-build-time-定下來決定">2. CSS / JS 的邊界由「值能否 build-time 定下來」決定&lt;/h3>
&lt;p>能在 build time 算出來的值（design token、固定 breakpoint、靜態尺寸）→ 寫進 CSS variable / static rule。&lt;strong>必須 runtime 才能知道的值&lt;/strong>（form 高度、scroll 位置、container 寬度）→ JS 量測後寫回 CSS variable、CSS 仍然只讀變數。&lt;/p>
&lt;p>JS 的職責是 &lt;strong>toggle class / 寫 var&lt;/strong>、不是設 inline style。&lt;code>!important&lt;/code> / inline &lt;code>display: none&lt;/code> 是 anti-pattern — 改用 class toggle 把樣式留在 CSS。Vendor CSS 用 &lt;code>@layer&lt;/code> 包起來、自家 unlayered 自動贏 specificity。&lt;/p>
&lt;h3 id="3-playwright-在開發循環的三個位置">3. Playwright 在開發循環的三個位置&lt;/h3>
&lt;p>&lt;strong>位置 1：假設驗證&lt;/strong>（寫 CSS 前）— 讀 ancestor chain、確認結構符合假設。
&lt;strong>位置 2：行為驗證&lt;/strong>（規則寫完後）— 讀 bounding rect / computed style、確認 layout 結果。
&lt;strong>位置 3：互動驗證&lt;/strong>（dispatch event 後讀 state）— 模擬 input / click、量化驗證互動結果。&lt;/p></description><content:encoded><![CDATA[<p>框架無關的前端開發協議 + Playwright 驗證。原則適用於 vanilla HTML/CSS/JS、Vue、React、jQuery — 因為核心是「DOM / CSS / JS 三者的本質行為」加上「Playwright 用 live DOM 量測驗證」、不依賴特定框架的渲染機制。</p>
<p>協議的核心命題：<strong>先讀真實狀態、再寫規則；先量再改、不要靠假設</strong>。前端 bug 多半來自「寫 CSS 時假設的 DOM 結構與實際不符」、「JS 改完元素被 framework 還原」、「listener 觸發頻率失控」。Playwright 把這些假設變成可驗證的量測值。</p>
<hr>
<h2 id="core-pillars支柱">Core Pillars（支柱）</h2>
<table>
  <thead>
      <tr>
          <th>支柱</th>
          <th>意義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Read Before Write</strong> 先讀真實狀態</td>
          <td>寫 CSS 前用 playwright/DevTools 量真實 DOM；寫 JS 前確認 framework 邊界</td>
      </tr>
      <tr>
          <td><strong>CSS-First, JS-Augment</strong> CSS 為主、JS 補強</td>
          <td>能 build-time 算的進 CSS、必須 runtime 量測的進 JS、邊界清楚不混搭</td>
      </tr>
      <tr>
          <td><strong>Measure, Don&rsquo;t Assume</strong> 量測、不要假設</td>
          <td>Layout / 行為 / 互動三層、用 playwright <code>browser_evaluate</code> 把假設變已知</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="principles原則速查">Principles（原則速查）</h2>
<p>讀者在本區塊能完成大方向判斷；具體展開（步驟 / 範例）依下方「觸發路由」進對應 reference。</p>
<h3 id="1-寫-css-前先確認-dom-topology">1. 寫 CSS 前先確認 DOM topology</h3>
<p>Class name 是約定、不是結構保證。寫 CSS 規則之前、用 playwright <code>browser_evaluate</code> 讀目標元素的 ancestor chain — 確認它在 DOM tree 的哪個位置、parent / sibling / 共用的 grid cell 是什麼。</p>
<p>Selector 設計三維度：<strong>起點（document / 元件根 / 函式參數 / closest）+ 範圍（直接子節點 / 子孫）+ 過濾（attribute / 已處理標記）</strong>。預設用最精準的、有證據再放寬。</p>
<h3 id="2-css--js-的邊界由值能否-build-time-定下來決定">2. CSS / JS 的邊界由「值能否 build-time 定下來」決定</h3>
<p>能在 build time 算出來的值（design token、固定 breakpoint、靜態尺寸）→ 寫進 CSS variable / static rule。<strong>必須 runtime 才能知道的值</strong>（form 高度、scroll 位置、container 寬度）→ JS 量測後寫回 CSS variable、CSS 仍然只讀變數。</p>
<p>JS 的職責是 <strong>toggle class / 寫 var</strong>、不是設 inline style。<code>!important</code> / inline <code>display: none</code> 是 anti-pattern — 改用 class toggle 把樣式留在 CSS。Vendor CSS 用 <code>@layer</code> 包起來、自家 unlayered 自動贏 specificity。</p>
<h3 id="3-playwright-在開發循環的三個位置">3. Playwright 在開發循環的三個位置</h3>
<p><strong>位置 1：假設驗證</strong>（寫 CSS 前）— 讀 ancestor chain、確認結構符合假設。
<strong>位置 2：行為驗證</strong>（規則寫完後）— 讀 bounding rect / computed style、確認 layout 結果。
<strong>位置 3：互動驗證</strong>（dispatch event 後讀 state）— 模擬 input / click、量化驗證互動結果。</p>
<p>第 2 次同個版型 bug → 把 query 寫成 playwright 測試固化、CI 防回歸。</p>
<h3 id="4-與-framework-managed-dom-共處的邊界辨識">4. 與 framework-managed DOM 共處的邊界辨識</h3>
<p>把 framework 子樹當「禁區」、客製 UI 注入到 framework 邊界外、用 CSS 控制視覺位置（absolute / margin / grid）。框架重渲染時、邊界外的客製 UI 不被 reconcile 清掉。</p>
<p><strong>JS 操作的邊界穩定性</strong>（從穩到不穩）：reparent 整節點 &gt; 改 inline style &gt; 改 attribute &gt; 改 textContent &gt; 改 innerHTML &gt; 改 framework 子節點。穩定性低的需要 MutationObserver 重做、或乾脆別碰。</p>
<p><strong>外部組件客製的合作層次</strong>（穩定性梯度）：CSS variable / API &gt; class hook &gt; boundary DOM &gt; 內部結構。離公共介面越近、升級越穩。</p>
<h3 id="5-reactive-監聽器的頻率盤點">5. Reactive 監聽器的頻率盤點</h3>
<p>MutationObserver 三維度：<strong>root（最窄）、options（最少）、debounce（最長可接受）</strong>。預設 <code>observer.observe(scope, { childList: true })</code>、不寫 <code>subtree: true</code> 除非有 case。</p>
<p>Polling（<code>setTimeout</code> / <code>setInterval</code>）有事件可監聽就替換成 MutationObserver — 0 latency / 0 idle CPU。Reactive perf debug 從 <code>console.count(callbackName)</code> 起、確認觸發頻率符合預期。</p>
<p>效能風險點四面向：<strong>iteration 成本（500 results × regex test）、reflow 成本（&gt;16ms 觸發 jank）、listener 頻率（如上）、resource 載入時序（lazy chunk vs critical path）</strong>。</p>
<h3 id="6-a11y-三道防線">6. A11y 三道防線</h3>
<p><strong>鍵盤可達性</strong>：visible focus indicator、邏輯 tab 順序、modal 有 escape 路徑。三者缺一不可。
<strong>動態 a11y</strong>：JS reparent / hide 時保存並還原 focus；變動內容用 <code>aria-live=&quot;polite&quot;</code> 廣播給 screen reader。
<strong>Native &gt; ARIA</strong>：能用 <code>&lt;button&gt;</code> / <code>&lt;fieldset&gt;</code> / <code>&lt;dialog&gt;</code> 就不要自己組 ARIA role — native HTML 自帶 keyboard / focus / a11y tree、ARIA 是補強不是替代。</p>
<hr>
<h2 id="when-to-consult-this-skill觸發路由">When to Consult This Skill（觸發路由）</h2>
<table>
  <thead>
      <tr>
          <th>觸發情境</th>
          <th>讀哪份 reference</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>要寫 CSS 規則、需要先確認 DOM 結構 / selector 該怎麼寫</td>
          <td><code>references/dom-topology-first.md</code></td>
      </tr>
      <tr>
          <td>不確定 selector 該多寬、命中其他元素</td>
          <td><code>references/dom-topology-first.md</code></td>
      </tr>
      <tr>
          <td>不確定值該寫進 CSS 還是 JS、CSS layers / variable / class toggle 取捨</td>
          <td><code>references/css-js-boundary.md</code></td>
      </tr>
      <tr>
          <td>用 <code>!important</code> / inline style 解 specificity</td>
          <td><code>references/css-js-boundary.md</code></td>
      </tr>
      <tr>
          <td>要用 playwright 驗證 layout / 假設 / 互動</td>
          <td><code>references/playwright-in-loop.md</code></td>
      </tr>
      <tr>
          <td>Layout bug 第 2 次出現、想寫成測試</td>
          <td><code>references/playwright-in-loop.md</code></td>
      </tr>
      <tr>
          <td>客製 UI 被 framework 還原、不知道該注入到哪</td>
          <td><code>references/framework-coexistence.md</code></td>
      </tr>
      <tr>
          <td>要客製外部組件（pagefind / vendor library）</td>
          <td><code>references/framework-coexistence.md</code></td>
      </tr>
      <tr>
          <td>使用者反映卡頓、CPU 100%、scroll lag、resize jank</td>
          <td><code>references/reactive-performance.md</code></td>
      </tr>
      <tr>
          <td>要設計 MutationObserver / event listener 範圍</td>
          <td><code>references/reactive-performance.md</code></td>
      </tr>
      <tr>
          <td>要驗收鍵盤 / screen reader / motor / 視覺 a11y</td>
          <td><code>references/accessibility-and-focus.md</code></td>
      </tr>
      <tr>
          <td>JS reparent 後 focus 跑掉、aria-live 沒朗讀</td>
          <td><code>references/accessibility-and-focus.md</code></td>
      </tr>
      <tr>
          <td>設計 filter / sort / count 操作、source 是分批 / streaming</td>
          <td><code>references/data-flow-and-filter-composition.md</code></td>
      </tr>
      <tr>
          <td>「Load more 後畫面閃但內容沒變」的 silent 缺口</td>
          <td><code>references/data-flow-and-filter-composition.md</code>（層錯位）</td>
      </tr>
      <tr>
          <td>Backend / 演算法 / map-reduce 的 post-filter 漏項</td>
          <td><code>references/data-flow-and-filter-composition.md</code>（跨領域同結構）</td>
      </tr>
  </tbody>
</table>
<p>每份 reference 自包含：以該情境為核心、把六大原則翻譯成可直接套用的協議步驟與範例。閱讀任一 reference 不需要回來看其他 reference。</p>
<hr>
<h2 id="success-criteriam1-m2-認知負擔類">Success Criteria（M1-M2 認知負擔類）</h2>
<table>
  <thead>
      <tr>
          <th>Metric</th>
          <th>定義</th>
          <th>目標</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>M1</strong></td>
          <td>從 SKILL.md 出發、解決一個觸發情境需要開幾個檔案</td>
          <td>≤ 2</td>
      </tr>
      <tr>
          <td><strong>M2</strong></td>
          <td>隨機抽一份 reference、不讀其他 reference 能否獨立套用</td>
          <td>100%</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="directory-index">Directory Index</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">frontend-with-playwright/
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── SKILL.md                                    # 本檔：六大原則速查 + 觸發路由
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">└── references/
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    ├── dom-topology-first.md                   # 情境 1：寫 CSS 前用 playwright/DevTools 量真實 DOM、selector 設計
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    ├── css-js-boundary.md                      # 情境 2：CSS-only vs JS-assisted、class toggle、layers、variable 單一位置、檔案拆分
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    ├── playwright-in-loop.md                   # 情境 3：playwright 三個位置（假設 / 行為 / 互動驗證）+ 寫成 layout test
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    ├── framework-coexistence.md                # 情境 4：custom UI 留 framework 邊界外、外部組件四層合作、JS 操作邊界辨識
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    ├── reactive-performance.md                 # 情境 5：observer scope、polling→observer、頻率盤點、iteration / regex / reflow
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    ├── accessibility-and-focus.md              # 情境 6：focus on DOM move、keyboard 三要素、aria-live、native HTML &gt; ARIA
</span></span><span class="line"><span class="ln">10</span><span class="cl">    └── data-flow-and-filter-composition.md     # 情境 7：Filter × Source 層錯位 + 五策略 + 跨領域（前端 / 後端 / 演算法 / DB）</span></span></code></pre></div><hr>
<h2 id="reading-order建議閱讀順序">Reading Order（建議閱讀順序）</h2>
<ol>
<li>第一次接觸 → 從本 SKILL.md 的「三大支柱 + 六大原則」讀起</li>
<li>進入實際情境 → 依觸發路由讀對應 reference（只讀一份）</li>
<li>想驗證自己有沒有套用對 → 用該 reference 結尾的 self-check checklist 自評</li>
</ol>
<hr>
<h2 id="跟-requirement-protocol-的關係">跟 requirement-protocol 的關係</h2>
<p><code>requirement-protocol</code> 是上層的「對話協議」（澄清需求、失敗轉折、覆寫成本、工具切換時機）；本 skill 是下層的「前端執行協議」（DOM / CSS / JS / Playwright 的具體做法）。</p>
<p>當情境是「不確定該怎麼跟使用者溝通」 → 讀 requirement-protocol。
當情境是「知道要做什麼、不確定前端該怎麼實作驗證」 → 讀本 skill。
兩個 skill 的 <code>playwright</code> 段落互補：<code>requirement-protocol/tool-switching-timing</code> 講「何時切」、本 skill 的 <code>playwright-in-loop</code> 講「切了之後具體寫什麼 query」。</p>
<p><code>requirement-protocol/clarifying-ambiguous-instructions</code> 的「類型 5：篩選類」跟本 skill 的 <code>data-flow-and-filter-composition</code> 互補：上層講「該怎麼澄清」、本層講「澄清完該怎麼實作」。</p>
<hr>
<h2 id="相關抽象層原則在-contentreport">相關抽象層原則（在 content/report/）</h2>
<p>本 skill 的協議建立在幾條抽象層原則上：</p>
<ul>
<li><a href="/blog/report/two-occurrence-threshold/" data-link-title="2 次門檻：第一次是運氣、第二次是訊號" data-link-desc="同一個問題出現第 2 次時、就該停下來把處理層級升一階 — 從推理升到量測、從手動驗證升到自動化、從同方向嘗試升到換思路。第 1 次失敗的資訊不足、第 2 次提供「重複出現」的證據、值得付出升級成本。本文是 #11 / #15 / #20 / #23 四篇實作的共同抽象。">#42 2 次門檻</a> — 第 1 次失敗是運氣、第 2 次是訊號（playwright 切換時機的根據）</li>
<li><a href="/blog/report/minimum-necessary-scope-is-sanity-defense/" data-link-title="最小必要範圍是 sanity 防線：保護行為可預測性" data-link-desc="縮 selector 範圍、observer 範圍、JS 操作範圍 — 不是為了效能、是為了讓行為可預測、不被未來變動打破。本文是 #13 / #14 / #29 三篇實作的共同抽象。">#43 最小必要範圍</a> — selector / observer / 操作邊界從窄起（DOM 設計、Reactive 效能的根據）</li>
<li><a href="/blog/report/single-source-of-truth/" data-link-title="Single Source of Truth：值的住址只能有一處" data-link-desc="同一個值（CSS token、視覺基準、runtime 量測）的權威來源只能有一個位置 — 多源時會分歧、會漏改、會讓讀者不知道哪個生效。本文是 #3 / #26 / #27 三篇實作的共同抽象。">#44 SSOT</a> — 值的住址只能一處（CSS 變數、量測一致性的根據）</li>
<li><a href="/blog/report/external-component-collaboration-layers/" data-link-title="跟外部組件合作的層次：離介面越近、合作越穩" data-link-desc="客製外部組件的穩定性與「離組件作者保證的對外介面多遠」成反比。每往內推一層、依賴前提增加、升級風險上升、可逆性下降。本文是 #1 / #5 / #19 / #24 四篇實作的共同抽象。">#45 外部組件合作四層</a> — 離公共介面越近越穩（framework 共處的根據）</li>
<li><a href="/blog/report/compose-feature-at-source-layer/" data-link-title="Feature 操作要跟 Source 同層合成" data-link-desc="Filter / sort / count / transform / search 是 stream 操作、必須跟 stream 的 materialization 同層或更上游合成。在下游做 = 操作 subset 不是 stream。本原則跨前端 UI、後端 API、演算法管線通用、不只是視覺層 vs 資料層。">#64 同層合成</a> — Stream 操作必須跟 materialization 同層（Filter × Source 的本質）</li>
<li><a href="/blog/report/ease-of-writing-vs-intent-alignment/" data-link-title="寫作便利度跟意圖對齊反相關" data-link-desc="寫程式時最容易寫出的版本、通常是離意圖最遠的版本。便利度建立在「現有上下文 / 已 materialize 資料 / 已存在 API」上、而意圖對齊需要找到正確的層、處理上游、跨抽象層 — 兩者方向相反。識別這個反相關 = 識別自己掉進「容易寫的陷阱」。">#67 寫作便利度跟意圖對齊反相關</a> — 容易寫的位置通常是錯位的位置（meta-principle、解釋為什麼層錯位 / 寬 selector / inline style 等便利寫法都會出問題）</li>
<li><a href="/blog/report/verification-timeline-checkpoints/" data-link-title="驗收的時間軸：四個 checkpoint" data-link-desc="驗收不是單一動作、是分散在四個時點（寫之前 / 開發中 / ship 前 / ship 後）的累積判斷。每個 checkpoint 能 catch 不同類型的失敗、成本不同。早期 checkpoint 抓越多、晚期 checkpoint 越輕鬆。實務上常常 collapse 成「寫的時候 &#43; ship 後出問題才修」、跳過寫之前 / ship 前。">#68 驗收的時間軸：四個 checkpoint</a> — Layout test 屬 Ship 前 checkpoint 的具體做法</li>
<li><a href="/blog/report/test-first-red-before-green/" data-link-title="Test-First：先看到 RED 才相信 GREEN" data-link-desc="一個只看過 GREEN 的測試是「未驗證的訊號」、不是「會抓回歸的測試」。必須先在「該失敗的版本」上看到 RED、再在「該通過的版本」上看到 GREEN — 兩次跑都對、才能相信測試真的 catch 到該 catch 的東西。跳過 RED 等於把驗收標準降到「跑得通」、漏掉「測試自己有沒有 bug」這層。">#69 Test-First：先看到 RED 才相信 GREEN</a> — Playwright 測試的驗證協議：寫完測試 + 第一次跑就 GREEN 是警訊、要先在 buggy code 上看到 RED 才相信測試 catch 到該 catch 的東西</li>
<li><a href="/blog/report/url-as-state-container/" data-link-title="URL 是 stateful UI 的儲存層 — 哪些 state 該寫進 URL" data-link-desc="互動式 UI 的 state 散落在多層（in-memory / URL / localStorage / server / index）、每層有不同特性。可分享 / 可恢復 / 可導航的 state 該寫進 URL — 不寫進 = silent 把這些特性犧牲掉。本文展開「state 的儲存層選擇」協議與 URL 的具體位置。">#70 URL 是 stateful UI 的儲存層</a> — 互動式 UI 的可分享 / 可恢復 / 可導航 state 該寫進 URL（搜尋 / filter / tab / sort / pagination 都該檢視）</li>
<li><a href="/blog/report/tab-order-mental-model-alignment/" data-link-title="Tab Order = DOM Order = Mental Model 三者對齊" data-link-desc="Tab 順序由 DOM 順序決定（除非用 tabindex 強制覆寫）。三者該對齊：DOM 順序、tab 順序、使用者 mental model 的互動順序。三者不一致時、優先重排 DOM 而非用 tabindex — tabindex &gt; 0 是反模式（[#52]）。">#71 Tab Order = DOM Order = Mental Model 三者對齊</a> — DOM 順序預設 = tab 順序、不對齊時優先重排 DOM、tabindex &gt; 0 是反模式</li>
<li><a href="/blog/report/external-trigger-for-high-roi-work/" data-link-title="高 ROI 無外部觸發的工作會被結構性跳過" data-link-desc="工作有兩個獨立維度：ROI 高低 &#43; 是否有外部觸發。高 ROI &#43; 無觸發 = ROI 的承諾、拖延的現實。靠紀律不可行 — 結構性偏差需要結構性對策（外部觸發 / CI / hook / 排程 / pair）。本卡是 #67 便利反相關、#68 checkpoint 跳過、#69 RED 跳過的共同上位原則。">#72 高 ROI 無外部觸發的工作會被結構性跳過</a> — meta-原則：寫測試 / refactor / a11y review / Ship 前 case 設計都需要外部觸發（CI / pre-commit / PR template）、不是靠紀律</li>
<li><a href="/blog/report/search-engine-matching-mode-mismatch/" data-link-title="搜尋引擎的匹配模式跟使用者預期的對齊" data-link-desc="搜尋引擎的匹配模式（prefix / substring / fuzzy / semantic）各有不同。預設多半是 prefix（為了 index size）、但使用者被 Google 訓練成預期 substring。沒對齊 = silent 失敗：搜「pre」找不到 backpressure。本卡展開五種匹配模式、跟使用者意圖的對齊協議、五個合成策略。">#73 搜尋引擎的匹配模式跟使用者預期的對齊</a> — Search feature 的 capability 維度：prefix vs substring vs fuzzy vs semantic 各自取捨、預設多為 prefix（為 index size）、跟使用者預期不對齊 = silent 失敗</li>
<li><a href="/blog/report/decision-dialogue-dimensions/" data-link-title="決策對話的五個維度：保持完整選擇空間" data-link-desc="對話中的「決策」不是單一動作、是多維度選擇空間：呈現格式 / 策略疊加 / 批次邊界 / 時間軸 / 選項類型。預設多半 collapse 到最窄格（開放問 &#43; 單策略 &#43; 一次完成 &#43; 立刻決 &#43; 單選）、塞使用者進最少自由度的盒子。本卡是 #74-#78 的上層串連 — 五張卡各對應一個維度的鬆綁。">#79 決策對話的五維度</a> — 設計取捨呈現給使用者時的 meta-框架（呈現 / 策略疊加 / 批次 / 時間 / 選項類型）— 「設計取捨段落」常用的五策略表 + 推薦 + 「先 ship X、Y 下輪」就是這五維度的展現</li>
<li><a href="/blog/report/literal-interception-vs-behavioral-refinement/" data-link-title="字面攔截 vs 行為精煉：驗證手段跟錯誤層次的對齊" data-link-desc="驗證手段必須跟錯誤層次對齊：字面錯誤（typo / syntax / 缺欄位）用 hook / lint / CI 攔截；行為錯誤（思考偏差 / 判斷錯位 / collapse 反模式）用 multi-pass spiral 收斂。強行用 hook 蓋行為錯誤 = 給出 false confidence、反而比沒保護危險。本卡是 #72 結構性對策在「驗證粒度」維度的 ceiling — 不是所有錯誤都該被攔截。">#82 字面攔截 vs 行為精煉</a> — playwright 測試是字面驗證（input → output 比對）、抓不到「為什麼這個 selector 設計錯」這類行為錯誤、需要 multi-pass review 配合</li>
</ul>
<hr>
<p><strong>Last Updated</strong>: 2026-04-26
<strong>Version</strong>: 0.4.0 — 接入 #79 決策對話五維度（對應 #74-#78 系列）；協助前端設計取捨段落的呈現格式對齊 user-facing 決策協議
<strong>Version</strong>: 0.3.0 — 接入 #69-#73：相關抽象層原則段補 Test-First (#69)、URL state (#70)、tab order (#71)、外部觸發 meta (#72)、search 匹配模式 (#73)
<strong>Version</strong>: 0.2.0 — 接入 #55-#68 系列：新增第 7 份 reference <code>data-flow-and-filter-composition</code>（涵蓋 Filter × Source 層錯位 + 五策略 + 跨前端 / 後端 / 演算法 / DB 領域範例）；description 補跨領域 stream 操作觸發詞；SKILL.md 加「相關抽象層原則」段（#42-45 + #64 + #67-68）；強調「不只前端、stream 操作通用」
<strong>Version</strong>: 0.1.0 — 從 <code>content/report/</code> 50+ 篇事後檢討萃取「前端網頁開發 + Playwright 驗證」這條主軸；六份 references 對應「DOM topology / CSS-JS 邊界 / Playwright 三位置 / framework 共處 / Reactive 效能 / A11y」六個情境</p>
]]></content:encoded></item><item><title>Frontend with Playwright — 框架無關的前端開發 + Playwright 驗證</title><link>https://tarrragon.github.io/blog/skills/frontend-with-playwright/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/frontend-with-playwright/</guid><description>&lt;h2 id="這個資料夾是什麼">這個資料夾是什麼&lt;/h2>
&lt;p>&lt;code>frontend-with-playwright&lt;/code> 是一套前端開發協議 skill，原生位置在 &lt;a href="https://github.com/tarrragon/blog/tree/main/.claude/skills/frontend-with-playwright">&lt;code>.claude/skills/frontend-with-playwright/&lt;/code>&lt;/a> 供 Claude runtime 呼叫；這份是&lt;strong>同內容的文章版本&lt;/strong>，讓人類讀者也能直接在 blog 閱讀。&lt;/p>
&lt;p>原則框架無關 — 適用 vanilla HTML/CSS/JS、Vue、React、jQuery — 因為核心是「DOM / CSS / JS 三者的本質行為」加上「Playwright 用 live DOM 量測驗證」、不依賴特定框架的渲染機制。源頭是 &lt;a href="https://tarrragon.github.io/blog/report/" data-link-title="Report — 開發過程的事後檢討" data-link-desc="blog 開發過程中、把實際遇到的版型 / 整合 / 框架共處等情境、整理成『應該怎麼做、沒這樣做會有什麼麻煩』的事後檢討。每篇皆為正向指引、幫助下一輪同類任務跳過反覆試錯。">&lt;code>content/report/&lt;/code>&lt;/a> 累積的 50+ 篇事後檢討、由本 skill 的六份 reference 萃取對應六個情境的協議步驟。&lt;/p>
&lt;h2 id="閱讀順序">閱讀順序&lt;/h2>
&lt;h3 id="場景-1第一次接觸">場景 1：第一次接觸&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>順序&lt;/th>
 &lt;th>檔案&lt;/th>
 &lt;th>目的&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/skill/" data-link-title="Frontend with Playwright — SKILL 入口" data-link-desc="框架無關的前端開發 &amp;#43; Playwright 驗證 SKILL 入口：三大支柱、六大原則速查、六份情境 reference 的觸發路由。">SKILL.md&lt;/a>&lt;/td>
 &lt;td>三大支柱 + 六大原則速查、觸發路由表&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2&lt;/td>
 &lt;td>依情境挑一份 reference（見下表）&lt;/td>
 &lt;td>把原則翻譯成可套用的協議步驟、模板與範例&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3&lt;/td>
 &lt;td>該 reference 結尾的 self-check checklist&lt;/td>
 &lt;td>自評有沒有按協議走&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="場景-2已熟悉協議想直接解決當前任務">場景 2：已熟悉協議、想直接解決當前任務&lt;/h3>
&lt;p>直接依觸發情境跳對應 reference：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>觸發情境&lt;/th>
 &lt;th>reference&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>要寫 CSS 規則、需要先確認 DOM 結構 / selector 該怎麼寫&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/dom-topology-first/" data-link-title="DOM Topology First — 寫 CSS 前先確認 DOM 結構" data-link-desc="frontend-with-playwright reference：寫 CSS 前用 playwright/DevTools 量真實 DOM、selector 三維度設計、起點四選一、idempotency 兩選一。">dom-topology-first&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>不確定 selector 該多寬、命中其他元素&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/dom-topology-first/" data-link-title="DOM Topology First — 寫 CSS 前先確認 DOM 結構" data-link-desc="frontend-with-playwright reference：寫 CSS 前用 playwright/DevTools 量真實 DOM、selector 三維度設計、起點四選一、idempotency 兩選一。">dom-topology-first&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>不確定值該寫進 CSS 還是 JS、CSS layers / variable / class toggle 取捨&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/css-js-boundary/" data-link-title="CSS / JS Boundary — CSS / JS 邊界與 specificity 處理" data-link-desc="frontend-with-playwright reference：CSS-only vs JS-assisted 判準、class toggle 取代 inline style、CSS layers 取代 specificity 戰、variable 單一定義位置、檔案拆分。">css-js-boundary&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>用 &lt;code>!important&lt;/code> / inline style 解 specificity&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/css-js-boundary/" data-link-title="CSS / JS Boundary — CSS / JS 邊界與 specificity 處理" data-link-desc="frontend-with-playwright reference：CSS-only vs JS-assisted 判準、class toggle 取代 inline style、CSS layers 取代 specificity 戰、variable 單一定義位置、檔案拆分。">css-js-boundary&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>要用 playwright 驗證 layout / 假設 / 互動&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/playwright-in-loop/" data-link-title="Playwright in the Development Loop — 開發循環的三個位置" data-link-desc="frontend-with-playwright reference：Playwright 三個位置（假設 / 行為 / 互動驗證）的 evaluate 範例、寫成 layout test 的時機與模板、最低門檻 setup。">playwright-in-loop&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Layout bug 第 2 次出現、想寫成測試&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/playwright-in-loop/" data-link-title="Playwright in the Development Loop — 開發循環的三個位置" data-link-desc="frontend-with-playwright reference：Playwright 三個位置（假設 / 行為 / 互動驗證）的 evaluate 範例、寫成 layout test 的時機與模板、最低門檻 setup。">playwright-in-loop&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>客製 UI 被 framework 還原、不知道該注入到哪&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/framework-coexistence/" data-link-title="Framework Coexistence — 跟 framework-managed DOM 共處" data-link-desc="frontend-with-playwright reference：framework 邊界辨識、JS 操作四級安全度、客製 UI 注入到邊界外、外部組件四層合作（公共介面 → 邊界 → 邊界 DOM → 內部結構）。">framework-coexistence&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>要客製外部組件（pagefind / vendor library）&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/framework-coexistence/" data-link-title="Framework Coexistence — 跟 framework-managed DOM 共處" data-link-desc="frontend-with-playwright reference：framework 邊界辨識、JS 操作四級安全度、客製 UI 注入到邊界外、外部組件四層合作（公共介面 → 邊界 → 邊界 DOM → 內部結構）。">framework-coexistence&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>使用者反映卡頓、CPU 100%、scroll lag、resize jank&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/reactive-performance/" data-link-title="Reactive Performance — Reactive 效能盤點與優化" data-link-desc="frontend-with-playwright reference：MutationObserver 三維度、polling → observer、iteration / regex 成本、layout reflow、resource 載入時序、reactive listener 盤點協議。">reactive-performance&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>要設計 MutationObserver / event listener 範圍&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/reactive-performance/" data-link-title="Reactive Performance — Reactive 效能盤點與優化" data-link-desc="frontend-with-playwright reference：MutationObserver 三維度、polling → observer、iteration / regex 成本、layout reflow、resource 載入時序、reactive listener 盤點協議。">reactive-performance&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>要驗收鍵盤 / screen reader / motor / 視覺 a11y&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/accessibility-and-focus/" data-link-title="Accessibility and Focus — A11y 三道防線" data-link-desc="frontend-with-playwright reference：鍵盤可達性三要素、focus management on DOM move、aria-live 動態廣播、Native HTML &amp;gt; ARIA、視覺 / motor a11y。">accessibility-and-focus&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>JS reparent 後 focus 跑掉、aria-live 沒朗讀&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/accessibility-and-focus/" data-link-title="Accessibility and Focus — A11y 三道防線" data-link-desc="frontend-with-playwright reference：鍵盤可達性三要素、focus management on DOM move、aria-live 動態廣播、Native HTML &amp;gt; ARIA、視覺 / motor a11y。">accessibility-and-focus&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>設計 filter / sort / count 操作、source 是分批 / streaming&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/" data-link-title="Data Flow and Filter Composition — Filter × Source 層錯位與五策略" data-link-desc="frontend-with-playwright reference：Filter / sort / count / transform stream 操作的層錯位識別 &amp;#43; 五策略合成。原則跨前端 / 後端 / 演算法 / DB 通用、playwright 驗證模板。">data-flow-and-filter-composition&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>「Load more 後畫面閃但內容沒變」的 silent 缺口&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/" data-link-title="Data Flow and Filter Composition — Filter × Source 層錯位與五策略" data-link-desc="frontend-with-playwright reference：Filter / sort / count / transform stream 操作的層錯位識別 &amp;#43; 五策略合成。原則跨前端 / 後端 / 演算法 / DB 通用、playwright 驗證模板。">data-flow-and-filter-composition&lt;/a>（層錯位）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Backend / 演算法 / map-reduce 的 post-filter 漏項&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/" data-link-title="Data Flow and Filter Composition — Filter × Source 層錯位與五策略" data-link-desc="frontend-with-playwright reference：Filter / sort / count / transform stream 操作的層錯位識別 &amp;#43; 五策略合成。原則跨前端 / 後端 / 演算法 / DB 通用、playwright 驗證模板。">data-flow-and-filter-composition&lt;/a>（跨領域同結構）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每份 reference 自包含：讀任一份不需要回頭讀其他 reference。&lt;/p></description><content:encoded><![CDATA[<h2 id="這個資料夾是什麼">這個資料夾是什麼</h2>
<p><code>frontend-with-playwright</code> 是一套前端開發協議 skill，原生位置在 <a href="https://github.com/tarrragon/blog/tree/main/.claude/skills/frontend-with-playwright"><code>.claude/skills/frontend-with-playwright/</code></a> 供 Claude runtime 呼叫；這份是<strong>同內容的文章版本</strong>，讓人類讀者也能直接在 blog 閱讀。</p>
<p>原則框架無關 — 適用 vanilla HTML/CSS/JS、Vue、React、jQuery — 因為核心是「DOM / CSS / JS 三者的本質行為」加上「Playwright 用 live DOM 量測驗證」、不依賴特定框架的渲染機制。源頭是 <a href="/blog/report/" data-link-title="Report — 開發過程的事後檢討" data-link-desc="blog 開發過程中、把實際遇到的版型 / 整合 / 框架共處等情境、整理成『應該怎麼做、沒這樣做會有什麼麻煩』的事後檢討。每篇皆為正向指引、幫助下一輪同類任務跳過反覆試錯。"><code>content/report/</code></a> 累積的 50+ 篇事後檢討、由本 skill 的六份 reference 萃取對應六個情境的協議步驟。</p>
<h2 id="閱讀順序">閱讀順序</h2>
<h3 id="場景-1第一次接觸">場景 1：第一次接觸</h3>
<table>
  <thead>
      <tr>
          <th>順序</th>
          <th>檔案</th>
          <th>目的</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td><a href="/blog/skills/frontend-with-playwright/skill/" data-link-title="Frontend with Playwright — SKILL 入口" data-link-desc="框架無關的前端開發 &#43; Playwright 驗證 SKILL 入口：三大支柱、六大原則速查、六份情境 reference 的觸發路由。">SKILL.md</a></td>
          <td>三大支柱 + 六大原則速查、觸發路由表</td>
      </tr>
      <tr>
          <td>2</td>
          <td>依情境挑一份 reference（見下表）</td>
          <td>把原則翻譯成可套用的協議步驟、模板與範例</td>
      </tr>
      <tr>
          <td>3</td>
          <td>該 reference 結尾的 self-check checklist</td>
          <td>自評有沒有按協議走</td>
      </tr>
  </tbody>
</table>
<h3 id="場景-2已熟悉協議想直接解決當前任務">場景 2：已熟悉協議、想直接解決當前任務</h3>
<p>直接依觸發情境跳對應 reference：</p>
<table>
  <thead>
      <tr>
          <th>觸發情境</th>
          <th>reference</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>要寫 CSS 規則、需要先確認 DOM 結構 / selector 該怎麼寫</td>
          <td><a href="/blog/skills/frontend-with-playwright/dom-topology-first/" data-link-title="DOM Topology First — 寫 CSS 前先確認 DOM 結構" data-link-desc="frontend-with-playwright reference：寫 CSS 前用 playwright/DevTools 量真實 DOM、selector 三維度設計、起點四選一、idempotency 兩選一。">dom-topology-first</a></td>
      </tr>
      <tr>
          <td>不確定 selector 該多寬、命中其他元素</td>
          <td><a href="/blog/skills/frontend-with-playwright/dom-topology-first/" data-link-title="DOM Topology First — 寫 CSS 前先確認 DOM 結構" data-link-desc="frontend-with-playwright reference：寫 CSS 前用 playwright/DevTools 量真實 DOM、selector 三維度設計、起點四選一、idempotency 兩選一。">dom-topology-first</a></td>
      </tr>
      <tr>
          <td>不確定值該寫進 CSS 還是 JS、CSS layers / variable / class toggle 取捨</td>
          <td><a href="/blog/skills/frontend-with-playwright/css-js-boundary/" data-link-title="CSS / JS Boundary — CSS / JS 邊界與 specificity 處理" data-link-desc="frontend-with-playwright reference：CSS-only vs JS-assisted 判準、class toggle 取代 inline style、CSS layers 取代 specificity 戰、variable 單一定義位置、檔案拆分。">css-js-boundary</a></td>
      </tr>
      <tr>
          <td>用 <code>!important</code> / inline style 解 specificity</td>
          <td><a href="/blog/skills/frontend-with-playwright/css-js-boundary/" data-link-title="CSS / JS Boundary — CSS / JS 邊界與 specificity 處理" data-link-desc="frontend-with-playwright reference：CSS-only vs JS-assisted 判準、class toggle 取代 inline style、CSS layers 取代 specificity 戰、variable 單一定義位置、檔案拆分。">css-js-boundary</a></td>
      </tr>
      <tr>
          <td>要用 playwright 驗證 layout / 假設 / 互動</td>
          <td><a href="/blog/skills/frontend-with-playwright/playwright-in-loop/" data-link-title="Playwright in the Development Loop — 開發循環的三個位置" data-link-desc="frontend-with-playwright reference：Playwright 三個位置（假設 / 行為 / 互動驗證）的 evaluate 範例、寫成 layout test 的時機與模板、最低門檻 setup。">playwright-in-loop</a></td>
      </tr>
      <tr>
          <td>Layout bug 第 2 次出現、想寫成測試</td>
          <td><a href="/blog/skills/frontend-with-playwright/playwright-in-loop/" data-link-title="Playwright in the Development Loop — 開發循環的三個位置" data-link-desc="frontend-with-playwright reference：Playwright 三個位置（假設 / 行為 / 互動驗證）的 evaluate 範例、寫成 layout test 的時機與模板、最低門檻 setup。">playwright-in-loop</a></td>
      </tr>
      <tr>
          <td>客製 UI 被 framework 還原、不知道該注入到哪</td>
          <td><a href="/blog/skills/frontend-with-playwright/framework-coexistence/" data-link-title="Framework Coexistence — 跟 framework-managed DOM 共處" data-link-desc="frontend-with-playwright reference：framework 邊界辨識、JS 操作四級安全度、客製 UI 注入到邊界外、外部組件四層合作（公共介面 → 邊界 → 邊界 DOM → 內部結構）。">framework-coexistence</a></td>
      </tr>
      <tr>
          <td>要客製外部組件（pagefind / vendor library）</td>
          <td><a href="/blog/skills/frontend-with-playwright/framework-coexistence/" data-link-title="Framework Coexistence — 跟 framework-managed DOM 共處" data-link-desc="frontend-with-playwright reference：framework 邊界辨識、JS 操作四級安全度、客製 UI 注入到邊界外、外部組件四層合作（公共介面 → 邊界 → 邊界 DOM → 內部結構）。">framework-coexistence</a></td>
      </tr>
      <tr>
          <td>使用者反映卡頓、CPU 100%、scroll lag、resize jank</td>
          <td><a href="/blog/skills/frontend-with-playwright/reactive-performance/" data-link-title="Reactive Performance — Reactive 效能盤點與優化" data-link-desc="frontend-with-playwright reference：MutationObserver 三維度、polling → observer、iteration / regex 成本、layout reflow、resource 載入時序、reactive listener 盤點協議。">reactive-performance</a></td>
      </tr>
      <tr>
          <td>要設計 MutationObserver / event listener 範圍</td>
          <td><a href="/blog/skills/frontend-with-playwright/reactive-performance/" data-link-title="Reactive Performance — Reactive 效能盤點與優化" data-link-desc="frontend-with-playwright reference：MutationObserver 三維度、polling → observer、iteration / regex 成本、layout reflow、resource 載入時序、reactive listener 盤點協議。">reactive-performance</a></td>
      </tr>
      <tr>
          <td>要驗收鍵盤 / screen reader / motor / 視覺 a11y</td>
          <td><a href="/blog/skills/frontend-with-playwright/accessibility-and-focus/" data-link-title="Accessibility and Focus — A11y 三道防線" data-link-desc="frontend-with-playwright reference：鍵盤可達性三要素、focus management on DOM move、aria-live 動態廣播、Native HTML &gt; ARIA、視覺 / motor a11y。">accessibility-and-focus</a></td>
      </tr>
      <tr>
          <td>JS reparent 後 focus 跑掉、aria-live 沒朗讀</td>
          <td><a href="/blog/skills/frontend-with-playwright/accessibility-and-focus/" data-link-title="Accessibility and Focus — A11y 三道防線" data-link-desc="frontend-with-playwright reference：鍵盤可達性三要素、focus management on DOM move、aria-live 動態廣播、Native HTML &gt; ARIA、視覺 / motor a11y。">accessibility-and-focus</a></td>
      </tr>
      <tr>
          <td>設計 filter / sort / count 操作、source 是分批 / streaming</td>
          <td><a href="/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/" data-link-title="Data Flow and Filter Composition — Filter × Source 層錯位與五策略" data-link-desc="frontend-with-playwright reference：Filter / sort / count / transform stream 操作的層錯位識別 &#43; 五策略合成。原則跨前端 / 後端 / 演算法 / DB 通用、playwright 驗證模板。">data-flow-and-filter-composition</a></td>
      </tr>
      <tr>
          <td>「Load more 後畫面閃但內容沒變」的 silent 缺口</td>
          <td><a href="/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/" data-link-title="Data Flow and Filter Composition — Filter × Source 層錯位與五策略" data-link-desc="frontend-with-playwright reference：Filter / sort / count / transform stream 操作的層錯位識別 &#43; 五策略合成。原則跨前端 / 後端 / 演算法 / DB 通用、playwright 驗證模板。">data-flow-and-filter-composition</a>（層錯位）</td>
      </tr>
      <tr>
          <td>Backend / 演算法 / map-reduce 的 post-filter 漏項</td>
          <td><a href="/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/" data-link-title="Data Flow and Filter Composition — Filter × Source 層錯位與五策略" data-link-desc="frontend-with-playwright reference：Filter / sort / count / transform stream 操作的層錯位識別 &#43; 五策略合成。原則跨前端 / 後端 / 演算法 / DB 通用、playwright 驗證模板。">data-flow-and-filter-composition</a>（跨領域同結構）</td>
      </tr>
  </tbody>
</table>
<p>每份 reference 自包含：讀任一份不需要回頭讀其他 reference。</p>
<h2 id="跟-requirement-protocol-的關係">跟 requirement-protocol 的關係</h2>
<p><a href="/blog/skills/requirement-protocol/" data-link-title="Requirement Protocol — 需求確認到實作的對話協議" data-link-desc="從需求確認到實作的對話協議：模糊指令澄清、可決定 vs 該確認、失敗 2 次轉折、覆寫成本告知、revert checkpoint、漸進驗證、工具切換時機。六大原則 &#43; 五份情境 reference。">requirement-protocol</a> 是上層的「對話協議」（澄清需求、失敗轉折、覆寫成本、工具切換時機）；本 skill 是下層的「前端執行協議」（DOM / CSS / JS / Playwright 的具體做法）。</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>該讀哪個 skill</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>不確定該怎麼跟使用者溝通、需求模糊、失敗該怎麼轉折</td>
          <td>requirement-protocol</td>
      </tr>
      <tr>
          <td>知道要做什麼、不確定前端該怎麼實作驗證</td>
          <td>frontend-with-playwright（本 skill）</td>
      </tr>
  </tbody>
</table>
<p>兩個 skill 的 <code>playwright</code> 段落互補：requirement-protocol 講「何時切」、本 skill 講「切了之後具體寫什麼 query」。</p>
<h2 id="與-blog-專案其他資料的關係">與 blog 專案其他資料的關係</h2>
<table>
  <thead>
      <tr>
          <th>位置</th>
          <th>角色</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>.claude/skills/frontend-with-playwright/</code></td>
          <td>實際 skill — Claude runtime 呼叫的檔案來源</td>
      </tr>
      <tr>
          <td><code>content/skills/frontend-with-playwright/</code>（本處）</td>
          <td>文章版本 — 人類讀者在 blog 閱讀</td>
      </tr>
      <tr>
          <td><a href="/blog/report/" data-link-title="Report — 開發過程的事後檢討" data-link-desc="blog 開發過程中、把實際遇到的版型 / 整合 / 框架共處等情境、整理成『應該怎麼做、沒這樣做會有什麼麻煩』的事後檢討。每篇皆為正向指引、幫助下一輪同類任務跳過反覆試錯。"><code>content/report/</code></a></td>
          <td>50+ 篇事後檢討、本 skill 的素材來源；reference 結尾連回對應篇</td>
      </tr>
      <tr>
          <td><a href="/blog/skills/requirement-protocol/" data-link-title="Requirement Protocol — 需求確認到實作的對話協議" data-link-desc="從需求確認到實作的對話協議：模糊指令澄清、可決定 vs 該確認、失敗 2 次轉折、覆寫成本告知、revert checkpoint、漸進驗證、工具切換時機。六大原則 &#43; 五份情境 reference。"><code>content/skills/requirement-protocol/</code></a></td>
          <td>上層對話協議 skill</td>
      </tr>
  </tbody>
</table>
<h2 id="last-updated">Last Updated</h2>
<p>2026-04-26 — v0.2.0 接入 #55-#68 系列：新增第 7 份 reference <code>data-flow-and-filter-composition</code>（Filter × Source 層錯位 + 五策略 + 跨前端 / 後端 / 演算法 / DB 範例）；強調原則跨領域通用、不只前端。</p>
<p>歷史版本：</p>
<ul>
<li>2026-04-26 — v0.1.0 初版：六份 references 對應「DOM topology / CSS-JS 邊界 / Playwright 三位置 / framework 共處 / Reactive 效能 / A11y」六個情境</li>
</ul>
]]></content:encoded></item></channel></rss>