Web · 通用流程與心法

通用網站製作參考

一套跨網站類型都適用的標準:版面與動效的判準、可量化的規格,以及交付前由 Claude 自己跑完的驗收流程

從哪裡開始
§00 — Principles

三個原則

01

標準不分等級

不管是形象官網、內部工具、還是一次性的活動頁,版面秩序與完成度都用同一把尺。「這只是內部用的」不是降標理由。差別在做什麼,不在做多好。

02

先判斷類型,再套規則

同一條規則在不同類型的網站上,可能是對的也可能是錯的。單欄敘事對形象頁是加分,對後台是災難。開工第一件事是確認這是哪一類網站——見 §01

03

「做好了」是有條件的

沒跑完 §09 驗收 SOP,不能說做好了。宣稱驗過但沒驗,比沒驗更糟——它會讓問題被帶到上線之後。

§01 — Site types

網站分類

四個大類。先判斷屬於哪一類,再決定哪些規則適用——用錯類型的規則,做得越認真錯得越徹底。

類型成敗在哪這類特別要注意不適用什麼
一頁式
單頁把事情講完
第一屏與捲動節奏。內容順序就是說服順序 字級落差要夠大、段落寬度要有變化;動效用來分段,不是裝飾 下層頁、複雜導覽
活動網站
有時效、有明確行動
主視覺與行動按鈕。資訊要在三秒內看完 倒數與狀態要真實;表單流程越短越好;分享預覽圖一定要有 過度敘事、需要學習的互動
官網(前後台)
前台形象+後台維護
前台看質感,後台看好不好維護 兩邊是兩套設計:前台可以雜誌感,後台要密度與一致性。共用的是 token 不是版型 後台不適用單欄敘事與捲動揭露
CRM/工具介面
每天用、用很久
資訊密度與操作效率。看久不累比好看重要 多欄、表格、固定工具列是正確做法;狀態必須完整;鍵盤可達 大型主視覺、捲動動效、雜誌排版
各類型的預設區塊順序 · 開場用的大架構

這是「需求不明確時先給大架構」用的預設,不是答案——給出來讓人指著改,比從零開始問快得多。

類型預設順序
一頁式主視覺 → 一句話價值 → 痛點或情境 → 方案與特點 → 證明(數字/見證)→ 常見問題 → 行動
活動網站主視覺(時間地點在第一屏)→ 亮點 → 參加辦法 → 常見問題 → 行動(可重複出現)
官網前台主視覺 → 定位一句話 → 服務或產品 → 實績案例 → 信任要素 → 聯絡
後台/CRM側欄或頂欄導覽 → 總覽 → 列表(搜尋・篩選・排序)→ 詳情 → 設定

共通的下限:不管哪一類,§05 版面規格§08 手機版§10 相容性§09 驗收都要做滿。可以省的是手法,不是秩序與完整度。

需求還不明確的時候怎麼開場

不要因為資訊不足就先追問一長串問題,那會卡住開始。先判斷類型,直接給一個大架構——區塊順序、每一區放什麼、大概的密度,讓對話有個可以指的東西。

架構出來之後才開始問,而且一次問一件、附上你建議的答案,讓人只要說「對」或「不是,我要 X」。架構每被修正一次就更新一次,不要累積到最後才一起改。

順序反過來(先問完再做)的問題是:需求描述能力通常比判斷能力弱得多。看到東西才說得出哪裡不對。

收到現有網站時,先判斷修還是重構: 秩序層的問題(間距、對齊、字級亂)→ ; 結構層的問題(區塊順序錯、密度失衡、版型本身陽春)→ 重構聽到「大改」一律重構,不是調參數——重構仍沿用原本的內容與資產,改的是骨架。

§02 — What actually looks good

怎麼做會看起來好看

這一章不是設計理論,是從 9,539 個得獎網站的實際資料統計出來的—— 國際獎項站 6,048 個、日系藝廊 3,491 個,含官方分類標籤、偵測到的技術棧、字體與主色。下面每個數字都可以自己驗。

一 · 三成以上的站都在做的七件事

這七項是「不做就明顯低一階」的基本盤。做到是及格,不是加分。

手法出現可執行的意思
版面有巧思3,026不是等寬網格排到底。至少一處打破規律:滿版與收窄交替、非對稱、錯位、大小懸殊
單欄版面2,977主軸單欄、靠寬度變化做節奏。多欄只用在真的並列的內容
主視覺與按鈕出色2,836第一屏與主要按鈕是投資重點。按鈕是視覺主角之一,尺寸留白 hover 都要單獨設計
動態與特效2,720有動、而且看得出層次。完全靜態的站在這個池子裡幾乎不存在
雜誌感排版2,654字級落差大、欄寬有變化、圖文交錯、留白當構圖,不是把段落均勻鋪滿
照片出色2,407回到 §06:這一項買不到捷徑
下層頁做得好2,384內頁不能是首頁的陽春版。最常被放棄、也最容易拉開差距的一項
二 · 配色的實際分布(25,491 個色票)
  • 明度兩極化:極暗 16%+極亮 20%,中間調只佔 31%。底色要嘛很暗要嘛很亮,別停在灰灰的中間
  • 低飽和是多數:飽和度中位數 0.17,53% 低於 0.2。高飽和只用在強調色
  • 四成是中性色(灰/黑/白)。有彩色裡藍 22%、紅 11%、綠 7%

可執行版:大面積中性色(極暗或極亮)+一個低飽和的主色鋪陳+高飽和只留給按鈕與重點。這就是統計出來的「看起來高級」的實際做法。

三 · 字體的實際分布
  • 雲端字體服務 38%、自架 @font-face 19%
  • 可變字體 31%——三成的得獎站在用。字重可以連續調,一個檔案涵蓋整組
  • 常見無襯線:Inter、Montserrat、Lato、Poppins、DM Sans、Jost、Manrope
  • 顯示字級的襯線:EB Garamond、Cormorant Garamond

可執行版:一支無襯線當內文+一支有個性的顯示字,兩支就夠。中文用系統堆疊(見 §10),英文與數字才換字體。

四 · 技術棧的實際分布(7,445 個偵測成功的站)
技術佔比實際拿來做什麼
clip-path30%比動畫庫還常見。遮罩揭露、斜切分隔、形狀轉場——純 CSS 就能做,CP 值最高
可變字體31%字重連續變化、hover 加粗、捲動時字寬變化
輪播元件23%橫向滑軌。「輪播滑軌」這個標籤本身出現 2,026 次
canvas / shader16%粒子以外的生成式視覺
跑馬燈 marquee15%無縫橫向滾動的字帶。低成本、高辨識度
GSAP14%時間軸與捲動編排
Lottie9%向量動畫(設計端出稿,前端只播)
Lenis8%平滑捲動
mix-blend-mode8%文字疊在圖上自動反白、游標與底層互斥

最重要的一個發現clip-path 的普及度是動畫庫的兩倍。要讓網站看起來「有做過」,先把遮罩揭露、斜切分隔、形狀轉場這些純 CSS 手法做好,再考慮上動畫庫。先做 CP 值高的那 30%。

五 · 拿高分的站在多做什麼(135 站 ≥8.0 分 vs 其餘 971 站)
超額出現
  • 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、不用動畫庫
同色系分層層次靠明度差,不靠換色相換色相會讓畫面變花;同色不同明度才是「高級」的實際做法
留白優先於裝飾覺得空的時候先加留白拉開層級,不是加東西填填東西會讓密度失控,拉開留白反而讓現有內容更清楚

這份統計的限制:分類與標籤來自兩個網站的官方分類、技術棧來自讀取首頁原始碼,兩者都是機器判斷。它能告訴你「這站載了某個函式庫」,告訴不了你「它把那個函式庫用在哪個效果」。

§03 — Judgement

五條判斷軸

這五條是「看起來對不對」的判準。做完自己逐條檢查,不要等別人指出來。

軸一基本功:對齊、間距、比例、平衡 這是底線,不是加分項。看起來「怪怪的」有九成是這一條。

間距不同、沒對齊、不平衡都是低級錯誤,不該犯。 交出第一版之前就要自己檢查完並修好——這些不是「等人看到再說」的項目,是交付的前提。 判斷方式全部可以自己量:同排卡片高度差、padding 值的種類數、間距是否落在階梯上、左右兩欄的高度差。

  • 全站共用一組間距階梯,不得出現階梯外的魔術數字
  • 同一列的元素基線對齊;卡片內的左緣全部對齊到同一條線。
  • 上下留白成對:區塊上下 padding 的合法比例只有 1:13:2,選定一種後全站一致
  • 圖示與文字用 flexalign-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 感」的具體來源 它不是抽象感覺,是這幾樣具體的東西——看到就刪。
AI 感來源改成
卡片左/上邊一條彩色線拿掉,用留白與字級分層
重陰影厚框卡片一路堆疊整頁1px 細線 + 留白分區
按鈕漸層、文字漸層實色
粒子背景、向量地圖線、點點背景換別的效果類型(不是換顏色)
emoji 當 icon正規圖示集的向量圖示
emoji 每段都在同一個位置位置與有無都要不規則
每句話都以句號結尾混用斷句
同一個角色出現兩種尺寸、字重滿天飛字級角色 ≤5、字重 ≤3
早期流行的大圓角(16px+)6–10px,更俐落耐看
用色塊分隔區域(整條橫幅換底色分段)1px 細線+留白。禁的是「用底色分區」,不是禁止色塊本身——軸二說的幾何色塊是裝飾層,該留
軸四狀態完整度 只做了預設狀態的元件,是「成品感」和「半成品」的分界線。
  • 每一個互動元件都要有 hover/focus/載入中/空狀態/錯誤狀態,五種都設計過,不是只有預設樣子。
  • hover 與狀態切換要有過渡(150–250ms),不是瞬間跳變。
  • 列表與資料區要有空狀態(為什麼是空的+下一步)與載入中(骨架屏優於轉圈)。
  • 檢查對象是「所有存在的」互動元件——逐個列出來核對,不是抽查。
軸五密度與收合
  • 結論在上、推導在下(或用 <details> 收起來)。過程、警語、免責、細節說明一律收合。
  • 警語、備註、小字用更小的字級(12–13px)與降低的對比。
  • 長頁面要有錨點導覽:第一屏就給可點的錨點,讓人直接跳到想看的段落。
  • 長頁面標配右下角懸浮的回頂按鈕:向上箭頭圖示、尺寸 ≥44px、捲超過一屏才出現、跟著主題配色,減少動態時直接跳不滑動。
  • 每一區的重點用跳色標出來,一眼抓到這段在講什麼。
  • 密度的判準不是「字多不多」,是掃一眼能不能分辨層級。分不出來就是太密。
§04 — Blacklist

黑名單

開工前掃一次、交付前再掃一次,而且它是 §09 驗收 的必跑項目。分三級:硬禁 看情況 可以用

速查

要用某個效果之前先查。中英文都可以。

硬禁

不准出現。不得拿「要有質感」「要有記憶點」當理由放回來。

視覺

粒子背景、向量地圖線、點點背景、卡片邊緣一條彩色線、按鈕漸層文字漸層、emoji 當 icon、退流行的圖示風格、大圓角(16px+)、引導用箭頭(改拉細線)、用色塊分隔區域、游標特效的陰影與過大外環。

動效

磁吸按鈕、3D 傾斜卡片、滑過會跑掉的按鈕、會遮住文字的效果、hover 才能點的功能、打字機循環等玩具型效果。

內容

自行增補文字、無意義的裝飾欄位、次要欄位放第一層、看不懂用途的功能名、內部流程說明外露、沒有單位的數字、大量無意義空白、橫式排列吃版面。文案的 AI 感:每句都句號、emoji 位置統一、堆專業術語。

流程

每輪開新檔、只做保守增量、宣稱完成但沒驗、未經指示就部署或推 git。

看情況

不是不能用,是不能預設用——要用得說得出這一次為什麼適合。

  • 漸層:只當柔和背景暈染或層次過渡;按鈕漸層與文字漸層仍是硬禁。
  • 重陰影厚卡:需要明確層級分離時可用;不要整頁卡片都套。
  • three.js/WebGL:只用在真的需要氛圍的 hero,不要整站都上;而且關掉之後頁面必須完整。
可以用
  • 自訂游標跟隨:不要陰影、外環圈圈要小。
  • 裝飾層與裝飾英文:明確作為裝飾即可,不要做成「看起來該有功能卻沒有」。
  • clip-path 遮罩揭露與斜切:純 CSS、CP 值最高的一類。
  • 1px 細線分隔:預設的分區方式。
§05 — Specs

版面規格

全部是預設值。有品牌規範的以規範為準,本章只補規範沒講的部分。

間距 · 版面階梯

區塊之間、元件之間、元件內 padding 一律用這幾個值,不得出現階梯外的魔術數字。下面是實際比例。

元件內 padding 12 / 16 / 24 · 元件之間 16 / 24 / 32 · 區塊之間 64 / 96 / 128(手機減半)

微調層(唯一的例外):小晶片 padding、圖示與文字的視覺對位、1px 邊框補償,允許 2 / 3 / 5 / 6 / 9 / 10只能用在元件內部,不得用於區塊或元件之間。

圓角 · 角色 token 制

「統一」的意思是「同一個角色永遠同一個值」,不是所有東西同一個值。16px 以上不用。

卡片 10px
按鈕/輸入 8px
標籤/圖片 4px
陰影與分隔

預設不用陰影。要用就 0 2px 8px rgba(0,0,0,.06),最多一層。分區用 1px 細線+留白,不要色塊。引導標示拉細線,不加箭頭。

數字
font-variant-numeric: tabular-nums;
font-feature-settings: "zero" 0;

前者讓表格與金額對齊,後者關掉 0 中間那一點。

字級與字重 · 實際大小

字級以「角色」計算,最多 5 個角色;字重最多 3 種。裝飾層共用既有角色的 token,不另計級。

Hero主標題clamp(36px, 4.5vw, 64px) · 手機 ≥3 倍內文 · 600
區塊標題章節標題clamp(24px, 2.6vw, 40px) · 600
小標分清楚裝飾層與假功能16–18px · 500
內文內文最寬 46–72ch,行高 1.7–1.85。15–17px · 400
小字警語、備註、免責——降對比。12–13px · 400

「同一角色不得兩種尺寸」的算法:一個 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;淺色系主色不要直接當文字色,另備深色可讀版。
  • 不要用透明度疊出主色的中間色。

不准用「看起來高級/乾淨」當通過理由——這兩個詞不可檢查。要判斷夠不夠好,用 §02 的統計與 §03 的判斷軸。

深色模式
  • 預設亮色,深色是選配。不做就明確只做單主題,顏色照樣全部寫死。
  • 要做就做完整三態:明確亮/明確暗/未標記(跟系統偏好)。多數人看到的是未標記狀態。
  • 全部顏色 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))

§06 — Assets

主視覺與素材

規則能保證版面不亂、不醜、沒有 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 擇一不混用不同來源
LogoSVG;深底與淺底各一版不得自行重繪
分享預覽圖1200×630,≤300KB,絕對網址公開頁必備
FaviconSVG + 32×32 PNG + 180×180加入主畫面時會用到
可以交給 AI 生的
  • 氛圍背景、質感底紋、抽象光影
  • 非特定人物的情境照
  • 去背、擴圖、色調統一、光影重構
  • 示意用的插畫與圖示風格草稿
不可以交給 AI 生的
  • 真實標的——實際存在的建物、店面、產品、場地
  • 特定真人
  • 中文字——幾乎一定壞字,標題字要自己做
  • 會被當成事實的圖——平面圖、規格圖、地圖、數據圖表
沒有主視覺的時候

不要用免費圖庫照片充數——那是最快讓網站看起來廉價的方式。改用這四種:

  • 大型字本身當主視覺——標題放到極大(內文的 5–8 倍),用字距、斷行、對比撐出畫面。
  • 幾何與線條系統——用主色的色塊、細線、網格排出構成。
  • 資料視覺化——把數字做成刻度、比例尺,圖表本身就是視覺。
  • 單一高品質實拍——一張真的比十張圖庫好。裁成滿版橫幅、統一色調。

選了哪一種要寫進交付說明,讓人知道這是刻意的,不是省略。

交付前的素材檢查
01

所有圖片裁切比例統一——同一組卡片裡不得有的 3:2 有的 4:3。

02

色調一致——不同來源的圖要調到同一個色溫與對比,否則整頁像拼貼。

03

不是原圖直出——壓過、指定尺寸、首屏以外 lazy-load。

04

授權可追溯——每張圖說得出來源與可商用依據。見法務底線一章。

05

每張圖都有 alt,寫的是內容不是檔名。

06

真的沒有圖片時,明確寫出視覺重量靠什麼撐。這是陳述事實,不是降標。

Copy & Forms — 版面的材料

文案與表單

版面的材料是字和欄位。字數失控=版面失控——這是版面炸掉最常見的上游原因。

字數上限
  • 中文主標 4–12 字;副標一行內(≤20 字);eyebrow ≤12
  • 內文一段 ≤3 行(手機 ≤5 行),一段講一件事
  • 卡片標題一行內;超過就是標題寫成了句子
  • 塞不下時先刪字,不是縮字級——縮字級會破壞字級系統
按鈕與微文案
  • 按鈕動詞開頭、2–6 字,說清楚按下去會發生什麼:「下載簡章」不是「點我」「更多」
  • 同一畫面主要按鈕只有一個,其餘降為次要樣式
  • 錯誤訊息寫「哪裡錯+怎麼修」:「電話少一碼,應為 10 碼」不是「格式錯誤」
  • 空狀態寫「為什麼是空的+下一步」:「還沒有資料——按右上角新增」不是一片空白
  • 日期與數字格式全站統一;千分位;單位不省略
表單規則
01

label 永遠可見,不能只用 placeholder——開始打字就看不到欄位是什麼了。placeholder 只放範例格式。

02

一排一欄,由上往下填;欄位能少就少,相關欄位分組並下小標。

03

必填標示統一——標必填或標選填,擇一,全站一致。

04

錯誤就地顯示在欄位旁;送出失敗時焦點跳到第一個錯誤欄位,不是只在頂端放一條紅字。

05

手機鍵盤要對:電話 inputmode="tel"、信箱 type="email"、數字 inputmode="numeric"——跳錯鍵盤是最容易被放棄的填寫體驗。

06

送出鈕要有送出中狀態並防連點;成功與失敗都有明確畫面。安全面(伺服器端驗證、輸出編碼、防重複送出)見 §09程式碼庫

§07 — Motion

動效

要的

滑順、有質感、看得出來「有在動」但不干擾操作。
兩種都可以:需要捲動編排、視差、時間軸控制時用 GSAP + ScrollTrigger + Lenis;只需要進場揭露與狀態過渡時,純 CSS + IntersectionObserver 就夠,而且失效面更小。骨架見 程式碼庫

不要的

磁吸、3D 傾斜、粒子、跑來跑去的游標追蹤按鈕、會遮住文字的效果、需要等很久的入場動畫。

技術底線 · 踩過的坑,不得再犯
01

prefers-reduced-motion?noanim同一條路徑,直接到位,不是播很快。

02

用動畫庫時 lagSmoothing(0);固定時長的載入畫面要有 setTimeout failsafe 強制放行。

03

任何「延後一幀再渲染」要 rAF + setTimeout 併用——背景分頁的 rAF 會被暫停或大幅降頻,頁面會長時間卡在載入畫面,而且 console 完全沒有錯誤。

04

內容 CSS 預設就是可見的,動畫只是把它藏起來再演出來。反過來寫(預設 opacity:0 靠 JS 顯示)=動畫庫載入失敗就整頁空白。

05

保險絲要持續監看:只要還有元素「已經捲到可視範圍卻仍隱形」就強制退成靜態。只檢查一次不夠——揭露了前幾個之後才停止工作,剩下的會永久隱形。

06

所有 fallback 都要 once guard,成功後取消計時器,否則會重複註冊、跑出兩份動畫。

07

建站時就留除錯參數?nofx(關 WebGL)、?noanim(關動畫)、?testvh(固定 hero 高)、?audit(DOM 斷言)。事後補很痛苦。

§08 — Mobile

手機版

手機不是縮小版的桌機,是另一種版面。不管主場是哪一邊,兩邊都必須正常可用——這是硬性下限。

硬性要求
項目要求為什麼
驗收寬度320 / 375 / 390 / 1440四個都要,不因主場而省略。320 是最容易爆版的寬度
觸控目標≥44×44px寬與高都要。小於這個點得到但點不準
hover 功能一律要有等效點擊路徑手機沒有 hover。只靠 hover 出現的功能在手機上等於不存在
水平溢出0px彈性欄位一律 minmax(0,1fr);包住可捲動內容的 flex/grid 子項要 min-width:0
全高單位100svh100vh 對應瀏覽器 UI 收起時的大高度,UI 展開會遮住內容。dvh 會隨捲動改變高度造成跳動
字級對比Hero ≥ 內文 3 倍手機空間小,對比不夠就沒有層次
斷行標題不得出現奇怪斷行關鍵詞組用 white-space:nowraptext-wrap:balance
寬內容表格與程式碼自己捲放進 overflow-x:auto 的容器,頁面本身永遠不橫向捲動
手機版面的取捨
  • 多欄一律降成單欄,並重新決定順序——桌機的左右在手機變成上下,重要的要在上面。
  • 區塊間距減半(96 → 48、64 → 32),但階梯不變。
  • 導覽收成可橫向捲動的一列或抽屜,不要擠成兩行。
  • 表格不要硬塞——欄位少就轉成卡片式,欄位多就讓它自己橫向捲。
  • 固定在底部的操作列要留安全區(避開系統手勢區)。
最常見的四個手機 bug
  • 格線只寫 1fr,被寬表格撐爆整頁
  • 用了 100vw,撞上實體捲軸產生水平捲軸
  • hover 才出現的選單,手機完全打不開
  • 觸控目標小於 44px,點了常常沒中

驗證手機版不能用縮小視窗:無頭瀏覽器有最小視窗寬度,把視窗設成 390 會拿到「用 500px 排版後裁切」的假畫面——你以為在測手機,其實根本沒測到。要用 iframe 夾具,iframe 的寬度才是真正的 layout viewport,做法見 §09程式碼庫

§09 — QA

驗收 SOP

這一章是給 Claude 跑的,不是列給使用者檢查的清單。 收到一個網站要優化、或做完一個網站要交付,下面八步全部由 Claude 自己執行完,附上實際輸出當證據,才可以說做好了。 沒有輸出就是沒過——「沒有錯誤訊息」不等於「沒有問題」。

八個步驟 · 照順序跑
01

靜態掃描

只掃樣式,不要掃到說明文字。並排容器不得用 align-items:start(會造成同排卡片高度不一);同類元件的 padding 值必須只有一種。100vw100vh/按鈕與文字漸層/backdrop-filter 沒 fallback /圓角 ≥16px 各要 0 處;中文字型堆疊要寫全;字重種類 ≤3 且內文 ≥400;字級不得有鄰近值(任兩角色差 <15%);間距不得出現階梯外的值。每項要有明確 PASS/FAIL,「grep 沒輸出」不等於 PASS——可能是根本沒找到宣告。

02

版面自檢(量出來,不是用看的)

這四項全部可以用腳本量,交出第一版之前就要自己跑完: ①同排卡片高度差 ≤2px ②同類元件的 padding 只能有一種值 ③兩欄區塊左右高度差 ≤2px  ④容器直接子層的右邊空白 ≤200px(限寬元素不要留下固定空白)。 這些是低級錯誤,被別人指出來就已經太遲。

03

黑名單掃描

逐條跑 §04硬禁項 0 處:粒子背景、卡片邊緣彩色線、按鈕與文字漸層、emoji 當 icon、大圓角、磁吸、3D 傾斜、hover-only 功能、色塊分區、引導箭頭。看情況的項目有用到就要說得出這一次為什麼適合。

04

渲染驗收

無頭瀏覽器整頁截圖,自己實際看過。文字描述不算證據。有些問題只有渲染成圖才看得到——例如選擇器誤傷造成「一個詞佔一行」,DOM 斷言完全查不出來。

05

四個寬度的 DOM 斷言

320/375/390 用 iframe 夾具(不可用縮小視窗),1440 用真實 viewport。掃水平溢出、超出視窗的元素、觸控目標尺寸。fail-closed:讀不到結果一律判不過,空字串不等於 PASS。

06

互動實測

實際點過每一個連結、按鈕、分頁、收合、彈窗,並驗證「點完發生了什麼」(網址變了/DOM 變了/彈窗開了),不是看有沒有綁事件。鍵盤 Tab 走得完、focus 看得見。錨點每一個都跳得到。

07

錯誤收集與失效測試

收集 pageerror、console error、失敗請求、非 2xx/3xx——任何未列白名單的錯誤都算 FAIL。另外實際模擬prefers-reduced-motion,以及把動畫庫停掉,確認內容仍然完整可見

08

舉一反三

發現的每一個問題都當成「這一類問題」全站搜一遍,回報寫「同類共 N 處,已修 N 處」。同一個錯誤幾乎不會只出現一次。

整套的可執行版本在 程式碼庫 的一鍵驗收器;症狀對不上原因時查 診斷表

還要確認的內容面
  • 頁面上的文字逐字來自原始素材,沒有自己加的句子
  • 品牌事實(色票、字體、聲明字句)逐項對照規範,不是憑記憶
  • 每個數字都有單位或口徑;介面沒有自我解釋的說明;同一資訊沒出現兩次
  • 內部流程說明與技術註記沒有出現在對外畫面上
  • 公開頁:title/description/lang/canonical/分享預覽圖,h1 唯一不跳級,圖片有 alt
  • 效能:LCP ≤2.5s、CLS ≤0.1、首屏總傳輸量 ≤1.5MB、單張圖 ≤300KB(主視覺 ≤500KB)、圖片指定尺寸、首屏外 lazy-load、中文字型子集化
  • 有表單:伺服器端驗證、輸出編碼、防重複送出、三種狀態都有畫面
檔案紀律
  • 固定改同一個工作檔,不要每輪開新檔;備份走 .backup-日期-說明 或 git
  • 批次改良已驗證的檔案,先輸出到暫存資料夾比對過再取代——「已驗證」是最貴的資產,覆蓋等於銷毀
  • 批次替換每條要有命中確認,有一條沒中就整批不寫檔(樣板見程式碼庫
  • 驗收之後又改了檔案,舊證據作廢,受影響項要重驗
回報的紀律
  • 先講結果與檔案位置,再列後續步驟
  • 列出過程中替人做掉的關鍵決定
  • 附截圖——視覺問題用文字描述無效
  • 沒做到、做不到的明講。寧可說「這項沒做」,不要含糊帶過
  • 用詞誠實:引擎測試不能講成實機測試(見 §10
Last mile — 成品 vs demo

成品感的最後一哩

全部是小東西,但少了任何一項都會被一眼看出「還沒做完」。交付前逐條掃。

一 · 一定會被踩到的五個
項目怎麼做不做會怎樣
錨點被固定導覽遮住 目標區塊加 scroll-margin-top: 導覽高度 + 16px 點錨點跳過去,標題被導覽蓋住——最常見的成品瑕疵
iOS 輸入框自動放大 所有 input/select/textareafont-size ≥16px 手機一點輸入框,整頁被放大且回不去
手機點擊藍框 -webkit-tap-highlight-color: transparent,並自己給 :active 回饋 每次點按鈕都閃一個系統藍框,很廉價
長網址撐爆版面 可能出現長字串的容器加 overflow-wrap: anywhere 一個沒有空格的長網址就把手機版撐出水平捲軸
圖片載入時版面跳動 圖片外框指定 aspect-ratio,或 widthheight 屬性都寫 圖一載入,下面的內容整個往下跳(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別等別人指出來——這些都是自己量得到的。

§10 — Compatibility

相容性

先講清楚做得到與做不到的,不要宣稱沒做過的驗證。

測到的只能這樣寫不准這樣寫
Chromium 引擎「Chromium 引擎驗證通過」Chrome 和 Edge 都測過
WebKit 引擎「WebKit 引擎驗證通過」
引擎 ≠ Safari app
Safari 測過了
沒有 Windows 機器「已做 Windows 靜態檢查,實機未驗證全平台都正常
Windows 靜態檢查 · 每次必跑

這六項在 macOS 上 100% 正常,只有 Windows 才會炸。每項要明確 PASS/FAIL。

  1. 不得使用 width:100vw——Windows 捲軸實體佔位 15–17px,會超出可視寬度產生水平捲軸;macOS 浮動捲軸看不出來。改 width:100%
  2. 中文字型堆疊要寫全,含 Microsoft JhengHei。只寫 PingFang 在 Windows 會掉到系統預設,整站質感崩掉。
  3. 內文字重不低於 400。Windows 的 300 細字比 macOS 更細更難讀。
  4. backdrop-filter 必須有 fallback 背景色——不支援時會變全透明,文字直接看不見。
  5. 只寫 -webkit- 前綴的屬性 → 補標準寫法。
  6. 自訂表單控制項樣式(select/checkbox/date)——Windows 預設外觀差很多。

掃描範圍要涵蓋建置後的完整輸出(外部 CSS、模板、JS 產生的樣式),不是只掃單一檔案。

Safari/iOS 檢查
  1. 100vh 對應大視窗高度,瀏覽器 UI 展開時內容會被遮住 → 滿版首屏用 100svh(最保守);dvh 會隨捲動改變高度。三者語意不同,不可互換。
  2. backdrop-filter-webkit- 前綴。
  3. position: sticky 相對最近的捲動容器定位。祖先若有 overflow,黏附範圍會變成那個容器,看起來像失效。
  4. flex gap:has()text-wrap: balance 在舊版要有降級。
  5. 影片 autoplay 必須同時有 muted playsinline,否則不播。
  6. CSS multi-column 瀑布流在 iOS 會欄頂錯位——上一欄尾端的 margin 外溢到下一欄頂端,桌機看不出來。改用 JS 分配到「累積高度最短」的欄。
Diagnosis — 症狀對照

症狀診斷表

使用者回報的都是症狀,不是原因。這張表把症狀對到最可能的幾個原因,以及怎麼查證。 先查,不要先改——大部分「看起來像設計問題」的東西其實是技術問題。

顏色好淺、階層糊掉、看起來都一樣色
原因 1
用到沒定義的 CSS 變數。color: var(--ink-soft) 若變數沒定義又沒給 fallback,整條宣告在計算值階段無效,等同 unset——繼承型屬性(如 color)繼承父層,非繼承型屬性回到初始值。看起來就是「淺淺的、都一樣色」。var(--x, 安全值) 可以避免。從別的專案複製樣式最容易踩到(變數命名不同)。
原因 2
class 撞名。後加的區塊用了 .blk .row 這種通用名,被前面某條規則吃到,跑出莫名其妙的底色。
原因 3
繼承。嵌在既有區塊裡的新內容沒明確寫死 colorfont-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 斷言看不出來,只有整頁渲染成圖才看得到。這一類問題一律靠截圖驗。

點了沒反應
原因 1
事件綁在還不存在的元素上(腳本跑在 DOM 建好之前)。
原因 2
被上層元素蓋住——透明遮罩、z-index、或某個滿版容器擋在前面。
原因 3
hover-only。桌機滑過才出現,手機根本觸發不了。
原因 4
觸控目標小於 44×44px,點得到但點不準。

怎麼查:用 Playwright 實際點過每一個,並驗證「點完發生了什麼」(URL 變了/DOM 變了/彈窗開了),不是看有沒有綁 handler。?audit 掃觸控尺寸與 onclick 有無反應。

手機跑版、出現水平捲軸
原因 1
彈性欄位沒寫 minmax(0, 1fr)只寫 1fr 等同 minmax(auto,1fr),該欄不允許收縮到內容寬度以下。多欄要寫 repeat(n, minmax(0,1fr));單欄 grid 不宣告時預設 auto 也會被寬內容撐爆。包住可捲動內容的 flex/grid 子項另外要 min-width:0
原因 2
表格或程式碼框有 min-width,但外層的 flex/grid 子項沒有 min-width:0,捲動容器包不住。
原因 3
用了 100vw——Windows 捲軸實體佔位 15–17px 會超出可視寬度。
原因 4
長網址或長英文字串沒有斷行機制。

怎麼查:iframe 夾具在 320/375/390 三個寬度跑 ?audit,掃 scrollWidth - clientWidth不可用 --window-size=390,那量到的是 500px 排版後裁切的假畫面。

卡在載入畫面,但 console 完全沒有錯誤
原因 1
requestAnimationFrame 在背景分頁會被暫停或大幅降頻(不保證永不觸發,但不能當唯一的進度來源)。使用者從通訊軟體開連結、或用 ⌘-click 開新分頁就會中。
原因 2
固定時長的 preloader 沒有 setTimeout failsafe,動畫時間軸被卡住就永遠不放行。
原因 3
動畫庫載入失敗,而內容預設是隱藏的(opacity:0 靠 JS 顯示)。

怎麼查:把分頁切到背景再開一次;把動畫庫 stub 成空物件看內容還在不在。正確架構是內容 CSS 預設可見,動畫只是把它藏起來再演出來。

本機好好的,上線就壞
原因 1
CSP 擋外部資源(Artifact 環境擋所有外部主機,只放行 Google Fonts)。
原因 2
相對路徑在子目錄或重導後失效。
原因 3
檔名大小寫。macOS 不分大小寫、Linux 主機分——本機能載、上線 404。
原因 4
後端 API 沒跟著上去,或部署時繫結錯了專案。

怎麼查:上線後 curl 打一次每個資源路徑與 API 路徑;看 Network 面板所有非 2xx/3xx 的請求。任何未列白名單的錯誤都算 FAIL。

改了沒生效(很像快取問題)
原因 1
存檔不等於部署。Apps Script 的正式網址(/exec,版本化部署)跑的是上次部署的快照,按儲存不會生效。(測試網址 /dev 會跑最新儲存的程式碼,但只有編輯者能開——正式前端絕不能指向它。)
原因 2
build 沒重跑/CDN 沒清/Service Worker 拿舊檔。
原因 3
批次替換靜默漏改。s.replace(a,b,1) 找不到錨點就什麼都不做也不報錯。
原因 4
多條替換寫在同一腳本、最後才一次寫檔,中途某條失敗會讓前面成功的也沒存進去。看到「✓ 第一條」不代表它有生效。

怎麼查:每條替換包成 rep(a,b,label) 印 ✓/✗ 並計數,或直接 assert;錨點先 grep -n 確認實際字串。改完一定跑功能驗收,不能只看「腳本沒報錯」。

Windows 上看起來很醜、字很細、有水平捲軸
原因 1
中文字型堆疊沒寫 Microsoft JhengHei,掉回系統預設,整站質感崩掉。
原因 2
內文字重 300——Windows 的細字比 macOS 更細更難讀。
原因 3
width:100vw 撞上實體捲軸。
原因 4
backdrop-filter 沒有 fallback 背景色,不支援時變全透明,文字直接看不見。
原因 5
自訂表單控制項(select/checkbox/date)在 Windows 預設外觀差很多。

怎麼查:§10 的 Windows 靜態檢查六項,每項必須是明確 PASS/FAIL——「grep 沒輸出」不等於 PASS(可能根本沒找到 font-family 宣告)。

iPhone 上爆版、影片不播、sticky 失效
原因 1
100vh 對應大視窗高度,瀏覽器 UI 展開時內容會被遮住 → 滿版首屏改用 100svh(最保守),dvh 會隨捲動改變高度。
原因 2
position:sticky 相對最近的捲動容器定位;祖先有 overflow 時黏附範圍會變成那個容器,看起來像失效。
原因 3
autoplay 沒有同時寫 muted playsinline
原因 4
CSS multi-column 瀑布流欄頂錯位——上一欄尾端的 margin 外溢到下一欄頂端,桌機 Chrome 看不出來。

怎麼查:WebKit 引擎 + 手機寬度夾具可涵蓋大部分版面問題,但觸控與網址列行為無法完全模擬,回報時要誠實寫「WebKit 引擎驗證通過」而非「Safari 測過了」。

驗收全都過了,但畫面明顯不對
原因 1
驗證工具本身在騙你。--window-size=390 量到的是 500px;--virtual-time-budget 不推進 CSS transition,量到的是動畫起始值;--dump-dom 讀不到 iframe 內容。
原因 2
少一個 --allow-file-access-from-files,夾具讀不到 iframe,印出空字串——看起來像全部正常。
原因 3
把視窗撐成幾萬 px 一次截整頁,會出現分塊重複的假象。

怎麼查:所有斷言一律 fail-closed:讀不到結果 = FAIL,空字串不等於 PASS。量元素位置前先注入 *{transition:none !important}。桌機要在真實 1440 viewport 實際捲動看過。

有人說「有 AI 感」但說不出哪裡
處理
這不是抽象感覺,是具體清單。照 §03 軸三逐條掃:卡片邊緣彩色線、重陰影厚卡整頁堆疊、按鈕漸層、文字漸層、粒子背景、向量地圖線、emoji 當 icon、每句都句號、字級字重滿天飛、大圓角。

怎麼查:逐條刪,然後附前後對照截圖給他看。不要反問「哪裡有 AI 感」——提出的人答不出來,那是你要判斷的。

有人說「改動幅度太少、看起來沒啥差別」
原因
每一輪都在做保守增量。「大改」要的是整頁重構,不是調參數。
警訊
開始出現數字要求(「至少 50 個」「總數 200 個」)=前面幾輪都沒讓他看到差別。那個數字是症狀,不是需求。

怎麼查:停下來重新對焦方向,不要真的去湊數量。每一輪都附前後對照截圖。

Library — 可照抄

可複製的程式碼庫

前面的章節說「要做什麼」,這一章給「怎麼做」。 全部預設收合——要用哪一段才展開,不然這頁會長到違反自己的密度規則。

① 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 實機、對比度量測,那幾項要另外做。

存成 qa.sh 後 chmod +x
#!/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 可以上,不要每一區都放。
§13 — Map & Appendix

章節地圖與附錄

術語表 · 用人話講一遍(24 個)
LCP最大內容繪製。最大那一塊內容多久才出現,門檻 2.5 秒
CLS版面位移。載入過程畫面有沒有亂跳,門檻 0.1
viewport可視區域。手機的 320/375/390 指的就是這個寬度
layout viewport真正用來排版的寬度。無頭瀏覽器的視窗寬度不一定等於它,所以要用 iframe 夾具
computed style瀏覽器算完所有規則後的最終值。顏色不對時要量這個,不是看原始碼
CSP內容安全政策。限制頁面能載入哪些外部資源,Artifact 環境幾乎全擋
rAFrequestAnimationFrame。「下一次畫面更新時執行」,背景分頁不會觸發
svh / dvh視窗高度單位。svh=UI 展開時的小高度(最保守)、dvh=會跟著變動、vh=UI 收起時的大高度。滿版首屏用 svh
minmax(0,1fr)格線欄位的寫法。允許欄位縮到比內容還窄,不寫就會被寬表格撐爆
tabular-nums等寬數字。讓表格與金額的位數對齊
token把數值取名字集中管理(間距、圓角、顏色)。改一個地方全站生效
stagger一組元素依序進場,不是同時。動效有沒有層次的關鍵
IntersectionObserver偵測元素有沒有進入畫面。捲動揭露動效靠它
focus trap彈窗打開時把鍵盤焦點鎖在裡面,關掉後還回原處
WCAG AA無障礙的對比度標準。一般文字 4.5:1、大字 3:1
XSS把惡意程式碼混在使用者輸入裡,顯示時被執行。防法是輸出轉義
CSRF誘導已登入的使用者在不知情下送出請求。有寫入動作就要防
rate limit限制同一來源在時間內的請求次數,擋機器人灌單
Service Worker瀏覽器裡的快取代理。改了沒生效常常是它拿舊檔
OGOpen Graph。決定連結分享到通訊軟體時的預覽標題、描述、圖
canonical告訴搜尋引擎這頁的正式網址是哪一個,避免重複內容
webfont網頁用字型。授權跟桌面排稿的授權是兩回事,通常另計價
fail-closed查不到結果就判失敗。驗收腳本一律這樣設計,「沒有輸出」不等於「沒有問題」
子集化只保留會用到的字,大幅縮小字型檔。中文字型不做這件事動輒好幾 MB
常用資源

全部免費、免註冊、可直接複製。

用途說明
動態元件Kinetics微動效主來源。單頁站,要用搜尋找元件
圖示Lucide唯一 icon 來源。同頁尺寸與線寬統一
得獎網站awwwards collections國際獎項的效果案例與完整站
日系藝廊SANKOU!212 個細分類。日系乾淨清新、細緻排版、配色情緒
後台元件shadcn/ui表單、表格、後台元件
⑪ 版面自檢 layout-qa.html(同排等高、padding 一致、兩欄平衡、右邊空白)

四項低級錯誤的自動量測。交出第一版之前自己跑完——這些被人指出來就已經太遲。

存成 layout-qa.html
// 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 本身,不要逐處補。
聽到這些詞 · 對應到哪一章
聽到的說法真正的意思去看
陽春、普通、傳統、制式七件基本盤沒做滿§02
AI 感、太向量命中具體的 AI 感清單§03 軸三 · §04
好醜、不協調、比例怪對齊/間距/字級對比§03 軸一 · §05
不平衡兩欄高度差太多,配錯對§03 軸一
不整齊同排卡片沒等高、最後一排留孤兒格§03 軸一
右邊很空、很空寬螢幕容器沒用滿、留白不是刻意的§03 軸一 · 軸二
眼花撩亂、字太多密度與收合§03 軸四
沒意義、看不懂假裝有功能的元件、沒單位的數字§03 軸二
改動幅度太少保守增量,要整頁重構診斷表
跑版、點不到手機版與互動驗收沒做實§08 · §09
本機好好的、上線就壞CSP/路徑/大小寫/部署診斷表