diff --git a/CHANGELOG.md b/CHANGELOG.md index cb62a8e..ad6f887 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable public product changes are recorded here. Release notes contain only product scope and reproducible validation; platform identifiers and private operational details are excluded. +## v1.49.1 — 文字對比與首頁閱讀 + +- 加深淺色模式的珊瑚色,改善來源連結、目錄標籤、導覽選取與滑鼠移入狀態的小字閱讀。 +- 深色模式的 OP/ED 標籤與彩色按鈕使用深色文字;首頁示意標籤依各自背景調整前景色。 +- 搜尋輸入提示改用完整不透明的次要文字色;首頁標題精簡並按可用寬度換行,保留 OP/ED 不拆行,避免平板版面水平溢出。 +- 全域導覽、資料與 API 不變。 + ## v1.49.0 — 2019 春季《妖怪手錶!》 - 收錄 2019 年 TV 系列與 2 筆 OP/ED,保留演唱名義、共同作詞、原曲編曲與 CD 完整版日期;不與 2021 年《妖怪手錶♪》合併。 diff --git a/README.md b/README.md index 51f5fd6..6b9f36f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Anisonary 是以 Astro + strict TypeScript 建立的動畫歌曲目錄,按季 This repository contains the completed static product: season directory, anime detail pages, traceable OP/ED credits and links, per-song source ledgers, source-attributed media, local-only cross-season search, privacy-bounded offline reading, a GitHub correction flow, and deployment through Cloudflare Workers Static Assets. The default catalogue covers twenty-nine reviewed snapshots across 2019–2026, with 1,950 unique titles and 4,325 known OP/ED records. The 2019 spring snapshot contains thirty-three TV titles, including television shorts and a TV recut; summer contains 38 TV titles and four web series. Both quarters are still being expanded, and 2025 fall is not yet included. Fictional Mock Data remains test-only. -目前 source 版本:**v1.49.0**。新增 2019 年《妖怪手錶!》與 2 筆 OP/ED、3 筆官方影片 metadata;保留共同作詞、原曲編曲與 CD 日期,並區分動畫影片及 MV/振付短版。目錄共有 29 個季度、1,950 套作品與 4,325 筆 OP/ED;春季為 33 套作品、96 筆歌曲,春、夏季仍在補充,2025 秋季尚未收錄。 +目前 source 版本:**v1.49.1**。改善淺色模式的小字、來源連結與選取狀態對比,調整深色模式的 OP/ED 標籤及按鈕文字;搜尋提示更清楚,首頁標題可按可用寬度換行。目錄共有 29 個季度、1,950 套作品與 4,325 筆 OP/ED;春季為 33 套作品、96 筆歌曲,春、夏季仍在補充,2025 秋季尚未收錄。 導覽延續固定五個入口與年代、年份、季度目錄;搜尋可按動畫、歌曲、創作者、年份、季度及 OP/ED 篩選,每頁顯示 12 套作品。搜尋完全在瀏覽器內進行,YouTube 只於明確操作後連線。網站與同源靜態 JSON API 共用已審閱資料,無 application backend 或 database binding。依賴安全 gate 繼續鎖定 `fast-uri` 3.1.6 並稽核開發依賴。 @@ -12,7 +12,7 @@ Production build 會從最終 HTML 自動產生 hash-based Content Security Poli | 可用性 / Availability | 成熟度 / Maturity | 證據 / Evidence | | -------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 公開靜態目錄 / Public static catalogue | Source 版本為 `v1.49.0`;正式發布狀態以對應 Release 驗收摘要為準 | [入口網站 / Live](https://anisonary.k-y.cc) · [版本與驗收 / Releases](https://github.com/kyeunga25/anisonary/releases) · [資料來源 / Sources](docs/DATA_SOURCES.md) · [安全政策 / Security](SECURITY.md) · [授權 / Licence](LICENSING.md) · [版權 / Copyright](COPYRIGHT.md) | +| 公開靜態目錄 / Public static catalogue | Source 版本為 `v1.49.1`;正式發布狀態以對應 Release 驗收摘要為準 | [入口網站 / Live](https://anisonary.k-y.cc) · [版本與驗收 / Releases](https://github.com/kyeunga25/anisonary/releases) · [資料來源 / Sources](docs/DATA_SOURCES.md) · [安全政策 / Security](SECURITY.md) · [授權 / Licence](LICENSING.md) · [版權 / Copyright](COPYRIGHT.md) | ## 技術棧|Technology stack diff --git a/docs/PROJECT_PLAN.md b/docs/PROJECT_PLAN.md index 0013cdb..3156c0b 100644 --- a/docs/PROJECT_PLAN.md +++ b/docs/PROJECT_PLAN.md @@ -4,8 +4,8 @@ ## Current product scope -- 2026 夏季 70 套、2026 春季 70 套、2026 冬季 66 套、2025 夏季 75 套、2025 春季 82 套、2025 冬季 59 套、2024 秋季 88 套、2024 夏季 68 套、2024 春季 76 套、2024 冬季 75 套、2023 秋季 100 套、夏季 75 套、春季 79 套、冬季 72 套、2022 秋季 80 套、夏季 69 套、春季 79 套、冬季 58 套、2021 秋季 65 套、夏季 46 套、春季 73 套、冬季 67 套、2020 秋季 67 套、夏季 31 套、春季 61 套、冬季 58 套、2019 秋季 67 套、2019 夏季 42 套及 2019 春季 29 套非成人 TV/網絡連載作品; -- 跨季作品共用詳情頁,共 1,946 個唯一作品及 4,305 筆已審閱 OP/ED; +- 2026 夏季 70 套、2026 春季 70 套、2026 冬季 66 套、2025 夏季 75 套、2025 春季 82 套、2025 冬季 59 套、2024 秋季 88 套、2024 夏季 68 套、2024 春季 76 套、2024 冬季 75 套、2023 秋季 100 套、夏季 75 套、春季 79 套、冬季 72 套、2022 秋季 80 套、夏季 69 套、春季 79 套、冬季 58 套、2021 秋季 65 套、夏季 46 套、春季 73 套、冬季 67 套、2020 秋季 67 套、夏季 31 套、春季 61 套、冬季 58 套、2019 秋季 67 套、2019 夏季 42 套及 2019 春季 33 套非成人 TV/網絡連載作品; +- 跨季作品共用詳情頁,共 1,950 個唯一作品及 4,325 筆已審閱 OP/ED; - 五個固定導覽入口、年代/年份/季度目錄、星期一至星期日及不定期分組、分頁本機搜尋、動畫詳情與同源 static JSON API; - 每個季度、作品及歌曲公開來源、語言、review state 及核對日期;每首歌曲同時具備第一方/交叉核對來源; - 官方/licensed YouTube 導航、明確第三方連線同意、來源歸屬及無圖 fallback; @@ -82,6 +82,7 @@ - **v1.47.0 source**:歌曲超過 4 筆的作品新增可收合 OP/ED 導覽,完整列出曲名與演唱者,保留原有深連結、鍵盤焦點及無 JavaScript/離線閱讀;長清單高度受限,資料、API 與全域導覽數量不變;發布證據見對應 GitHub Release。 - **v1.48.0 source**:2019 春季增加《爆丸 決戰星球》日本播出版與 3 筆 OP/ED;區分前後期片尾、共同製作署名與台灣名稱來源,未確認發行日期及影片留空,春季共 32 套作品與 94 筆歌曲;發布證據見對應 GitHub Release。 - **v1.49.0 source**:2019 春季增加《妖怪手錶!》、2 筆 OP/ED 及 3 筆官方影片 metadata;區分 2019/2021 年系列、共同作詞、原曲與樂譜改編,以及 CD 日期與短版影片,春季共 33 套作品與 96 筆歌曲;發布證據見對應 GitHub Release。 +- **v1.49.1 source**:改善淺色文字、來源與選取狀態的對比,深色 OP/ED 標籤及滑鼠移入按鈕使用適合彩色背景的文字;搜尋提示更清楚,首頁標題可換行以避免平板溢出,全域導覽、歌曲資料與 API 不變,發布證據見對應 GitHub Release。 - 正式網站使用 custom domain;非正式 Cloudflare hostname 不在公開文檔記錄; - 私有 backend、資料庫、crawler、帳戶設定、憑證與內部規則不屬於本公開 repository。 diff --git a/docs/QA_V1_49_1_READABILITY.md b/docs/QA_V1_49_1_READABILITY.md new file mode 100644 index 0000000..475b074 --- /dev/null +++ b/docs/QA_V1_49_1_READABILITY.md @@ -0,0 +1,39 @@ +# v1.49.1|文字對比與首頁閱讀 QA + +核對日期:2026-09-08。本文件記錄 source 範圍;正式部署須於固定版本合併後獨立驗收。 + +## 介面範圍 + +- 加深淺色模式的珊瑚色,改善來源連結、選取中的全域導覽、目錄與搜尋標籤,以及滑鼠移入狀態的文字。 +- 彩色底上的文字使用獨立前景色:淺色模式維持白字,深色模式使用深色文字,涵蓋 OP/ED 標籤與主要按鈕。首頁 ED 示意標籤依其背景色使用相對應文字色。 +- 搜尋提示採用不透明的次要文字色,保留已停用按鈕的既有狀態。 +- 首頁標題精簡並按可用寬度換行,只保留 OP/ED 不拆行,修正平板寬度下禁止整句換行造成的溢出;既有側欄、示意圖與入口連結持續顯示。 +- 同步公開範圍摘要為 1,950 套作品、4,325 筆 OP/ED 與春季 33 套作品。沒有變更歌曲、來源、日期、識別碼、API、資料模型或部署架構。 + +## 對比與版面證據 + +依 [W3C 對比說明](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html),對選定一般文字使用至少 4.5:1 的回歸門檻。測試讀取瀏覽器計算後的前景與背景、合成半透明背景並計算 sRGB 相對亮度,使用未四捨五入的比值判斷。以下是本機量測的代表值,表格僅為閱讀而取近似值。 + +| 元素 | 修正前 | 修正後 | +| --- | --- | --- | +| 淺色首頁目前導覽文字 | 2.83:1 | 4.80:1 | +| 深色歌曲 OP 標籤 | 2.54:1 | 7.00:1 | +| 深色歌曲 ED 標籤 | 2.28:1 | 7.79:1 | + +- 對比回歸先重現淺色導覽、深色按鈕及搜尋提示的不足,再驗證淺深色、1280/390px 下的正常、滑鼠移入、鍵盤焦點、展開來源及搜尋結果。包含搜尋 placeholder,並等待重設按鈕可操作後再量度。 +- 32 個頁面狀態的實際文字抽查,包含首頁、目錄、年度、季度、歌曲、搜尋、來源與 404;修正後所抽查樣本沒有低於各自文字門檻。透明群組、圖片/漸層背景、隱藏與停用內容不在這項抽查的判定範圍;影片同意介面另由既有回歸檢查。 +- 首頁回歸先重現 761px 的溢出,再檢查 320、390、760、761、959、960、1040、1100、1101、1200、1280px 的標題範圍、與示意圖的分離及入口連結。 +- 四種寬度的淺深色首頁、年度、季度、歌曲與搜尋,加上 390px 無 JavaScript 的年度及歌曲來源,共 51 個畫面;沒有水平溢出、頁面錯誤、提前建立 iframe 或 YouTube 請求。人工檢視桌面首頁、手機搜尋及淺深色歌曲來源截圖。 + +## 本機驗收 + +- `npm run catalog:check`:236 項通過。 +- `npm run check -- -- --workers=1`:公開邊界、Lint、339 項單元測試、建置、Cloudflare dry-run 與 47 項 Playwright 通過,沒有重試。 +- 初次四個 worker 的瀏覽器執行中,長歌曲導覽的可見性檢查有一次失敗;保留原有斷言獨立執行三次均通過,再以單一 worker 完整驗證。未改動該導覽的產品實作或放寬斷言。 +- 完整依賴與正式依賴稽核均為 0 項漏洞;`fast-uri` 保持 3.1.6,既有安全回歸通過。 +- 與 v1.49.0 source 比較:全部 1,980 份 API 逐位元相同;建置產生 1,994 份 HTML、3,987 份公開產物,dry-run 沒有 runtime bindings。 +- 原有搜尋、季度/歌曲導覽、官方影片同意、離線、404 及錯誤狀態回歸通過。 + +## 發布界線 + +本機與分支建置結果不代表正式網站已更新。發布時仍須核對合併 SHA、CI、Cloudflare 實際版本與流量,以及 live routes、headers、API 與產物內容。本切片未驗證實體裝置、Safari、Firefox、Lighthouse/Core Web Vitals 或實際 YouTube 播放。這是選定文字與狀態的對比回歸,不代表整站 WCAG 合規認證。 diff --git a/package-lock.json b/package-lock.json index 75634f6..90a2487 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "anisonary", - "version": "1.49.0", + "version": "1.49.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "anisonary", - "version": "1.49.0", + "version": "1.49.1", "license": "SEE LICENSE IN LICENSING.md", "dependencies": { "@astrojs/sitemap": "^3.7.3", diff --git a/package.json b/package.json index 6fbc9f4..4b1b8d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "anisonary", - "version": "1.49.0", + "version": "1.49.1", "private": true, "license": "SEE LICENSE IN LICENSING.md", "type": "module", diff --git a/src/components/home/CatalogPreview.astro b/src/components/home/CatalogPreview.astro index 3768e8f..31be308 100644 --- a/src/components/home/CatalogPreview.astro +++ b/src/components/home/CatalogPreview.astro @@ -122,7 +122,7 @@ .catalog-preview__type { background: var(--color-accent); - color: #fff; + color: var(--color-on-accent); font-size: 0.62rem; font-weight: 800; letter-spacing: 0.08em; @@ -130,7 +130,7 @@ text-align: center; } - .catalog-preview__type--ed { background: var(--color-ink); } + .catalog-preview__type--ed { background: var(--color-ink); color: var(--color-canvas); } .catalog-preview__track { display: grid; min-width: 0; } .catalog-preview__track b { font-size: 0.78rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .catalog-preview__track small { color: var(--color-muted); font-size: 0.5rem; letter-spacing: 0.08em; margin-top: 0.18rem; } diff --git a/src/components/theme/ThemeCard.astro b/src/components/theme/ThemeCard.astro index 222356f..9fb9549 100644 --- a/src/components/theme/ThemeCard.astro +++ b/src/components/theme/ThemeCard.astro @@ -115,7 +115,7 @@ const sourceRoleLabels = { .theme-card:target { border-color: var(--color-focus); } .theme-card + .theme-card { margin-top: 0.8rem; } .theme-card__summary { display: grid; grid-template-columns: 5rem minmax(0, 1.3fr) minmax(0, 1fr); } - .theme-card__type { align-items: center; background: var(--color-accent); color: #fff; display: flex; flex-direction: column; justify-content: center; padding: 1rem; } + .theme-card__type { align-items: center; background: var(--color-accent); color: var(--color-on-accent); display: flex; flex-direction: column; justify-content: center; padding: 1rem; } .theme-card__type--ed { background: color-mix(in srgb, var(--color-accent) 86%, var(--color-ink)); } .theme-card__type strong { font-size: 1.35rem; } .theme-card__type svg { fill: none; height: 2.25rem; margin-top: 0.45rem; stroke: currentColor; stroke-linecap: round; stroke-width: 1.5; width: 2.25rem; } diff --git a/src/pages/index.astro b/src/pages/index.astro index e7ad657..66aeef0 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -24,7 +24,7 @@ const isMockData = data.status === "ready" && Boolean(data.current?.isMockData |
-

按季度與動畫,
找到每一首 OP/ED 的官方入口。

+

按季度與動畫,
找到 OP/ED 的官方入口。

以日本播出日整理動畫與主題曲,連接官方影片、串流平台與資料來源。

@@ -118,7 +118,7 @@ const isMockData = data.status === "ready" && Boolean(data.current?.isMockData | max-width: 13.2em; } - .hero h1 span { white-space: nowrap; } + .hero h1 .hero-title-line > span { white-space: nowrap; } .hero__copy > p { color: var(--color-muted); diff --git a/src/pages/search.astro b/src/pages/search.astro index 6b89014..0203cac 100644 --- a/src/pages/search.astro +++ b/src/pages/search.astro @@ -64,7 +64,7 @@ const themeCount = index.reduce((total, anime) => total + anime.themes.length, 0 .catalog-search__control { align-items: center; display: grid; gap: 0.8rem; grid-template-columns: auto 1fr auto; } .catalog-search__control > svg { fill: none; height: 1.35rem; stroke: var(--color-muted); stroke-linecap: round; stroke-width: 1.7; width: 1.35rem; } .catalog-search__control input { background: transparent; border: 0; color: var(--color-ink); font-size: clamp(1rem, 2vw, 1.4rem); font-weight: 650; min-width: 0; outline: 0; padding: 0.35rem 0; width: 100%; } - .catalog-search__control input::placeholder { color: color-mix(in srgb, var(--color-muted) 72%, transparent); } + .catalog-search__control input::placeholder { color: var(--color-muted); opacity: 1; } .catalog-search__control input:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 4px; } .catalog-search__control button { background: transparent; border: 0; color: var(--color-accent); cursor: pointer; font-size: 0.82rem; font-weight: 720; padding: 0.7rem; } .catalog-search__control button:disabled { color: var(--color-muted); cursor: default; opacity: 0.5; } diff --git a/src/styles/global.css b/src/styles/global.css index df680f4..1e00a74 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -5,7 +5,8 @@ --color-subtle: #f3f6fb; --color-ink: #07143f; --color-muted: #58647d; - --color-accent: #ef6259; + --color-accent: #b83d35; + --color-on-accent: #ffffff; --color-focus: #2f63d8; --color-border: rgba(20, 38, 82, 0.13); --color-border-strong: rgba(20, 38, 82, 0.24); @@ -24,6 +25,7 @@ --color-ink: #f4f6ff; --color-muted: #aab4cd; --color-accent: #ff7a70; + --color-on-accent: #07143f; --color-focus: #8cb0ff; --color-border: rgba(194, 207, 244, 0.14); --color-border-strong: rgba(194, 207, 244, 0.28); @@ -72,7 +74,7 @@ img, svg { display: block; max-width: 100%; } .button:hover { transform: translateY(-2px); } .button--primary { background: var(--color-ink); color: var(--color-canvas); } -.button--primary:hover { background: var(--color-accent); border-color: var(--color-accent); color: #fff; } +.button--primary:hover { background: var(--color-accent); border-color: var(--color-accent); color: var(--color-on-accent); } .button--outline { color: var(--color-ink); } .button--outline:hover { border-color: var(--color-accent); color: var(--color-accent); } .state-message { border: 1px solid var(--color-border); color: var(--color-muted); padding: 2rem; text-align: center; } diff --git a/tests/e2e/phase1.spec.ts b/tests/e2e/phase1.spec.ts index 3b17bb2..85991f1 100644 --- a/tests/e2e/phase1.spec.ts +++ b/tests/e2e/phase1.spec.ts @@ -2363,3 +2363,163 @@ test("broken remote posters show an accessible fallback", async ({ page }) => { await expect(page.getByRole("img", { name: /圖片暫時無法載入/ })).toBeVisible(); await expect(poster.getByText("暫時無法顯示")).toBeVisible(); }); + +async function renderedTextContrast(locator: import("@playwright/test").Locator, pseudo?: "::placeholder") { + return locator.evaluate((element, pseudoElement) => { + type Color = [number, number, number, number]; + const parseColor = (value: string): Color => { + const srgb = value.startsWith("color(srgb "); + const body = srgb ? value.slice(11, -1) : value.replace(/^rgba?\(/, "").slice(0, -1); + if (!srgb && !/^rgba?\(/.test(value)) throw new Error(`Unsupported contrast color: ${value}`); + const values = body.split(/[\s,/]+/).map(Number); + if (values.some((part) => !Number.isFinite(part)) || ![3, 4].includes(values.length)) throw new Error("Invalid contrast color"); + return [values[0]! / (srgb ? 1 : 255), values[1]! / (srgb ? 1 : 255), values[2]! / (srgb ? 1 : 255), values[3] ?? 1]; + }; + const composite = (foreground: Color, background: Color): Color => [ + foreground[0] * foreground[3] + background[0] * (1 - foreground[3]), + foreground[1] * foreground[3] + background[1] * (1 - foreground[3]), + foreground[2] * foreground[3] + background[2] * (1 - foreground[3]), 1 + ]; + const layers: Color[] = []; + for (let node: Element | null = element; node; node = node.parentElement) { + const style = getComputedStyle(node); + if (style.opacity !== "1") throw new Error("Contrast sample must have full element opacity"); + if (style.backgroundImage !== "none") throw new Error("Contrast sample needs separate image-background review"); + const color = parseColor(style.backgroundColor); + layers.push(color); + if (color[3] === 1) break; + } + if (layers.at(-1)?.[3] !== 1) throw new Error("Contrast sample needs an opaque background"); + const background = layers.reverse().reduce((back, front) => composite(front, back), [1, 1, 1, 1] as Color); + const style = getComputedStyle(element, pseudoElement); + const color = parseColor(style.color); + if (pseudoElement) color[3] *= Number(style.opacity); + const foreground = composite(color, background); + // WCAG relative luminance uses linearized sRGB; compare the unrounded ratio. + const luminance = (rgb: Color) => rgb.slice(0, 3) + .map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4) + .reduce((sum, value, index) => sum + value * [0.2126, 0.7152, 0.0722][index]!, 0); + const light = Math.max(luminance(foreground), luminance(background)); + const dark = Math.min(luminance(foreground), luminance(background)); + return (light + 0.05) / (dark + 0.05); + }, pseudo); +} + +for (const theme of ["light", "dark"] as const) { + test(`accent text, song badges and controls retain readable contrast in ${theme} mode`, async ({ page }) => { + test.setTimeout(90_000); + await page.emulateMedia({ colorScheme: "light", reducedMotion: "reduce" }); + const mediaRequests: string[] = []; + page.on("request", (request) => { + if (/youtube|ytimg|googlevideo/.test(new URL(request.url()).hostname)) mediaRequests.push(request.url()); + }); + const check = async (selector: string, hover = false, pseudo?: "::placeholder") => { + const element = page.locator(selector).first(); + await expect(element).toBeVisible(); + if (hover) await element.hover(); + await expect.poll(() => renderedTextContrast(element, pseudo), { + message: `${theme}: ${selector}${hover ? " on hover" : ""}${pseudo ?? ""}` + }).toBeGreaterThanOrEqual(4.5); + }; + for (const width of [1280, 390]) { + await page.setViewportSize({ width, height: width < 600 ? 844 : 900 }); + await page.goto("/"); + if (await page.locator("html").getAttribute("data-theme") !== theme) await page.locator("[data-theme-toggle]").click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", theme); + if (width < 600) await page.locator("[data-menu-toggle]").click(); + await expect(page.locator(".site-nav a")).toHaveCount(5); + await check(".site-nav a[aria-current] span"); + await check(".site-nav a[aria-current] small"); + if (width < 600) await page.keyboard.press("Escape"); + await check(".catalog-status > a"); + await check(".hero__search a"); + await check(".button--primary"); + await check(".button--primary", true); + await check(".button--outline", true); + await check(".catalog-preview__type"); + await check(".catalog-preview__type--ed"); + + await page.goto("/catalog/"); + await check('.catalog-heading [lang="en"]'); + await page.locator(".catalog-years a").first().hover(); + await check(".catalog-years a strong"); + await check(".catalog-years a small"); + await page.goto("/catalog/2019/"); + await check(".catalog-adjacent a"); + await page.locator(".catalog-quarters a").first().hover(); + await check(".catalog-quarters a strong"); + + await page.goto("/seasons/2019-spring/"); + await check(".season-siblings a[aria-current]"); + await check(".season-header__review"); + await check(".season-filters button"); + await check(".anime-card__video"); + await check(".catalog-references__heading p"); + await check(".catalog-references__heading > a"); + + await page.goto("/anime/youkai-watch-2019/"); + await check(".theme-card__type--op strong"); + await check(".theme-card__type--ed strong"); + for (const summary of await page.locator(".theme-card__verification > summary").all()) await summary.click(); + await check(".theme-card__verification a"); + await check(".theme-card__source-meta"); + await check(".correction-link"); + await check(".creator-search-link", true); + await page.locator(".source-list a").first().hover(); + await check(".source-list__label"); + await check(".youtube-media__external", true); + await expect(page.locator("iframe")).toHaveCount(0); + + await page.goto("/search/"); + await check(".catalog-search__header div > p"); + await check(".catalog-search__browse"); + await check("#catalog-query", false, "::placeholder"); + await page.locator("#catalog-query").fill("YOASOBI"); + await expect(page.locator("[data-catalog-search-clear]")).toBeEnabled(); + await check("[data-catalog-search-clear]"); + await check(".catalog-result__season"); + await check(".theme-index__type"); + await check(".theme-index__title", true); + await check(".catalog-result__open", true); + await page.locator(".catalog-result__open").first().focus(); + await page.keyboard.press("Tab"); + await expect(page.locator(":focus-visible")).toHaveCount(1); + await expect.poll(() => renderedTextContrast(page.locator(":focus-visible"))).toBeGreaterThanOrEqual(4.5); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + + await page.goto("/sources/"); + await check(".source-registry__heading strong"); + await page.goto("/not-a-real-route/"); + await check(".not-found > p:first-child"); + await check(".button--primary", true); + } + expect(mediaRequests).toEqual([]); + }); +} + +test("home content fits around the sidebar breakpoint without hiding the introduction", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/"); + for (const width of [320, 390, 760, 761, 959, 960, 1040, 1100, 1101, 1200, 1280]) { + await page.setViewportSize({ width, height: 900 }); + const heading = page.locator(".hero h1"); + const visual = page.locator(".hero__visual"); + await expect(heading).toBeVisible(); + await expect(visual).toBeVisible(); + const bounds = await page.locator(".hero").evaluate((hero) => { + const h = hero.querySelector("h1")!.getBoundingClientRect(); + const v = hero.querySelector(".hero__visual")!.getBoundingClientRect(); + const headingRange = document.createRange(); + headingRange.selectNodeContents(hero.querySelector("h1")!); + const textRects = [...headingRange.getClientRects()]; + return { + overflow: document.documentElement.scrollWidth > innerWidth, + headingInside: textRects.every((rect) => rect.left >= h.left - 1 && rect.right <= h.right + 1), + separated: h.bottom <= v.top || v.bottom <= h.top || h.right <= v.left || v.right <= h.left + }; + }); + expect(bounds, `Homepage layout at ${width}px`).toEqual({ overflow: false, headingInside: true, separated: true }); + await expect(page.locator(".hero__actions a")).toHaveCount(2); + await expect(page.locator(".hero__search a")).toBeVisible(); + } +});