今天是第 26 天。
距離鐵人賽結束,只剩下四天。
經過一番深思熟慮,我們終於得到了一個非常重要的結論。
做不完了。
……蛤?
屌爆了。
你不是昨天才把教師端做出來嗎?
是啊。
那怎麼突然就做不完了?
因為寫得出來,跟能不能取代原本的系統,是兩回事。
如果只是要讓這套系統能夠登入、發布廣播、確認廣播、回覆廣播,那我們確實已經做得差不多了。
甚至還有前幾天做出來的 Hash Window,讓學生端不需要每隔 30 秒就重新下載整份廣播。
但如果今天真的要讓學校的老師和學生放棄原本使用了 20 多年的數位校園,改用我們這套新系統呢?
我想,答案恐怕還是不行。
光是昨天的教師端,就還有不少地方需要調整。
例如附件還沒做,操作流程還需要打磨,整套系統也還沒有經過充分的實際使用者驗證。
我們目前證明的,是這套系統在測試環境中可以運作。
但這不等於它已經準備好取代一套每天都有人使用的正式系統。
所以你終於認清現實了?
那剩下四天要幹嘛?開始收尾?
雖然我覺得比較像收屍。
嗯……
我們先來做 Theme System。
……
不是。
你剛剛不是才說做不完?
現在還有心情換顏色?
有啊。
而且今天要做的事情,還真的不只是換顏色。
如果今天只是要替網站增加深色模式,其實沒有必要搞得太複雜。
最簡單的做法,大概就是在根元素上加個 Class,再透過 CSS Variables 切換色彩。
:root {
--background: #ffffff;
--foreground: #202020;
}
.dark {
--background: #161616;
--foreground: #f5f5f5;
}
只要元件使用這些變數,就能跟著切換明暗。
那不就好了?
今天的文章可以結束了。
你是不是很想下班?
剩四天了,我怕你把自己寫死。
咳。
我看似還活著,但實際上早就死了。
回歸正題。
如果我們希望使用者不只能選擇明暗,還能自己決定網站長什麼樣子呢?
例如把廣播列表從表格改成卡片、替登入頁加上動畫,或是重新設計導覽列。
當然,我們可以繼續增加設定。
今天增加圓角,明天增加字型,後天增加動畫速度。
最後可能就會得到一個有幾十個選項的設定頁。
但只要有人問:
「請問我可以把廣播表格改成卡片嗎?」
我們就會發現,自己設計的選項根本不夠用。
因為我們一直在做同一件事:
預先猜測使用者會想修改哪些地方。
那如果反過來想呢?
既然瀏覽器本來就使用 CSS 決定網頁長什麼樣子,為什麼不直接讓使用者寫 CSS?
這個想法其實已經有人實現了。
如果你曾經研究過 Discord 的第三方擴充工具,應該多少聽過 BetterDiscord。
它有一個很有意思的功能,就是允許使用者安裝 Theme。
而它的 Theme,基本上就是一份 CSS。
使用者不需要重新編譯整個應用程式,只要寫好 .theme.css,就能修改介面外觀。
例如:
/**
* @name Midnight Campus
* @description 深色校園主題
* @version 1.0.0
* @author Zalic
*/
.broadcast-card {
border-radius: 18px;
}
前面的註解提供主題資訊,後面的 CSS 則負責修改畫面。
這樣的設計有個我很喜歡的地方:
主題作者不需要是原本應用程式的開發者。
只要知道有哪些 CSS Selector 可以使用,就能設計自己的視覺風格。
所以我們要把 BetterDiscord 搬過來?
某種程度上,是。
不過 BetterDiscord 除了 Theme,還有 Plugin。
Theme 主要負責 CSS,Plugin 則能透過 JavaScript 擴充功能。
我們一開始也考慮過,要不要讓管理員安裝能執行 JavaScript 的全域主題。
但仔細想想……
你現在連原本的系統都做不完。
還想做 Plugin Engine?
……
對。
而且 JavaScript 跟 CSS 面對的安全問題並不相同。
如果管理員安裝的 JavaScript 會在所有學生與教師的瀏覽器執行,我們就得處理程式碼信任、權限、版本管理與緊急停用等問題。
這些不是加一個 <script> 就能假裝完成的。
所以最後決定:
今天只做 Theme,不做 Plugin。
反正 CSS 本身就能實現不少動畫與布局變化,沒有必要為了修改外觀而引入 JavaScript。
這次我們採用類似 BetterDiscord 的主題格式。
/**
* @id zalic.midnight-campus
* @name Midnight Campus
* @description 深色校園主題
* @version 1.0.0
* @author Zalic
*/
:root {
--campus-accent: #8b8cff;
}
.broadcast-card {
border-radius: 18px;
}
除了 CSS 本身,檔案前面還有一段 Metadata。
它告訴系統主題名稱、作者、版本,以及主題的識別資訊。
其中比較值得注意的是 @id。
名字不就能當 ID 了嗎?
那如果作者改名字呢?
假設今天的主題叫做 Midnight Campus,明天作者覺得不夠帥,改成 Midnight Campus Ultimate。
如果直接用名稱當識別依據,系統可能就會把它當成另一套主題。
因此,我們允許作者提供固定的 @id。
只要 ID 相同,重新匯入就可以更新原本的主題。
如果沒有提供 ID,系統則會使用檔案內容的 SHA-256 作為識別值。
這樣可以辨認相同檔案,但只要內容改變,雜湊值也會跟著改變。
所以系統會提醒使用者:沒有固定 ID 的主題,修改後可能被視為另一套主題。
這些 Metadata 看起來不起眼,卻是 Theme Engine 能夠管理主題的基礎。
如果只是想讓一份 CSS 生效,其實可以很簡單。
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
完成。
欸?
不是說要做 Theme Engine?
這樣三行就好了?
如果我們只在乎讓 CSS 生效,確實差不多。
但這三行沒有回答任何管理問題。
例如使用者匯入兩套主題,應該啟用哪一套?
重新整理後主題還在嗎?
如果想更新或刪除主題呢?
如果 CSS 有問題,甚至把設定頁藏起來呢?
因此,這次我們把 Theme System 拆成幾個部分。
Theme System
├── Theme Parser
├── Theme Validator
├── Theme Registry
├── Theme Service
├── Theme Storage
│ └── IndexedDB
└── Theme Settings UI
你不是說只剩四天?
我知道。
你不是說做不完?
我也知道。
那你現在——
這叫範圍管理。
你這個範圍是不是有點管理過頭了?
咳。
總之,這幾個部分各自負責不同的事情。
Theme Parser 負責讀取 Metadata,讓系統知道主題名稱、作者與版本。
Theme Validator 負責檢查 CSS,避免使用者直接匯入不符合規則的內容。
Theme Registry 負責安裝、更新、列出與刪除主題。
Theme Service 則負責實際啟用、停用與切換主題。
至於 Theme Storage,就是我們等等要談的儲存機制。
另外,我們也決定同一時間只允許啟用一套個人自訂 Theme。
畢竟 CSS 本身就有 Cascade。
如果同時載入十套主題,最後可能會變成一場 Specificity 大戰。
A 主題修改背景,B 主題修改文字,C 主題使用 !important,D 主題又使用更高的選擇器權重。
最後畫面會長什麼樣子,連主題作者自己都不一定知道。
先讓一套 Theme 穩定運作,比一次支援十套 Theme 更重要。
引擎做好後,還有一個問題。
假設使用者想修改廣播列表的樣式,他要怎麼知道應該修改哪個 Selector?
總不能每次都打開開發者工具,在 Angular 產生的 DOM 裡面到處挖吧?
為什麼不行?
找得到就好了啊。
因為這些結構不一定穩定。
今天某個元件長這樣,明天重構後可能就變了。
如果主題依賴的是偶然存在的元素階層,主程式稍微修改一下,就可能讓主題失效。
所以我們替現有頁面建立了一組語意化 CSS Hooks。
例如:
.campus-page {}
.campus-nav {}
.campus-panel {}
.login-card {}
.broadcast-card {}
.broadcast-progress {}
.editor {}
這些 Class 不只是拿來套用樣式。
它們同時也是提供給主題作者的擴充介面。
某種程度上,可以把它們理解成 CSS 層級的 API。
當我們公開承諾某個 Selector 可以被使用,主題作者就可能開始依賴它。
因此未來也不能隨便改名。
不然今天寫好的 Theme,可能明天就全部壞掉了。
會。
而且比想像中還麻煩。
不就是 CSS?
又不是 JavaScript。
這正是容易讓人掉以輕心的地方。
CSS 雖然不是 JavaScript,但不代表它完全沒有風險。
例如:
.confirm-button {
display: none;
}
這段 CSS 就能讓確認按鈕從畫面上消失。
如果有人把整個操作介面蓋住,使用者甚至可能連設定頁都打不開。
CSS 也能透過 url() 等語法請求外部資源。在特定條件下,這類請求可能造成資訊洩漏。
因此,我們不能毫無檢查地把使用者提供的 CSS 直接塞進網站。
這次我們使用 CSSTree 解析 CSS。
也就是先把 CSS 轉換成 AST(Abstract Syntax Tree,抽象語法樹),再根據語法結構進行檢查。
例如:
@import url("https://example.com/style.css");
在 AST 裡,@import 不再只是文字,而是具有明確類型的語法節點。
Validator 就能根據這些節點,檢查主題是否使用不允許的語法。
這次我們採用比較保守的限制:
@import。這代表主題作者暫時不能透過外部 URL 載入背景圖片或字型。
確實少了一些玩法。
但在第一版,我們寧願先把安全邊界訂清楚。
除了檢查語法,這次也把自訂樣式限制在應用程式的根容器:
#campus-theme-root
系統會處理主題的 Selector,讓樣式作用於指定範圍。
另外,動畫名稱也會加入命名空間,降低 @keyframes 名稱衝突的機會。
但這裡必須特別強調:
CSS 作用域限制,不等於完整的安全沙箱。
主題仍然可能遮蔽操作元件,或透過複雜的動畫造成效能問題。
Validator 能做的是降低風險,而不是保證任意 CSS 都絕對安全。
既然使用者能大幅修改網站外觀,就一定會有人把網站改壞。
這裡的「有人」也包括我自己。
這點我倒是完全不懷疑。
謝謝你的信任歐。
所以這次我們提供了一個安全模式。
如果主題讓網站無法正常操作,可以直接開啟:
/settings/themes?theme-safe=1
在這個模式下,系統會略過 IndexedDB 裡面的自訂 CSS。
即使主題把設定頁弄壞了,也能透過這個入口恢復預設樣式。
而且安全模式的恢復入口會放在自訂 Theme 的作用範圍之外。
不然就會發生:
我的主題把設定頁弄壞了。
沒關係,按恢復預設就好。
恢復預設在哪?
在被你弄壞的設定頁裡。
……
那大概跟沒有安全模式差不多。
解決載入與安全問題後,接下來就是儲存。
畢竟不可能要求使用者每次重新整理頁面,都要再匯入一次 .theme.css。
最直覺的做法可能是 LocalStorage。
但這次我們選擇 IndexedDB。
因為要保存的不只是目前選擇 Light 或 Dark,還包括 CSS 原始內容、Metadata 與多套已安裝主題。
IndexedDB 比較適合這類結構化資料,也能提供非同步存取。
不過,它仍然是瀏覽器本機儲存。
換一台電腦,主題不會自動同步。
那為什麼不直接存到 PostgreSQL?
因為數位校園目前有一個特殊情況:
學生使用的是共用帳號。
如果把主題偏好直接綁定 Account,就可能出現:
A 同學把網站改成粉紅色。
B 同學登入後發現網站變粉紅色。
B 同學改成深綠色。
A 同學下次登入又發現網站變深綠色。
這不是挺有互動性的嗎?
可以可以,All for u speak。
所以第一版先讓主題跟著瀏覽器,而不是跟著共用帳號。
至於未來的跨裝置同步,我們沒有直接放棄。
這次另外建立了 ThemeStorage 抽象,讓 UI 不需要直接依賴 IndexedDB。
未來如果真的要做遠端儲存,再研究如何處理個人身分、同步衝突、儲存配額與權限。
但那是之後的事情。
今天先不替還沒存在的功能新增資料表。
有。
這次 Agent 完成了 Theme Engine、設定頁、IndexedDB 儲存、安全模式,以及現有頁面的 CSS Hooks。
而且還替我們準備了一套範例主題。
不過我覺得最重要的,不是新增了多少 Service。
而是我們終於可以驗證一開始的問題:
只靠一份 CSS,真的能讓網站長得不一樣嗎?
答案是可以。
在實際瀏覽器測試中,我們透過獨立的 .theme.css,將桌面廣播列表從表格改成卡片布局。
同時也替登入頁加入動畫。
整個過程不需要修改 Angular Component。
這代表主題作者已經可以使用我們公開的 CSS Hooks,對介面做出比較大幅度的調整。
而不只是替按鈕換個顏色。
依照這次 Agent 的交付報告:
| 項目 | 結果 |
|---|---|
| 單元與回歸測試 | 118 passed |
| Chromium E2E | 12 項通過 |
| Production Build | 通過 |
| Mock Build | 通過 |
| Production Audit | 0 vulnerabilities |
| Git Commit | 3 個 |
| Backend 修改 | 無 |
| 正式部署 | 尚未進行 |
E2E 涵蓋了 IndexedDB 持久化、主題更新、安全模式,以及登入、教師發布、學生確認與教師管理流程。
這很重要。
畢竟如果做完 Theme System,結果把昨天才完成的教師端弄壞了,那就有點本末倒置。
不過目前還有一些限制。
Firefox 和 Safari 尚未完成實際瀏覽器驗證。
CSS 仍可能造成介面遮蔽或效能問題。
跨裝置同步與 Plugin 也沒有實作。
另外,這次雖然已經完成 Commit,但還沒有 Push,更沒有部署到正式環境。
所以現在還不能說學校的使用者已經可以使用這套 Theme System。
等等。
你剛剛不是說今天做完了?
對,今天的開發與測試完成了。
但還沒部署?
還沒。
那你到底什麼時候要部署?
後天。
為什麼是後天?
因為明天還有一件事情要做。
今天我們完成的是 Theme Engine。
它可以讀取、驗證、保存並套用使用者提供的 CSS。
但這套引擎到底好不好用,還有一個問題沒有回答。
主題作者真的能夠舒服地使用它嗎?
今天的範例已經證明 CSS 可以改變布局、加入動畫。
但範例終究只是範例。
如果我們真的想知道這套 Theme System 有沒有設計好,就應該換個身分。
不要再當系統開發者。
而是當一次主題作者。
試著不修改 Angular、不新增 Component,也不碰原本的業務邏輯。
只靠一份 .theme.css,重新設計整套數位校園的外觀。
如果發現某些地方根本不能修改,就代表 CSS Hooks 可能還不夠完整。
如果必須使用一堆奇怪的 Selector,才能達成想要的效果,就代表擴充介面還不夠好用。
這些事情,光靠單元測試不一定能全部發現。
所以明天的任務很簡單。
自己寫一套 Theme。
所以你花了一整天做 Theme System,就是為了明天可以繼續玩 CSS?
你可以這麼說。
那原本那套 20 多年的數位校園呢?
你不是說要取代它?
……
其實這幾天,我一直在想一件事情。
我們花了這麼多時間重新設計資料庫、權限、Session、廣播、確認、回覆,甚至連增量同步都做出來了。
但到了第 26 天,卻還是得承認:
我們沒有辦法在 30 天內,完整取代原本那套系統。
這到底是因為我們做得太慢?
還是因為一套真正被使用了 20 多年的系統,原本就比我們想像中複雜?
我想,這個問題可以留到最後一天再回答。
畢竟我們還有四天。
而且今天至少完成了一件事。
從現在開始,數位校園長什麼樣子,不必完全由原本的開發者決定。
至於這個設計到底能玩出多少花樣……
明天,我們自己寫一套來試試看。