書籤書架
四個無執行時期相依性的小型 TypeScript 模組。每個模組都可以獨立運作。它們一起構成了一個可拖曳、可開啟與閱讀的儲存連結書架。此技能描述的是 0.2.0 版本。
| 模組 | 資料夾 | 功能 | 不涉及的事物 |
|---|---|---|---|
| shelf | src/shelf | 根據姿勢繪製書本,回報意圖(聚焦、開啟、步進、跳躍) | 時間、手勢、儲存 |
| motion | src/motion | 彈簧、拖曳、快滑、對齊、開啟與關閉進度 | 書籍、DOM 結構 |
| store | src/store | 在本機儲存中的版本化庫:驗證、遷移、匯入與匯出、搜尋、排序、單一書架作為檔案或連結 | DOM、渲染 |
| reader | src/reader | 將儲存的文字以文字節點顯示,追蹤並還原位置、文字大小、私人筆記、「標記為已讀」 | 書架、儲存 |
這些模組不在這個技能資料夾內。它們位於 Bookmark Bookshelf 儲存庫的一份檢出副本中,首要任務是找到它。從中複製模組資料夾,不要重新打字。
0. 尋找原始碼根目錄
按此順序嘗試路徑,在第一個成功的地方停止。
-
技能旁邊。如果此檔案位於
<root>/.agents/skills/bookshelf/SKILL.md且<root>/src/shelf/index.ts存在,則<root>就是原始碼根目錄。當技能在原地使用,或作為保留整個儲存庫的外掛安裝時,就是這種情況。 -
使用者提供的路徑。如果技能被複製到別處(例如代理程式的技能資料夾),使用者可能已經有一份檢出副本。如果請求中包含路徑,則將其用作原始碼根目錄。如果沒有,請詢問一個,並在同一個問題中提供選項 3。
-
淺層複製,僅在使用者同意後進行。先詢問,並清楚說明將發生什麼事:公開儲存庫
https://github.com/OthmanAdi/bookmark-bookshelf將在發佈標籤v0.2.0處被複製到一個新的臨時資料夾中,不會下載其他任何東西。在使用者說「是」之前,不要擷取任何東西。在獲得同意後,執行:git clone --depth 1 --branch v0.2.0 https://github.com/OthmanAdi/bookmark-bookshelf <temporary folder>臨時資料夾是一個新的空目錄,位於專案外部(系統臨時目錄即可)。告訴使用者它的位置。它就是原始碼根目錄。從中複製,永遠不要在裡面編輯,並在最後說明可以將其刪除。如果複製失敗(沒有網路、沒有 git、標籤缺失),請說明情況並回到選項 2。
-
複製前驗證。在原始碼根目錄中:
package.json具有"name": "bookmark-bookshelf"。記下其version。- 存在以下標記:
src/shelf/index.ts、src/motion/index.ts、src/store/index.ts、src/reader/index.ts、src/app/bookshelf.ts、src/app/bookshelf.css、src/app/flight.ts、src/styles/tokens.css。 - 如果是 git 檢出副本,記下
git -C <root> rev-parse --short HEAD。
如果缺少某個標記,請停止並指出是哪個。此技能描述的是
0.2.x版本。對於其他版本,請在依賴此處的參考檔案之前,閱讀每個模組的types.ts:0.1.0 版沒有flight.ts,並且其 shelf、store 和 reader 合約不同(請參閱references/modules.md的結尾)。 -
報告原始碼路徑、版本以及您從中複製的提交,並說明是否為您建立的複製,以便將來更新時可以進行比較。
以下每個以 src/、examples/ 或 public/ 開頭的路徑都是相對於該原始碼根目錄。
1. 先決定範圍
詢問使用者需要什麼,然後只取用那些。
| 他們想要 | 取用 | 也取用 |
|---|---|---|
| 可拖曳的 3D 書背列 | shelf、motion | src/styles/tokens.css |
| 自訂點擊處理、無拖曳的書架 | shelf | tokens.css |
| 可恢復進度的閱讀檢視 | reader | tokens.css、來自 src/styles/base.css 的 .bb-button、.bb-label 和 .bb-visually-hidden 規則 |
| 無 UI 的書籤庫:匯入、匯出、搜尋、分享書架 | store | 無 |
| 為其他列添加拖曳、快滑和對齊功能 | motion | 無 |
| 可開啟閱讀器、使用自有資料的書架 | shelf、motion、reader | tokens.css、來自 base.css 的相同三個規則 |
| 完整組合 | 全部四個、src/app/bookshelf.ts、src/app/bookshelf.css、src/app/flight.ts | src/styles/*、如果需要範例連結則加上 src/data/* |
模組之間從不互相導入。如果兩個模組資料夾之間出現了導入,則整合是錯誤的:匯集點應屬於主機自己的組合檔案(參閱 src/app/bookshelf.ts)。
完整組合包含七個東西,而且很容易漏掉一個:四個模組資料夾、src/app/bookshelf.ts、其樣式表 src/app/bookshelf.css 和 src/app/flight.ts(後者負責在書架與頁面之間傳送封面)。該腳本會導入另外兩個,因此如果缺少它們,建置會因找不到檔案而失敗。將這三個檔案一起複製到一個與模組資料夾相鄰的資料夾中。
組合周圍的應用程式(工具列及其選單、用於新增連結、批量匯入和分享書架的對話框、列表檢視)是 src/app/ 的其餘部分以及 app.html。這些檔案相互依賴,並且依賴於 app.html 中的元素 ID。將它們作為一個整體取用並進行調整,或者基於 store 的函數建立您自己的介面,references/composition.md 中展示了這一點。
2. 引入檔案
- 將每個選定的資料夾從
src/不變地複製(包括測試*.test.ts)到主機的原始碼樹中。 - 複製
src/styles/tokens.css並載入一次。模組會讀取--bb-*自訂屬性以獲取顏色、字體、間距和時序。要重新樣式化,請覆蓋 token,不要編輯模組的 CSS。Token 只定義值:頁面背景、正文字體和重置位於src/styles/base.css。在一個空專案中,也複製該檔案(它會導入tokens.css和fonts.css,因此如果跳過步驟 3,請移除 fonts 導入),或者自己在body上設定background: var(--bb-paper); color: var(--bb-ink); font-family: var(--bb-font-serif)。 - 字體是可選的。
src/styles/fonts.css捆綁了 Newsreader 和 IBM Plex Mono (SIL OFL 1.1),並且需要兩個開發依賴項:@fontsource-variable/newsreader和@fontsource/ibm-plex-mono。如果您提供它們,請同時在public/licenses/中提供授權條款文本。如果不提供,請從base.css中移除@import './fonts.css'這一行,token 堆疊會回退到系統字體。 shelf.ts和reader.ts會導入它們的 CSS(import './shelf.css')。這需要一個能處理 CSS 導入的打包工具(Vite、webpack、Parcel、搭配 CSS 載入器的 esbuild)。如果沒有,請刪除這些導入行,並從頁面連結 CSS 檔案:書架需要shelf.css、cover.css和variants.css;閱讀器需要reader.css和reader-text.css。- TypeScript:程式碼是為
strict、noUncheckedIndexedAccess和verbatimModuleSyntax設定編寫的,搭配moduleResolution: "bundler"。它在較寬鬆的設定下也能編譯。 - 空專案的開發工具:
npm i -D vite typescript vitest happy-dom @playwright/test,Node^22.12.0 || ^24.0.0 || >=26.0.0。references/checks.md中提供了最小的 Vitest 和 Playwright 配置。
框架主機(React、Vue、Svelte):每個模組都是 mount(element, options),返回一個帶有 destroy() 的控制代碼。在框架的「首次渲染後」鉤子中掛載,並在其清理中銷毀。references/composition.md 中有一個用於包裝的簡單函數。
3. 連接
閱讀 references/modules.md 以了解合約(輸入、輸出、事件)以及 references/composition.md 以了解每種組合的可複製連接方式。最簡單的實用組合:shelf 加上 motion:
import { createShelfMotion, prefersReducedMotion } from './motion';
import { mountShelf } from './shelf';
const motion = createShelfMotion({ count: items.length, reducedMotion: prefersReducedMotion });
const shelf = mountShelf(host, {
items,
label: 'My shelf',
onLoopChange: (loop) => motion.setLoop(loop), // 由書架決定該列是否循環
});
motion.setLoop(shelf.loop);
motion.setTiming(shelf.variant.motion);
motion.subscribe((state) => {
shelf.setPose({ position: state.position, open: 0, reduced: state.reduced });
shelf.element.dataset.settled = state.settled ? '1' : '0'; // 讓測試等待靜止
});
motion.attach(shelf.surface, {
pxPerItem: () => shelf.pitch(),
extent: () => shelf.surface.clientWidth,
enabled: () => !shelf.fixed, // 短列會靜止不動,因此拖曳不能移動它
});
shelf.on((intent) => {
if (intent.type === 'focus') motion.goTo(intent.slot);
else if (intent.type === 'step') motion.step(intent.direction);
else if (intent.type === 'jump') motion.goTo(intent.to === 'first' ? 0 : items.length - 1);
else openSomething(intent.id);
});
items 是一個陣列,包含 { id, title, source?, author?, cloth?, pattern?, seed?, progress?, thickness?, stature?, outline?, note?, done? }。這些組合的可執行版本位於 examples/ 中。
循環由書架決定。只有當書本的體積至少填滿場景的五分之四時,該列才會循環。較短的列會靜止在兩個書擋之間(shelf.fixed):箭頭、方向鍵和點擊書背會選擇顯示的書本,而拖曳則無效。請根據項目數量告訴使用者他們會看到哪一種情況,因為桌面螢幕上的八個標題會靜止不動。
4. 保持其運作的規則
這些是更改時會導致問題的事項。每個都有其原因。
.bb-shelf__row與書本正面之間的任何元素不得設定overflow、filter、clip-path、mask、contain: paint或opacity低於 1。任何一項都會展平 3D 上下文,導致書籍塌陷成一個平面。請淡化場景(透視元素)代替。- 書架會繪製給定的姿勢。沿著列的移動和開啟動作來自
setPose,它運行在 motion 模組擁有的影格循環上。對.bb-book添加 CSS 過渡會與該循環衝突。懸停效果應放在.bb-book__body上。 - 書架不會讓封面飛入。當
pose.open高於 0 時,它會隱藏居中的書本並分開其相鄰書本。移動的封面是主機的職責(src/app/flight.ts):一個副本位於固定圖層上,僅透過transform移動,從shelf.coverPlacement()到reader.flightTarget()。書架的書本、該副本和閱讀器自己的封面永遠不會同時可見。 - 每個項目一個原生按鈕,而不是每個渲染的書本一個。循環列會渲染副本。只有真正的副本攜帶
button.bb-book__control,而副本是aria-hidden的。書面是普通的 span,按鈕是它們前面一個獨立的、不可見的元素。不要讓書面可聚焦,也不要將書面包裹在<button>中。 - 在拖曳表面上保留
touch-action: pan-y。如果設為none,手指觸及書架後將無法再滾動頁面。 - 在觸控拖曳期間,忽略來自後代元素的
lostpointercapture。手指下的元素會隱式捕獲指針,當表面捕獲時,該後代的失去事件會向上冒泡。gesture.ts會檢查event.target === element。保留此檢查。 - 透過指針事件識別對書本的觸控點擊,而不是
click。在以一定速度滑動釋放後,瀏覽器可能會吞掉下一次點擊的點擊事件。因此,shelf.ts會在pointerup時啟動書本,條件是觸控從未離開 10 px 半徑且持續時間少於 700 毫秒,並跳過瀏覽器為同一次點擊發送的 click 事件(鍵盤點擊的detail為 0,始終計算)。gesture.ts也會在拖曳鎖定後消耗touchmove,以防止瀏覽器開始翻頁效果。 - 閱讀器僅使用
createElement和textContent建構 DOM。src/reader中任何地方都沒有innerHTML,並且有一個原始碼層級的測試來強制執行此規則。連結僅保留給http:和https:。 - Store 僅接受
http:和https:地址,並且永遠不會請求它們。應用程式會顯示一個連結以及使用者貼上的任何文字。不要添加遠端請求來讓閱讀器「更智能」:瀏覽器會阻止對大多數網站的此類請求,而代理會改變安全模型。 - 匯入永遠不會覆寫,並且來自外部的任何東西都不會在沒有預覽的情況下應用。書架檔案或書架連結來自陌生人:使用
readShelfFile或decodeShelfLink讀取,顯示summariseShelfFile,並且僅在使用者同意後才調用importShelf。閱讀位置、已讀標記和私人筆記永遠不會進入書架檔案。 - 減少動畫是其自身的路徑,沒有任何東西移動。motion 模組上的
reducedMotion使移動立即完成,並將開啟和關閉轉變為 150 毫秒的淡入淡出。傳遞即時偏好設定(prefersReducedMotion),而不是一次讀取的值。 - 始終在使用者可見的地方說明閱讀器邊界。沒有文字的連結必須顯示「開啟原始連結」和一個添加文字的一步操作方式。
5. 檢查結果
執行適用的檢查。references/checks.md 中包含了命令、用於斷言的 DOM 鉤子以及用於真實滑鼠和觸控拖曳的 Playwright 程式碼片段。
| 取用 | 最低驗證 |
|---|---|
| 任何模組 | npx tsc --noEmit 乾淨通過,該模組自身的 *.test.ts 在 Vitest 搭配 happy-dom 下通過 |
| shelf | 書本能渲染,data-focus 在 setPose 後改變,每個項目恰好一個 .bb-book__control,方向鍵觸發 step |
| shelf + motion | 在長到足以循環的列上,真實滑鼠拖曳會改變 data-focus-id 並以 data-settled="1" 結束。在靜止列上,方向鍵會改變它,而拖曳不會。垂直觸控拖曳會滾動頁面 |
| reader | Escape 鍵調用 onClose,重新載入還原 scrollTop,文字中的 <script> 顯示為文字 |
| store | javascript: 地址被拒絕,匯出然後匯入可往返,在同一個儲存空間上第二個 createStore 能看到資料,書架能通過 encodeShelfLink 然後 decodeShelfLink 的處理 |
| 全部四個 | references/checks.md 中的設定冒煙測試通過。開啟一本書,滾動,重新載入:同一本書在同一個地方開啟。關閉:焦點回到那本書上 |
報告您執行了什麼以及沒有執行什麼。通過類型檢查的建置並未被證明可以拖曳。
外觀
paper(預設