三個原則
標準不分等級
不管是形象官網、內部工具、還是一次性的活動頁,版面秩序與完成度都用同一把尺。「這只是內部用的」不是降標理由。差別在做什麼,不在做多好。
先判斷類型,再套規則
同一條規則在不同類型的網站上,可能是對的也可能是錯的。單欄敘事對形象頁是加分,對後台是災難。開工第一件事是確認這是哪一類網站——見 §01。
「做好了」是有條件的
沒跑完 §09 驗收 SOP,不能說做好了。宣稱驗過但沒驗,比沒驗更糟——它會讓問題被帶到上線之後。
網站分類
四個大類。先判斷屬於哪一類,再決定哪些規則適用——用錯類型的規則,做得越認真錯得越徹底。
| 類型 | 成敗在哪 | 這類特別要注意 | 不適用什麼 |
|---|---|---|---|
| 一頁式 單頁把事情講完 |
第一屏與捲動節奏。內容順序就是說服順序 | 字級落差要夠大、段落寬度要有變化;動效用來分段,不是裝飾 | 下層頁、複雜導覽 |
| 活動網站 有時效、有明確行動 |
主視覺與行動按鈕。資訊要在三秒內看完 | 倒數與狀態要真實;表單流程越短越好;分享預覽圖一定要有 | 過度敘事、需要學習的互動 |
| 官網(前後台) 前台形象+後台維護 |
前台看質感,後台看好不好維護 | 兩邊是兩套設計:前台可以雜誌感,後台要密度與一致性。共用的是 token 不是版型 | 後台不適用單欄敘事與捲動揭露 |
| CRM/工具介面 每天用、用很久 |
資訊密度與操作效率。看久不累比好看重要 | 多欄、表格、固定工具列是正確做法;狀態必須完整;鍵盤可達 | 大型主視覺、捲動動效、雜誌排版 |
這是「需求不明確時先給大架構」用的預設,不是答案——給出來讓人指著改,比從零開始問快得多。
| 類型 | 預設順序 |
|---|---|
| 一頁式 | 主視覺 → 一句話價值 → 痛點或情境 → 方案與特點 → 證明(數字/見證)→ 常見問題 → 行動 |
| 活動網站 | 主視覺(時間地點在第一屏)→ 亮點 → 參加辦法 → 常見問題 → 行動(可重複出現) |
| 官網前台 | 主視覺 → 定位一句話 → 服務或產品 → 實績案例 → 信任要素 → 聯絡 |
| 後台/CRM | 側欄或頂欄導覽 → 總覽 → 列表(搜尋・篩選・排序)→ 詳情 → 設定 |
需求還不明確的時候怎麼開場
不要因為資訊不足就先追問一長串問題,那會卡住開始。先判斷類型,直接給一個大架構——區塊順序、每一區放什麼、大概的密度,讓對話有個可以指的東西。
架構出來之後才開始問,而且一次問一件、附上你建議的答案,讓人只要說「對」或「不是,我要 X」。架構每被修正一次就更新一次,不要累積到最後才一起改。
順序反過來(先問完再做)的問題是:需求描述能力通常比判斷能力弱得多。看到東西才說得出哪裡不對。
收到現有網站時,先判斷修還是重構: 秩序層的問題(間距、對齊、字級亂)→ 修; 結構層的問題(區塊順序錯、密度失衡、版型本身陽春)→ 重構。 聽到「大改」一律重構,不是調參數——重構仍沿用原本的內容與資產,改的是骨架。
怎麼做會看起來好看
這一章不是設計理論,是從 9,539 個得獎網站的實際資料統計出來的—— 國際獎項站 6,048 個、日系藝廊 3,491 個,含官方分類標籤、偵測到的技術棧、字體與主色。下面每個數字都可以自己驗。
這七項是「不做就明顯低一階」的基本盤。做到是及格,不是加分。
| 手法 | 出現 | 可執行的意思 |
|---|---|---|
| 版面有巧思 | 3,026 | 不是等寬網格排到底。至少一處打破規律:滿版與收窄交替、非對稱、錯位、大小懸殊 |
| 單欄版面 | 2,977 | 主軸單欄、靠寬度變化做節奏。多欄只用在真的並列的內容 |
| 主視覺與按鈕出色 | 2,836 | 第一屏與主要按鈕是投資重點。按鈕是視覺主角之一,尺寸留白 hover 都要單獨設計 |
| 動態與特效 | 2,720 | 有動、而且看得出層次。完全靜態的站在這個池子裡幾乎不存在 |
| 雜誌感排版 | 2,654 | 字級落差大、欄寬有變化、圖文交錯、留白當構圖,不是把段落均勻鋪滿 |
| 照片出色 | 2,407 | 回到 §06:這一項買不到捷徑 |
| 下層頁做得好 | 2,384 | 內頁不能是首頁的陽春版。最常被放棄、也最容易拉開差距的一項 |
- 明度兩極化:極暗 16%+極亮 20%,中間調只佔 31%。底色要嘛很暗要嘛很亮,別停在灰灰的中間
- 低飽和是多數:飽和度中位數 0.17,53% 低於 0.2。高飽和只用在強調色
- 四成是中性色(灰/黑/白)。有彩色裡藍 22%、紅 11%、綠 7%
可執行版:大面積中性色(極暗或極亮)+一個低飽和的主色鋪陳+高飽和只留給按鈕與重點。這就是統計出來的「看起來高級」的實際做法。
- 雲端字體服務 38%、自架
@font-face19% - 可變字體 31%——三成的得獎站在用。字重可以連續調,一個檔案涵蓋整組
- 常見無襯線:Inter、Montserrat、Lato、Poppins、DM Sans、Jost、Manrope
- 顯示字級的襯線:EB Garamond、Cormorant Garamond
可執行版:一支無襯線當內文+一支有個性的顯示字,兩支就夠。中文用系統堆疊(見 §10),英文與數字才換字體。
| 技術 | 佔比 | 實際拿來做什麼 |
|---|---|---|
clip-path | 30% | 比動畫庫還常見。遮罩揭露、斜切分隔、形狀轉場——純 CSS 就能做,CP 值最高 |
| 可變字體 | 31% | 字重連續變化、hover 加粗、捲動時字寬變化 |
| 輪播元件 | 23% | 橫向滑軌。「輪播滑軌」這個標籤本身出現 2,026 次 |
| canvas / shader | 16% | 粒子以外的生成式視覺 |
| 跑馬燈 marquee | 15% | 無縫橫向滾動的字帶。低成本、高辨識度 |
| GSAP | 14% | 時間軸與捲動編排 |
| Lottie | 9% | 向量動畫(設計端出稿,前端只播) |
| Lenis | 8% | 平滑捲動 |
mix-blend-mode | 8% | 文字疊在圖上自動反白、游標與底層互斥 |
最重要的一個發現:clip-path 的普及度是動畫庫的兩倍。要讓網站看起來「有做過」,先把遮罩揭露、斜切分隔、形狀轉場這些純 CSS 手法做好,再考慮上動畫庫。先做 CP 值高的那 30%。
- WebGL 1.91x · 敘事 1.86x
- 3D 1.67x · 實驗性 1.64x
- 滿版 1.52x · three.js 1.35x
- 動畫 1.29x · 網頁互動 1.28x · GSAP 1.27x
- 字體排印 0.48x
- 乾淨簡潔 0.49x · 極簡 0.55x
- 轉場 0.69x · 視差 0.74x · 捲動 0.77x
高分靠的是敘事野心與技術強度,不是把版面收乾淨——「乾淨簡潔」「極簡」跟高分是負相關。
但那是獎項評審的偏好,不等於實際案子的好:一個以填表為目的的頁面做成 WebGL 敘事體驗,轉換率會死。
正確用法:把它當成「這個案子要不要爭取記憶點」的判斷依據。要 → 押一個技術或敘事主張;不要 → 把七件基本盤做滿就夠。
日系藝廊把 3,491 個站按版面手法、動態類型、配色情緒、質感分成 212 類。用這些詞溝通方向,比講「高級」「乾淨」精準得多——而且每個詞都查得到實例。
單欄版面 · 雜誌感排版 · 版面有巧思 · 大量留白 · 字級落差大 · 滿版 · 堆疊層次感 · 局部橫向捲動 · 裝飾線框 · 資訊圖表出色
開場動畫 · 視差捲動 · 頁面轉場 · 元素會動 · 插畫會動 · 細膩動態 · 動態適中 · Hover 效果 · 輪播滑軌 · 影片播放
主色鮮明 · 沉穩配色 · 知性冷冽 · 柔和親切 · 米白奶油色 · 粉彩色系 · 黑白單色 · 暖色系 · 寒色系 · 暗色系 · 高級感配色
誘人質感影像 · 質感與紋樣 · 手作拼貼紙感 · 使用插畫 · 圓角 · 玩心與細節
時髦俐落 · 帥氣 · 親切自然 · 簡約 · 優雅 · 可愛
鎖方向時挑 1 個版面手法 + 1 個動態類型 + 1 個配色情緒 + 1 個調性,四個詞就足以把方向定下來。
全部低成本、高槓桿。先做這七項,再考慮加東西。
| 手法 | 怎麼做 | 為什麼有效 |
|---|---|---|
| 大標負字距 | letter-spacing:-.02em(僅 32px 以上的標題) | 大字級的預設字距偏鬆,收緊後才像刻意排過 |
| 細線用半透明 | rgba(色,.12) 不用實色 | 實色細線在深淺底上都會太搶,半透明會自動融進背景 |
| 圖片微幅放大 | 外層 overflow:hidden,圖片 hover 時 scale(1.03)、.5s | 幅度要小到「感覺得到但看不出來」,超過 1.05 就變俗 |
| 文字疊圖自動反白 | mix-blend-mode:difference | 不用管底圖亮暗,文字永遠讀得到,還很有設計感 |
clip-path 遮罩揭露 | 從 inset(0 0 100% 0) 動到 inset(0) | 統計上最普及的手法(30%),純 CSS、不用動畫庫 |
| 同色系分層 | 層次靠明度差,不靠換色相 | 換色相會讓畫面變花;同色不同明度才是「高級」的實際做法 |
| 留白優先於裝飾 | 覺得空的時候先加留白拉開層級,不是加東西填 | 填東西會讓密度失控,拉開留白反而讓現有內容更清楚 |
這份統計的限制:分類與標籤來自兩個網站的官方分類、技術棧來自讀取首頁原始碼,兩者都是機器判斷。它能告訴你「這站載了某個函式庫」,告訴不了你「它把那個函式庫用在哪個效果」。
五條判斷軸
這五條是「看起來對不對」的判準。做完自己逐條檢查,不要等別人指出來。
間距不同、沒對齊、不平衡都是低級錯誤,不該犯。 交出第一版之前就要自己檢查完並修好——這些不是「等人看到再說」的項目,是交付的前提。 判斷方式全部可以自己量:同排卡片高度差、padding 值的種類數、間距是否落在階梯上、左右兩欄的高度差。
- 全站共用一組間距階梯,不得出現階梯外的魔術數字。
- 同一列的元素基線對齊;卡片內的左緣全部對齊到同一條線。
- 上下留白成對:區塊上下 padding 的合法比例只有 1:1 或 3:2,選定一種後全站一致。
- 圖示與文字用
flex+align-items:center,不要靠margin-top硬喬。 - 兩欄並排要等量配對:左右高度差超過一張卡,就是配錯對——重新配對或改成各自一排,不要留一邊一大塊空(「不平衡」)。
- 同一排的卡片等高切齊——並排容器用
align-items:stretch(不是start),卡片本身height:100%。格數要湊滿整排,最後一排不要留孤兒格(「不整齊」)。 - 同類元件的內距必須完全相同:所有卡片同一個 padding、卡片內元素同一個 gap、清單最後一項不留殘餘邊距(
:last-child{margin-bottom:0})。內距不一致是最容易被一眼看出的低級錯誤。 - 寬螢幕要用得滿:容器寬度要跟著大螢幕放大,短句能一行就一行——留白要是設計出來的,不是版面剩下的(「右邊很空」)。
- 限寬只給連續段落:內文限 70–76ch 是為了好讀;但清單、卡片、表格、規則列表不要限寬,它們有自己的視覺邊界,限了只會在右邊留一條固定的空白。
可量化判準:容器直接子層的右邊空白 ≤200px。超過就是限寬限錯了對象——同一個 class 出現在十幾章,錯一次就是錯十幾次,要改 class 本身不要逐處微調。 - 發現一處歪掉,就要掃全站同類——同一個錯誤通常不會只出現一次。
裝飾不是罪。裝飾讓版面更有層次。要刪的不是裝飾,是「看起來該有功能、結果沒有」的東西。
| 這是什麼 | 處理 |
|---|---|
| 明確的裝飾層 裝飾性英文字、eyebrow、編號、線條、幾何色塊、大型數字、留白節奏 |
留,而且該多用。這是版面層次的來源 |
| 假裝有功能的元件 看不懂用途的欄位名、點不下去卻長得像按鈕的東西、沒有口徑的數字 |
刪,或換成真的有功能的東西 |
| 該是資訊卻放錯層級 次要欄位擠在第一層 | 移到第二層 |
| 無意義的大片空白 不是刻意的留白節奏,是排版沒收好 | 收掉 |
- 每個數字都要有單位或口徑。「已完成 30」要講清楚是 30 件還是 30%、怎麼算的。
- 介面不自我解釋。「本區塊固定顯示於最上方」這種操作說明不放介面上——好的介面直觀到不需要解釋。保留有資料價值的字(筆數、名稱),刪純解釋的字。
- 同一資訊不出現兩次。卡片右上已有日期,下面就不要再寫一次。
- 不自行增補文字。給定的文字原樣保留,不改語意、不加句子。缺文案要問,不要腦補。
- 內部流程說明與技術註記不出現在對外畫面上。
| AI 感來源 | 改成 |
|---|---|
| 卡片左/上邊一條彩色線 | 拿掉,用留白與字級分層 |
| 重陰影厚框卡片一路堆疊整頁 | 1px 細線 + 留白分區 |
| 按鈕漸層、文字漸層 | 實色 |
| 粒子背景、向量地圖線、點點背景 | 換別的效果類型(不是換顏色) |
| emoji 當 icon | 正規圖示集的向量圖示 |
| emoji 每段都在同一個位置 | 位置與有無都要不規則 |
| 每句話都以句號結尾 | 混用斷句 |
| 同一個角色出現兩種尺寸、字重滿天飛 | 字級角色 ≤5、字重 ≤3 種 |
| 早期流行的大圓角(16px+) | 6–10px,更俐落耐看 |
| 用色塊分隔區域(整條橫幅換底色分段) | 1px 細線+留白。禁的是「用底色分區」,不是禁止色塊本身——軸二說的幾何色塊是裝飾層,該留 |
- 每一個互動元件都要有 hover/focus/載入中/空狀態/錯誤狀態,五種都設計過,不是只有預設樣子。
- hover 與狀態切換要有過渡(150–250ms),不是瞬間跳變。
- 列表與資料區要有空狀態(為什麼是空的+下一步)與載入中(骨架屏優於轉圈)。
- 檢查對象是「所有存在的」互動元件——逐個列出來核對,不是抽查。
- 結論在上、推導在下(或用
<details>收起來)。過程、警語、免責、細節說明一律收合。 - 警語、備註、小字用更小的字級(12–13px)與降低的對比。
- 長頁面要有錨點導覽:第一屏就給可點的錨點,讓人直接跳到想看的段落。
- 長頁面標配右下角懸浮的回頂按鈕:向上箭頭圖示、尺寸 ≥44px、捲超過一屏才出現、跟著主題配色,減少動態時直接跳不滑動。
- 每一區的重點用跳色標出來,一眼抓到這段在講什麼。
- 密度的判準不是「字多不多」,是掃一眼能不能分辨層級。分不出來就是太密。
黑名單
開工前掃一次、交付前再掃一次,而且它是 §09 驗收 的必跑項目。分三級:硬禁 看情況 可以用
要用某個效果之前先查。中英文都可以。
不准出現。不得拿「要有質感」「要有記憶點」當理由放回來。
粒子背景、向量地圖線、點點背景、卡片邊緣一條彩色線、按鈕漸層、文字漸層、emoji 當 icon、退流行的圖示風格、大圓角(16px+)、引導用箭頭(改拉細線)、用色塊分隔區域、游標特效的陰影與過大外環。
磁吸按鈕、3D 傾斜卡片、滑過會跑掉的按鈕、會遮住文字的效果、hover 才能點的功能、打字機循環等玩具型效果。
自行增補文字、無意義的裝飾欄位、次要欄位放第一層、看不懂用途的功能名、內部流程說明外露、沒有單位的數字、大量無意義空白、橫式排列吃版面。文案的 AI 感:每句都句號、emoji 位置統一、堆專業術語。
每輪開新檔、只做保守增量、宣稱完成但沒驗、未經指示就部署或推 git。
不是不能用,是不能預設用——要用得說得出這一次為什麼適合。
- 漸層:只當柔和背景暈染或層次過渡;按鈕漸層與文字漸層仍是硬禁。
- 重陰影厚卡:需要明確層級分離時可用;不要整頁卡片都套。
- three.js/WebGL:只用在真的需要氛圍的 hero,不要整站都上;而且關掉之後頁面必須完整。
- 自訂游標跟隨:不要陰影、外環圈圈要小。
- 裝飾層與裝飾英文:明確作為裝飾即可,不要做成「看起來該有功能卻沒有」。
clip-path遮罩揭露與斜切:純 CSS、CP 值最高的一類。- 1px 細線分隔:預設的分區方式。
版面規格
全部是預設值。有品牌規範的以規範為準,本章只補規範沒講的部分。
區塊之間、元件之間、元件內 padding 一律用這幾個值,不得出現階梯外的魔術數字。下面是實際比例。
元件內 padding 12 / 16 / 24 · 元件之間 16 / 24 / 32 · 區塊之間 64 / 96 / 128(手機減半)
微調層(唯一的例外):小晶片 padding、圖示與文字的視覺對位、1px 邊框補償,允許 2 / 3 / 5 / 6 / 9 / 10。只能用在元件內部,不得用於區塊或元件之間。
「統一」的意思是「同一個角色永遠同一個值」,不是所有東西同一個值。16px 以上不用。
預設不用陰影。要用就 0 2px 8px rgba(0,0,0,.06),最多一層。分區用 1px 細線+留白,不要色塊。引導標示拉細線,不加箭頭。
font-variant-numeric: tabular-nums;
font-feature-settings: "zero" 0;前者讓表格與金額對齊,後者關掉 0 中間那一點。
字級以「角色」計算,最多 5 個角色;字重最多 3 種。裝飾層共用既有角色的 token,不另計級。
「同一角色不得兩種尺寸」的算法:一個 clamp() 的連續範圍算一個角色,同一角色在不同斷點的宣告也算一個角色。真正禁止的是鄰近值——不得同時出現 14 與 14.5px 這種肉眼分不出、卻讓版面顯得亂的差距。判準:任兩個角色至少相差 15%。
字距:中文內文 .02–.04em · 標題 .04–.06em · 全大寫小標 .12–.24em。行高:內文 1.7–1.85 · 標題 1.25–1.35 · 單行元件 1.45。
- 主色查品牌規範,禁止憑記憶寫 hex。
- 大面積中性色(極暗或極亮)+低飽和主色鋪陳+高飽和只給按鈕與重點(見 §02 的統計)。
- 狀態色四階:完成(綠)/進行中(藍)/待確認(琥珀)/異常(磚紅)。
- 重點字要過 WCAG AA;淺色系主色不要直接當文字色,另備深色可讀版。
- 不要用透明度疊出主色的中間色。
- 預設亮色,深色是選配。不做就明確只做單主題,顏色照樣全部寫死。
- 要做就做完整三態:明確亮/明確暗/未標記(跟系統偏好)。多數人看到的是未標記狀態。
- 全部顏色 token 化,元件只吃 token;body 一定要上底色——透明會借到外層的底。
- 顏色不能只定義在媒體查詢或主題屬性裡——未標記狀態拿不到,會出現「一個主題的字配另一個主題的底」。完整寫法見程式碼庫的 token 起手式。
- 兩個主題各自過對比檢查,不是把亮色反轉了事。
位階:品牌指定的圖示 > 自有 SVG > 通用圖示集。同一頁尺寸統一(16/20/24 擇一),線寬 1.5 或 2 擇一。不要混用不同來源的圖示。
內容容器 min(1440px, 92vw);滿版需求改 100%。斷點 768px(平板)、480px(小手機)。彈性欄位一律 minmax(0,1fr),多欄寫 repeat(n, minmax(0,1fr))。
主視覺與素材
規則能保證版面不亂、不醜、沒有 AI 感。但一個網站看起來「厲害」, 一半以上來自素材本身。這一章講素材要什麼規格、哪些不能交給 AI、沒有主視覺時怎麼辦。
| 素材 | 規格 | 備註 |
|---|---|---|
| Hero 主視覺 | 橫式 2400×1350 起(2x 用);壓到 ≤500KB;WebP/AVIF 並留降級格式 | 第一眼的全部 |
| 標題字處理 立體、金屬、鏤空、描邊 | 透明背景 PNG/SVG,2x;或直接做進主視覺 | AI 生的中文字幾乎都會壞 |
| 區塊配圖 | 統一裁切比例(3:2 或 4:3 擇一,全站一致);單張 ≤300KB | 去背與調色可由 AI 協助 |
| 圖示 | 同頁尺寸統一(16/20/24 擇一),線寬 1.5 或 2 擇一 | 不混用不同來源 |
| Logo | SVG;深底與淺底各一版 | 不得自行重繪 |
| 分享預覽圖 | 1200×630,≤300KB,絕對網址 | 公開頁必備 |
| Favicon | SVG + 32×32 PNG + 180×180 | 加入主畫面時會用到 |
- 氛圍背景、質感底紋、抽象光影
- 非特定人物的情境照
- 去背、擴圖、色調統一、光影重構
- 示意用的插畫與圖示風格草稿
- 真實標的——實際存在的建物、店面、產品、場地
- 特定真人
- 中文字——幾乎一定壞字,標題字要自己做
- 會被當成事實的圖——平面圖、規格圖、地圖、數據圖表
不要用免費圖庫照片充數——那是最快讓網站看起來廉價的方式。改用這四種:
- 大型字本身當主視覺——標題放到極大(內文的 5–8 倍),用字距、斷行、對比撐出畫面。
- 幾何與線條系統——用主色的色塊、細線、網格排出構成。
- 資料視覺化——把數字做成刻度、比例尺,圖表本身就是視覺。
- 單一高品質實拍——一張真的比十張圖庫好。裁成滿版橫幅、統一色調。
選了哪一種要寫進交付說明,讓人知道這是刻意的,不是省略。
所有圖片裁切比例統一——同一組卡片裡不得有的 3:2 有的 4:3。
色調一致——不同來源的圖要調到同一個色溫與對比,否則整頁像拼貼。
不是原圖直出——壓過、指定尺寸、首屏以外 lazy-load。
授權可追溯——每張圖說得出來源與可商用依據。見法務底線一章。
每張圖都有 alt,寫的是內容不是檔名。
真的沒有圖片時,明確寫出視覺重量靠什麼撐。這是陳述事實,不是降標。
文案與表單
版面的材料是字和欄位。字數失控=版面失控——這是版面炸掉最常見的上游原因。
- 中文主標 4–12 字;副標一行內(≤20 字);eyebrow ≤12 字
- 內文一段 ≤3 行(手機 ≤5 行),一段講一件事
- 卡片標題一行內;超過就是標題寫成了句子
- 塞不下時先刪字,不是縮字級——縮字級會破壞字級系統
- 按鈕動詞開頭、2–6 字,說清楚按下去會發生什麼:「下載簡章」不是「點我」「更多」
- 同一畫面主要按鈕只有一個,其餘降為次要樣式
- 錯誤訊息寫「哪裡錯+怎麼修」:「電話少一碼,應為 10 碼」不是「格式錯誤」
- 空狀態寫「為什麼是空的+下一步」:「還沒有資料——按右上角新增」不是一片空白
- 日期與數字格式全站統一;千分位;單位不省略
label 永遠可見,不能只用 placeholder——開始打字就看不到欄位是什麼了。placeholder 只放範例格式。
一排一欄,由上往下填;欄位能少就少,相關欄位分組並下小標。
必填標示統一——標必填或標選填,擇一,全站一致。
錯誤就地顯示在欄位旁;送出失敗時焦點跳到第一個錯誤欄位,不是只在頂端放一條紅字。
手機鍵盤要對:電話 inputmode="tel"、信箱 type="email"、數字 inputmode="numeric"——跳錯鍵盤是最容易被放棄的填寫體驗。
動效
滑順、有質感、看得出來「有在動」但不干擾操作。
兩種都可以:需要捲動編排、視差、時間軸控制時用 GSAP + ScrollTrigger + Lenis;只需要進場揭露與狀態過渡時,純 CSS + IntersectionObserver 就夠,而且失效面更小。骨架見 程式碼庫。
磁吸、3D 傾斜、粒子、跑來跑去的游標追蹤按鈕、會遮住文字的效果、需要等很久的入場動畫。
prefers-reduced-motion 與 ?noanim 走同一條路徑,直接到位,不是播很快。
用動畫庫時 lagSmoothing(0);固定時長的載入畫面要有 setTimeout failsafe 強制放行。
任何「延後一幀再渲染」要 rAF + setTimeout 併用——背景分頁的 rAF 會被暫停或大幅降頻,頁面會長時間卡在載入畫面,而且 console 完全沒有錯誤。
內容 CSS 預設就是可見的,動畫只是把它藏起來再演出來。反過來寫(預設 opacity:0 靠 JS 顯示)=動畫庫載入失敗就整頁空白。
保險絲要持續監看:只要還有元素「已經捲到可視範圍卻仍隱形」就強制退成靜態。只檢查一次不夠——揭露了前幾個之後才停止工作,剩下的會永久隱形。
所有 fallback 都要 once guard,成功後取消計時器,否則會重複註冊、跑出兩份動畫。
建站時就留除錯參數:?nofx(關 WebGL)、?noanim(關動畫)、?testvh(固定 hero 高)、?audit(DOM 斷言)。事後補很痛苦。
手機版
手機不是縮小版的桌機,是另一種版面。不管主場是哪一邊,兩邊都必須正常可用——這是硬性下限。
| 項目 | 要求 | 為什麼 |
|---|---|---|
| 驗收寬度 | 320 / 375 / 390 / 1440 | 四個都要,不因主場而省略。320 是最容易爆版的寬度 |
| 觸控目標 | ≥44×44px | 寬與高都要。小於這個點得到但點不準 |
| hover 功能 | 一律要有等效點擊路徑 | 手機沒有 hover。只靠 hover 出現的功能在手機上等於不存在 |
| 水平溢出 | 0px | 彈性欄位一律 minmax(0,1fr);包住可捲動內容的 flex/grid 子項要 min-width:0 |
| 全高單位 | 100svh | 100vh 對應瀏覽器 UI 收起時的大高度,UI 展開會遮住內容。dvh 會隨捲動改變高度造成跳動 |
| 字級對比 | Hero ≥ 內文 3 倍 | 手機空間小,對比不夠就沒有層次 |
| 斷行 | 標題不得出現奇怪斷行 | 關鍵詞組用 white-space:nowrap 或 text-wrap:balance |
| 寬內容 | 表格與程式碼自己捲 | 放進 overflow-x:auto 的容器,頁面本身永遠不橫向捲動 |
- 多欄一律降成單欄,並重新決定順序——桌機的左右在手機變成上下,重要的要在上面。
- 區塊間距減半(96 → 48、64 → 32),但階梯不變。
- 導覽收成可橫向捲動的一列或抽屜,不要擠成兩行。
- 表格不要硬塞——欄位少就轉成卡片式,欄位多就讓它自己橫向捲。
- 固定在底部的操作列要留安全區(避開系統手勢區)。
- 格線只寫
1fr,被寬表格撐爆整頁 - 用了
100vw,撞上實體捲軸產生水平捲軸 - hover 才出現的選單,手機完全打不開
- 觸控目標小於 44px,點了常常沒中
驗收 SOP
這一章是給 Claude 跑的,不是列給使用者檢查的清單。 收到一個網站要優化、或做完一個網站要交付,下面八步全部由 Claude 自己執行完,附上實際輸出當證據,才可以說做好了。 沒有輸出就是沒過——「沒有錯誤訊息」不等於「沒有問題」。
靜態掃描
只掃樣式,不要掃到說明文字。並排容器不得用 align-items:start(會造成同排卡片高度不一);同類元件的 padding 值必須只有一種。100vw/100vh/按鈕與文字漸層/backdrop-filter 沒 fallback /圓角 ≥16px 各要 0 處;中文字型堆疊要寫全;字重種類 ≤3 且內文 ≥400;字級不得有鄰近值(任兩角色差 <15%);間距不得出現階梯外的值。每項要有明確 PASS/FAIL,「grep 沒輸出」不等於 PASS——可能是根本沒找到宣告。
版面自檢(量出來,不是用看的)
這四項全部可以用腳本量,交出第一版之前就要自己跑完: ①同排卡片高度差 ≤2px ②同類元件的 padding 只能有一種值 ③兩欄區塊左右高度差 ≤2px ④容器直接子層的右邊空白 ≤200px(限寬元素不要留下固定空白)。 這些是低級錯誤,被別人指出來就已經太遲。
黑名單掃描
逐條跑 §04。硬禁項 0 處:粒子背景、卡片邊緣彩色線、按鈕與文字漸層、emoji 當 icon、大圓角、磁吸、3D 傾斜、hover-only 功能、色塊分區、引導箭頭。看情況的項目有用到就要說得出這一次為什麼適合。
渲染驗收
無頭瀏覽器整頁截圖,自己實際看過。文字描述不算證據。有些問題只有渲染成圖才看得到——例如選擇器誤傷造成「一個詞佔一行」,DOM 斷言完全查不出來。
四個寬度的 DOM 斷言
320/375/390 用 iframe 夾具(不可用縮小視窗),1440 用真實 viewport。掃水平溢出、超出視窗的元素、觸控目標尺寸。fail-closed:讀不到結果一律判不過,空字串不等於 PASS。
互動實測
實際點過每一個連結、按鈕、分頁、收合、彈窗,並驗證「點完發生了什麼」(網址變了/DOM 變了/彈窗開了),不是看有沒有綁事件。鍵盤 Tab 走得完、focus 看得見。錨點每一個都跳得到。
錯誤收集與失效測試
收集 pageerror、console error、失敗請求、非 2xx/3xx——任何未列白名單的錯誤都算 FAIL。另外實際模擬prefers-reduced-motion,以及把動畫庫停掉,確認內容仍然完整可見。
舉一反三
發現的每一個問題都當成「這一類問題」全站搜一遍,回報寫「同類共 N 處,已修 N 處」。同一個錯誤幾乎不會只出現一次。
- 頁面上的文字逐字來自原始素材,沒有自己加的句子
- 品牌事實(色票、字體、聲明字句)逐項對照規範,不是憑記憶
- 每個數字都有單位或口徑;介面沒有自我解釋的說明;同一資訊沒出現兩次
- 內部流程說明與技術註記沒有出現在對外畫面上
- 公開頁:title/description/lang/canonical/分享預覽圖,h1 唯一不跳級,圖片有 alt
- 效能:LCP ≤2.5s、CLS ≤0.1、首屏總傳輸量 ≤1.5MB、單張圖 ≤300KB(主視覺 ≤500KB)、圖片指定尺寸、首屏外 lazy-load、中文字型子集化
- 有表單:伺服器端驗證、輸出編碼、防重複送出、三種狀態都有畫面
- 固定改同一個工作檔,不要每輪開新檔;備份走
.backup-日期-說明或 git - 批次改良已驗證的檔案,先輸出到暫存資料夾比對過再取代——「已驗證」是最貴的資產,覆蓋等於銷毀
- 批次替換每條要有命中確認,有一條沒中就整批不寫檔(樣板見程式碼庫)
- 驗收之後又改了檔案,舊證據作廢,受影響項要重驗
- 先講結果與檔案位置,再列後續步驟
- 列出過程中替人做掉的關鍵決定
- 附截圖——視覺問題用文字描述無效
- 沒做到、做不到的明講。寧可說「這項沒做」,不要含糊帶過
- 用詞誠實:引擎測試不能講成實機測試(見 §10)
成品感的最後一哩
全部是小東西,但少了任何一項都會被一眼看出「還沒做完」。交付前逐條掃。
| 項目 | 怎麼做 | 不做會怎樣 |
|---|---|---|
| 錨點被固定導覽遮住 | 目標區塊加 scroll-margin-top: 導覽高度 + 16px |
點錨點跳過去,標題被導覽蓋住——最常見的成品瑕疵 |
| iOS 輸入框自動放大 | 所有 input/select/textarea 的 font-size ≥16px |
手機一點輸入框,整頁被放大且回不去 |
| 手機點擊藍框 | -webkit-tap-highlight-color: transparent,並自己給 :active 回饋 |
每次點按鈕都閃一個系統藍框,很廉價 |
| 長網址撐爆版面 | 可能出現長字串的容器加 overflow-wrap: anywhere |
一個沒有空格的長網址就把手機版撐出水平捲軸 |
| 圖片載入時版面跳動 | 圖片外框指定 aspect-ratio,或 width/height 屬性都寫 |
圖一載入,下面的內容整個往下跳(CLS) |
::selection——選取文字的顏色跟著主色,不要留瀏覽器預設的藍- 外部連結——
target="_blank"必配rel="noopener"(安全性,不是可選) z-index分層——定成 token(內容 1/浮層 10/固定列 50/彈窗 100),不要隨手寫 9999- 彈窗內捲動——加
overscroll-behavior: contain,捲到底不要帶動背後的頁面 - 影片——一定要有
poster,否則載入前是一塊黑
- 404 頁——要有,而且要能回首頁;不要留主機的預設錯誤畫面
- 表單自動填入——Chrome 會把欄位塗成黃色,用
:-webkit-autofill覆蓋成自己的底色 - 列印樣式——至少讓導覽、按鈕、裝飾層
display:none,內容能印出來 - 導覽項目 ≤7 個,且當前位置要標示——超過就分層或收起來
- 頁尾要有東西——版權、聯絡方式、必要連結;空頁尾等於話沒講完
這一章全部可以自動掃。交付前用一支腳本檢查:scroll-margin 有沒有設、輸入框字級是否 ≥16px、z-index 是否有超過 token 上限的值、target="_blank" 是否都配了 noopener、圖片是否都有尺寸或 aspect-ratio。別等別人指出來——這些都是自己量得到的。
相容性
先講清楚做得到與做不到的,不要宣稱沒做過的驗證。
| 測到的 | 只能這樣寫 | 不准這樣寫 |
|---|---|---|
| Chromium 引擎 | 「Chromium 引擎驗證通過」 | |
| WebKit 引擎 | 「WebKit 引擎驗證通過」 引擎 ≠ Safari app | |
| 沒有 Windows 機器 | 「已做 Windows 靜態檢查,實機未驗證」 |
這六項在 macOS 上 100% 正常,只有 Windows 才會炸。每項要明確 PASS/FAIL。
- 不得使用
width:100vw——Windows 捲軸實體佔位 15–17px,會超出可視寬度產生水平捲軸;macOS 浮動捲軸看不出來。改width:100%。 - 中文字型堆疊要寫全,含
Microsoft JhengHei。只寫 PingFang 在 Windows 會掉到系統預設,整站質感崩掉。 - 內文字重不低於 400。Windows 的 300 細字比 macOS 更細更難讀。
backdrop-filter必須有 fallback 背景色——不支援時會變全透明,文字直接看不見。- 只寫
-webkit-前綴的屬性 → 補標準寫法。 - 自訂表單控制項樣式(select/checkbox/date)——Windows 預設外觀差很多。
掃描範圍要涵蓋建置後的完整輸出(外部 CSS、模板、JS 產生的樣式),不是只掃單一檔案。
100vh對應大視窗高度,瀏覽器 UI 展開時內容會被遮住 → 滿版首屏用100svh(最保守);dvh會隨捲動改變高度。三者語意不同,不可互換。backdrop-filter補-webkit-前綴。position: sticky相對最近的捲動容器定位。祖先若有overflow,黏附範圍會變成那個容器,看起來像失效。- flex
gap、:has()、text-wrap: balance在舊版要有降級。 - 影片
autoplay必須同時有muted playsinline,否則不播。 - CSS multi-column 瀑布流在 iOS 會欄頂錯位——上一欄尾端的 margin 外溢到下一欄頂端,桌機看不出來。改用 JS 分配到「累積高度最短」的欄。
症狀診斷表
使用者回報的都是症狀,不是原因。這張表把症狀對到最可能的幾個原因,以及怎麼查證。 先查,不要先改——大部分「看起來像設計問題」的東西其實是技術問題。
顏色好淺、階層糊掉、看起來都一樣色
color: var(--ink-soft) 若變數沒定義又沒給 fallback,整條宣告在計算值階段無效,等同 unset——繼承型屬性(如 color)繼承父層,非繼承型屬性回到初始值。看起來就是「淺淺的、都一樣色」。寫 var(--x, 安全值) 可以避免。從別的專案複製樣式最容易踩到(變數命名不同)。.blk .row 這種通用名,被前面某條規則吃到,跑出莫名其妙的底色。color 與 font-size,被外層規則接管。怎麼查:抓 :root 裡所有 --x: 宣告(不要限行首,同一行常有多個),比對全檔的 var(--x),列出差集。第二種用 grep -n '\.classname' 看有幾處定義。顏色不對時先量 computed style,不要靠肉眼調——量出來是 rgba(...,0.34) 就知道是繼承到別的變數。
一個詞佔一行、版面整個碎裂
.card b{display:block;font-size:33px} 會同時命中內文裡的 <b>。內文用 <b> 做跳色重點字時,每個重點字各自佔一整行。.card > b;再加保險 b.hl{display:inline}。怎麼查:DOM 斷言看不出來,只有整頁渲染成圖才看得到。這一類問題一律靠截圖驗。
點了沒反應
z-index、或某個滿版容器擋在前面。怎麼查:用 Playwright 實際點過每一個,並驗證「點完發生了什麼」(URL 變了/DOM 變了/彈窗開了),不是看有沒有綁 handler。?audit 掃觸控尺寸與 onclick 有無反應。
手機跑版、出現水平捲軸
minmax(0, 1fr)。只寫 1fr 等同 minmax(auto,1fr),該欄不允許收縮到內容寬度以下。多欄要寫 repeat(n, minmax(0,1fr));單欄 grid 不宣告時預設 auto 也會被寬內容撐爆。包住可捲動內容的 flex/grid 子項另外要 min-width:0。min-width,但外層的 flex/grid 子項沒有 min-width:0,捲動容器包不住。100vw——Windows 捲軸實體佔位 15–17px 會超出可視寬度。怎麼查:iframe 夾具在 320/375/390 三個寬度跑 ?audit,掃 scrollWidth - clientWidth。不可用 --window-size=390,那量到的是 500px 排版後裁切的假畫面。
卡在載入畫面,但 console 完全沒有錯誤
requestAnimationFrame 在背景分頁會被暫停或大幅降頻(不保證永不觸發,但不能當唯一的進度來源)。使用者從通訊軟體開連結、或用 ⌘-click 開新分頁就會中。setTimeout failsafe,動畫時間軸被卡住就永遠不放行。opacity:0 靠 JS 顯示)。怎麼查:把分頁切到背景再開一次;把動畫庫 stub 成空物件看內容還在不在。正確架構是內容 CSS 預設可見,動畫只是把它藏起來再演出來。
本機好好的,上線就壞
怎麼查:上線後 curl 打一次每個資源路徑與 API 路徑;看 Network 面板所有非 2xx/3xx 的請求。任何未列白名單的錯誤都算 FAIL。
改了沒生效(很像快取問題)
/exec,版本化部署)跑的是上次部署的快照,按儲存不會生效。(測試網址 /dev 會跑最新儲存的程式碼,但只有編輯者能開——正式前端絕不能指向它。)s.replace(a,b,1) 找不到錨點就什麼都不做也不報錯。怎麼查:每條替換包成 rep(a,b,label) 印 ✓/✗ 並計數,或直接 assert;錨點先 grep -n 確認實際字串。改完一定跑功能驗收,不能只看「腳本沒報錯」。
Windows 上看起來很醜、字很細、有水平捲軸
Microsoft JhengHei,掉回系統預設,整站質感崩掉。width:100vw 撞上實體捲軸。backdrop-filter 沒有 fallback 背景色,不支援時變全透明,文字直接看不見。怎麼查:跑 §10 的 Windows 靜態檢查六項,每項必須是明確 PASS/FAIL——「grep 沒輸出」不等於 PASS(可能根本沒找到 font-family 宣告)。
iPhone 上爆版、影片不播、sticky 失效
100vh 對應大視窗高度,瀏覽器 UI 展開時內容會被遮住 → 滿版首屏改用 100svh(最保守),dvh 會隨捲動改變高度。position:sticky 相對最近的捲動容器定位;祖先有 overflow 時黏附範圍會變成那個容器,看起來像失效。autoplay 沒有同時寫 muted playsinline。怎麼查:WebKit 引擎 + 手機寬度夾具可涵蓋大部分版面問題,但觸控與網址列行為無法完全模擬,回報時要誠實寫「WebKit 引擎驗證通過」而非「Safari 測過了」。
驗收全都過了,但畫面明顯不對
--window-size=390 量到的是 500px;--virtual-time-budget 不推進 CSS transition,量到的是動畫起始值;--dump-dom 讀不到 iframe 內容。--allow-file-access-from-files,夾具讀不到 iframe,印出空字串——看起來像全部正常。怎麼查:所有斷言一律 fail-closed:讀不到結果 = FAIL,空字串不等於 PASS。量元素位置前先注入 *{transition:none !important}。桌機要在真實 1440 viewport 實際捲動看過。
有人說「有 AI 感」但說不出哪裡
怎麼查:逐條刪,然後附前後對照截圖給他看。不要反問「哪裡有 AI 感」——提出的人答不出來,那是你要判斷的。
有人說「改動幅度太少、看起來沒啥差別」
怎麼查:停下來重新對焦方向,不要真的去湊數量。每一輪都附前後對照截圖。
可複製的程式碼庫
前面的章節說「要做什麼」,這一章給「怎麼做」。 全部預設收合——要用哪一段才展開,不然這頁會長到違反自己的密度規則。
① Token 起手式(間距/字級/圓角/主題三態)
新專案第一件事就是把這段貼上去。主題有三態:明確 dark、明確 light、未標記(只看系統偏好)——顏色若只定義在媒體查詢或 [data-theme] 區塊裡,未標記狀態下不會生效,那是最經典的「一個主題的字配另一個主題的底」bug。
/* 一次定好,全站只用這一組。品牌規範有規定的以規範為準。 */
:root{
/* 間距階梯:不得出現階梯外的魔術數字 */
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px;
--s6:32px; --s7:48px; --s8:64px; --s9:96px; --s10:128px;
/* 圓角:角色 token 制,同一角色永遠同一個值 */
--r-card:10px; --r-btn:8px; --r-chip:4px;
/* 亮色為完整定義(未標記主題時的預設)
⚠️ 下面這組 hex 是這份文件自己的介面色,只是讓你有個能跑的起手式。
實際專案裡一律換成該品牌規範裡的色票,禁止沿用這裡的值。 */
--paper:#f6f6f4; --paper-2:#fcfcfb;
--ink:#17181c; --ink-2:#4a4c55; --ink-3:#6b6d76;
--line:rgba(23,24,28,.13); --line-2:rgba(23,24,28,.06);
--accent:#2b3fa0;
/* 中文字型堆疊一定要寫全,少了 JhengHei 在 Windows 會崩 */
--sans:"PingFang TC","Microsoft JhengHei","Noto Sans TC",
-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
}
/* 主題有三態:明確 dark / 明確 light / 未標記(只看系統偏好) */
@media (prefers-color-scheme:dark){
:root:not([data-theme="light"]){
--paper:#131417; --paper-2:#1a1c21;
--ink:#e9e9e6; --ink-2:#a8aab2; --ink-3:#8b8d96;
--line:rgba(233,233,230,.15); --line-2:rgba(233,233,230,.07);
--accent:#8aa0ff;
}
}
:root[data-theme="dark"]{
--paper:#131417; --paper-2:#1a1c21;
--ink:#e9e9e6; --ink-2:#a8aab2; --ink-3:#8b8d96;
--line:rgba(233,233,230,.15); --line-2:rgba(233,233,230,.07);
--accent:#8aa0ff;
}
/* body 一定要明確上底色,透明會借到宿主的主題 */
body{background:var(--paper); color:var(--ink); font-family:var(--sans);
font-size:16px; line-height:1.8; letter-spacing:.02em}
/* 字級:角色最多 5 個,同角色不得兩種尺寸;字重最多 3 種 */
.t-hero{font-size:clamp(50px,7.2vw,84px); line-height:1.06; font-weight:600}
.t-sec {font-size:clamp(26px,3vw,42px); line-height:1.28; font-weight:600}
.t-sub {font-size:17px; font-weight:500}
.t-body{font-size:16px; font-weight:400}
.t-fine{font-size:12.5px; font-weight:400; color:var(--ink-3)}
/* 數字:表格金額對齊、關掉 0 中間那一點 */
.num{font-variant-numeric:tabular-nums; font-feature-settings:"zero" 0}
/* 格線起手式:不寫 minmax(0,1fr) 就會被寬內容撐爆 */
.grid{display:grid; gap:var(--s4); grid-template-columns:minmax(0,1fr)}
.flexcol > *{min-width:0}
.scroll-x{overflow-x:auto; min-width:0; max-width:100%}② ?audit fail-closed DOM 斷言(完整可貼)
放在頁尾。設計原則:缺任何一項都判 FAIL,不能因為「沒東西輸出」就當成過。這段同時處理了兩個量測陷阱——可橫向捲動容器的子元素、以及勾選框與連結的「有效點擊區」。
<pre id="audit" hidden></pre>
<script>
if (location.search.includes('audit')) {
var out = [], fail = 0, W = document.documentElement;
var run = function(){
try {
var vw = window.innerWidth;
out.push('AUDIT-V2 viewport=' + vw + 'x' + window.innerHeight);
var ox = W.scrollWidth - W.clientWidth;
out.push('水平溢出=' + ox + 'px'); if (ox > 0) fail++;
// 刻意可橫向捲動的容器,其子元素超出視窗是正常的
var all = document.querySelectorAll('*'), scrollers = [];
for (var c=0;c<all.length;c++){
var ov = getComputedStyle(all[c]).overflowX;
if (ov === 'auto' || ov === 'scroll') scrollers.push(all[c]);
}
var inScroller = function(el){
for (var k=0;k<scrollers.length;k++)
if (scrollers[k] !== el && scrollers[k].contains(el)) return true;
return false;
};
for (var i=0;i<all.length;i++){
var r = all[i].getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
if (inScroller(all[i])) continue;
if (r.right > vw + 1){ out.push('右側溢出: ' + (all[i].className || all[i].tagName)); fail++; }
if (r.left < -1) { out.push('左側溢出: ' + (all[i].className || all[i].tagName)); fail++; }
}
// 觸控目標量的是「有效點擊區」:勾選框由 label 承接、連結由 ::after 擴大層承接
var hit = document.querySelectorAll('a,button,input,select,summary,[onclick],[role=button],[tabindex]');
for (var j=0;j<hit.length;j++){
var el = hit[j], q = el.getBoundingClientRect();
if (!q.width && !q.height) continue;
var w = q.width, h = q.height;
if (el.tagName.toLowerCase() === 'input') {
var lab = el.closest('label') || (el.id ? document.querySelector('label[for="'+el.id+'"]') : null);
if (lab) { var lr = lab.getBoundingClientRect(); w = lr.width; h = lr.height; }
}
try {
var af = getComputedStyle(el, '::after');
if (af && af.content && af.content !== 'none') {
var aw = parseFloat(af.width), ah = parseFloat(af.height);
if (aw > 0) w = Math.max(w, aw);
if (ah > 0) h = Math.max(h, ah);
}
} catch(e){}
if (h < 44 || w < 44){
out.push('觸控過小(' + Math.round(w) + 'x' + Math.round(h) + '): ' +
(el.textContent || el.tagName).trim().slice(0,20));
fail++;
}
}
out.push(fail === 0 ? 'RESULT=PASS' : 'RESULT=FAIL(' + fail + ')');
} catch(err){ out.push('RESULT=FAIL(audit 自己噴錯: ' + err.message + ')'); }
var el2 = document.getElementById('audit');
el2.hidden = false; el2.textContent = out.join('\n');
};
if (document.readyState === 'complete') setTimeout(run, 600);
else addEventListener('load', function(){ setTimeout(run, 600); });
}
</script>
判讀規則(這條最重要):
讀到 RESULT=PASS → 過
讀到 RESULT=FAIL → 不過
讀不到 AUDIT-V2 → 一律判不過(頁面沒放 audit,或腳本沒跑起來)
空字串 ≠ PASS③ 手機 iframe 夾具與截圖指令
實測某些 Chrome 版本有最小視窗寬度(約 500px),--window-size=390 會拿到 500px 排版後裁切的假畫面。這會隨版本改變,所以夾具裡要印出實際 innerWidth 斷言;iframe 的寬度才是真正的 layout viewport。指令少了 --allow-file-access-from-files 會讀不到 iframe,印出空字串——最危險的假陰性。
<!-- fixture.html:放在跟目標頁同一層。iframe 的寬度才是真正的 layout viewport -->
<!doctype html><meta charset="utf-8">
<body style="margin:0;display:flex;gap:8px;align-items:flex-start">
<div id="frames"></div><pre id="report" style="font:12px monospace"></pre>
<script>
const t = new URLSearchParams(location.search).get('t') || 'index.html';
const H = new URLSearchParams(location.search).get('h') || '3000';
const box = document.getElementById('frames');
box.style.display = 'flex'; box.style.gap = '8px';
[320, 375, 390].forEach(w => {
const f = document.createElement('iframe');
const u = new URL(t, location.href); // 目標頁本身可能已經帶參數(例如 ?nofx)
u.searchParams.set('audit', '1'); // 直接串 '?audit' 會變成 page.html?nofx?audit
f.src = u.href; f.width = w; f.height = H; f.dataset.w = w;
box.appendChild(f);
});
// 等每個 iframe 都 load 完再讀,不要固定睡幾秒——慢頁會假失敗
Promise.all([...document.querySelectorAll('iframe')].map(f =>
f.contentDocument && f.contentDocument.readyState === 'complete'
? Promise.resolve()
: new Promise(r => f.addEventListener('load', r, {once:true}))
)).then(() => new Promise(r => setTimeout(r, 1500))).then(() => {
const out = [];
document.querySelectorAll('iframe').forEach(f => {
let d = null; try { d = f.contentDocument } catch(e) {}
if (!d) { out.push('[' + f.dataset.w + '] 讀不到 iframe → 判 FAIL(少了 --allow-file-access-from-files?)'); return; }
const a = d.getElementById('audit');
out.push('[' + f.dataset.w + '] ' + (a && a.textContent.trim() ? a.textContent.trim() : '無 audit 輸出 → 判 FAIL'));
});
document.getElementById('report').textContent = out.join('\n\n');
});
</script>
# ── 指令 ──────────────────────────────────────────
CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
# 手機:跑夾具,讀三個寬度的 audit
"$CHROME" --headless=new --disable-gpu --allow-file-access-from-files \
--window-size=1400,3000 --virtual-time-budget=25000 \
--dump-dom "file://$PWD/fixture.html?t=index.html"
# 桌機:真實 1440 viewport
"$CHROME" --headless=new --disable-gpu --allow-file-access-from-files \
--window-size=1440,900 --virtual-time-budget=9000 \
--screenshot=desk.png "file://$PWD/index.html?noanim"
# 量元素位置前先停 transition,否則量到的是動畫起始值
# 在頁面注入: *{transition:none !important}
# 減少動態效果的分支要實際模擬,不是宣稱
"$CHROME" --headless=new --force-prefers-reduced-motion ...④ 動效保險絲(rAF 保底、once guard、forceStatic)
最常見的災難不是動畫不好看,是動畫沒跑起來、整頁空白。這段保證三件事:背景分頁也會渲染、動畫庫掛掉內容還在、減少動態效果與 ?noanim 走同一條路徑。
// 原則:內容 CSS 預設就是可見的,動畫只是把它藏起來再演出來。
// 反過來寫(預設 opacity:0 靠 JS 顯示)= 動畫庫掛掉就整頁空白。
/* CSS:只有掛上 .anim 才會藏起來 */
:root.anim [data-rev]{opacity:0; transform:translateY(14px)}
:root.anim [data-rev].in{opacity:1; transform:none;
transition:opacity .6s cubic-bezier(.22,.61,.36,1),
transform .6s cubic-bezier(.22,.61,.36,1);
transition-delay:var(--d,0ms)}
@media (prefers-reduced-motion:reduce){
:root.anim [data-rev]{opacity:1; transform:none}
*,*::before,*::after{transition-duration:.001ms !important; animation-duration:.001ms !important}
}
// JS
var P = new URLSearchParams(location.search);
var reduce = false;
try { reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; } catch(e){}
var noanim = P.has('noanim') || reduce; // 兩者走同一條路徑
var fuseDone = false;
function forceStatic(){ // once guard,且不依賴任何動畫庫
if (fuseDone) return; fuseDone = true;
document.documentElement.classList.remove('anim');
document.querySelectorAll('[data-rev]').forEach(function(e){ e.classList.add('in'); });
}
if (!noanim) document.documentElement.classList.add('anim');
var started = false, revealed = 0;
function start(){
if (started) return; started = true;
if (noanim) return;
// 保險絲:只有在「一個都沒揭露過」時才強制退成靜態。
// 不能因為 IntersectionObserver 建得起來就取消——建得起來但從不觸發,內容會永遠隱形。
var fuse = setTimeout(function(){ if (revealed === 0) forceStatic(); }, 3000);
try {
var io = new IntersectionObserver(function(es){
es.forEach(function(e){
if (!e.isIntersecting) return;
revealed++;
e.target.classList.add('in');
io.unobserve(e.target);
});
}, {rootMargin:'0px 0px -8% 0px', threshold:0.05});
document.querySelectorAll('[data-rev]').forEach(function(e){ io.observe(e); });
} catch(err){ clearTimeout(fuse); forceStatic(); }
}
requestAnimationFrame(start); // 背景分頁不觸發 rAF →
setTimeout(start, 120); // setTimeout 保底⑤ 表單:防重複送出、三種狀態、後端四件事
只要頁面有表單,你就在處理別人的個資。前端只是體驗,擋不住任何人——所有驗證後端一定要再做一次。
// 前端只是體驗,擋不住任何人。所有驗證後端一定要再做一次。
// 1) 防重複送出(once guard + 送出中狀態)
var sending = false;
form.addEventListener('submit', async function(e){
e.preventDefault();
if (sending) return;
sending = true; btn.disabled = true; btn.textContent = '送出中';
try {
const res = await fetch(endpoint, {method:'POST', body:new FormData(form)});
if (!res.ok) throw new Error('HTTP ' + res.status);
showSuccess();
} catch (err) {
// 錯誤要說「哪裡錯、怎麼修」,而且要定位到出錯的欄位
showError('送出失敗,請再試一次;持續失敗請改用電話聯絡。');
} finally {
sending = false; btn.disabled = false; btn.textContent = '送出';
}
});
// ⚠️ 用 no-cors 送出時 res 永遠是 opaque,不能拿來判斷成功——那是假的成功狀態。
// 2) 後端一定要有的四件事(語言不拘)
// a. 伺服器端驗證:型別、長度、必填、格式,前端擋過的全部再擋一次
// b. 儲存與輸出要分開想:
// 儲存時保留驗證過的原始值,不要先做 HTML escape(會污染資料、之後重複轉義)
// 輸出時依「用在哪裡」各自編碼:HTML 內文 / HTML 屬性 / URL / JavaScript / HTML email 規則都不同
// 寫進試算表另防公式注入:開頭是 = + - @ 的值,前面補一個單引號
// c. rate limit:同 IP/同信箱在時間窗內限次,擋機器人灌單
// d. 有寫入動作要有 CSRF 防護
// 3) 三種狀態都要有畫面
// 成功:說明接下來會發生什麼(多久回覆、去哪查詢)
// 失敗:說明原因與下一步,不要只寫「發生錯誤」
// 驗證錯誤:焦點跳到第一個出錯的欄位,欄位旁邊寫清楚哪裡不對⑥ meta 與 OG 起手式
最常漏的一項。連結貼到通訊軟體時出現一片空白加網址,前面所有設計都白費。
<!-- 公開頁必備。分享到通訊軟體沒有預覽圖,前面所有設計都白費 -->
<title>頁面名稱|品牌名</title>
<meta name="description" content="一句話說清楚這頁是什麼,控制在 80 字內">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="canonical" href="https://example.com/page">
<meta property="og:type" content="website">
<meta property="og:title" content="頁面名稱">
<meta property="og:description" content="同上,可以再短一點">
<meta property="og:image" content="https://example.com/og.jpg">
<meta property="og:url" content="https://example.com/page">
<meta name="twitter:card" content="summary_large_image">
<!-- og:image 規格:1200×630,檔案壓到 300KB 以內,必須是絕對網址。
改完一定要實際貼一次連結到通訊軟體看預覽,或用平台的偵錯工具重抓快取。 -->
<!-- 標題層級:一頁一個 h1,h1→h2→h3 不跳級。圖片一律有 alt。 -->⑦ 批次替換的 assert 樣板
replace 找不到錨點時什麼都不做也不報錯。這個樣板讓每一條都印 ✓/✗ 並計數,而且有一條沒中就整批不寫檔——避免留下半套狀態。
# 批次改檔案時,replace 找不到錨點會「什麼都不做也不報錯」。
# 一次改十處、其中一處錨點猜錯,腳本看起來成功,功能卻沒生效。
import io, sys
p = 'target.html'
s = io.open(p, encoding='utf-8').read()
log = []
def rep(a, b, label):
global s
if a in s:
s = s.replace(a, b, 1); log.append(('OK', label)); print(' [v]', label)
else:
log.append(('MISS', label)); print(' [x] 找不到錨點:', label)
rep('舊字串1', '新字串1', '第一項改什麼')
rep('舊字串2', '新字串2', '第二項改什麼')
miss = [l for st, l in log if st == 'MISS']
print('總計 %d 條,命中 %d 條' % (len(log), len(log) - len(miss)))
if miss:
print('未命中:', miss)
print('*** 未寫檔,避免半套 ***') # 關鍵:有一條沒中就整批不寫
sys.exit(1)
io.open(p, 'w', encoding='utf-8').write(s)
print('已寫檔')
# 三條紀律:
# 1. 錨點先 grep -n 確認實際字串與縮排,不要憑印象寫
# 2. 有一條沒命中就整批不寫檔——否則會留下半套狀態
# 3. 改完跑功能驗收(渲染/DOM 斷言),不能只看「腳本沒報錯」⑧ 個資蒐集告知的欄位骨架
台灣《個人資料保護法》第 8 條要求蒐集當下就要告知。這是欄位骨架不是法律意見,實際文字要法務或負責人確認後定稿。
個資蒐集告知(放在表單送出鈕上方,或用連結開啟同意視窗)
【○○○(公司全名)個人資料蒐集告知事項】
一、蒐集目的:○○○(例:線上報名與聯繫、售後服務、行銷推廣)
二、個人資料類別:○○○(例:姓名、電話、電子郵件、公司名稱)
三、利用期間:自蒐集之日起至蒐集目的消失之日止/○年
四、利用地區:中華民國境內(如需跨境傳輸,須另行列明國家)
五、利用對象:本公司及本公司委託之服務廠商
六、利用方式:以電子、書面或其他適當方式進行聯繫與服務
(本告知的蒐集者為:○○○,統一編號 ○○○○○○○○)
七、您得依個人資料保護法第 3 條規定,就您的個人資料
請求查詢、閱覽、製給複製本、補充或更正、停止蒐集處理利用、刪除。
行使方式:來信 ○○○@○○○ 或來電 ○○-○○○○○○○○。
八、您可自由選擇是否提供個人資料,但若不提供或提供不完整,
將無法完成○○○(例:報名程序)。
□ 我已閱讀並同意上述告知事項 ← 預設不可勾選,必須由使用者主動勾
※ 什麼時候需要這個勾選框:當「同意」是你蒐集個資的法律依據時。
若依據是履行契約、法定義務或其他法定事由,勾選框不是唯一要件,
仍要有告知,但不要假裝一個 checkbox 就涵蓋全部法律基礎。
個資法第 8 條另有得免告知的例外情形——哪一種適用,由法務判斷。
──────────────────────────────────────
⚠️ 這是欄位骨架,不是法律意見。實際文字請法務或負責人確認後定稿,
定稿版本存進案件資料夾,並記下是誰、哪一天確認的。⑩ 一鍵驗收器 qa.sh(靜態檢查+四個寬度+動效失效+截圖)
把它跟網站放一起,./qa.sh index.html。每一項 fail-closed——讀不到結果一律判不過。它不涵蓋:Playwright 實際點擊、WebKit 引擎、Windows 實機、對比度量測,那幾項要另外做。
#!/usr/bin/env bash
# qa.sh — 一鍵驗收。放在網站資料夾旁邊,./qa.sh index.html
# 這支腳本是整份文件的驗收章節的可執行版;每一項都 fail-closed。
set -u
PAGE="${1:-index.html}"
CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
OUT="qa-out"; mkdir -p "$OUT"
FAIL=0
say(){ printf '%-46s %s\n' "$1" "$2"; }
bad(){ FAIL=$((FAIL+1)); }
echo "════ 1. 靜態檢查(只掃 CSS,不要掃到說明文字)════"
python3 - "$PAGE" <<'PY'
import sys,re,io,glob,os
page=sys.argv[1]; base=os.path.dirname(os.path.abspath(page)) or '.'
src=io.open(page,encoding='utf-8').read()
css=''.join(re.findall(r'<style[^>]*>(.*?)</style>',src,re.S))
for m in re.findall(r'<link[^>]+href="([^"]+\.css)"',src):
f=os.path.join(base,m.split('?')[0])
if os.path.exists(f): css+=io.open(f,encoding='utf-8').read()
def chk(n,pat,want_zero=True):
h=re.findall(pat,css); ok=(not h) if want_zero else bool(h)
print((' PASS ' if ok else ' FAIL ')+n+' = %d'%len(h))
return ok
r=[]
r.append(chk('100vw(Windows 捲軸會撐出水平捲軸)',r'100vw'))
r.append(chk('100vh(iOS 會被 UI 遮住,改 svh)',r'(?<![sdl])100vh'))
r.append(chk('按鈕/文字漸層',r'(?:linear|radial)-gradient'))
r.append(chk('中文字型堆疊含 Microsoft JhengHei',r'Microsoft JhengHei',False))
w=sorted(set(re.findall(r'font-weight:\s*(\d+)',css)))
ok=len(w)<=3 and all(int(x)>=400 for x in w)
print((' PASS ' if ok else ' FAIL ')+'字重種類 %s'%w); r.append(ok)
fs=sorted(set(re.findall(r'font-size:\s*(\d+(?:\.\d+)?)px',css)),key=float)
near=[(a,b) for a,b in zip(fs,fs[1:]) if float(b) and abs(float(b)-float(a))/float(b)<0.15]
print((' PASS ' if not near else ' FAIL ')+'鄰近字級(差<15%%)%s'%(near[:4] or '無'))
r.append(not near)
br=sorted(set(re.findall(r'border-radius:\s*(\d+)px',css)),key=int)
big=[x for x in br if int(x)>=16]
print((' PASS ' if not big else ' FAIL ')+'圓角 >=16px %s'%(big or '無'))
r.append(not big)
sys.exit(0 if all(r) else 1)
PY
[ $? -ne 0 ] && bad
echo
echo "════ 2. 桌機 1440 + ?audit ════"
A=$("$CHROME" --headless=new --disable-gpu --allow-file-access-from-files \
--window-size=1440,1000 --virtual-time-budget=12000 \
--dump-dom "file://$PWD/$PAGE?audit" 2>/dev/null \
| python3 -c "import sys,re,html;m=re.search(r'<pre id=\"audit\"[^>]*>(.*?)</pre>',sys.stdin.read(),re.S);print(html.unescape(m.group(1)).strip() if m else '')")
if [ -z "$A" ]; then say "1440 audit" "FAIL(讀不到=不過,不是沒問題)"; bad
else echo "$A" | sed 's/^/ /'; echo "$A" | grep -q 'RESULT=PASS' || bad; fi
echo
echo "════ 3. 手機 320/375/390(iframe 夾具,不用 --window-size)════"
cat > "$OUT/fixture.html" <<'FX'
<!doctype html><meta charset="utf-8"><body style="margin:0;display:flex;gap:8px">
<div id="f" style="display:flex;gap:8px"></div><pre id="r"></pre>
<script>
const t=new URLSearchParams(location.search).get('t');
[320,375,390].forEach(w=>{const i=document.createElement('iframe');
const u=new URL(t,location.href); u.searchParams.set('audit','1');
i.src=u.href; i.width=w; i.height=3000; i.dataset.w=w; document.getElementById('f').appendChild(i);});
Promise.all([...document.querySelectorAll('iframe')].map(i=>new Promise(r=>{
if(i.contentDocument&&i.contentDocument.readyState==='complete')r(); else i.addEventListener('load',r,{once:true});})))
.then(()=>new Promise(r=>setTimeout(r,2500))).then(()=>{
const o=[];document.querySelectorAll('iframe').forEach(i=>{let d=null;try{d=i.contentDocument}catch(e){}
if(!d){o.push('['+i.dataset.w+'] 讀不到 iframe → FAIL');return;}
const a=d.getElementById('audit');
o.push('['+i.dataset.w+'] '+(a&&a.textContent.trim()?a.textContent.trim():'無 audit 輸出 → FAIL'));});
document.getElementById('r').textContent=o.join('\n\n');});
</script>
FX
M=$("$CHROME" --headless=new --disable-gpu --allow-file-access-from-files \
--window-size=1400,3000 --virtual-time-budget=40000 \
--dump-dom "file://$PWD/$OUT/fixture.html?t=../$PAGE" 2>/dev/null \
| python3 -c "import sys,re,html;m=re.search(r'<pre id=\"r\"[^>]*>(.*?)</pre>',sys.stdin.read(),re.S);print(html.unescape(m.group(1)).strip() if m else '')")
if [ -z "$M" ]; then say "手機夾具" "FAIL(無輸出=不過)"; bad
else echo "$M" | grep -E '^\[|RESULT' | sed 's/^/ /'
[ "$(echo "$M" | grep -c 'RESULT=PASS')" -eq 3 ] || bad; fi
echo
echo "════ 4. 動效失效時內容還在嗎 ════"
python3 - "$PAGE" "$OUT" <<'PY'
import sys,io,re,os
page,out=sys.argv[1],sys.argv[2]
s=io.open(page,encoding='utf-8').read()
io.open(os.path.join(out,'nojs.html'),'w',encoding='utf-8').write(re.sub(r'<script[^>]*>.*?</script>','',s,flags=re.S))
PY
"$CHROME" --headless=new --disable-gpu --allow-file-access-from-files --window-size=1440,900 \
--virtual-time-budget=8000 --screenshot="$OUT/nojs.png" "file://$PWD/$OUT/nojs.html" 2>/dev/null
python3 -c "
from PIL import Image; import statistics,sys
im=Image.open('$OUT/nojs.png').convert('L'); px=list(im.getdata())[::97]
ok=statistics.pstdev(px)>8
print((' PASS ' if ok else ' FAIL ')+'JS 全掛時畫面仍有內容')
sys.exit(0 if ok else 1)" 2>/dev/null || bad
echo
echo "════ 5. 減少動態效果 ════"
"$CHROME" --headless=new --disable-gpu --allow-file-access-from-files --force-prefers-reduced-motion \
--window-size=1440,900 --virtual-time-budget=8000 --screenshot="$OUT/reduce.png" "file://$PWD/$PAGE" 2>/dev/null
python3 -c "
from PIL import Image; import statistics,sys
im=Image.open('$OUT/reduce.png').convert('L'); px=list(im.getdata())[::97]
ok=statistics.pstdev(px)>8
print((' PASS ' if ok else ' FAIL ')+'reduced-motion 下內容看得到')
sys.exit(0 if ok else 1)" 2>/dev/null || bad
echo
echo "════ 6. 桌機截圖(自己看過才算驗過)════"
"$CHROME" --headless=new --disable-gpu --allow-file-access-from-files --window-size=1440,900 \
--virtual-time-budget=9000 --screenshot="$OUT/desk-1440.png" "file://$PWD/$PAGE?noanim" 2>/dev/null
say "截圖" "$OUT/desk-1440.png(請實際打開看)"
echo
echo "════════════════════════════════════════"
if [ "$FAIL" -eq 0 ]; then echo "RESULT=PASS(但截圖仍要用眼睛看過)"; else echo "RESULT=FAIL($FAIL)"; fi
echo "還沒被這支腳本涵蓋的:Playwright 實際點擊、WebKit 引擎、Windows 實機、對比度量測。"
echo "那幾項要另外做,不要因為這支過了就宣稱全部驗過。"
exit $FAIL⑨ GSAP + ScrollTrigger + Lenis 起手式(含 hero 進場序列、捲動揭露、視差、保險絲)
要做出有層次的動效(不是整頁同一種 fade-in),這是最短路徑。含 reduced-motion 同一路徑、三秒保險絲、以及 three.js 該不該用的三個判斷題。
// 何時用這一套:需要捲動編排、視差、時間軸控制、進場序列。
// 只需要「進場揭露 + 狀態過渡」時,純 CSS + IntersectionObserver 就夠,失效面更小。
// 三個檔要自己下載內嵌或放 vendor/(Artifact 環境擋所有 CDN)。
/* CSS:內容預設可見,只有掛上 .anim 才藏起來——動畫庫掛掉時頁面仍完整 */
:root.anim [data-a]{opacity:0}
@media (prefers-reduced-motion:reduce){ :root.anim [data-a]{opacity:1} }
// ── 開機 ────────────────────────────────────────
const P = new URLSearchParams(location.search);
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const noanim = P.has('noanim') || reduce;
const nofx = P.has('nofx'); // 關 WebGL,驗收與低階裝置用
gsap.registerPlugin(ScrollTrigger);
gsap.ticker.lagSmoothing(0); // 低幀率時不要把時間軸拖慢
if (noanim) {
// 減少動態效果與 ?noanim 走「同一條路徑」:直接到位,不是播很快
gsap.set('[data-a]', {opacity:1, y:0, clearProps:'all'});
} else {
document.documentElement.classList.add('anim');
// Lenis 平滑捲動,並把它接到 ScrollTrigger 的時間軸上
const lenis = new Lenis({ duration: 1.1, smoothWheel: true });
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add(t => lenis.raf(t * 1000));
// ① Hero 進場序列(stagger)
gsap.timeline({defaults:{ease:'power3.out'}})
.from('[data-a="eyebrow"]', {opacity:0, y:16, duration:.6})
.from('[data-a="title"] .line', {opacity:0, y:40, duration:.9, stagger:.08}, '-=.35')
.from('[data-a="lead"]', {opacity:0, y:20, duration:.7}, '-=.5')
.from('[data-a="cta"]', {opacity:0, y:16, duration:.6, stagger:.06}, '-=.45');
// ② 捲動揭露:每個區塊自己觸發,不要一次全部
gsap.utils.toArray('[data-a="reveal"]').forEach(el => {
gsap.from(el, {
opacity:0, y:28, duration:.8, ease:'power2.out',
scrollTrigger:{ trigger: el, start:'top 85%', once: true }
});
});
// ③ 視差:只動背景層,不要動文字(會影響可讀性)
gsap.utils.toArray('[data-a="parallax"]').forEach(el => {
gsap.to(el, {
yPercent: -12, ease:'none',
scrollTrigger:{ trigger: el.parentElement, start:'top bottom', end:'bottom top', scrub: true }
});
});
// ④ 圖片/數字進場的 stagger 群組
gsap.utils.toArray('[data-a="grid"]').forEach(grid => {
gsap.from(grid.children, {
opacity:0, y:24, duration:.7, stagger:.07, ease:'power2.out',
scrollTrigger:{ trigger: grid, start:'top 80%', once: true }
});
});
}
// ── 保險絲(必要,不可省)──────────────────────
// 條件:只要還有元素「已經捲到可視範圍卻仍隱形」,就強制退成靜態。
// 不能只檢查一次,也不能因為「第一個成功了」就取消。
let fuseDone = false;
const forceStatic = () => {
if (fuseDone) return; fuseDone = true;
document.documentElement.classList.remove('anim');
document.querySelectorAll('[data-a]').forEach(e => e.style.opacity = '1');
};
let ticks = 0;
const fuse = setInterval(() => {
ticks++;
const stuck = [...document.querySelectorAll('[data-a]')].filter(e => {
const r = e.getBoundingClientRect();
return r.top < innerHeight && getComputedStyle(e).opacity < 0.05;
}).length;
if (ticks >= 3 && stuck > 0) { clearInterval(fuse); forceStatic(); }
if (ticks >= 40) clearInterval(fuse);
}, 1000);
// ── three.js hero:用之前先過這三題 ─────────────
// 1. 這個氛圍非它不可嗎?用影片、CSS 漸層動畫、Canvas 2D 做不到嗎?
// 2. 手機要不要跑?要的話量過低階機的幀率沒有?(three.js UMD 只到 r149)
// 3. ?nofx 關掉它之後,頁面還完整嗎?(這是硬性要求,不是選配)
// 三題有一題答不出來就不要用。整站只有 hero 可以上,不要每一區都放。一定會炸的十二件事
前面是品味與規則,這一章是看不見的地雷。每一條都實際炸過。症狀怎麼查在診斷表,這裡只講原理與代價。
驗證工具本身會騙你
視窗寬度不等於版面寬度(某些 Chrome 版本有最小寬度);--virtual-time-budget 不推進 CSS transition,量到的是動畫起始值;--dump-dom 讀不到 iframe;少一個 --allow-file-access-from-files 會印出空字串。
斷言一律 fail-closed:讀不到結果 = FAIL,空字串不等於 PASS。量位置前先停 transition,寬度靠印出 innerWidth 斷言。
「我測過了」很容易是假的
Playwright WebKit ≠ Safari 實機;Chromium ≠ Chrome + Edge;沒有 Windows 機器就是沒測過 Windows。
寧可寫「未驗證」。用詞照 §10 的表。交出去之後在別人的 Windows 上爆掉,賠的是信任不是工時。
Windows 才會炸的東西,Mac 上永遠看不到
100vw 撞實體捲軸、中文字型掉回系統預設、300 字重細到看不清、backdrop-filter 沒 fallback 讓文字消失。
這四樣在 Mac 上 100% 正常。§08 的靜態六項每次都要跑。
動畫掛掉時,內容必須還在
最常見的災難不是動畫不好看,是動畫沒跑起來、整頁空白。
內容 CSS 預設可見,動畫是「藏起來再演出來」。預設 opacity:0 靠 JS 顯示=動畫庫失敗就全白。骨架見程式碼庫。
背景分頁的 rAF 會被暫停
使用者從通訊軟體開連結、或 ⌘-click 開新分頁,會長時間卡在「載入中」,而且 console 完全沒有錯誤。rAF 的 callback 還發生在重繪之前,也不保證載入畫面已先畫出。
任何「延後一幀再渲染」一律 rAF + setTimeout 併用,加 once guard。
靜默失敗才是最貴的 bug
批次替換找不到錨點時不報錯;多條寫在同一腳本最後才寫檔時,中途失敗會讓前面成功的也沒存進去;用到沒定義的 CSS 變數會讓整條宣告等同 unset。
每條替換印 ✓/✗ 並計數,有一條沒中就整批不寫檔。顏色不對先量 computed style。樣板見程式碼庫。
CSS 選擇器會誤傷
.card b{display:block} 會同時命中內文裡的 <b>,讓每個重點字各佔一行。單檔大型 HTML 還常撞 class 名。
標題寫直接子層 .card > b,新區塊用專屬前綴。這種問題 DOM 斷言看不出來,只有整頁渲染成圖才看得到。
表單 = 個資責任,不是排版練習
伺服器端驗證、輸出編碼、rate limit、原始碼與截圖裡不能有金鑰或真實個資、蒐集當下的法定告知。
這幾項沒做,網站再漂亮都是負債。見法務底線一章。
分享出去沒有預覽圖
OG 標籤是最常漏的一項。連結貼到群組出現一片空白加網址,前面所有設計都白費。
公開頁一律補 og:title / description / image,並實際貼一次看預覽。起手式見程式碼庫。
部署的隔離邊界是資料夾,不是參數
上傳目錄與專案名都不決定 Functions 與繫結——那些來自 cwd 往上找到的設定檔,以及平台後台的專案設定。
實際炸過:對外的站讀得到另一個站的內部資料。每站自己的資料夾+設定檔,部署完 curl 打一次 API 路徑驗。
存檔 ≠ 生效
Apps Script 正式網址跑的是上次版本化部署的快照;還有 build 沒重跑、CDN 沒清、Service Worker 拿舊檔。
症狀非常像「快取問題」,會浪費半天在錯的方向。
無障礙不是慈善,是相容性
reduced-motion、鍵盤可達、對比 AA、觸控 44px。開了「減少動態效果」的人比想像中多,沒處理的結果是他們看到空白頁。
而且這條分支很容易寫壞:*{transform:none!important} 常把浮層的隱藏位移一起清掉。要用瀏覽器實際模擬跑過。
法務底線
這三件事在交付當下都看不出來,但出問題的代價比重做一版高。
只要表單收姓名、電話、電子郵件、地址、生日其中任何一項,就落入《個人資料保護法》。 第 8 條要求「蒐集當下」就要告知——不是放在隱私權政策裡讓人自己找。
告知至少要寫清楚:蒐集者名稱、蒐集目的、個資類別、利用期間、利用地區、利用對象、利用方式、當事人可行使的權利與行使方式、不提供的後果。欄位骨架在 程式碼庫。
- 同意的勾選框不得預設勾選。
- 什麼時候需要勾選框:當「同意」是蒐集的法律依據時。若依據是履行契約或其他法定事由,仍要告知,但不要假裝一個勾選框就涵蓋全部法律基礎。第 8 條另有得免告知的例外情形。
- 告知文字要由法務或負責人確認後定稿並存檔,記下是誰、哪一天確認的。這不是提供法律意見,是確保欄位不缺。
- 資料存哪、誰看得到、多久刪除,寫進交付文件。
桌面授權 ≠ webfont 授權。能用來排稿的字型,不代表能放上網站。 webfont 通常另外計價,而且常按月瀏覽量或網域數收費。
- 硬規則:用到非系統字型,開工前先確認 webfont 授權範圍與網域數,憑證存檔。
- 免費字型也要看條款。開源授權可商用,但不是每個「免費下載」的字型都行。
- 中文字型單價高、字數多、也最容易被追——中文案子這一項風險最大。
- 用系統字型堆疊沒有授權問題,這也是預設做法。
- 圖片、圖示、影片素材的來源與可商用依據同樣要可追溯。
「圖片壓過」不可檢查。給數字才驗得了。
| 指標 | 門檻 | 白話 |
|---|---|---|
| LCP | ≤ 2.5 秒 | 最大那塊內容多久才出現。手機 4G 條件下量 |
| CLS | ≤ 0.1 | 版面有沒有在載入時亂跳 |
| 首屏總傳輸量 | ≤ 1.5 MB | 第一屏要下載多少東西 |
| 單張圖片 | ≤ 300 KB | 主視覺可放寬到 500 KB |
| 圖片格式 | WebP/AVIF | 並保留一份可降級的格式 |
| 字型 | 子集化 | 中文字型不子集化動輒好幾 MB |
所有圖片指定尺寸(避免版面跳動)、首屏以外 lazy-load。低階裝置或關掉 WebGL 的路徑必須可用。
章節地圖與附錄
每個主題都有一個正本章節。診斷表與程式碼庫會再次引用同一條規則,那是刻意的:查的時候要能獨立看懂。規則有出入時以正本章節為準。
術語表 · 用人話講一遍(24 個)
全部免費、免註冊、可直接複製。
| 用途 | 站 | 說明 |
|---|---|---|
| 動態元件 | Kinetics | 微動效主來源。單頁站,要用搜尋找元件 |
| 圖示 | Lucide | 唯一 icon 來源。同頁尺寸與線寬統一 |
| 得獎網站 | awwwards collections | 國際獎項的效果案例與完整站 |
| 日系藝廊 | SANKOU! | 212 個細分類。日系乾淨清新、細緻排版、配色情緒 |
| 後台元件 | shadcn/ui | 表單、表格、後台元件 |
⑪ 版面自檢 layout-qa.html(同排等高、padding 一致、兩欄平衡、右邊空白)
四項低級錯誤的自動量測。交出第一版之前自己跑完——這些被人指出來就已經太遲。
// layout-qa.html — 版面自檢。把目標頁放進 iframe,量四項低級錯誤。
// 用法:跟目標頁放一起,用無頭瀏覽器開這一頁並讀 <pre id="o"> 的內容。
// 四項全部是「量出來」的,不是用看的——被人指出來就已經太遲。
<!doctype html><meta charset="utf-8"><body><pre id="o"></pre>
<iframe id="f" src="index.html?noanim" width="1440" height="900" style="border:0"></iframe>
<script>
setTimeout(() => {
const d = document.getElementById('f').contentDocument;
const w = document.getElementById('f').contentWindow;
const R = e => e.getBoundingClientRect();
const CS = e => w.getComputedStyle(e);
const out = []; let fail = 0;
// ① 同排卡片高度差 ≤2px
let rowsBad = 0, worst = 0;
d.querySelectorAll('.grid, .map, [data-row-group]').forEach(g => {
const kids = [...g.children].filter(k => R(k).height > 0);
if (kids.length < 2) return;
const rows = {};
kids.forEach(k => {
const t = Math.round(R(k).top);
const key = Object.keys(rows).find(x => Math.abs(x - t) < 4) || t;
(rows[key] = rows[key] || []).push(k);
});
Object.values(rows).forEach(row => {
if (row.length < 2) return;
const hs = row.map(k => R(k).height);
const diff = Math.max(...hs) - Math.min(...hs);
if (diff > 2) { rowsBad++; worst = Math.max(worst, diff); }
});
});
out.push('① 同排卡片高度差: ' + (rowsBad ? '✗ ' + rowsBad + ' 排不一致,最大差 ' + Math.round(worst) + 'px' : '✓'));
if (rowsBad) fail++;
// ② 同類元件的 padding 只能有一種值
const pad = {};
d.querySelectorAll('.card, .spec, [data-same-pad]').forEach(e => {
const p = CS(e).padding; pad[p] = (pad[p] || 0) + 1;
});
const kinds = Object.keys(pad);
out.push('② 卡片 padding 種類: ' + (kinds.length <= 1 ? '✓ ' + JSON.stringify(pad) : '✗ ' + kinds.length + ' 種 ' + JSON.stringify(pad)));
if (kinds.length > 1) fail++;
// ③ 兩欄區塊左右高度差 ≤2px
const g2 = [];
d.querySelectorAll('.grid').forEach((g, i) => {
const kids = [...g.children].filter(k => R(k).height > 0);
if (kids.length !== 2) return;
const diff = Math.abs(R(kids[0]).height - R(kids[1]).height);
if (diff > 2) g2.push('#' + i + ' 差 ' + Math.round(diff) + 'px');
});
out.push('③ 兩欄左右高度差: ' + (g2.length ? '✗ ' + g2.join(' / ') : '✓'));
if (g2.length) fail++;
// ④ 容器直接子層的右邊空白 ≤200px(限寬限錯對象的訊號)
// 只量直接子層——卡片內的內容有自己的邊界,不算空白
const gaps = [];
d.querySelectorAll('section, .wrap').forEach(sec => {
if (sec.hasAttribute('hidden')) return;
const box = sec.matches('.wrap') ? sec : sec.querySelector('.wrap');
if (!box) return;
const right = R(box).right;
[...box.children].forEach(e => {
const r = R(e);
if (r.width < 80 || r.height < 20 || CS(e).display === 'none') return;
const g = right - r.right;
if (g > 200) gaps.push((e.className || e.tagName).slice(0, 24) + ' 空 ' + Math.round(g) + 'px');
});
});
out.push('④ 直接子層右邊空白: ' + (gaps.length ? '✗ ' + [...new Set(gaps)].slice(0, 5).join(' / ') : '✓'));
if (gaps.length) fail++;
out.push('');
out.push(fail === 0 ? 'RESULT=PASS' : 'RESULT=FAIL(' + fail + ')');
document.getElementById('o').textContent = out.join('\n');
}, 2500);
</script>
// 修法提示:
// ①③ 並排容器用 align-items:stretch(不是 start),卡片本身 height:100%
// ② 同類元件共用同一個 padding token,不要逐處微調
// ④ 限寬(max-width)只給連續段落;清單、卡片、表格、規則列表不要限寬。
// 同一個 class 出現在十幾章,錯一次就是錯十幾次——改 class 本身,不要逐處補。純 CSS 與輕量動效工具(11 個)
先用這些做完,再考慮上動畫庫——統計顯示 clip-path 這類純 CSS 手法的普及度是動畫庫的兩倍。
| 站 | 用途 |
|---|---|
| Animista | CSS 動畫產生器,勾選就出 CSS,不用寫 JS |
| Transition.css | clip-path 轉場,一行 class 就會動。呼應「clip-path 是得獎站最常用手法」那個統計 |
| Animate.css | 經典 CSS 動畫類別庫,MIT |
| Hover.css | Hover 效果專門庫,純 CSS,MIT |
| AnimXYZ | 用 class 組合出動畫,不寫 keyframes,MIT |
| cssfx | 載入動畫與小特效,點一下複製 CSS,MIT |
| AutoAnimate | 一行程式讓清單增刪自動有動畫,任何框架都能用,MIT |
| ScrollReveal | 捲動進場,比自己寫 IntersectionObserver 快 |
| Splide | 輪播,比 Swiper 輕、無依賴,MIT |
| Swiper | 輪播主流(統計:得獎站 15%、日系站 26% 在用),MIT |
| Barba.js | 頁面轉場(日系站 7% 在用),MIT |
React 動畫元件庫(14 個)
做 React/Next.js 專案時用。有 Pro 版的,免費那部分仍完整可用。
| 站 | 授權 / 熱門度 |
|---|---|
| Magic UI | MIT · ★22.1k,討論度最高 |
| React Bits | 自訂授權(可商用)· ★46.7k,星數最多 |
| Aceternity UI | 官方寫明免費、可複製貼上 |
| Fancy Components | 較炫、實驗性的互動元件 |
| beUI | MIT。React / Next.js 動畫元件 |
| SmoothUI | MIT · ★945。給 shadcn 生態用的動畫元件 |
| Motion Primitives | MIT · ★6.2k ⚠️ 要用瀏覽器開 |
| Cult UI | MIT · ★6.1k ⚠️ 要用瀏覽器開 |
| Inspira UI | MIT · ★5.0k Vue 版 |
| Animate UI | 自訂授權(可商用)· ★4.2k |
| Kibo UI | MIT · ★3.9k,shadcn 生態擴充 |
| Eldora UI | MIT · ★2.0k |
| SyntaxUI | MIT · ★984 |
| Origin UI | AGPL-3.0 · ★10.5k ⚠️ 見下方 |
Origin UI 是 AGPL-3.0,不是 MIT。AGPL 有「衍生作品也要開源」的條款——做商業網站要用它的元件,先確認過再用,不要默默用。其他都是 MIT 或明確可商用。
靈感、教學與調參工具(10 個)
| 站 | 用途 |
|---|---|
| Codrops | 效果教學站,每篇都有可下載的 demo。要做「別人做不出來的效果」先翻這裡 |
| CSS-Tricks | 前端技法查詢 |
| FreeFrontend | CSS 動畫庫匯總,要找冷門的來這裡 |
| Easings.net | 緩動曲線對照,選 easing 不要憑感覺 |
| cubic-bezier.com | 自訂緩動曲線 |
| Haikei | 產生 SVG 背景形狀(波浪、斑點、格線) |
| Get Waves | 產生 SVG 波浪分隔 |
| Theatre.js | 視覺化動畫時間軸編輯器,開源 |
| The Component Gallery | 同一個元件別人怎麼設計的對照 |
| React Aria | 無障礙互動行為 |
已排除的站——不要放回來
| 站 | 排除原因 |
|---|---|
| LottieFiles | 指名刪除 |
| loading.io | 指名刪除 |
| 21st.dev | 每天只能免費複製 2 個元件,超過要付費會員 |
| ogBlocks | 付費產品 |
| Tailark | 主打 Pro 付費 |
| Skiper UI | 大量元件標 premium |
| Hover.dev | 免費範圍不明確,主推 pricing |
| Framer University | 資源綁特定工具,非通用 |
找參考時也不要提到這些。
| 聽到的說法 | 真正的意思 | 去看 |
|---|---|---|
| 陽春、普通、傳統、制式 | 七件基本盤沒做滿 | §02 |
| AI 感、太向量 | 命中具體的 AI 感清單 | §03 軸三 · §04 |
| 好醜、不協調、比例怪 | 對齊/間距/字級對比 | §03 軸一 · §05 |
| 不平衡 | 兩欄高度差太多,配錯對 | §03 軸一 |
| 不整齊 | 同排卡片沒等高、最後一排留孤兒格 | §03 軸一 |
| 右邊很空、很空 | 寬螢幕容器沒用滿、留白不是刻意的 | §03 軸一 · 軸二 |
| 眼花撩亂、字太多 | 密度與收合 | §03 軸四 |
| 沒意義、看不懂 | 假裝有功能的元件、沒單位的數字 | §03 軸二 |
| 改動幅度太少 | 保守增量,要整頁重構 | 診斷表 |
| 跑版、點不到 | 手機版與互動驗收沒做實 | §08 · §09 |
| 本機好好的、上線就壞 | CSP/路徑/大小寫/部署 | 診斷表 |