diff --git a/CHANGELOG.md b/CHANGELOG.md index 55da474..bfb482a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to Wallpect are recorded in this file. The format follows [K ## [Unreleased] +## [0.3.0] - 2026-08-10 + +### Added + +- Added common legacy iPhone, iPad, Intel MacBook, iMac, Apple display, Studio Display XDR, and Pro Display XDR coverage. +- Added searchable compatible-model metadata and a backward-compatible mapping from previous per-model profile IDs. + +### Changed + +- Consolidated 74 per-model entries into 47 distinct wallpaper configurations covering 191 named Apple models; configurations remain separate when cutout, logical canvas, safe area, or overlay geometry differs. +- Split Apple device data into platform modules outside UI components and exposed model counts, year ranges, diagonals, and compatible-model details in the bilingual interface. +- Expanded both READMEs with the Cloudflare Workers Static Assets deployment model, technology stack, safe self-hosting steps, runtime AI boundary, and source acknowledgements. +- Expanded the bilingual deployment guide with fork-safe Wrangler configuration, local and Workers Builds workflows, Custom Domain setup, deployed verification, rollback, and public-repository data controls. +- Removed a jurisdiction-specific privacy reference from the public documentation and in-app legal links. + +### Security + +- Extended profile validation to reject duplicate compatible-model names, malformed alias metadata, and identical wallpaper configurations that were not consolidated. +- Kept recent-device migration limited to canonical device identifiers; image bytes, filenames, composition state, and export settings remain browser-memory only. + ## [0.2.3] - 2026-08-02 ### Added @@ -94,7 +114,8 @@ All notable changes to Wallpect are recorded in this file. The format follows [K - Uploaded images remain in browser memory and are not sent to an upload API or analytics service. - Local secrets, credentials, build output, browser reports, and editor/OS metadata are excluded from version control. -[Unreleased]: https://github.com/kyeunga25/wallpect/compare/v0.2.3...HEAD +[Unreleased]: https://github.com/kyeunga25/wallpect/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/kyeunga25/wallpect/compare/v0.2.3...v0.3.0 [0.2.3]: https://github.com/kyeunga25/wallpect/compare/v0.2.2...v0.2.3 [0.2.2]: https://github.com/kyeunga25/wallpect/compare/v0.2.1...v0.2.2 [0.2.1]: https://github.com/kyeunga25/wallpect/compare/v0.2.0...v0.2.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c34959..e1562ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,7 @@ - 預覽與匯出必須共用相同的變換及渲染計算。 - 持久偏好必須使用版本化 key 及明確遷移;目前只允許 `wallpect:locale:v1` 與 `wallpect:recent-devices:v1`。 - 裝置資料必須位於 `src/data/devices`,不要寫入 UI 元件。 +- 只有 wallpaper 畫布、開孔、安全區域及覆疊幾何完全相同的型號才可合併;相同解析度但遮擋不同時必須分開。 - 推算或估算的設定檔資料不得標示為 `verified`。 ### 開發流程 @@ -61,6 +62,7 @@ Thank you for helping improve Wallpect. Keep each issue or pull request focused - Preview and export must share the same transform and rendering calculations. - Persistent preferences require a versioned key and explicit migration. The only current keys are `wallpect:locale:v1` and `wallpect:recent-devices:v1`. - Keep device data in `src/data/devices`, outside UI components. +- Consolidate models only when their wallpaper canvas, cutout, safe area, and overlay geometry all match; equal resolution with different obstructions must remain separate. - Never label derived or estimated profile data as `verified`. ### Development workflow diff --git a/README.md b/README.md index f9ce895..a54deb6 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **Privacy-first wallpaper preview and fitting for Apple devices.** -[Open Wallpect](https://wallpect.k-y.cc) · [繁體中文](README.zh-Hant.md) · [Documentation](#documentation) +[Open Wallpect](https://wallpect.k-y.cc) · [繁體中文(主要文件)](README.zh-Hant.md) · [Documentation](#documentation) [![CI](https://github.com/kyeunga25/wallpect/actions/workflows/ci.yml/badge.svg)](https://github.com/kyeunga25/wallpect/actions/workflows/ci.yml) [![Latest release](https://img.shields.io/github/v/release/kyeunga25/wallpect?display_name=tag&sort=semver)](https://github.com/kyeunga25/wallpect/releases/latest) @@ -15,7 +15,7 @@ ![Wallpect desktop workspace showing an iPhone wallpaper preview and export controls](docs/screenshots/wallpect-desktop.png) -Wallpect helps you compose an image for a selected Apple device, inspect likely system obstructions, and export it at the profile's exact pixel dimensions. Image decoding, editing, preview, and export all happen inside your browser—there is no image upload endpoint, account, or third-party image analysis. +Wallpect helps you compose an image for a selected Apple device, inspect likely system obstructions, and export it at the profile's exact pixel dimensions. The production app is deployed on Cloudflare Workers using the assets-only Static Assets mode. Image decoding, editing, preview, and export all happen inside your browser—there is no image upload endpoint, account, database, or third-party image analysis. ## What you can do @@ -25,7 +25,8 @@ Wallpect helps you compose an image for a selected Apple device, inspect likely ### Highlights -- 74 profiles: 27 iPhone, 20 iPad, and 27 Mac/display profiles, covering every requested display family introduced from 2021 through July 2026 +- 47 distinct canvas configurations: 19 iPhone, 11 iPad, and 17 Mac/Apple-display configurations covering 191 named models from 2007–2026 +- Devices with the same wallpaper canvas and obstruction geometry share one option; every model name, year, diagonal, and resolution remains searchable - Local PNG, JPEG, and WebP decoding, limited to 30 MB, 12,000 px on the longest edge, and 72 megapixels - Portrait and landscape compositions stored independently for each device - Solid, transparent, or blurred-extension backgrounds @@ -62,6 +63,20 @@ Wallpect separates published display facts from geometry that must be measured o See the [accuracy policy](docs/ACCURACY_POLICY.md) for the complete definition and reporting requirements. +## Technology stack + +| Layer | Technology | Purpose | +| --------------------- | --------------------------------------------------------- | -------------------------------------------------------------- | +| UI and application | React 19, strict TypeScript 5, Vite 8 | Typed single-page application compiled to static assets | +| Browser image runtime | Canvas 2D, File/Blob APIs, Object URLs | Local decode, composition, preview, and export | +| Interface assets | Lucide React | Open-source interface icons | +| Hosting | Cloudflare Workers Static Assets and Wrangler 4 | Assets-only edge delivery, SPA fallback, previews, and deploys | +| Offline shell | Service Worker and Cache Storage | Same-origin app-shell fallback after the first online visit | +| Quality | ESLint, Prettier, Vitest, Testing Library, and Playwright | Static checks and unit, integration, and browser testing | +| Automation | GitHub Actions and Cloudflare Workers Builds | Read-only CI checks plus branch previews and production builds | + +The current deployment has no SSR, Worker request handler, runtime API, authentication, analytics, database, object-storage binding, or runtime AI model. Exact dependency versions are locked in [`package-lock.json`](package-lock.json), while [`package.json`](package.json) is the readable source of truth for scripts and direct dependencies. + ## Local development Requirements: Node.js 22 or newer and npm. @@ -89,11 +104,37 @@ npm run test:e2e Set `PLAYWRIGHT_BASE_URL` to run the same browser matrix against a deployed Workers preview or the production app. -## Deployment and releases +## Deployment, self-hosting, and releases Wallpect is deployed as an assets-only Cloudflare Worker with Static Assets. The Worker owns `wallpect.k-y.cc` as a Custom Domain. A push to `main` triggers a production Workers Build; non-production branches create preview versions without changing production traffic. Production rollback uses Workers version history; no Cloudflare Pages project is required. -See the [deployment guide](docs/DEPLOYMENT.md) for platform settings and the [release guide](docs/RELEASING.md) for the version, validation, preview, production, and rollback checklist. +You can self-deploy a fork to your own Cloudflare account without a database, storage binding, AI key, or runtime secret: + +1. Fork or clone the repository and install Node.js 22 or newer. +2. Before the first deploy, edit `wrangler.toml`: choose a Worker `name` in your own account, and remove the checked-in `[[routes]]` block for a `workers.dev` deployment or replace it with a Custom Domain that you control. +3. Install and validate the locked project: + + ```bash + npm ci + npm run check + npm run format:check + npm run worker:check + ``` + +4. Authenticate locally and deploy the static build: + + ```bash + npx wrangler login + npm run worker:deploy + ``` + +5. Run the browser suite against the URL returned by Wrangler: + + ```bash + PLAYWRIGHT_BASE_URL=https:// npm run test:e2e + ``` + +Wrangler credentials stay outside the repository. Never add API tokens, account or zone identifiers, deployment IDs, private domains, production data, local paths, logs, or screenshots containing private content to source control. The [deployment and self-hosting guide](docs/DEPLOYMENT.md) contains the safe configuration template, Workers Builds setup, Custom Domain steps, validation checklist, and rollback procedure. The [release guide](docs/RELEASING.md) covers the versioned release workflow. ## Architecture @@ -115,7 +156,7 @@ See the [architecture document](docs/ARCHITECTURE.md) for the runtime boundary, - [Accuracy policy](docs/ACCURACY_POLICY.md) - [Architecture](docs/ARCHITECTURE.md) - [Device profile guide](docs/DEVICE_PROFILE_GUIDE.md) -- [Deployment guide](docs/DEPLOYMENT.md) +- [Deployment and self-hosting guide](docs/DEPLOYMENT.md) - [Release guide](docs/RELEASING.md) - [Legal, privacy, and data policy](docs/LEGAL_PRIVACY_AND_DATA_POLICY.md) - [Changelog](CHANGELOG.md) @@ -131,3 +172,10 @@ Wallpect is available under the [MIT License](LICENSE). Wallpect is an independent tool. It is not affiliated with or endorsed by Apple Inc. Apple product names are used only to identify compatible device profiles. Device specifications are manually curated from linked public manufacturer support pages. Wallpect does not use an Apple or third-party device-data API at runtime and does not scrape or mirror Apple website content. See the [Terms, Privacy, and Data Policy](docs/LEGAL_PRIVACY_AND_DATA_POLICY.md) for the detailed limitations and data-governance gate. + +## Technology and source references + +- **Web and platform technology:** [React](https://react.dev/), [TypeScript](https://www.typescriptlang.org/docs/), [Vite](https://vite.dev/), the browser [Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API) and [Service Worker API](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API), plus [Cloudflare Workers Static Assets](https://developers.cloudflare.com/workers/static-assets/) and [Wrangler](https://developers.cloudflare.com/workers/wrangler/). +- **Device references:** Each profile records credential-free links to public Apple Support pages. The evidence levels, review rules, and correction process are documented in the [accuracy policy](docs/ACCURACY_POLICY.md) and [device profile guide](docs/DEVICE_PROFILE_GUIDE.md). +- **Privacy and legal references:** Public guidance includes the [Cloudflare Privacy Policy](https://www.cloudflare.com/policies/privacy/), [Apple Website Terms of Use](https://www.apple.com/legal/internet-services/terms/site.html), and [Apple trademark and copyright guidelines](https://www.apple.com/legal/intellectual-property/guidelinesfor3rdparties.html). Their application to Wallpect is summarized in the [legal, privacy, and data policy](docs/LEGAL_PRIVACY_AND_DATA_POLICY.md). +- **Licensing:** Wallpect's source is released under the [MIT License](LICENSE). Third-party packages and referenced content remain subject to their own licences and terms; a link is a source citation, not an endorsement or transfer of rights. diff --git a/README.zh-Hant.md b/README.zh-Hant.md index e40ef83..dca9239 100644 --- a/README.zh-Hant.md +++ b/README.zh-Hant.md @@ -15,7 +15,7 @@ ![Wallpect 桌面工作區,顯示 iPhone 桌布預覽及匯出控制](docs/screenshots/wallpect-desktop.png) -Wallpect 讓你按所選 Apple 裝置調整圖片構圖、檢查可能遮擋內容的系統介面,並以設定檔的精確像素尺寸匯出桌布。圖片解碼、編輯、預覽及匯出全部在瀏覽器內完成;沒有圖片上傳端點、帳戶系統或第三方圖片分析。 +Wallpect 讓你按所選 Apple 裝置調整圖片構圖、檢查可能遮擋內容的系統介面,並以設定檔的精確像素尺寸匯出桌布。正式版本以純 Static Assets 模式部署在 Cloudflare Workers。圖片解碼、編輯、預覽及匯出全部在瀏覽器內完成;沒有圖片上傳端點、帳戶系統、資料庫或第三方圖片分析。 ## 可以做甚麼 @@ -25,7 +25,8 @@ Wallpect 讓你按所選 Apple 裝置調整圖片構圖、檢查可能遮擋內 ### 主要功能 -- 74 個設定檔:27 個 iPhone、20 個 iPad,以及 27 個 Mac/顯示器,涵蓋 2021 年至 2026 年 7 月要求支援的所有顯示系列 +- 47 個不重複的畫布配置:19 個 iPhone、11 個 iPad、17 個 Mac/Apple 顯示器配置,共覆蓋 191 個 2007–2026 年具名型號 +- 相同 wallpaper 畫布及遮擋幾何的裝置共用一個選項;所有型號名稱、年份、吋數及解析度仍可搜尋 - 在本機解碼 PNG、JPEG 與 WebP,限制為 30 MB、最長邊 12,000 px 及 7,200 萬像素 - 每部裝置分別保存直向及橫向構圖 - 純色、透明或模糊延伸背景 @@ -62,6 +63,20 @@ Wallpect 將公開的顯示資料與需要量度或推算的幾何資料分開 完整定義及資料問題回報要求請參閱[準確度政策](docs/ACCURACY_POLICY.md)。 +## 技術棧 + +| 層面 | 技術 | 用途 | +| -------------- | ----------------------------------------------------- | ----------------------------------------- | +| 介面與應用程式 | React 19、strict TypeScript 5、Vite 8 | 編譯成靜態資源、具型別檢查的單頁應用程式 | +| 瀏覽器圖片處理 | Canvas 2D、File/Blob API、Object URL | 在本機解碼、構圖、預覽及匯出 | +| 介面圖示 | Lucide React | 開源介面圖示 | +| 託管 | Cloudflare Workers Static Assets、Wrangler 4 | 純靜態邊緣傳送、SPA fallback、預覽及部署 | +| 離線 app shell | Service Worker、Cache Storage | 首次連線載入後提供同源 app shell fallback | +| 品質檢查 | ESLint、Prettier、Vitest、Testing Library、Playwright | 靜態、單元、整合及瀏覽器測試 | +| 自動化 | GitHub Actions、Cloudflare Workers Builds | 唯讀 CI 檢查、分支預覽及正式 build | + +目前部署不包含 SSR、Worker request handler、runtime API、身份驗證、分析服務、資料庫、物件儲存 binding 或執行時 AI 模型。精確依賴版本鎖定在 [`package-lock.json`](package-lock.json),而 [`package.json`](package.json) 是 scripts 及直接依賴的可讀來源。 + ## 本機開發 需求:Node.js 22 或更新版本,以及 npm。 @@ -89,11 +104,37 @@ npm run test:e2e 設定 `PLAYWRIGHT_BASE_URL` 後,可對已部署的 Workers 預覽版本或正式網站執行相同的瀏覽器矩陣。 -## 部署與版本發布 +## 部署、自行託管與版本發布 Wallpect 以不含 Worker script 的 Cloudflare Workers Static Assets 模式部署,並由 Worker 以 Custom Domain 直接管理 `wallpect.k-y.cc`。推送至 `main` 會觸發正式 Workers Build;其他分支只會建立預覽版本,不會改變正式流量。正式回退使用 Workers 版本記錄,不再需要 Cloudflare Pages 專案。 -平台設定請參閱[部署指南](docs/DEPLOYMENT.md);版本、驗證、預覽、正式部署及回退程序請參閱[發布指南](docs/RELEASING.md)。 +你可以把 fork 自行部署到自己的 Cloudflare 帳戶;目前不需要資料庫、儲存 binding、AI key 或執行時 secret: + +1. Fork 或 clone repository,並安裝 Node.js 22 或更新版本。 +2. 首次部署前修改 `wrangler.toml`:在自己的帳戶選用 Worker `name`;如只使用 `workers.dev`,移除 repository 內原有的 `[[routes]]`,否則把它改成自己控制的 Custom Domain。 +3. 安裝並驗證鎖定版本: + + ```bash + npm ci + npm run check + npm run format:check + npm run worker:check + ``` + +4. 在本機登入並部署靜態 build: + + ```bash + npx wrangler login + npm run worker:deploy + ``` + +5. 對 Wrangler 回傳的 URL 執行瀏覽器測試: + + ```bash + PLAYWRIGHT_BASE_URL=https:// npm run test:e2e + ``` + +Wrangler 憑證應保留在 repository 之外。不要把 API token、帳戶/zone 識別碼、deployment ID、私人網域、正式數據、本機路徑、log,或含私人內容的截圖加入版本控制。[部署與自行託管指南](docs/DEPLOYMENT.md)提供安全設定範本、Workers Builds、Custom Domain、部署驗證及回退步驟;[發布指南](docs/RELEASING.md)則說明版本化發布流程。 ## 架構 @@ -115,7 +156,7 @@ Wallpect 以不含 Worker script 的 Cloudflare Workers Static Assets 模式部 - [準確度政策](docs/ACCURACY_POLICY.md) - [架構文件](docs/ARCHITECTURE.md) - [裝置設定檔指南](docs/DEVICE_PROFILE_GUIDE.md) -- [部署指南](docs/DEPLOYMENT.md) +- [部署與自行託管指南](docs/DEPLOYMENT.md) - [發布指南](docs/RELEASING.md) - [條款、私隱與資料政策](docs/LEGAL_PRIVACY_AND_DATA_POLICY.md) - [版本記錄](CHANGELOG.md) @@ -131,3 +172,10 @@ Wallpect 採用 [MIT License](LICENSE)。 Wallpect 是獨立工具,與 Apple Inc. 無關,亦未獲其背書。Apple 產品名稱只用於識別相容的裝置設定檔。 裝置規格由連結的公開製造商支援頁面人工整理;Wallpect 不會在執行時使用 Apple 或第三方裝置資料 API,也不會自動擷取或鏡像 Apple 網站內容。詳細限制及資料治理方法見 [條款、私隱與資料政策](docs/LEGAL_PRIVACY_AND_DATA_POLICY.md)。 + +## 技術與資料來源參考 + +- **Web 與平台技術:** [React](https://react.dev/)、[TypeScript](https://www.typescriptlang.org/docs/)、[Vite](https://vite.dev/)、瀏覽器 [Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API) 與 [Service Worker API](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API),以及 [Cloudflare Workers Static Assets](https://developers.cloudflare.com/workers/static-assets/) 和 [Wrangler](https://developers.cloudflare.com/workers/wrangler/)。 +- **裝置資料參考:** 每個設定檔均記錄不含憑證、可公開存取的 Apple Support 來源連結;證據等級、審閱規則及更正流程見[準確度政策](docs/ACCURACY_POLICY.md)及[裝置設定檔指南](docs/DEVICE_PROFILE_GUIDE.md)。 +- **私隱與法律參考:** 公開指引包括 [Cloudflare Privacy Policy](https://www.cloudflare.com/policies/privacy/)、[Apple 網站使用條款](https://www.apple.com/legal/internet-services/terms/site.html)及 [Apple 商標與著作權指引](https://www.apple.com/legal/intellectual-property/guidelinesfor3rdparties.html);Wallpect 的適用摘要見[條款、私隱與資料政策](docs/LEGAL_PRIVACY_AND_DATA_POLICY.md)。 +- **授權:** Wallpect 原始碼採用 [MIT License](LICENSE);第三方套件及引用內容仍受各自授權與條款約束。來源連結只作引用,不代表背書或權利轉讓。 diff --git a/docs/ACCURACY_POLICY.md b/docs/ACCURACY_POLICY.md index 5b94a8f..781e4e7 100644 --- a/docs/ACCURACY_POLICY.md +++ b/docs/ACCURACY_POLICY.md @@ -13,6 +13,8 @@ Wallpect 會將顯示硬件的公開事實,與必須量度或推算的幾何 初始資料集會刻意標記為 `high` 而非 `verified`:顯示解析度已有公開資料,但大部分安全區域、圓角、鎖定畫面、選單列與 Dock 尺寸都是推算近似值。 +多個型號只會在實體及邏輯畫布、開孔、圓角、安全區域、覆疊與 wallpaper 行為一致時合併。相同吋數或相同實體解析度本身不等於相同預覽;任何會改變構圖遮擋的差異都必須保留獨立配置。合併配置內的機身尺寸是外框顯示代表值,精確輸出聲明只適用於列明的共同畫布。 + ### 預覽所代表的意思 - 裝置長寬比與匯出像素尺寸直接來自所選設定檔。 @@ -41,6 +43,8 @@ Wallpect separates facts that are published for display hardware from geometry t The initial data set is intentionally marked `high`, not `verified`: display resolutions are published, while most safe-area, corner, Lock Screen, menu-bar, and Dock dimensions are derived approximations. +Models are consolidated only when their physical and logical canvases, cutouts, corners, safe areas, overlays, and wallpaper behavior match. A shared diagonal or physical resolution does not by itself imply an identical preview; any difference that changes composition obstructions requires a separate configuration. Chassis dimensions in a grouped configuration are representative frame values, while the exact-output claim applies only to the listed shared canvas. + ### What the preview means - Device aspect ratio and export pixel dimensions come directly from the selected profile. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3d7334d..23ea031 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Wallpect 架構 / Architecture -本文件描述 Wallpect 0.2.3 已實作的系統邊界、資料生命週期及必須維持的工程不變條件。 +本文件描述 Wallpect 0.3.0 已實作的系統邊界、資料生命週期及必須維持的工程不變條件。 ## 繁體中文 @@ -40,8 +40,8 @@ flowchart LR 1. 預覽與匯出均呼叫 `renderWallpaper`;構圖平移採用目標 Canvas 的標準化座標,因此相同比例的預覽與完整輸出保持一致。 2. 匯出 Canvas 只在操作期間存在,完成後會縮回 `1 × 1`;下載與上傳建立的 Object URL 必須撤銷。 3. 圖片 bytes、檔名、Canvas 像素及 transform 不得寫入 `localStorage`、Cache Storage、網絡請求或分析服務。 -4. 可持久保存的資料只有 `wallpect:locale:v1` 及最多四個有效裝置 ID 的 `wallpect:recent-devices:v1`。舊 `wallpect:recent-devices` 會在讀取時清理、遷移並移除。 -5. 裝置資料留在 `src/data/devices`;推算或估算的幾何不得標示為 `verified`。 +4. 可持久保存的資料只有 `wallpect:locale:v1` 及最多四個有效裝置 ID 的 `wallpect:recent-devices:v1`。舊 storage key 及 0.3.0 前的個別型號 ID 會在讀取時清理、映射到標準配置、去除重複並寫回。 +5. 裝置資料留在 `src/data/devices`;具名型號只可在完整 wallpaper 幾何相同時合併,推算或估算的幾何不得標示為 `verified`。 6. 缺少的離線導覽可回退到已快取的 `/`,但缺少的 script、style 或其他非導覽資源必須失敗,不得回傳 HTML shell。 ### 資料生命週期 @@ -88,8 +88,8 @@ The diagram above intentionally has no data path from the selected image, Canvas 1. Preview and export both call `renderWallpaper`. Pan values use normalized target-Canvas coordinates, preserving a composition between an aspect-matched preview and full output. 2. The export Canvas exists only for the operation and is reduced to `1 × 1` afterward. Object URLs created for uploads and downloads must be revoked. 3. Image bytes, filenames, Canvas pixels, and transforms must not enter `localStorage`, Cache Storage, network requests, or analytics. -4. Persisted data is limited to `wallpect:locale:v1` and up to four valid device IDs in `wallpect:recent-devices:v1`. The legacy `wallpect:recent-devices` value is sanitized, migrated, and removed when read. -5. Device data stays in `src/data/devices`; derived or estimated geometry must not be labelled `verified`. +4. Persisted data is limited to `wallpect:locale:v1` and up to four valid device IDs in `wallpect:recent-devices:v1`. The legacy storage key and pre-0.3.0 per-model IDs are sanitized, mapped to canonical configurations, deduplicated, and written back when read. +5. Device data stays in `src/data/devices`; named models may be consolidated only when their complete wallpaper geometry matches, and derived or estimated geometry must not be labelled `verified`. 6. An unavailable offline navigation may fall back to cached `/`. A missing script, style, or other non-navigation asset must fail and must not receive the HTML shell. ### Data lifecycle diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 6b5482d..61840f9 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,4 +1,4 @@ -# Cloudflare Workers Static Assets 部署指南 / Deployment Guide +# Cloudflare Workers Static Assets 部署與自行託管指南 / Deployment and Self-hosting Guide ## 中文 @@ -16,13 +16,56 @@ Wallpect 使用 Workers Static Assets 的純靜態模式。`wrangler.toml` 不 Repository 亦包含鎖定版本的 Wrangler、`public/_headers` 與正式版本的 service worker。Vite 會為 JS/CSS 資源加入 hash;`_headers` 會長期快取這些不可變資源,同時讓 `index.html` 保持重新驗證,並避免長期快取 `sw.js`。註冊 service worker 時使用 `updateViaCache: "none"`;網絡失敗時只讓導覽請求回退到快取的 `/`,缺少的 JS、CSS 或其他非導覽檔案不會收到 HTML shell。 +### 從 fork 自行部署 + +#### 1. 準備自己的環境 + +你需要: + +- 自己控制的 Cloudflare 帳戶; +- Node.js 22 或更新版本、npm 及 Git; +- repository 的 fork,或一份本機 clone; +- 如要使用 Custom Domain,該 hostname 所屬的 active Cloudflare zone。 + +此版本不需要 D1、KV、R2、Durable Objects、環境變數、AI key 或任何 runtime secret。不要為了完成基本部署而建立資料庫或 binding。 + +```bash +git clone https://github.com//wallpect.git +cd wallpect +npm ci +``` + +#### 2. 先把部署目標改成自己的 Worker + +不要直接部署 repository 內原有的 `wrangler.toml`:它描述維護者的公開部署目標。首次部署前,最少要選用自己帳戶內的 Worker `name`,並移除原有的 `[[routes]]`。如先使用免費的 `workers.dev` 網址,可採用以下不含任何 secret 或帳戶識別碼的最小設定: + +```toml +name = "your-wallpect" +compatibility_date = "2026-07-18" +workers_dev = true +preview_urls = true + +[assets] +directory = "./dist" +not_found_handling = "single-page-application" +``` + +為重現目前已測試版本,先保留 repository 的 `compatibility_date`;日後更新日期應作為獨立變更,並重新執行完整測試。`name` 只需要是你帳戶內的部署名稱,不要在設定中加入 account ID、zone ID、token 或其他供應者識別碼。 + +如 fork 會公開,請只提交可公開的 Worker 名稱及你確實希望公開的網域。私人測試網域可留在未追蹤的本機設定中,不要推送至 GitHub。 + +#### 3. 在部署前完成本機驗證 + 本機驗證 Workers 靜態資源設定: ```bash -npm run build +npm run check +npm run format:check npm run worker:check ``` +`npm run check` 已包括 lint、單元/整合測試、strict production build 及 build-size budget;`worker:check` 是 dry-run,不會建立或更新遠端 Worker。 + 在 Workers runtime 本機預覽: ```bash @@ -32,7 +75,24 @@ npm run worker:dev `npm run test:e2e` 會自行建立 production build,並在 `127.0.0.1:4174` 啟動 Vite production preview,因此會實際註冊及檢查 service worker。不要以 Vite dev server 的結果代替離線/更新驗證。 -### 網域 +#### 4. 登入及部署到 `workers.dev` + +```bash +npx wrangler login +npm run worker:deploy +``` + +`wrangler login` 會在瀏覽器完成授權;憑證應由 Wrangler 保存在 repository 之外。不要把登入輸出、token、Dashboard 畫面或本機設定複製到 source、issue、pull request 或 build log。部署成功後,Wrangler 會回傳你帳戶下的公開 URL。 + +#### 5. 驗證已部署版本 + +```bash +PLAYWRIGHT_BASE_URL=https:// npm run test:e2e +``` + +另外以無私人圖片的新瀏覽器 session 手動檢查首頁、任一非檔案導覽路徑、示範圖、匯出尺寸、語言切換、安全 header 與離線 app shell。測試輸出或截圖如包含本機路徑、帳戶資訊或私人圖片,不應加入 Git。 + +### Custom Domain 與 Git 自動部署 建議對應: @@ -42,6 +102,16 @@ npm run worker:dev `wrangler.toml` 以 `custom_domain = true` 將 `wallpect.k-y.cc` 直接綁定至 Worker。Cloudflare 會為 Custom Domain 管理相應 DNS 記錄及憑證;此專案不依賴外部 origin、Pages CNAME 或 Pages project。發生部署問題時,使用 Workers deployment history 或 `wrangler rollback` 回退,不要移除正式 Custom Domain。 +自行託管者只可加入自己控制的 hostname: + +```toml +[[routes]] +pattern = "app.example.com" +custom_domain = true +``` + +Custom Domain 需要 active Cloudflare zone,且目標 hostname 不可有互相衝突的 CNAME。確認 hostname 及帳戶後才再次執行 `npm run worker:deploy`;這個操作會建立/更新 DNS 與憑證,因此不要用不屬於自己的網域測試。 + Workers Builds 設定: 1. 連接 GitHub repository; @@ -50,10 +120,12 @@ Workers Builds 設定: 4. Deploy command 使用 Workers Builds 預設的 `npx wrangler deploy`; 5. 開啟 non-production branch builds,並使用預設的 `npx wrangler versions upload` 建立不影響正式流量的 preview version。 -Cloudflare 會使用 `package.json` 鎖定的 Wrangler 版本;本機仍可使用 `npm run worker:deploy` 與 `npm run worker:preview` 作為相同命令的簡寫。 +Cloudflare Dashboard 內的 Worker 名稱必須與 repository 指定 root directory 內 `wrangler.toml` 的 `name` 相符。Cloudflare 會使用 `package.json` 鎖定的 Wrangler 版本;本機仍可使用 `npm run worker:deploy` 與 `npm run worker:preview` 作為相同命令的簡寫。Workers Builds 自動產生或使用的 build token 只應保存在 Cloudflare Dashboard,不要寫入 GitHub 檔案、README、環境範本或截圖。 相關官方文件: +- [Static Assets 開始及部署](https://developers.cloudflare.com/workers/static-assets/get-started/) +- [Wrangler 設定](https://developers.cloudflare.com/workers/wrangler/configuration/) - [Workers Builds 設定](https://developers.cloudflare.com/workers/ci-cd/builds/configuration/) - [Build branch 控制](https://developers.cloudflare.com/workers/ci-cd/builds/build-branches/) - [Static Assets SPA 導覽](https://developers.cloudflare.com/workers/static-assets/routing/single-page-application/) @@ -64,6 +136,21 @@ Cloudflare 會使用 `package.json` 鎖定的 Wrangler 版本;本機仍可使 Workers Static Assets 會直接讀取 `public/_headers` 建置後的 `dist/_headers`。它會設定 CSP、`nosniff`、referrer policy、permissions policy、same-origin opener policy 與快取規則。CSP 刻意只允許同源 scripts/styles,以及本機圖片處理與下載所需的 `blob:` 圖片/worker。收緊設定後,必須重新測試 Canvas 匯出與 service worker。 +### 公開 repository 安全檢查 + +每次 push 或部署前,逐一檢查變更: + +```bash +git status --short --branch +git diff --name-status +git diff --check +git ls-files --others --exclude-standard +``` + +不得提交 `.env`、`.dev.vars`、`.wrangler/`、API token、帳戶/zone/deployment 識別碼、私人 hostname、真實用戶圖片或檔名、正式應用數據、資料庫結構、內部架構圖、非公開規劃、個人資料、本機絕對路徑或含上述內容的 log/截圖。這些項目即使暫時可用,也不是自行部署所需的公開設定。 + +公開文件只應描述可從 source 驗證的產品邊界與通用部署流程。任何新 API、資料庫、遠端圖片處理、分析服務、AI runtime 或 Cloudflare binding 都屬私隱及架構變更,必須先完成獨立設計、法律/私隱審閱及測試,不可直接照本指南加入。 + ### 發佈檢查 ```bash @@ -108,13 +195,56 @@ Wallpect uses the assets-only Workers Static Assets mode. `wrangler.toml` has no The repository also includes a pinned Wrangler version, `public/_headers`, and a production service worker. Vite hashes JS/CSS assets; `_headers` caches those immutable assets for the long term while leaving `index.html` revalidated and preventing long-lived caching of `sw.js`. Service-worker registration uses `updateViaCache: "none"`. On network failure, only navigation requests can fall back to cached `/`; a missing JS, CSS, or other non-navigation file does not receive the HTML shell. +### Self-deploy a fork + +#### 1. Prepare an account and local environment + +You need: + +- a Cloudflare account that you control; +- Node.js 22 or newer, npm, and Git; +- a repository fork or local clone; +- an active Cloudflare zone for the hostname if you want a Custom Domain. + +This release needs no D1, KV, R2, Durable Objects, environment variables, AI key, or runtime secret. Do not create a database or binding merely to complete the basic deployment. + +```bash +git clone https://github.com//wallpect.git +cd wallpect +npm ci +``` + +#### 2. Point the configuration at your own Worker + +Do not deploy the checked-in `wrangler.toml` unchanged: it describes the maintainer's public deployment target. Before the first deployment, choose a Worker `name` in your account and remove the existing `[[routes]]` block. To start with a `workers.dev` URL, use this minimum configuration, which contains no secret or account identifier: + +```toml +name = "your-wallpect" +compatibility_date = "2026-07-18" +workers_dev = true +preview_urls = true + +[assets] +directory = "./dist" +not_found_handling = "single-page-application" +``` + +Keep the repository's `compatibility_date` when reproducing the tested release. Update the date later as a separate, fully tested change. `name` is only the deployment name inside your account; do not add an account ID, zone ID, token, or other provider identifier to the configuration. + +For a public fork, commit only a publishable Worker name and a domain that you intentionally expose. Keep private test hostnames in an untracked local configuration instead of pushing them to GitHub. + +#### 3. Validate locally before deployment + Validate the Workers static assets configuration locally: ```bash -npm run build +npm run check +npm run format:check npm run worker:check ``` +`npm run check` includes lint, unit/integration tests, the strict production build, and the build-size budget. `worker:check` is a dry run and does not create or update a remote Worker. + Preview with the Workers runtime locally: ```bash @@ -124,7 +254,24 @@ npm run worker:dev `npm run test:e2e` creates a production build and starts a Vite production preview at `127.0.0.1:4174`, so it exercises the real service-worker registration and offline behavior. Do not substitute Vite dev-server results for offline/update verification. -### Domains +#### 4. Authenticate and deploy to `workers.dev` + +```bash +npx wrangler login +npm run worker:deploy +``` + +`wrangler login` completes authorization in the browser. Wrangler credentials must remain outside the repository. Do not paste login output, tokens, dashboard screens, or local configuration into source files, issues, pull requests, or build logs. After a successful deployment, Wrangler returns the public URL under your account. + +#### 5. Verify the deployed build + +```bash +PLAYWRIGHT_BASE_URL=https:// npm run test:e2e +``` + +Also use a fresh browser session without private imagery to inspect the home page, a non-file navigation route, bundled sample, export dimensions, locale switching, security headers, and offline app shell. Do not commit test output or screenshots containing local paths, account information, or private imagery. + +### Custom Domains and Git-based deployments Suggested mappings: @@ -134,6 +281,16 @@ Suggested mappings: `wrangler.toml` binds `wallpect.k-y.cc` directly to the Worker with `custom_domain = true`. Cloudflare manages the corresponding DNS record and certificate for the Custom Domain; the project does not depend on an external origin, Pages CNAME, or Pages project. If a deployment is unhealthy, use Workers deployment history or `wrangler rollback` instead of removing the production Custom Domain. +A self-hosted fork must use only a hostname that you control: + +```toml +[[routes]] +pattern = "app.example.com" +custom_domain = true +``` + +A Custom Domain requires an active Cloudflare zone, and the target hostname cannot have a conflicting CNAME. Confirm the hostname and account before running `npm run worker:deploy` again. This operation creates or updates DNS and certificate state, so never test it with a domain that you do not own. + Workers Builds settings: 1. Connect the GitHub repository. @@ -142,10 +299,12 @@ Workers Builds settings: 4. Keep the Workers Builds default deploy command, `npx wrangler deploy`. 5. Enable non-production branch builds and keep the default `npx wrangler versions upload` command to create preview versions without affecting production traffic. -Cloudflare uses the Wrangler version pinned in `package.json`; `npm run worker:deploy` and `npm run worker:preview` remain available as equivalent local aliases. +The Worker name in the Cloudflare dashboard must match the `name` in `wrangler.toml` under the configured repository root directory. Cloudflare uses the Wrangler version pinned in `package.json`; `npm run worker:deploy` and `npm run worker:preview` remain available as equivalent local aliases. Keep the generated or selected Workers Builds token only in the Cloudflare dashboard. Never add it to GitHub files, a README, an environment template, or a screenshot. Related official documentation: +- [Static Assets get started and deploy](https://developers.cloudflare.com/workers/static-assets/get-started/) +- [Wrangler configuration](https://developers.cloudflare.com/workers/wrangler/configuration/) - [Workers Builds configuration](https://developers.cloudflare.com/workers/ci-cd/builds/configuration/) - [Build branch control](https://developers.cloudflare.com/workers/ci-cd/builds/build-branches/) - [Static Assets SPA routing](https://developers.cloudflare.com/workers/static-assets/routing/single-page-application/) @@ -156,6 +315,21 @@ Related official documentation: Workers Static Assets reads `public/_headers` from the built `dist/_headers`. It sets CSP, `nosniff`, referrer policy, permissions policy, same-origin opener policy, and cache rules. The CSP intentionally allows same-origin scripts/styles plus `blob:` images/workers for local image handling and download. Re-test Canvas export and the service worker after tightening it. +### Public-repository safety check + +Review the change set before every push or deployment: + +```bash +git status --short --branch +git diff --name-status +git diff --check +git ls-files --others --exclude-standard +``` + +Never commit `.env`, `.dev.vars`, `.wrangler/`, API tokens, account/zone/deployment identifiers, private hostnames, real user imagery or filenames, production application data, database layouts, internal architecture diagrams, non-public plans, personal data, absolute local paths, or logs/screenshots containing any of them. None of those items is required public configuration for this deployment. + +Public documentation should describe only source-verifiable product boundaries and generic deployment procedures. A new API, database, remote image processor, analytics service, AI runtime, or Cloudflare binding is a privacy and architecture change that requires a separate design, legal/privacy review, and test plan. Do not add one by following this guide alone. + ### Release check ```bash diff --git a/docs/DEVICE_PROFILE_GUIDE.md b/docs/DEVICE_PROFILE_GUIDE.md index 9183c60..ab5107b 100644 --- a/docs/DEVICE_PROFILE_GUIDE.md +++ b/docs/DEVICE_PROFILE_GUIDE.md @@ -1,87 +1,97 @@ -# 裝置設定檔指南 / Device Profile Guide +# 裝置配置指南 / Device Configuration Guide -## 中文 +## 繁體中文 -裝置設定檔是位於 `src/data/devices/apple/profiles.ts` 的純 TypeScript 記錄。預覽介面不含針對個別型號的分支;只要加入有效的設定檔,它便會自動出現在搜尋、選擇器、覆疊、資訊與匯出功能中。 +Wallpect 把「可在選單選取的桌布配置」與「使用該配置的具名裝置型號」分開。資料位於 `src/data/devices/apple/`:`iphone.ts`、`ipad.ts` 與 `mac.ts` 保存平台資料,`catalog.ts` 建立共用結構,`profiles.ts` 只組合公開清單及舊 ID 映射。UI 元件不保存裝置資料。 -目前資料集包含 27 個 iPhone、20 個 2021 年或之後的 iPad,以及 27 個 2021 年或之後、具備內建螢幕的 Mac/Apple 顯示器設定檔。Mac mini、Mac Studio 與 Mac Pro 沒有內建螢幕,其桌布解析度由外接顯示器決定,因此不建立虛構的主機螢幕設定檔。 +截至 2026 年 8 月 10 日,資料集提供 47 個互不重複的配置:19 個 iPhone、11 個 iPad,以及 17 個 Mac/Apple 顯示器配置,共覆蓋 191 個具名型號。範圍由第一代 iPhone、早期 iPad 與 Intel Mac,延伸至 iPhone 17e、M5 Mac、Studio Display XDR 與 Pro Display XDR。 -### 必填欄位 +Mac mini、Mac Studio 與 Mac Pro 沒有內建螢幕;它們應按實際連接的 Apple 顯示器配置預覽,不會獲得虛構的主機螢幕設定。 -每個設定檔必須包含: +### 合併規則 -- 穩定的 `apple-*` id 與 slug; -- 平台、類別、系列、型號與推出年份; -- 支援的螢幕方向清單; -- 用於相對外觀顯示的機身尺寸與對角線; -- 實體像素、邏輯點數、DPR、比例、原生方向與圓角半徑; -- 開孔類型及可選的像素邊界; -- 附有證據來源的安全區域邊距; -- 與平台相關的覆疊矩形; -- 桌布行為說明; -- 來源 URL、上次檢查日期、準確度等級與備註。 +只有下列 wallpaper 相關資料相同時,多個型號才可合併成一個選項: -所有像素幾何均使用設定檔的原生實體座標空間。對於原生直向的設定檔,橫向幾何會由 `orientDevice` 與 `orientRect` 旋轉。 +- 平台及支援方向; +- 實體輸出解析度、邏輯畫布、DPR、長寬比及圓角; +- 瀏海、Dynamic Island 或其他開孔幾何; +- 安全區域及預覽覆疊; +- 系統 wallpaper 行為。 -### 加入設定檔 +只有相同螢幕吋數或相同實體解析度並不足以合併。例如 iPhone 12 與 iPhone 13 的輸出同為 `1170 × 2532`,但瀏海幾何不同,因此保留兩個配置。相反,iPhone 14 Pro、iPhone 15、iPhone 15 Pro 與 iPhone 16 的畫布及遮擋幾何相同,所以共用一個選項。 -1. 找出製造商的技術規格,以及任何未公開幾何資料的可靠來源。 -2. 使用適合的 phone、tablet 或 Mac helper 加入記錄。 -3. 由實體寬度/高度計算 `aspectRatio`;不要輸入四捨五入的市場推廣比例。 -4. 將未公開的安全區域、開孔或圓角幾何標記為 `derived` 或 `estimated`。 -5. 引入新的幾何系列時,加入設定檔數量或特定設定檔的單元測試。 -6. 執行 `npm run check`。 +每個配置的 `compatibleModels` 保存完整型號名稱、推出年份及對角線;搜尋會讀取所有別名、年份、吋數、實體及邏輯解析度。`releaseYear` 代表該配置內最新型號的年份,只用於排序。合併配置的 `chassis` 是相對外框的代表值,不應被解讀為每個相容型號都具有完全相同的機身尺寸。 -`validateDeviceProfiles` 會在匯入裝置資料及測試期間執行。它會拒絕重複記錄、無效尺寸、不一致的長寬比、缺少來源、負數邊距及格式錯誤的檢查日期。 +### 舊 ID 遷移 -### 資料慣例 +0.3.0 以前每個型號各有一個 profile ID。`APPLE_DEVICE_PROFILE_ID_ALIASES` 會把仍可能存在於 `wallpect:recent-devices:v1` 的舊 ID 映射到新的標準配置 ID,去除重複後再寫回。遷移只處理最多四個裝置識別碼,不會加入圖片、檔名、構圖或其他資料。 -- 實體顯示尺寸一律使用像素。 -- 邏輯視窗尺寸使用點數。 -- 機身尺寸使用毫米;對角線使用英吋。 -- 矩形使用原生左上角為原點的 `{ x, y, width, height }`。 -- 安全區域邊距使用 `{ top, right, bottom, left }`。 -- 硬件解析度與系統 UI 幾何屬於兩項獨立陳述。 +### 必填資料與驗證 + +每個配置必須包含: + +- 穩定的 `apple-*` ID、slug、平台、類別及精簡顯示名稱; +- 至少一個具唯一名稱、有效年份及正數對角線的 `compatibleModels` 項目; +- 支援方向、代表機身資料、實體像素、邏輯點數、DPR、原生方向及圓角; +- 開孔、安全區域、平台覆疊與 wallpaper 行為; +- 不含憑證的 `https://support.apple.com` 來源、真實檢查日期、準確度及備註。 + +`validateDeviceProfiles` 會拒絕重複 ID/slug/型號名稱、相同但未合併的 wallpaper 配置、非有限或越界幾何、錯誤長寬比、不安全來源 URL、無效日期,以及與最新相容型號不一致的 `releaseYear`。 + +### 新增資料 + +1. 先在 Apple 官方技術規格或型號辨識頁核對型號、年份及原生解析度。 +2. 若 wallpaper 配置已存在,只在相應配置的 `compatibleModels` 加入型號及來源;不要建立重複選項。 +3. 若開孔、邏輯畫布、安全區域、圓角或系統行為不同,建立新的配置。 +4. 未公開的幾何必須標為 `derived` 或 `estimated`;不可把推算資料標為 `verified`。 +5. 更新覆蓋數及加入代表性測試,並執行 `npm run lint`、`npm test` 與 `npm run build`。 + +所有像素幾何均使用設定檔的原生實體座標空間。原生直向配置的橫向幾何由 `orientDevice` 與 `orientRect` 旋轉;預覽與匯出仍共用 `renderWallpaper`。 ## English -Device profiles are plain TypeScript records in `src/data/devices/apple/profiles.ts`. The preview does not contain model-specific branches: adding a valid profile is enough for it to appear in search, selection, overlays, information, and export. +Wallpect separates a selectable wallpaper configuration from the named device models that use it. Data lives under `src/data/devices/apple/`: `iphone.ts`, `ipad.ts`, and `mac.ts` hold platform records, `catalog.ts` creates the shared structure, and `profiles.ts` only assembles the public list and previous-ID map. UI components do not own device data. + +As reviewed on 10 August 2026, the data set provides 47 distinct configurations: 19 iPhone, 11 iPad, and 17 Mac/Apple-display configurations covering 191 named models. Coverage runs from the first iPhone, early iPads, and Intel Macs through iPhone 17e, M5 Macs, Studio Display XDR, and Pro Display XDR. + +Mac mini, Mac Studio, and Mac Pro have no built-in screen. Preview them through the Apple display that is actually attached; Wallpect does not invent a computer-screen profile for those hosts. + +### Consolidation rule + +Models may share one option only when all wallpaper-relevant data matches: + +- platform and supported orientations; +- physical output resolution, logical canvas, DPR, aspect ratio, and corners; +- notch, Dynamic Island, or other cutout geometry; +- safe areas and preview overlays; +- system wallpaper behavior. + +Matching screen size or physical resolution alone is insufficient. For example, iPhone 12 and iPhone 13 both output `1170 × 2532`, but their notch geometry differs, so they remain separate configurations. iPhone 14 Pro, iPhone 15, iPhone 15 Pro, and iPhone 16 share the same canvas and obstruction geometry, so they use one option. -The current data set contains 27 iPhones, 20 iPads introduced in 2021 or later, and 27 Macs with built-in displays or Apple displays introduced in 2021 or later. Mac mini, Mac Studio, and Mac Pro have no built-in display, so their wallpaper resolution is determined by the attached display and no fictitious computer-screen profile is created. +Each configuration's `compatibleModels` records every full model name, release year, and diagonal. Search indexes all aliases, years, diagonals, physical resolutions, and logical resolutions. `releaseYear` is the newest compatible-model year and is used only for sorting. A grouped configuration's `chassis` is a representative frame value; it does not claim that every compatible model has identical body dimensions. -### Required fields +### Previous-ID migration -Every profile must include: +Before 0.3.0, each model had its own profile ID. `APPLE_DEVICE_PROFILE_ID_ALIASES` maps IDs that may still exist in `wallpect:recent-devices:v1` to the new canonical configuration, removes duplicates, and writes the canonical list back. Migration handles only up to four device identifiers; it does not add images, filenames, compositions, or other data. -- stable `apple-*` id and slug; -- platform, category, family, model, and release year; -- supported orientation list; -- chassis dimensions and diagonal for relative presentation; -- physical pixels, logical points, DPR, ratio, orientation, and corner radius; -- cutout type and optional pixel bounds; -- safe-area insets with a declared evidence source; -- overlay rectangles relevant to the platform; -- wallpaper behavior notes; -- source URLs, last-review date, accuracy level, and notes. +### Required data and validation -All pixel geometry uses the profile's native physical coordinate space. For a portrait-native profile, landscape geometry is rotated by `orientDevice` and `orientRect`. +Every configuration must include: -### Adding a profile +- a stable `apple-*` ID, slug, platform, category, and concise display label; +- at least one uniquely named `compatibleModels` item with a valid year and positive diagonal; +- orientations, representative chassis data, physical pixels, logical points, DPR, native orientation, and corners; +- cutout, safe area, platform overlays, and wallpaper behavior; +- credential-free `https://support.apple.com` sources, a real review date, accuracy, and notes. -1. Locate the manufacturer's technical specification and a reliable source for any unpublished geometry. -2. Add the record through the appropriate phone, tablet, or Mac helper. -3. Compute `aspectRatio` from physical width / height; never type a rounded marketing ratio. -4. Mark unpublished safe-area, cutout, or corner geometry as `derived` or `estimated`. -5. Add a profile-count or profile-specific unit test when introducing a new geometry family. -6. Run `npm run check`. +`validateDeviceProfiles` rejects duplicate IDs, slugs, or model names; identical but unconsolidated wallpaper configurations; non-finite or out-of-bounds geometry; inconsistent ratios; unsafe source URLs; invalid dates; and a `releaseYear` that does not match the newest compatible model. -`validateDeviceProfiles` runs when device data is imported and during tests. It rejects duplicates, invalid dimensions, inconsistent aspect ratios, missing sources, negative insets, and malformed review dates. +### Adding data -### Data conventions +1. Verify the model, year, and native resolution in an official Apple specification or identification page. +2. If the wallpaper configuration already exists, add the model and source to its `compatibleModels`; do not create another option. +3. Create a configuration when cutout, logical canvas, safe area, corner, or system behavior differs. +4. Mark unpublished geometry `derived` or `estimated`; never promote inferred data to `verified`. +5. Update coverage counts and representative tests, then run `npm run lint`, `npm test`, and `npm run build`. -- Physical display dimensions are always pixels. -- Logical viewport dimensions are points. -- Chassis measurements are millimetres; diagonal is inches. -- Rectangles use `{ x, y, width, height }` from the native top-left origin. -- Safe-area insets use `{ top, right, bottom, left }`. -- Hardware resolution and system UI geometry are separate claims. +All pixel geometry uses the profile's native physical coordinate space. `orientDevice` and `orientRect` rotate portrait-native geometry, while preview and export continue to share `renderWallpaper`. diff --git a/docs/LEGAL_PRIVACY_AND_DATA_POLICY.md b/docs/LEGAL_PRIVACY_AND_DATA_POLICY.md index b0ae15e..bcf5112 100644 --- a/docs/LEGAL_PRIVACY_AND_DATA_POLICY.md +++ b/docs/LEGAL_PRIVACY_AND_DATA_POLICY.md @@ -1,7 +1,7 @@ # Wallpect 條款、私隱與資料政策 -生效日期:2026 年 8 月 2 日 -適用版本:0.2.3 +生效日期:2026 年 8 月 10 日 +適用版本:0.3.0 > 本文件記錄 Wallpect 測試版目前的產品行為及維護規則,只屬一般資料,並非法律意見。若加入收費、帳戶、廣告、分析、個人資料表格、使用者內容託管或新司法管轄區,必須先接受獨立法律審閱。 @@ -26,9 +26,9 @@ Wallpect 前端不設定廣告 Cookie、不載入客戶端分析、不建立帳戶、不收集表格資料,也不把使用者選擇的圖片傳送到 Cloudflare。正常瀏覽公開網站仍會產生網絡請求;不應把「圖片不上傳」誤寫成「託管供應商完全不處理任何網絡資料」。 -0.2.3 會在首次讀取時把舊 `wallpect:recent-devices` 值限制為最多四個有效且不重複的裝置 ID,寫入 `wallpect:recent-devices:v1`,然後移除舊 key。遷移不會加入圖片、檔名、構圖或其他資料;若 storage 不可用,編輯器仍可運作而不保存偏好。 +0.3.0 會在首次讀取時把舊 `wallpect:recent-devices` key 及 0.3.0 前按個別型號保存的裝置 ID,映射到新的整合配置 ID、移除重複值,並把最多四個有效 ID 寫入 `wallpect:recent-devices:v1`;舊 key 隨後會被移除。遷移不會加入圖片、檔名、構圖或其他資料;若 storage 不可用,編輯器仍可運作而不保存偏好。 -參考:[香港私隱公署《擬備收集個人資料聲明及私隱政策聲明指引》](https://www.pcpd.org.hk/tc_chi/resources_centre/publications/files/GN_picspps_c.pdf)、[Cloudflare Privacy Policy](https://www.cloudflare.com/policies/privacy/)。 +參考:[Cloudflare Privacy Policy](https://www.cloudflare.com/policies/privacy/)。 ## 3. 裝置資料來源與準確度 @@ -42,7 +42,7 @@ Wallpect 前端不設定廣告 Cookie、不載入客戶端分析、不建立帳 每個設定檔必須通過 schema 驗證:有限及正數尺寸、有效日期、唯一 ID/slug,以及無憑證的 `https://support.apple.com` 來源 URL。來源頁可能變更或停止提供,所以來源連結不構成持續準確的保證。 -Apple 的香港網站條款限制複製、重新發佈、鏡像及自動擷取其內容,因此 Wallpect 只作人工、有限的規格事實整理並連回來源;如使用模式或商業模式改變,應重新取得法律意見或適當授權。參考:[Apple 網站使用條款](https://www.apple.com/hk/legal/internet-services/terms/site.html)。 +Apple 網站條款限制複製、重新發佈、鏡像及自動擷取其內容,因此 Wallpect 只作人工、有限的規格事實整理並連回來源;如使用模式或商業模式改變,應重新取得法律意見或適當授權。參考:[Apple 網站使用條款](https://www.apple.com/legal/internet-services/terms/site.html)。 ## 4. 商標、版權與獨立性 @@ -52,7 +52,7 @@ Apple 的香港網站條款限制複製、重新發佈、鏡像及自動擷取 - 使用者圖片的權利不會因本機處理而轉移給 Wallpect;使用者仍須確保自己有權使用圖片。 - Wallpect 程式碼按 repository 的 [MIT License](../LICENSE) 發布;MIT License 不授予第三方商標、資料或使用者圖片的權利。 -參考:[Apple 使用商標和版權的指引](https://www.apple.com/hk/legal/intellectual-property/guidelinesfor3rdparties.html)。 +參考:[Apple 使用商標和版權的指引](https://www.apple.com/legal/intellectual-property/guidelinesfor3rdparties.html)。 ## 5. 新增第三方來源或 API 的合併門檻 @@ -85,8 +85,8 @@ Apple 的香港網站條款限制複製、重新發佈、鏡像及自動擷取 # Wallpect Terms, Privacy, and Data Policy -Effective date: 2 August 2026 -Applies to version: 0.2.3 +Effective date: 10 August 2026 +Applies to version: 0.3.0 > This document records the current behavior and maintenance rules of the Wallpect beta. It is general information, not legal advice. Independent legal review is required before adding payments, accounts, advertising, analytics, personal-data forms, hosted user content, or new jurisdictions. @@ -103,17 +103,17 @@ The user-facing version is available in the website's “Legal & data” dialog. Wallpect keeps selected image bytes, filenames, Canvas pixels, and transforms in browser memory and does not upload them. It stores only the selected locale in `wallpect:locale:v1` and up to four recent device identifiers in `wallpect:recent-devices:v1`. Public app assets may be cached for reliability, but these caches do not contain user-selected images. -On first read, version 0.2.3 sanitizes a legacy `wallpect:recent-devices` value to at most four valid, unique device IDs, writes the versioned key, and removes the legacy key. The migration adds no image, filename, composition, or other data. If storage is unavailable, the editor continues without persisting the preference. +On first read, version 0.3.0 maps both the legacy `wallpect:recent-devices` key and pre-0.3.0 per-model device IDs to the new consolidated configuration IDs, removes duplicates, writes up to four valid IDs to `wallpect:recent-devices:v1`, and then removes the legacy key. The migration adds no image, filename, composition, or other data. If storage is unavailable, the editor continues without persisting the preference. The site is hosted on Cloudflare. Requests for public files pass through Cloudflare, which may process IP addresses, routing data, system configuration, and request metadata under its privacy policy. The app itself does not load advertising cookies, client-side analytics, account systems, or personal-data forms. -References: [Hong Kong PCPD guidance](https://www.pcpd.org.hk/tc_chi/resources_centre/publications/files/GN_picspps_c.pdf), [Cloudflare Privacy Policy](https://www.cloudflare.com/policies/privacy/). +Reference: [Cloudflare Privacy Policy](https://www.cloudflare.com/policies/privacy/). ## 3. Device data and source rules Profiles are manually curated from linked public `https://support.apple.com` specification and support pages. Wallpect does not call an Apple or third-party device-data API at runtime, scrape or mirror Apple websites, or reproduce Apple articles, product images, logos, interface assets, or marketing material. Derived geometry remains clearly labelled and is never promoted to verified without evidence. -References: [Apple Website Terms of Use](https://www.apple.com/hk/legal/internet-services/terms/site.html), [Apple trademark and copyright guidelines](https://www.apple.com/hk/legal/intellectual-property/guidelinesfor3rdparties.html). +References: [Apple Website Terms of Use](https://www.apple.com/legal/internet-services/terms/site.html), [Apple trademark and copyright guidelines](https://www.apple.com/legal/intellectual-property/guidelinesfor3rdparties.html). ## 4. Third-party and API gate diff --git a/package-lock.json b/package-lock.json index 67ccca6..e7b955a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wallpect", - "version": "0.2.3", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wallpect", - "version": "0.2.3", + "version": "0.3.0", "license": "MIT", "dependencies": { "lucide-react": "1.24.0", diff --git a/package.json b/package.json index fc2aefe..5e1df79 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "wallpect", "private": true, - "version": "0.2.3", + "version": "0.3.0", "description": "Privacy-first wallpaper preview and fitting tool for Apple devices.", "license": "MIT", "homepage": "https://wallpect.k-y.cc", diff --git a/src/components/device-selector/DeviceSelector.tsx b/src/components/device-selector/DeviceSelector.tsx index f3c38dd..6d93602 100644 --- a/src/components/device-selector/DeviceSelector.tsx +++ b/src/components/device-selector/DeviceSelector.tsx @@ -1,5 +1,10 @@ import { Check, Monitor, Search, Smartphone, Tablet, X } from "lucide-react"; import { useId, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + getDeviceDiagonalLabel, + getDeviceReleaseYearLabel, + getDeviceSearchText, +} from "../../data/devices"; import { useEditor } from "../../state/editor-context"; import { useI18n } from "../../i18n/i18n"; import type { DeviceProfile } from "../../types/device"; @@ -20,21 +25,6 @@ function groupForProfile(profile: DeviceProfile): DeviceGroup { return "mac"; } -function searchableDeviceText(profile: DeviceProfile) { - const { display } = profile; - return [ - profile.model, - profile.releaseYear, - display.physicalWidthPx, - display.physicalHeightPx, - display.logicalWidthPt, - display.logicalHeightPt, - profile.chassis.screenDiagonalInches, - ] - .join(" ") - .toLowerCase(); -} - function DeviceGlyph({ group }: { group: DeviceGroup }) { if (group === "iphone") return ; if (group === "ipad") return ; @@ -64,7 +54,7 @@ export function DeviceSelector() { .filter( (item) => belongs(item, group) && - (!normalizedQuery || searchableDeviceText(item).includes(normalizedQuery)), + (!normalizedQuery || getDeviceSearchText(item).includes(normalizedQuery)), ) .sort((a, b) => b.releaseYear - a.releaseYear); }, [group, profiles, query]); @@ -152,46 +142,53 @@ export function DeviceSelector() { ) : null}
- {t("Available models")} + {t("Available configurations")} {t("{count} results", { count: filtered.length })}
    - {filtered.map((item) => ( -
  • - -
  • - ))} + {filtered.map((item) => { + const releaseYears = getDeviceReleaseYearLabel(item); + return ( +
  • + +
  • + ); + })} {!filtered.length ? (
  • {t("No matching devices.")}

    diff --git a/src/components/export/DeviceInformation.tsx b/src/components/export/DeviceInformation.tsx index ba512c1..2e03e1b 100644 --- a/src/components/export/DeviceInformation.tsx +++ b/src/components/export/DeviceInformation.tsx @@ -70,6 +70,20 @@ export function DeviceInformation() {
    {profile.metadata.lastVerified}
    + {profile.compatibleModels.length > 1 ? ( +
    + + {t("Compatible models")} · {profile.compatibleModels.length} + +
      + {profile.compatibleModels.map((item) => ( +
    • + {item.name} · {item.releaseYear} +
    • + ))} +
    +
    + ) : null}
    {t("Accuracy notes")} diff --git a/src/components/info/InfoDialog.tsx b/src/components/info/InfoDialog.tsx index b0226a5..9e78d2c 100644 --- a/src/components/info/InfoDialog.tsx +++ b/src/components/info/InfoDialog.tsx @@ -79,12 +79,26 @@ function PrivacyContent({ t }: { t: Translate }) { ); } -function DeviceContent({ count, t }: { count: number; t: Translate }) { +function DeviceContent({ + profileCount, + modelCount, + t, +}: { + profileCount: number; + modelCount: number; + t: Translate; +}) { return (
    -
    - {count} - {t("Apple-first profiles included")} +
    +
    + {profileCount} + {t("Consolidated preview configurations")} +
    +
    + {modelCount} + {t("Named Apple models covered")} +

    {t("Accuracy levels")}

    @@ -111,7 +125,7 @@ function DeviceContent({ count, t }: { count: number; t: Translate }) { function LegalContent({ t }: { t: Translate }) { return (
    -

    {t("Effective date: 2 August 2026")}

    +

    {t("Effective date: 10 August 2026")}

    {t("Terms of use")}

    @@ -184,26 +198,19 @@ function LegalContent({ t }: { t: Translate }) { {t("Apple technical specifications")} {t("Apple website terms")} {t("Apple trademark guidelines")} - - {t("Hong Kong privacy guidance")} - {t("Cloudflare privacy policy")} @@ -226,6 +233,10 @@ export function InfoDialog({ view, onClose }: { view: InfoView; onClose: () => v const titleId = useId(); const introId = useId(); const { profiles } = useEditor(); + const modelCount = profiles.reduce( + (count, profile) => count + profile.compatibleModels.length, + 0, + ); useEffect(() => { const dialog = ref.current; if (view && dialog && !dialog.open) dialog.showModal(); @@ -267,7 +278,9 @@ export function InfoDialog({ view, onClose }: { view: InfoView; onClose: () => v

    ) : null} {view === "privacy" ? : null} - {view === "devices" ? : null} + {view === "devices" ? ( + + ) : null} {view === "legal" ? : null}

    {t("Wallpect is independent and is not affiliated with or endorsed by Apple Inc.")} diff --git a/src/core/validation.ts b/src/core/validation.ts index a611764..b539f1c 100644 --- a/src/core/validation.ts +++ b/src/core/validation.ts @@ -18,6 +18,22 @@ function isValidReviewDate(value: string) { return !Number.isNaN(date.valueOf()) && date.toISOString().slice(0, 10) === value; } +function isValidReleaseYear(value: number) { + return Number.isInteger(value) && value >= 2007 && value <= 2100; +} + +function wallpaperConfigurationSignature(profile: DeviceProfile) { + return JSON.stringify({ + platform: profile.platform, + supportedOrientations: profile.supportedOrientations, + display: profile.display, + cutout: profile.cutout, + safeArea: profile.safeArea, + overlays: profile.overlays, + wallpaperBehavior: profile.wallpaperBehavior, + }); +} + function isAllowedSourceUrl(value: string) { try { const url = new URL(value); @@ -52,12 +68,25 @@ export function validateDeviceProfile(profile: DeviceProfile): string[] { if (!PROFILE_SLUG.test(profile.slug)) errors.push("slug must use lowercase URL-safe segments"); if (!profile.model.trim()) errors.push("model is required"); if (profile.brand !== "Apple") errors.push("brand must be Apple for this data set"); - if ( - !Number.isInteger(profile.releaseYear) || - profile.releaseYear < 2007 || - profile.releaseYear > 2100 - ) + if (!isValidReleaseYear(profile.releaseYear)) errors.push("releaseYear must be a plausible four-digit year"); + if (!profile.compatibleModels.length) errors.push("at least one compatible model is required"); + const compatibleNames = new Set(); + for (const compatibleModel of profile.compatibleModels) { + const normalizedName = compatibleModel.name.trim().toLocaleLowerCase("en"); + if (!normalizedName) errors.push("compatible model name is required"); + if (compatibleNames.has(normalizedName)) errors.push("compatible model names must be unique"); + compatibleNames.add(normalizedName); + if (!isValidReleaseYear(compatibleModel.releaseYear)) + errors.push("compatible model releaseYear must be a plausible four-digit year"); + if (!isPositiveFinite(compatibleModel.screenDiagonalInches)) + errors.push("compatible model diagonal must be finite and positive"); + } + const latestCompatibleYear = Math.max( + ...profile.compatibleModels.map((item) => item.releaseYear), + ); + if (profile.compatibleModels.length && profile.releaseYear !== latestCompatibleYear) + errors.push("releaseYear must match the latest compatible model year"); if (!profile.supportedOrientations.length) errors.push("at least one supported orientation is required"); if (new Set(profile.supportedOrientations).size !== profile.supportedOrientations.length) @@ -132,12 +161,25 @@ export function validateDeviceProfile(profile: DeviceProfile): string[] { export function validateDeviceProfiles(profiles: DeviceProfile[]): true { const ids = new Set(); const slugs = new Set(); + const compatibleNames = new Set(); + const configurationOwners = new Map(); const failures: string[] = []; for (const profile of profiles) { if (ids.has(profile.id)) failures.push(`${profile.id}: duplicate id`); ids.add(profile.id); if (slugs.has(profile.slug)) failures.push(`${profile.id}: duplicate slug`); slugs.add(profile.slug); + for (const compatibleModel of profile.compatibleModels) { + const normalizedName = compatibleModel.name.trim().toLocaleLowerCase("en"); + if (compatibleNames.has(normalizedName)) + failures.push(`${profile.id}: duplicate compatible model ${compatibleModel.name}`); + compatibleNames.add(normalizedName); + } + const signature = wallpaperConfigurationSignature(profile); + const existingOwner = configurationOwners.get(signature); + if (existingOwner) + failures.push(`${profile.id}: duplicates wallpaper configuration ${existingOwner}`); + else configurationOwners.set(signature, profile.id); const errors = validateDeviceProfile(profile); if (errors.length) failures.push(`${profile.id}: ${errors.join(", ")}`); } diff --git a/src/data/devices/apple/catalog.ts b/src/data/devices/apple/catalog.ts new file mode 100644 index 0000000..eb09757 --- /dev/null +++ b/src/data/devices/apple/catalog.ts @@ -0,0 +1,60 @@ +import type { DeviceProfile, Rect } from "../../../types/device"; + +export const REVIEWED_ON = "2026-08-10"; + +export type ProfileGroup = { + profile: DeviceProfile; + previousIds: string[]; +}; + +export type GroupIdentity = { + id: string; + model: string; + compatibleModels: DeviceProfile["compatibleModels"]; + previousIds?: string[]; + sourceUrls: string[]; +}; + +type ProfileDetails = Omit< + DeviceProfile, + "id" | "model" | "slug" | "releaseYear" | "compatibleModels" | "metadata" +> & { + metadata: Omit; +}; + +export function rect(x: number, y: number, width: number, height: number): Rect { + return { x, y, width, height }; +} + +export function compatibleModel( + name: string, + releaseYear: number, + screenDiagonalInches: number, +): DeviceProfile["compatibleModels"][number] { + return { name, releaseYear, screenDiagonalInches }; +} + +export function createProfileGroup(identity: GroupIdentity, details: ProfileDetails): ProfileGroup { + const canonicalId = `apple-${identity.id}`; + const releaseYear = Math.max(...identity.compatibleModels.map((item) => item.releaseYear)); + const sourceUrls = [...new Set(identity.sourceUrls)]; + const previousIds = [...new Set(identity.previousIds ?? [])] + .map((id) => (id.startsWith("apple-") ? id : `apple-${id}`)) + .filter((id) => id !== canonicalId); + + return { + profile: { + id: canonicalId, + model: identity.model, + slug: identity.id, + releaseYear, + compatibleModels: identity.compatibleModels, + ...details, + metadata: { + ...details.metadata, + sourceUrls, + }, + }, + previousIds, + }; +} diff --git a/src/data/devices/apple/ipad.ts b/src/data/devices/apple/ipad.ts new file mode 100644 index 0000000..8caa683 --- /dev/null +++ b/src/data/devices/apple/ipad.ts @@ -0,0 +1,296 @@ +import { + REVIEWED_ON, + compatibleModel, + createProfileGroup, + rect, + type GroupIdentity, + type ProfileGroup, +} from "./catalog"; + +const IPAD_SOURCE = "https://support.apple.com/108043"; + +type TabletGroupInput = Omit & { + sourceUrls?: string[]; + width: number; + height: number; + logicalWidth: number; + logicalHeight: number; + dpr: number; + bodyWidth: number; + bodyHeight: number; + corner?: number; + classic?: boolean; +}; + +function tablet(input: TabletGroupInput): ProfileGroup { + const representative = input.compatibleModels[0]; + const safeTop = input.classic ? Math.round(20 * input.dpr) : 72; + const safeSide = input.classic ? 0 : 54; + const safeBottom = input.classic ? 0 : 60; + + return createProfileGroup( + { + id: input.id, + model: input.model, + compatibleModels: input.compatibleModels, + previousIds: input.previousIds, + sourceUrls: [IPAD_SOURCE, ...(input.sourceUrls ?? [])], + }, + { + brand: "Apple", + platform: "iPadOS", + category: "ipad", + family: "iPad", + supportedOrientations: ["portrait", "landscape"], + chassis: { + physicalWidthMm: input.bodyWidth, + physicalHeightMm: input.bodyHeight, + screenDiagonalInches: representative.screenDiagonalInches, + bezelStyle: input.classic ? "classic" : "edge-to-edge", + }, + display: { + physicalWidthPx: input.width, + physicalHeightPx: input.height, + logicalWidthPt: input.logicalWidth, + logicalHeightPt: input.logicalHeight, + devicePixelRatio: input.dpr, + aspectRatio: input.width / input.height, + orientation: "portrait", + cornerRadiusPx: input.classic ? 0 : (input.corner ?? 52), + }, + cutout: { type: "none" }, + safeArea: { + insetsPx: { top: safeTop, right: safeSide, bottom: safeBottom, left: safeSide }, + source: "derived", + }, + overlays: { + statusBar: rect(0, 0, input.width, safeTop), + homeIndicator: input.classic + ? undefined + : rect(input.width * 0.41, input.height - 34, input.width * 0.18, 12), + }, + wallpaperBehavior: { + systemMayZoom: true, + systemMayExtend: true, + notes: ["Portrait and landscape compositions are stored independently in this editor."], + }, + metadata: { + lastVerified: REVIEWED_ON, + accuracy: "high", + notes: [ + "Published resolution with derived safe-area geometry.", + "Grouped options share the same wallpaper canvas and obstruction geometry.", + ], + }, + }, + ); +} + +export const IPAD_PROFILE_GROUPS: ProfileGroup[] = [ + tablet({ + id: "ipad-classic", + model: "iPad (1st–2nd gen) / iPad mini (1st gen)", + compatibleModels: [ + compatibleModel("iPad (1st gen)", 2010, 9.7), + compatibleModel("iPad 2", 2011, 9.7), + compatibleModel("iPad mini (1st gen)", 2012, 7.9), + ], + width: 768, + height: 1024, + logicalWidth: 768, + logicalHeight: 1024, + dpr: 1, + bodyWidth: 189.7, + bodyHeight: 242.8, + classic: true, + }), + tablet({ + id: "ipad-retina-classic", + model: "iPad 3–6 / Air 1–2 / mini 2–5 / Pro 9.7-inch", + compatibleModels: [ + compatibleModel("iPad (3rd gen)", 2012, 9.7), + compatibleModel("iPad (4th gen)", 2012, 9.7), + compatibleModel("iPad Air (1st gen)", 2013, 9.7), + compatibleModel("iPad Air 2", 2014, 9.7), + compatibleModel("iPad (5th gen)", 2017, 9.7), + compatibleModel("iPad (6th gen)", 2018, 9.7), + compatibleModel("iPad mini 2", 2013, 7.9), + compatibleModel("iPad mini 3", 2014, 7.9), + compatibleModel("iPad mini 4", 2015, 7.9), + compatibleModel("iPad mini (5th gen)", 2019, 7.9), + compatibleModel("iPad Pro 9.7-inch", 2016, 9.7), + ], + width: 1536, + height: 2048, + logicalWidth: 768, + logicalHeight: 1024, + dpr: 2, + bodyWidth: 169.5, + bodyHeight: 240, + classic: true, + }), + tablet({ + id: "ipad-9", + model: "iPad (7th–9th gen)", + compatibleModels: [ + compatibleModel("iPad (7th gen)", 2019, 10.2), + compatibleModel("iPad (8th gen)", 2020, 10.2), + compatibleModel("iPad (9th gen)", 2021, 10.2), + ], + width: 1620, + height: 2160, + logicalWidth: 810, + logicalHeight: 1080, + dpr: 2, + bodyWidth: 174.1, + bodyHeight: 250.6, + classic: true, + }), + tablet({ + id: "ipad-10-5-classic", + model: "iPad Pro 10.5-inch / iPad Air (3rd gen)", + compatibleModels: [ + compatibleModel("iPad Pro 10.5-inch", 2017, 10.5), + compatibleModel("iPad Air (3rd gen)", 2019, 10.5), + ], + sourceUrls: ["https://support.apple.com/111927"], + width: 1668, + height: 2224, + logicalWidth: 834, + logicalHeight: 1112, + dpr: 2, + bodyWidth: 174.1, + bodyHeight: 250.6, + classic: true, + }), + tablet({ + id: "ipad-mini-6", + model: "iPad mini (6th gen / A17 Pro)", + compatibleModels: [ + compatibleModel("iPad mini (6th gen)", 2021, 8.3), + compatibleModel("iPad mini (A17 Pro)", 2024, 8.3), + ], + previousIds: ["ipad-mini-a17-pro"], + width: 1488, + height: 2266, + logicalWidth: 744, + logicalHeight: 1133, + dpr: 2, + bodyWidth: 134.8, + bodyHeight: 195.4, + corner: 54, + }), + tablet({ + id: "ipad-air-11-m2", + model: "iPad 10 / A16 / Air 4–5 / Air 11-inch (M2–M4)", + compatibleModels: [ + compatibleModel("iPad Air (4th gen)", 2020, 10.9), + compatibleModel("iPad Air (5th gen)", 2022, 10.9), + compatibleModel("iPad (10th gen)", 2022, 10.9), + compatibleModel("iPad Air 11-inch (M2)", 2024, 11), + compatibleModel("iPad (A16)", 2025, 11), + compatibleModel("iPad Air 11-inch (M3)", 2025, 11), + compatibleModel("iPad Air 11-inch (M4)", 2026, 11), + ], + previousIds: ["ipad-air-5", "ipad-10", "ipad-a16", "ipad-air-11-m3", "ipad-air-11-m4"], + sourceUrls: ["https://support.apple.com/126471"], + width: 1640, + height: 2360, + logicalWidth: 820, + logicalHeight: 1180, + dpr: 2, + bodyWidth: 178.5, + bodyHeight: 247.6, + }), + tablet({ + id: "ipad-pro-11-3", + model: "iPad Pro 11-inch (1st–4th gen)", + compatibleModels: [ + compatibleModel("iPad Pro 11-inch (1st gen)", 2018, 11), + compatibleModel("iPad Pro 11-inch (2nd gen)", 2020, 11), + compatibleModel("iPad Pro 11-inch (3rd gen)", 2021, 11), + compatibleModel("iPad Pro 11-inch (4th gen)", 2022, 11), + ], + previousIds: ["ipad-pro-11-4"], + width: 1668, + height: 2388, + logicalWidth: 834, + logicalHeight: 1194, + dpr: 2, + bodyWidth: 178.5, + bodyHeight: 247.6, + }), + tablet({ + id: "ipad-pro-12-9-classic", + model: "iPad Pro 12.9-inch (1st–2nd gen)", + compatibleModels: [ + compatibleModel("iPad Pro 12.9-inch (1st gen)", 2015, 12.9), + compatibleModel("iPad Pro 12.9-inch (2nd gen)", 2017, 12.9), + ], + width: 2048, + height: 2732, + logicalWidth: 1024, + logicalHeight: 1366, + dpr: 2, + bodyWidth: 220.6, + bodyHeight: 305.7, + classic: true, + }), + tablet({ + id: "ipad-pro-12-9-5", + model: "iPad Pro 12.9-inch (3rd–6th gen) / Air 13-inch (M2–M4)", + compatibleModels: [ + compatibleModel("iPad Pro 12.9-inch (3rd gen)", 2018, 12.9), + compatibleModel("iPad Pro 12.9-inch (4th gen)", 2020, 12.9), + compatibleModel("iPad Pro 12.9-inch (5th gen)", 2021, 12.9), + compatibleModel("iPad Pro 12.9-inch (6th gen)", 2022, 12.9), + compatibleModel("iPad Air 13-inch (M2)", 2024, 13), + compatibleModel("iPad Air 13-inch (M3)", 2025, 13), + compatibleModel("iPad Air 13-inch (M4)", 2026, 13), + ], + previousIds: ["ipad-pro-12-9-6", "ipad-air-13-m2", "ipad-air-13-m3", "ipad-air-13-m4"], + width: 2048, + height: 2732, + logicalWidth: 1024, + logicalHeight: 1366, + dpr: 2, + bodyWidth: 214.9, + bodyHeight: 280.6, + }), + tablet({ + id: "ipad-pro-11-m4", + model: "iPad Pro 11-inch (M4 / M5)", + compatibleModels: [ + compatibleModel("iPad Pro 11-inch (M4)", 2024, 11), + compatibleModel("iPad Pro 11-inch (M5)", 2025, 11), + ], + previousIds: ["ipad-pro-11-m5"], + sourceUrls: ["https://support.apple.com/119892", "https://support.apple.com/125406"], + width: 1668, + height: 2420, + logicalWidth: 834, + logicalHeight: 1210, + dpr: 2, + bodyWidth: 177.5, + bodyHeight: 249.7, + corner: 48, + }), + tablet({ + id: "ipad-pro-13-m4", + model: "iPad Pro 13-inch (M4 / M5)", + compatibleModels: [ + compatibleModel("iPad Pro 13-inch (M4)", 2024, 13), + compatibleModel("iPad Pro 13-inch (M5)", 2025, 13), + ], + previousIds: ["ipad-pro-13-m5"], + sourceUrls: ["https://support.apple.com/119891", "https://support.apple.com/125407"], + width: 2064, + height: 2752, + logicalWidth: 1032, + logicalHeight: 1376, + dpr: 2, + bodyWidth: 215.5, + bodyHeight: 281.6, + corner: 48, + }), +]; diff --git a/src/data/devices/apple/iphone.ts b/src/data/devices/apple/iphone.ts new file mode 100644 index 0000000..88d1a61 --- /dev/null +++ b/src/data/devices/apple/iphone.ts @@ -0,0 +1,511 @@ +import type { DeviceProfile } from "../../../types/device"; +import { + REVIEWED_ON, + compatibleModel, + createProfileGroup, + rect, + type GroupIdentity, + type ProfileGroup, +} from "./catalog"; + +const PHONE_SOURCE = "https://support.apple.com/108044"; + +type PhoneGroupInput = Omit & { + sourceUrls?: string[]; + width: number; + height: number; + logicalWidth: number; + logicalHeight: number; + bodyWidth: number; + bodyHeight: number; + corner: number; + cutout: DeviceProfile["cutout"]["type"]; + cutoutBounds?: DeviceProfile["cutout"]["boundsPx"]; + safeTop: number; + safeBottom: number; + dpr?: number; +}; + +function phone(input: PhoneGroupInput): ProfileGroup { + const isClassic = input.cutout === "none"; + const dpr = input.dpr ?? input.width / input.logicalWidth; + const safeSide = isClassic ? 0 : Math.round(16 * dpr); + const representative = input.compatibleModels[0]; + + return createProfileGroup( + { + id: input.id, + model: input.model, + compatibleModels: input.compatibleModels, + previousIds: input.previousIds, + sourceUrls: [PHONE_SOURCE, ...(input.sourceUrls ?? [])], + }, + { + brand: "Apple", + platform: "iOS", + category: "iphone", + family: "iPhone", + supportedOrientations: ["portrait", "landscape"], + chassis: { + physicalWidthMm: input.bodyWidth, + physicalHeightMm: input.bodyHeight, + screenDiagonalInches: representative.screenDiagonalInches, + bezelStyle: isClassic ? "classic" : "edge-to-edge", + }, + display: { + physicalWidthPx: input.width, + physicalHeightPx: input.height, + logicalWidthPt: input.logicalWidth, + logicalHeightPt: input.logicalHeight, + devicePixelRatio: dpr, + aspectRatio: input.width / input.height, + orientation: "portrait", + cornerRadiusPx: input.corner, + }, + cutout: { type: input.cutout, boundsPx: input.cutoutBounds }, + safeArea: { + insetsPx: { + top: input.safeTop, + right: safeSide, + bottom: input.safeBottom, + left: safeSide, + }, + source: "derived", + }, + overlays: { + statusBar: rect(0, 0, input.width, input.safeTop), + lockScreenClock: rect( + input.width * 0.16, + input.height * 0.11, + input.width * 0.68, + input.height * 0.16, + ), + homeIndicator: isClassic + ? undefined + : rect( + input.width * 0.34, + input.height - Math.max(42, input.safeBottom * 0.45), + input.width * 0.32, + Math.max(8, Math.round(5 * dpr)), + ), + bottomActions: isClassic + ? undefined + : [ + rect(input.width * 0.1, input.height * 0.87, input.width * 0.13, input.width * 0.13), + rect(input.width * 0.77, input.height * 0.87, input.width * 0.13, input.width * 0.13), + ], + }, + wallpaperBehavior: { + systemMayZoom: true, + systemMayExtend: !isClassic, + notes: ["System wallpaper placement can vary by iOS version and user settings."], + }, + metadata: { + lastVerified: REVIEWED_ON, + accuracy: "high", + notes: [ + "Display resolution is published; overlay and corner geometry are derived approximations.", + "Grouped options share the same wallpaper canvas and obstruction geometry.", + ], + }, + }, + ); +} + +export const IPHONE_PROFILE_GROUPS: ProfileGroup[] = [ + phone({ + id: "iphone-3-5-classic", + model: "iPhone (1st gen) / 3G / 3GS", + compatibleModels: [ + compatibleModel("iPhone (1st gen)", 2007, 3.5), + compatibleModel("iPhone 3G", 2008, 3.5), + compatibleModel("iPhone 3GS", 2009, 3.5), + ], + width: 320, + height: 480, + logicalWidth: 320, + logicalHeight: 480, + bodyWidth: 61, + bodyHeight: 115, + corner: 0, + cutout: "none", + safeTop: 20, + safeBottom: 0, + dpr: 1, + }), + phone({ + id: "iphone-4-retina", + model: "iPhone 4 / 4s", + compatibleModels: [ + compatibleModel("iPhone 4", 2010, 3.5), + compatibleModel("iPhone 4s", 2011, 3.5), + ], + width: 640, + height: 960, + logicalWidth: 320, + logicalHeight: 480, + bodyWidth: 58.6, + bodyHeight: 115.2, + corner: 0, + cutout: "none", + safeTop: 40, + safeBottom: 0, + dpr: 2, + }), + phone({ + id: "iphone-4-inch-classic", + model: "iPhone 5 / 5c / 5s / SE (1st gen)", + compatibleModels: [ + compatibleModel("iPhone 5", 2012, 4), + compatibleModel("iPhone 5c", 2013, 4), + compatibleModel("iPhone 5s", 2013, 4), + compatibleModel("iPhone SE (1st gen)", 2016, 4), + ], + width: 640, + height: 1136, + logicalWidth: 320, + logicalHeight: 568, + bodyWidth: 58.6, + bodyHeight: 123.8, + corner: 0, + cutout: "none", + safeTop: 40, + safeBottom: 0, + dpr: 2, + }), + phone({ + id: "iphone-4-7-inch-classic", + model: "iPhone 6–8 / SE (2nd–3rd gen)", + compatibleModels: [ + compatibleModel("iPhone 6", 2014, 4.7), + compatibleModel("iPhone 6s", 2015, 4.7), + compatibleModel("iPhone 7", 2016, 4.7), + compatibleModel("iPhone 8", 2017, 4.7), + compatibleModel("iPhone SE (2nd gen)", 2020, 4.7), + compatibleModel("iPhone SE (3rd gen)", 2022, 4.7), + ], + previousIds: ["iphone-se-2", "iphone-se-3"], + sourceUrls: ["https://support.apple.com/111976"], + width: 750, + height: 1334, + logicalWidth: 375, + logicalHeight: 667, + bodyWidth: 67.3, + bodyHeight: 138.4, + corner: 0, + cutout: "none", + safeTop: 40, + safeBottom: 0, + dpr: 2, + }), + phone({ + id: "iphone-5-5-inch-classic", + model: "iPhone 6 Plus / 6s Plus / 7 Plus / 8 Plus", + compatibleModels: [ + compatibleModel("iPhone 6 Plus", 2014, 5.5), + compatibleModel("iPhone 6s Plus", 2015, 5.5), + compatibleModel("iPhone 7 Plus", 2016, 5.5), + compatibleModel("iPhone 8 Plus", 2017, 5.5), + ], + width: 1080, + height: 1920, + logicalWidth: 414, + logicalHeight: 736, + bodyWidth: 78.1, + bodyHeight: 158.2, + corner: 0, + cutout: "none", + safeTop: 60, + safeBottom: 0, + dpr: 3, + }), + phone({ + id: "iphone-x-5-8-inch", + model: "iPhone X / XS / 11 Pro", + compatibleModels: [ + compatibleModel("iPhone X", 2017, 5.8), + compatibleModel("iPhone XS", 2018, 5.8), + compatibleModel("iPhone 11 Pro", 2019, 5.8), + ], + width: 1125, + height: 2436, + logicalWidth: 375, + logicalHeight: 812, + bodyWidth: 70.9, + bodyHeight: 143.6, + corner: 120, + cutout: "notch", + cutoutBounds: rect(246, 0, 633, 90), + safeTop: 132, + safeBottom: 102, + dpr: 3, + }), + phone({ + id: "iphone-xr-6-1-inch", + model: "iPhone XR / 11", + compatibleModels: [ + compatibleModel("iPhone XR", 2018, 6.1), + compatibleModel("iPhone 11", 2019, 6.1), + ], + sourceUrls: ["https://support.apple.com/111865"], + width: 828, + height: 1792, + logicalWidth: 414, + logicalHeight: 896, + bodyWidth: 75.7, + bodyHeight: 150.9, + corner: 88, + cutout: "notch", + cutoutBounds: rect(207, 0, 414, 68), + safeTop: 88, + safeBottom: 68, + dpr: 2, + }), + phone({ + id: "iphone-xs-max-6-5-inch", + model: "iPhone XS Max / 11 Pro Max", + compatibleModels: [ + compatibleModel("iPhone XS Max", 2018, 6.5), + compatibleModel("iPhone 11 Pro Max", 2019, 6.5), + ], + width: 1242, + height: 2688, + logicalWidth: 414, + logicalHeight: 896, + bodyWidth: 77.4, + bodyHeight: 157.5, + corner: 138, + cutout: "notch", + cutoutBounds: rect(300, 0, 642, 90), + safeTop: 132, + safeBottom: 102, + dpr: 3, + }), + phone({ + id: "iphone-12-mini", + model: "iPhone 12 mini", + compatibleModels: [compatibleModel("iPhone 12 mini", 2020, 5.4)], + width: 1080, + height: 2340, + logicalWidth: 360, + logicalHeight: 780, + bodyWidth: 64.2, + bodyHeight: 131.5, + corner: 118, + cutout: "notch", + cutoutBounds: rect(314, 0, 452, 102), + safeTop: 150, + safeBottom: 102, + dpr: 3, + }), + phone({ + id: "iphone-13-mini", + model: "iPhone 13 mini", + compatibleModels: [compatibleModel("iPhone 13 mini", 2021, 5.4)], + width: 1080, + height: 2340, + logicalWidth: 375, + logicalHeight: 812, + bodyWidth: 64.2, + bodyHeight: 131.5, + corner: 118, + cutout: "notch", + cutoutBounds: rect(350, 0, 380, 102), + safeTop: 150, + safeBottom: 102, + dpr: 3, + }), + phone({ + id: "iphone-12-notch", + model: "iPhone 12 / 12 Pro / 16e / 17e", + compatibleModels: [ + compatibleModel("iPhone 12", 2020, 6.1), + compatibleModel("iPhone 12 Pro", 2020, 6.1), + compatibleModel("iPhone 16e", 2025, 6.1), + compatibleModel("iPhone 17e", 2026, 6.1), + ], + previousIds: ["iphone-12", "iphone-16e", "iphone-17e"], + sourceUrls: ["https://support.apple.com/126470"], + width: 1170, + height: 2532, + logicalWidth: 390, + logicalHeight: 844, + bodyWidth: 71.5, + bodyHeight: 146.7, + corner: 132, + cutout: "notch", + cutoutBounds: rect(340, 0, 490, 108), + safeTop: 141, + safeBottom: 102, + dpr: 3, + }), + phone({ + id: "iphone-13-notch", + model: "iPhone 13 / 13 Pro / 14", + compatibleModels: [ + compatibleModel("iPhone 13", 2021, 6.1), + compatibleModel("iPhone 13 Pro", 2021, 6.1), + compatibleModel("iPhone 14", 2022, 6.1), + ], + previousIds: ["iphone-13", "iphone-13-pro", "iphone-14"], + width: 1170, + height: 2532, + logicalWidth: 390, + logicalHeight: 844, + bodyWidth: 71.5, + bodyHeight: 146.7, + corner: 132, + cutout: "notch", + cutoutBounds: rect(380, 0, 410, 108), + safeTop: 141, + safeBottom: 102, + dpr: 3, + }), + phone({ + id: "iphone-12-pro-max", + model: "iPhone 12 Pro Max", + compatibleModels: [compatibleModel("iPhone 12 Pro Max", 2020, 6.7)], + width: 1284, + height: 2778, + logicalWidth: 428, + logicalHeight: 926, + bodyWidth: 78.1, + bodyHeight: 160.8, + corner: 144, + cutout: "notch", + cutoutBounds: rect(374, 0, 536, 118), + safeTop: 141, + safeBottom: 102, + dpr: 3, + }), + phone({ + id: "iphone-13-pro-max-notch", + model: "iPhone 13 Pro Max / 14 Plus", + compatibleModels: [ + compatibleModel("iPhone 13 Pro Max", 2021, 6.7), + compatibleModel("iPhone 14 Plus", 2022, 6.7), + ], + previousIds: ["iphone-13-pro-max", "iphone-14-plus"], + width: 1284, + height: 2778, + logicalWidth: 428, + logicalHeight: 926, + bodyWidth: 78.1, + bodyHeight: 160.8, + corner: 144, + cutout: "notch", + cutoutBounds: rect(417, 0, 450, 116), + safeTop: 141, + safeBottom: 102, + dpr: 3, + }), + phone({ + id: "iphone-15-pro", + model: "iPhone 14 Pro / 15 / 15 Pro / 16", + compatibleModels: [ + compatibleModel("iPhone 14 Pro", 2022, 6.1), + compatibleModel("iPhone 15", 2023, 6.1), + compatibleModel("iPhone 15 Pro", 2023, 6.1), + compatibleModel("iPhone 16", 2024, 6.1), + ], + previousIds: ["iphone-14-pro", "iphone-15", "iphone-16"], + width: 1179, + height: 2556, + logicalWidth: 393, + logicalHeight: 852, + bodyWidth: 70.6, + bodyHeight: 146.6, + corner: 138, + cutout: "dynamic-island", + cutoutBounds: rect(466, 33, 247, 90), + safeTop: 177, + safeBottom: 102, + dpr: 3, + }), + phone({ + id: "iphone-15-pro-max", + model: "iPhone 14 Pro Max / 15 Plus / 15 Pro Max / 16 Plus", + compatibleModels: [ + compatibleModel("iPhone 14 Pro Max", 2022, 6.7), + compatibleModel("iPhone 15 Plus", 2023, 6.7), + compatibleModel("iPhone 15 Pro Max", 2023, 6.7), + compatibleModel("iPhone 16 Plus", 2024, 6.7), + ], + previousIds: ["iphone-14-pro-max", "iphone-15-plus", "iphone-16-plus"], + width: 1290, + height: 2796, + logicalWidth: 430, + logicalHeight: 932, + bodyWidth: 76.7, + bodyHeight: 159.9, + corner: 150, + cutout: "dynamic-island", + cutoutBounds: rect(521, 36, 248, 90), + safeTop: 177, + safeBottom: 102, + dpr: 3, + }), + phone({ + id: "iphone-16-pro", + model: "iPhone 16 Pro / 17 / 17 Pro", + compatibleModels: [ + compatibleModel("iPhone 16 Pro", 2024, 6.3), + compatibleModel("iPhone 17", 2025, 6.3), + compatibleModel("iPhone 17 Pro", 2025, 6.3), + ], + previousIds: ["iphone-17", "iphone-17-pro"], + sourceUrls: ["https://support.apple.com/121031", "https://support.apple.com/125089"], + width: 1206, + height: 2622, + logicalWidth: 402, + logicalHeight: 874, + bodyWidth: 71.5, + bodyHeight: 149.6, + corner: 140, + cutout: "dynamic-island", + cutoutBounds: rect(480, 33, 246, 90), + safeTop: 177, + safeBottom: 102, + dpr: 3, + }), + phone({ + id: "iphone-16-pro-max", + model: "iPhone 16 Pro Max / 17 Pro Max", + compatibleModels: [ + compatibleModel("iPhone 16 Pro Max", 2024, 6.9), + compatibleModel("iPhone 17 Pro Max", 2025, 6.9), + ], + previousIds: ["iphone-17-pro-max"], + sourceUrls: ["https://support.apple.com/121032", "https://support.apple.com/125091"], + width: 1320, + height: 2868, + logicalWidth: 440, + logicalHeight: 956, + bodyWidth: 77.6, + bodyHeight: 163, + corner: 154, + cutout: "dynamic-island", + cutoutBounds: rect(536, 36, 248, 90), + safeTop: 177, + safeBottom: 102, + dpr: 3, + }), + phone({ + id: "iphone-air", + model: "iPhone Air", + compatibleModels: [compatibleModel("iPhone Air", 2025, 6.5)], + sourceUrls: ["https://support.apple.com/125092"], + width: 1260, + height: 2736, + logicalWidth: 420, + logicalHeight: 912, + bodyWidth: 74.7, + bodyHeight: 156.2, + corner: 146, + cutout: "dynamic-island", + cutoutBounds: rect(506, 34, 248, 90), + safeTop: 177, + safeBottom: 102, + dpr: 3, + }), +]; diff --git a/src/data/devices/apple/mac.ts b/src/data/devices/apple/mac.ts new file mode 100644 index 0000000..30e0aa1 --- /dev/null +++ b/src/data/devices/apple/mac.ts @@ -0,0 +1,426 @@ +import { + REVIEWED_ON, + compatibleModel, + createProfileGroup, + rect, + type GroupIdentity, + type ProfileGroup, +} from "./catalog"; + +const MACBOOK_AIR_SOURCE = "https://support.apple.com/102869"; +const MACBOOK_PRO_SOURCE = "https://support.apple.com/108052"; +const IMAC_SOURCE = "https://support.apple.com/108054"; +const DISPLAY_SOURCE = "https://support.apple.com/displays"; + +type MacGroupInput = Omit & { + sourceUrls: string[]; + category: "macbook" | "imac" | "display"; + width: number; + height: number; + dpr?: number; + bodyWidth: number; + bodyHeight: number; + notch?: boolean; +}; + +function mac(input: MacGroupInput): ProfileGroup { + const dpr = input.dpr ?? 2; + const menuHeight = input.notch ? Math.round(input.height * 0.035) : Math.round(28 * dpr); + const dockHeight = Math.round(input.height * 0.09); + const notchWidth = Math.round(input.width * 0.085); + const representative = input.compatibleModels[0]; + + return createProfileGroup( + { + id: input.id, + model: input.model, + compatibleModels: input.compatibleModels, + previousIds: input.previousIds, + sourceUrls: input.sourceUrls, + }, + { + brand: "Apple", + platform: "macOS", + category: input.category, + family: + input.category === "display" ? "Display" : input.category === "imac" ? "iMac" : "MacBook", + supportedOrientations: ["landscape"], + chassis: { + physicalWidthMm: input.bodyWidth, + physicalHeightMm: input.bodyHeight, + screenDiagonalInches: representative.screenDiagonalInches, + bezelStyle: input.category === "macbook" ? "laptop" : "desktop-display", + }, + display: { + physicalWidthPx: input.width, + physicalHeightPx: input.height, + logicalWidthPt: Math.round(input.width / dpr), + logicalHeightPt: Math.round(input.height / dpr), + devicePixelRatio: dpr, + aspectRatio: input.width / input.height, + orientation: "landscape", + cornerRadiusPx: input.notch ? 38 : 0, + }, + cutout: { + type: input.notch ? "notch" : "none", + boundsPx: input.notch + ? rect((input.width - notchWidth) / 2, 0, notchWidth, menuHeight) + : undefined, + }, + safeArea: { + insetsPx: { + top: menuHeight, + right: Math.round(12 * dpr), + bottom: dockHeight, + left: Math.round(12 * dpr), + }, + source: "derived", + }, + overlays: { + menuBar: rect(0, 0, input.width, menuHeight), + dock: rect( + input.width * 0.28, + input.height - dockHeight, + input.width * 0.44, + dockHeight * 0.78, + ), + }, + wallpaperBehavior: { + systemMayZoom: false, + systemMayExtend: false, + notes: [ + "macOS Fill, Fit, Stretch, and Center behavior is approximated by the editor fit modes.", + ], + }, + metadata: { + lastVerified: REVIEWED_ON, + accuracy: "high", + notes: [ + "Published native display resolution with approximate menu bar, Dock, and notch regions.", + "Grouped options share the same wallpaper canvas and obstruction geometry.", + ], + }, + }, + ); +} + +export const MAC_PROFILE_GROUPS: ProfileGroup[] = [ + mac({ + id: "macbook-air-11-inch", + model: "MacBook Air 11-inch (2010–2015)", + compatibleModels: [ + compatibleModel("MacBook Air 11-inch (Late 2010)", 2010, 11.6), + compatibleModel("MacBook Air 11-inch (Mid 2011)", 2011, 11.6), + compatibleModel("MacBook Air 11-inch (Mid 2012)", 2012, 11.6), + compatibleModel("MacBook Air 11-inch (Mid 2013)", 2013, 11.6), + compatibleModel("MacBook Air 11-inch (Early 2014)", 2014, 11.6), + compatibleModel("MacBook Air 11-inch (Early 2015)", 2015, 11.6), + ], + category: "macbook", + width: 1366, + height: 768, + dpr: 1, + bodyWidth: 300, + bodyHeight: 192, + sourceUrls: [MACBOOK_AIR_SOURCE], + }), + mac({ + id: "macbook-air-13-inch-classic", + model: "MacBook Air 13-inch (2010–2017)", + compatibleModels: [ + compatibleModel("MacBook Air 13-inch (Late 2010)", 2010, 13.3), + compatibleModel("MacBook Air 13-inch (Mid 2011)", 2011, 13.3), + compatibleModel("MacBook Air 13-inch (Mid 2012)", 2012, 13.3), + compatibleModel("MacBook Air 13-inch (Mid 2013)", 2013, 13.3), + compatibleModel("MacBook Air 13-inch (Early 2014)", 2014, 13.3), + compatibleModel("MacBook Air 13-inch (Early 2015)", 2015, 13.3), + compatibleModel("MacBook Air 13-inch (2017)", 2017, 13.3), + ], + category: "macbook", + width: 1440, + height: 900, + dpr: 1, + bodyWidth: 325, + bodyHeight: 227, + sourceUrls: [MACBOOK_AIR_SOURCE], + }), + mac({ + id: "macbook-retina-12-inch", + model: "MacBook Retina 12-inch (2015–2017)", + compatibleModels: [ + compatibleModel("MacBook Retina 12-inch (Early 2015)", 2015, 12), + compatibleModel("MacBook Retina 12-inch (Early 2016)", 2016, 12), + compatibleModel("MacBook Retina 12-inch (2017)", 2017, 12), + ], + category: "macbook", + width: 2304, + height: 1440, + bodyWidth: 280.5, + bodyHeight: 196.5, + sourceUrls: ["https://support.apple.com/111986"], + }), + mac({ + id: "macbook-pro-13-m2", + model: "MacBook Air Retina / MacBook Pro Retina 13-inch", + compatibleModels: [ + compatibleModel("MacBook Pro Retina 13-inch (Late 2012)", 2012, 13.3), + compatibleModel("MacBook Pro Retina 13-inch (Early 2013)", 2013, 13.3), + compatibleModel("MacBook Pro Retina 13-inch (Late 2013)", 2013, 13.3), + compatibleModel("MacBook Pro Retina 13-inch (Mid 2014)", 2014, 13.3), + compatibleModel("MacBook Pro Retina 13-inch (Early 2015)", 2015, 13.3), + compatibleModel("MacBook Pro 13-inch (2016)", 2016, 13.3), + compatibleModel("MacBook Pro 13-inch (2017)", 2017, 13.3), + compatibleModel("MacBook Pro 13-inch (2018)", 2018, 13.3), + compatibleModel("MacBook Pro 13-inch (2019)", 2019, 13.3), + compatibleModel("MacBook Pro 13-inch (Intel, 2020)", 2020, 13.3), + compatibleModel("MacBook Pro 13-inch (M1)", 2020, 13.3), + compatibleModel("MacBook Pro 13-inch (M2)", 2022, 13.3), + compatibleModel("MacBook Air Retina 13-inch (2018)", 2018, 13.3), + compatibleModel("MacBook Air Retina 13-inch (2019)", 2019, 13.3), + compatibleModel("MacBook Air Retina 13-inch (Intel, 2020)", 2020, 13.3), + compatibleModel("MacBook Air 13-inch (M1)", 2020, 13.3), + ], + category: "macbook", + width: 2560, + height: 1600, + bodyWidth: 304.1, + bodyHeight: 212.4, + sourceUrls: [MACBOOK_AIR_SOURCE, MACBOOK_PRO_SOURCE], + }), + mac({ + id: "macbook-pro-15-inch-retina", + model: "MacBook Pro Retina 15-inch (2012–2019)", + compatibleModels: [ + compatibleModel("MacBook Pro Retina 15-inch (Mid 2012)", 2012, 15.4), + compatibleModel("MacBook Pro Retina 15-inch (Early 2013)", 2013, 15.4), + compatibleModel("MacBook Pro Retina 15-inch (Late 2013)", 2013, 15.4), + compatibleModel("MacBook Pro Retina 15-inch (Mid 2014)", 2014, 15.4), + compatibleModel("MacBook Pro Retina 15-inch (Mid 2015)", 2015, 15.4), + compatibleModel("MacBook Pro 15-inch (2016)", 2016, 15.4), + compatibleModel("MacBook Pro 15-inch (2017)", 2017, 15.4), + compatibleModel("MacBook Pro 15-inch (2018)", 2018, 15.4), + compatibleModel("MacBook Pro 15-inch (2019)", 2019, 15.4), + ], + category: "macbook", + width: 2880, + height: 1800, + bodyWidth: 349.3, + bodyHeight: 240.7, + sourceUrls: [MACBOOK_PRO_SOURCE], + }), + mac({ + id: "macbook-pro-16-inch-intel", + model: "MacBook Pro 16-inch (Intel, 2019)", + compatibleModels: [compatibleModel("MacBook Pro 16-inch (Intel, 2019)", 2019, 16)], + category: "macbook", + width: 3072, + height: 1920, + bodyWidth: 357.9, + bodyHeight: 245.9, + sourceUrls: [MACBOOK_PRO_SOURCE], + }), + mac({ + id: "macbook-neo", + model: "MacBook Neo", + compatibleModels: [compatibleModel("MacBook Neo", 2026, 13)], + category: "macbook", + width: 2408, + height: 1506, + bodyWidth: 297.5, + bodyHeight: 206.4, + sourceUrls: ["https://support.apple.com/126322"], + }), + mac({ + id: "macbook-air-13-m2", + model: "MacBook Air 13-inch (M2–M5)", + compatibleModels: [ + compatibleModel("MacBook Air 13-inch (M2)", 2022, 13.6), + compatibleModel("MacBook Air 13-inch (M3)", 2024, 13.6), + compatibleModel("MacBook Air 13-inch (M4)", 2025, 13.6), + compatibleModel("MacBook Air 13-inch (M5)", 2026, 13.6), + ], + previousIds: ["macbook-air-13-m3", "macbook-air-13-m4", "macbook-air-13-m5"], + category: "macbook", + width: 2560, + height: 1664, + bodyWidth: 304.1, + bodyHeight: 215, + notch: true, + sourceUrls: [MACBOOK_AIR_SOURCE, "https://support.apple.com/126320"], + }), + mac({ + id: "macbook-air-15-m2", + model: "MacBook Air 15-inch (M2–M5)", + compatibleModels: [ + compatibleModel("MacBook Air 15-inch (M2)", 2023, 15.3), + compatibleModel("MacBook Air 15-inch (M3)", 2024, 15.3), + compatibleModel("MacBook Air 15-inch (M4)", 2025, 15.3), + compatibleModel("MacBook Air 15-inch (M5)", 2026, 15.3), + ], + previousIds: ["macbook-air-15-m3", "macbook-air-15-m4", "macbook-air-15-m5"], + category: "macbook", + width: 2880, + height: 1864, + bodyWidth: 340.4, + bodyHeight: 237.6, + notch: true, + sourceUrls: [MACBOOK_AIR_SOURCE, "https://support.apple.com/126321"], + }), + mac({ + id: "macbook-pro-14-m1-pro-max", + model: "MacBook Pro 14-inch (M1–M5 family)", + compatibleModels: [ + compatibleModel("MacBook Pro 14-inch (M1 Pro/Max)", 2021, 14.2), + compatibleModel("MacBook Pro 14-inch (M2 Pro/Max)", 2023, 14.2), + compatibleModel("MacBook Pro 14-inch (M3)", 2023, 14.2), + compatibleModel("MacBook Pro 14-inch (M3 Pro/Max)", 2023, 14.2), + compatibleModel("MacBook Pro 14-inch (M4)", 2024, 14.2), + compatibleModel("MacBook Pro 14-inch (M4 Pro/Max)", 2024, 14.2), + compatibleModel("MacBook Pro 14-inch (M5)", 2025, 14.2), + compatibleModel("MacBook Pro 14-inch (M5 Pro/Max)", 2026, 14.2), + ], + previousIds: [ + "macbook-pro-14-m2-pro-max", + "macbook-pro-14-m3", + "macbook-pro-14-m3-pro-max", + "macbook-pro-14-m4", + "macbook-pro-14-m4-pro-max", + "macbook-pro-14-m5", + "macbook-pro-14-m5-pro-max", + ], + category: "macbook", + width: 3024, + height: 1964, + bodyWidth: 312.6, + bodyHeight: 221.2, + notch: true, + sourceUrls: [MACBOOK_PRO_SOURCE, "https://support.apple.com/126318"], + }), + mac({ + id: "macbook-pro-16-m1-pro-max", + model: "MacBook Pro 16-inch (M1–M5 family)", + compatibleModels: [ + compatibleModel("MacBook Pro 16-inch (M1 Pro/Max)", 2021, 16.2), + compatibleModel("MacBook Pro 16-inch (M2 Pro/Max)", 2023, 16.2), + compatibleModel("MacBook Pro 16-inch (M3 Pro/Max)", 2023, 16.2), + compatibleModel("MacBook Pro 16-inch (M4 Pro/Max)", 2024, 16.2), + compatibleModel("MacBook Pro 16-inch (M5 Pro/Max)", 2026, 16.2), + ], + previousIds: [ + "macbook-pro-16-m2-pro-max", + "macbook-pro-16-m3-pro-max", + "macbook-pro-16-m4-pro-max", + "macbook-pro-16-m5-pro-max", + ], + category: "macbook", + width: 3456, + height: 2234, + bodyWidth: 355.7, + bodyHeight: 248.1, + notch: true, + sourceUrls: [MACBOOK_PRO_SOURCE, "https://support.apple.com/126319"], + }), + mac({ + id: "imac-21-5-inch", + model: "iMac 21.5-inch (Full HD, 2009–2017)", + compatibleModels: [ + compatibleModel("iMac 21.5-inch (Late 2009)", 2009, 21.5), + compatibleModel("iMac 21.5-inch (Mid 2010)", 2010, 21.5), + compatibleModel("iMac 21.5-inch (Mid 2011)", 2011, 21.5), + compatibleModel("iMac 21.5-inch (Late 2012)", 2012, 21.5), + compatibleModel("iMac 21.5-inch (Late 2013)", 2013, 21.5), + compatibleModel("iMac 21.5-inch (Mid 2014)", 2014, 21.5), + compatibleModel("iMac 21.5-inch (Late 2015)", 2015, 21.5), + compatibleModel("iMac 21.5-inch (2017)", 2017, 21.5), + ], + category: "imac", + width: 1920, + height: 1080, + dpr: 1, + bodyWidth: 528, + bodyHeight: 450, + sourceUrls: [IMAC_SOURCE], + }), + mac({ + id: "imac-21-5-inch-4k", + model: "iMac Retina 4K 21.5-inch (2015–2019)", + compatibleModels: [ + compatibleModel("iMac Retina 4K 21.5-inch (Late 2015)", 2015, 21.5), + compatibleModel("iMac Retina 4K 21.5-inch (2017)", 2017, 21.5), + compatibleModel("iMac Retina 4K 21.5-inch (2019)", 2019, 21.5), + ], + category: "imac", + width: 4096, + height: 2304, + bodyWidth: 528, + bodyHeight: 450, + sourceUrls: [IMAC_SOURCE, "https://support.apple.com/112026"], + }), + mac({ + id: "display-27-inch-qhd", + model: "iMac 27-inch / LED Cinema / Thunderbolt Display (2560 × 1440)", + compatibleModels: [ + compatibleModel("iMac 27-inch (Late 2009)", 2009, 27), + compatibleModel("iMac 27-inch (Mid 2010)", 2010, 27), + compatibleModel("iMac 27-inch (Mid 2011)", 2011, 27), + compatibleModel("iMac 27-inch (Late 2012)", 2012, 27), + compatibleModel("iMac 27-inch (Late 2013)", 2013, 27), + compatibleModel("LED Cinema Display 27-inch", 2010, 27), + compatibleModel("Thunderbolt Display 27-inch", 2011, 27), + ], + category: "display", + width: 2560, + height: 1440, + dpr: 1, + bodyWidth: 650, + bodyHeight: 491, + sourceUrls: [IMAC_SOURCE, DISPLAY_SOURCE], + }), + mac({ + id: "studio-display", + model: "iMac 27-inch 5K / Studio Display / Studio Display XDR", + compatibleModels: [ + compatibleModel("iMac Retina 5K 27-inch (Late 2014)", 2014, 27), + compatibleModel("iMac Retina 5K 27-inch (Mid 2015)", 2015, 27), + compatibleModel("iMac Retina 5K 27-inch (Late 2015)", 2015, 27), + compatibleModel("iMac Retina 5K 27-inch (2017)", 2017, 27), + compatibleModel("iMac Retina 5K 27-inch (2019)", 2019, 27), + compatibleModel("iMac Retina 5K 27-inch (2020)", 2020, 27), + compatibleModel("Studio Display", 2022, 27), + compatibleModel("Studio Display XDR", 2026, 27), + ], + category: "display", + width: 5120, + height: 2880, + bodyWidth: 623, + bodyHeight: 478, + sourceUrls: [IMAC_SOURCE, DISPLAY_SOURCE, "https://support.apple.com/126323"], + }), + mac({ + id: "imac-24-m1", + model: "iMac 24-inch (M1 / M3 / M4)", + compatibleModels: [ + compatibleModel("iMac 24-inch (M1)", 2021, 23.5), + compatibleModel("iMac 24-inch (M3)", 2023, 23.5), + compatibleModel("iMac 24-inch (M4)", 2024, 23.5), + ], + previousIds: ["imac-24-m3", "imac-24-m4"], + category: "imac", + width: 4480, + height: 2520, + bodyWidth: 547, + bodyHeight: 461, + sourceUrls: [IMAC_SOURCE, "https://support.apple.com/121556"], + }), + mac({ + id: "pro-display-xdr", + model: "Pro Display XDR", + compatibleModels: [compatibleModel("Pro Display XDR", 2019, 32)], + category: "display", + width: 6016, + height: 3384, + bodyWidth: 718, + bodyHeight: 412, + sourceUrls: ["https://support.apple.com/111892"], + }), +]; diff --git a/src/data/devices/apple/profiles.ts b/src/data/devices/apple/profiles.ts index 769f7ce..8fb1c95 100644 --- a/src/data/devices/apple/profiles.ts +++ b/src/data/devices/apple/profiles.ts @@ -1,1348 +1,15 @@ -import type { DeviceProfile, Rect } from "../../../types/device"; +import { IPAD_PROFILE_GROUPS } from "./ipad"; +import { IPHONE_PROFILE_GROUPS } from "./iphone"; +import { MAC_PROFILE_GROUPS } from "./mac"; -const VERIFIED = "2026-07-16"; -const PHONE_SOURCE = "https://support.apple.com/en-hk/108044"; -const IPAD_SOURCE = "https://support.apple.com/en-hk/108043"; -const MACBOOK_AIR_SOURCE = "https://support.apple.com/en-hk/102869"; -const MACBOOK_PRO_SOURCE = "https://support.apple.com/en-hk/108052"; -const IMAC_SOURCE = "https://support.apple.com/en-hk/108054"; -const DISPLAY_SOURCE = "https://support.apple.com/displays"; -const MACBOOK_NEO_SOURCE = "https://support.apple.com/en-hk/126322"; +const profileGroups = [...IPHONE_PROFILE_GROUPS, ...IPAD_PROFILE_GROUPS, ...MAC_PROFILE_GROUPS]; -function rect(x: number, y: number, width: number, height: number): Rect { - return { x, y, width, height }; -} +export const APPLE_DEVICE_PROFILES = profileGroups.map((group) => group.profile); -type PhoneInput = { - id: string; - model: string; - year: number; - width: number; - height: number; - logicalWidth: number; - logicalHeight: number; - diagonal: number; - bodyWidth: number; - bodyHeight: number; - corner: number; - cutout: DeviceProfile["cutout"]["type"]; - cutoutBounds?: Rect; - safeTop: number; - safeBottom: number; - dpr?: number; - sourceUrl?: string; -}; - -function phone(input: PhoneInput): DeviceProfile { - const isClassic = input.cutout === "none"; - return { - id: `apple-${input.id}`, - brand: "Apple", - platform: "iOS", - category: "iphone", - family: "iPhone", - model: input.model, - slug: input.id, - releaseYear: input.year, - supportedOrientations: ["portrait", "landscape"], - chassis: { - physicalWidthMm: input.bodyWidth, - physicalHeightMm: input.bodyHeight, - screenDiagonalInches: input.diagonal, - bezelStyle: isClassic ? "classic" : "edge-to-edge", - }, - display: { - physicalWidthPx: input.width, - physicalHeightPx: input.height, - logicalWidthPt: input.logicalWidth, - logicalHeightPt: input.logicalHeight, - devicePixelRatio: input.dpr ?? input.width / input.logicalWidth, - aspectRatio: input.width / input.height, - orientation: "portrait", - cornerRadiusPx: input.corner, - }, - cutout: { type: input.cutout, boundsPx: input.cutoutBounds }, - safeArea: { - insetsPx: { top: input.safeTop, right: 48, bottom: input.safeBottom, left: 48 }, - source: "derived", - }, - overlays: { - statusBar: rect(0, 0, input.width, input.safeTop), - lockScreenClock: rect( - input.width * 0.16, - input.height * 0.11, - input.width * 0.68, - input.height * 0.16, - ), - homeIndicator: rect( - input.width * 0.34, - input.height - Math.max(42, input.safeBottom * 0.45), - input.width * 0.32, - 15, - ), - bottomActions: [ - rect(input.width * 0.1, input.height * 0.87, input.width * 0.13, input.width * 0.13), - rect(input.width * 0.77, input.height * 0.87, input.width * 0.13, input.width * 0.13), - ], - }, - wallpaperBehavior: { - systemMayZoom: true, - systemMayExtend: !isClassic, - notes: ["System wallpaper placement can vary by iOS version and user settings."], - }, - metadata: { - sourceUrls: [input.sourceUrl ?? PHONE_SOURCE], - lastVerified: VERIFIED, - accuracy: "high", - notes: [ - "Display resolution is published; overlay and corner geometry are derived approximations.", - ], - }, - }; -} - -const iphones: DeviceProfile[] = [ - phone({ - id: "iphone-se-2", - model: "iPhone SE (2nd gen)", - year: 2020, - width: 750, - height: 1334, - logicalWidth: 375, - logicalHeight: 667, - diagonal: 4.7, - bodyWidth: 67.3, - bodyHeight: 138.4, - corner: 0, - cutout: "none", - safeTop: 40, - safeBottom: 0, - }), - phone({ - id: "iphone-12-mini", - model: "iPhone 12 mini", - year: 2020, - width: 1080, - height: 2340, - logicalWidth: 360, - logicalHeight: 780, - diagonal: 5.4, - bodyWidth: 64.2, - bodyHeight: 131.5, - corner: 118, - cutout: "notch", - cutoutBounds: rect(314, 0, 452, 102), - safeTop: 150, - safeBottom: 102, - }), - phone({ - id: "iphone-12", - model: "iPhone 12", - year: 2020, - width: 1170, - height: 2532, - logicalWidth: 390, - logicalHeight: 844, - diagonal: 6.1, - bodyWidth: 71.5, - bodyHeight: 146.7, - corner: 132, - cutout: "notch", - cutoutBounds: rect(340, 0, 490, 108), - safeTop: 141, - safeBottom: 102, - }), - phone({ - id: "iphone-12-pro-max", - model: "iPhone 12 Pro Max", - year: 2020, - width: 1284, - height: 2778, - logicalWidth: 428, - logicalHeight: 926, - diagonal: 6.7, - bodyWidth: 78.1, - bodyHeight: 160.8, - corner: 144, - cutout: "notch", - cutoutBounds: rect(374, 0, 536, 118), - safeTop: 141, - safeBottom: 102, - }), - phone({ - id: "iphone-13-mini", - model: "iPhone 13 mini", - year: 2021, - width: 1080, - height: 2340, - logicalWidth: 375, - logicalHeight: 812, - diagonal: 5.4, - bodyWidth: 64.2, - bodyHeight: 131.5, - corner: 118, - cutout: "notch", - cutoutBounds: rect(350, 0, 380, 102), - safeTop: 150, - safeBottom: 102, - dpr: 3, - }), - phone({ - id: "iphone-13", - model: "iPhone 13", - year: 2021, - width: 1170, - height: 2532, - logicalWidth: 390, - logicalHeight: 844, - diagonal: 6.1, - bodyWidth: 71.5, - bodyHeight: 146.7, - corner: 132, - cutout: "notch", - cutoutBounds: rect(380, 0, 410, 108), - safeTop: 141, - safeBottom: 102, - }), - phone({ - id: "iphone-13-pro", - model: "iPhone 13 Pro", - year: 2021, - width: 1170, - height: 2532, - logicalWidth: 390, - logicalHeight: 844, - diagonal: 6.1, - bodyWidth: 71.5, - bodyHeight: 146.7, - corner: 132, - cutout: "notch", - cutoutBounds: rect(380, 0, 410, 108), - safeTop: 141, - safeBottom: 102, - }), - phone({ - id: "iphone-13-pro-max", - model: "iPhone 13 Pro Max", - year: 2021, - width: 1284, - height: 2778, - logicalWidth: 428, - logicalHeight: 926, - diagonal: 6.7, - bodyWidth: 78.1, - bodyHeight: 160.8, - corner: 144, - cutout: "notch", - cutoutBounds: rect(417, 0, 450, 116), - safeTop: 141, - safeBottom: 102, - }), - phone({ - id: "iphone-se-3", - model: "iPhone SE (3rd gen)", - year: 2022, - width: 750, - height: 1334, - logicalWidth: 375, - logicalHeight: 667, - diagonal: 4.7, - bodyWidth: 67.3, - bodyHeight: 138.4, - corner: 0, - cutout: "none", - safeTop: 40, - safeBottom: 0, - sourceUrl: "https://support.apple.com/en-hk/111866", - }), - phone({ - id: "iphone-14", - model: "iPhone 14", - year: 2022, - width: 1170, - height: 2532, - logicalWidth: 390, - logicalHeight: 844, - diagonal: 6.1, - bodyWidth: 71.5, - bodyHeight: 146.7, - corner: 132, - cutout: "notch", - cutoutBounds: rect(380, 0, 410, 108), - safeTop: 141, - safeBottom: 102, - }), - phone({ - id: "iphone-14-plus", - model: "iPhone 14 Plus", - year: 2022, - width: 1284, - height: 2778, - logicalWidth: 428, - logicalHeight: 926, - diagonal: 6.7, - bodyWidth: 78.1, - bodyHeight: 160.8, - corner: 144, - cutout: "notch", - cutoutBounds: rect(417, 0, 450, 116), - safeTop: 141, - safeBottom: 102, - }), - phone({ - id: "iphone-14-pro", - model: "iPhone 14 Pro", - year: 2022, - width: 1179, - height: 2556, - logicalWidth: 393, - logicalHeight: 852, - diagonal: 6.1, - bodyWidth: 71.5, - bodyHeight: 147.5, - corner: 138, - cutout: "dynamic-island", - cutoutBounds: rect(466, 33, 247, 90), - safeTop: 177, - safeBottom: 102, - }), - phone({ - id: "iphone-14-pro-max", - model: "iPhone 14 Pro Max", - year: 2022, - width: 1290, - height: 2796, - logicalWidth: 430, - logicalHeight: 932, - diagonal: 6.7, - bodyWidth: 77.6, - bodyHeight: 160.7, - corner: 150, - cutout: "dynamic-island", - cutoutBounds: rect(521, 36, 248, 90), - safeTop: 177, - safeBottom: 102, - }), - phone({ - id: "iphone-15", - model: "iPhone 15", - year: 2023, - width: 1179, - height: 2556, - logicalWidth: 393, - logicalHeight: 852, - diagonal: 6.1, - bodyWidth: 71.6, - bodyHeight: 147.6, - corner: 138, - cutout: "dynamic-island", - cutoutBounds: rect(466, 33, 247, 90), - safeTop: 177, - safeBottom: 102, - }), - phone({ - id: "iphone-15-pro", - model: "iPhone 15 Pro", - year: 2023, - width: 1179, - height: 2556, - logicalWidth: 393, - logicalHeight: 852, - diagonal: 6.1, - bodyWidth: 70.6, - bodyHeight: 146.6, - corner: 138, - cutout: "dynamic-island", - cutoutBounds: rect(466, 33, 247, 90), - safeTop: 177, - safeBottom: 102, - }), - phone({ - id: "iphone-15-plus", - model: "iPhone 15 Plus", - year: 2023, - width: 1290, - height: 2796, - logicalWidth: 430, - logicalHeight: 932, - diagonal: 6.7, - bodyWidth: 77.8, - bodyHeight: 160.9, - corner: 150, - cutout: "dynamic-island", - cutoutBounds: rect(521, 36, 248, 90), - safeTop: 177, - safeBottom: 102, - }), - phone({ - id: "iphone-15-pro-max", - model: "iPhone 15 Pro Max", - year: 2023, - width: 1290, - height: 2796, - logicalWidth: 430, - logicalHeight: 932, - diagonal: 6.7, - bodyWidth: 76.7, - bodyHeight: 159.9, - corner: 150, - cutout: "dynamic-island", - cutoutBounds: rect(521, 36, 248, 90), - safeTop: 177, - safeBottom: 102, - }), - phone({ - id: "iphone-16", - model: "iPhone 16", - year: 2024, - width: 1179, - height: 2556, - logicalWidth: 393, - logicalHeight: 852, - diagonal: 6.1, - bodyWidth: 71.6, - bodyHeight: 147.6, - corner: 138, - cutout: "dynamic-island", - cutoutBounds: rect(466, 33, 247, 90), - safeTop: 177, - safeBottom: 102, - }), - phone({ - id: "iphone-16-plus", - model: "iPhone 16 Plus", - year: 2024, - width: 1290, - height: 2796, - logicalWidth: 430, - logicalHeight: 932, - diagonal: 6.7, - bodyWidth: 77.8, - bodyHeight: 160.9, - corner: 150, - cutout: "dynamic-island", - cutoutBounds: rect(521, 36, 248, 90), - safeTop: 177, - safeBottom: 102, - }), - phone({ - id: "iphone-16-pro", - model: "iPhone 16 Pro", - year: 2024, - width: 1206, - height: 2622, - logicalWidth: 402, - logicalHeight: 874, - diagonal: 6.3, - bodyWidth: 71.5, - bodyHeight: 149.6, - corner: 140, - cutout: "dynamic-island", - cutoutBounds: rect(480, 33, 246, 90), - safeTop: 177, - safeBottom: 102, - sourceUrl: "https://support.apple.com/en-hk/121031", - }), - phone({ - id: "iphone-16-pro-max", - model: "iPhone 16 Pro Max", - year: 2024, - width: 1320, - height: 2868, - logicalWidth: 440, - logicalHeight: 956, - diagonal: 6.9, - bodyWidth: 77.6, - bodyHeight: 163, - corner: 154, - cutout: "dynamic-island", - cutoutBounds: rect(536, 36, 248, 90), - safeTop: 177, - safeBottom: 102, - sourceUrl: "https://support.apple.com/en-hk/121032", - }), - phone({ - id: "iphone-16e", - model: "iPhone 16e", - year: 2025, - width: 1170, - height: 2532, - logicalWidth: 390, - logicalHeight: 844, - diagonal: 6.1, - bodyWidth: 71.5, - bodyHeight: 146.7, - corner: 132, - cutout: "notch", - cutoutBounds: rect(340, 0, 490, 108), - safeTop: 141, - safeBottom: 102, - }), - phone({ - id: "iphone-17", - model: "iPhone 17", - year: 2025, - width: 1206, - height: 2622, - logicalWidth: 402, - logicalHeight: 874, - diagonal: 6.3, - bodyWidth: 71.5, - bodyHeight: 149.6, - corner: 140, - cutout: "dynamic-island", - cutoutBounds: rect(480, 33, 246, 90), - safeTop: 177, - safeBottom: 102, - sourceUrl: "https://support.apple.com/en-hk/125089", - }), - phone({ - id: "iphone-air", - model: "iPhone Air", - year: 2025, - width: 1260, - height: 2736, - logicalWidth: 420, - logicalHeight: 912, - diagonal: 6.5, - bodyWidth: 74.7, - bodyHeight: 156.2, - corner: 146, - cutout: "dynamic-island", - cutoutBounds: rect(506, 34, 248, 90), - safeTop: 177, - safeBottom: 102, - sourceUrl: "https://support.apple.com/en-hk/125092", - }), - phone({ - id: "iphone-17-pro", - model: "iPhone 17 Pro", - year: 2025, - width: 1206, - height: 2622, - logicalWidth: 402, - logicalHeight: 874, - diagonal: 6.3, - bodyWidth: 71.9, - bodyHeight: 150, - corner: 140, - cutout: "dynamic-island", - cutoutBounds: rect(480, 33, 246, 90), - safeTop: 177, - safeBottom: 102, - sourceUrl: "https://support.apple.com/en-hk/125090", - }), - phone({ - id: "iphone-17-pro-max", - model: "iPhone 17 Pro Max", - year: 2025, - width: 1320, - height: 2868, - logicalWidth: 440, - logicalHeight: 956, - diagonal: 6.9, - bodyWidth: 78, - bodyHeight: 163.4, - corner: 154, - cutout: "dynamic-island", - cutoutBounds: rect(536, 36, 248, 90), - safeTop: 177, - safeBottom: 102, - sourceUrl: "https://support.apple.com/en-hk/125091", - }), - phone({ - id: "iphone-17e", - model: "iPhone 17e", - year: 2026, - width: 1170, - height: 2532, - logicalWidth: 390, - logicalHeight: 844, - diagonal: 6.1, - bodyWidth: 71.5, - bodyHeight: 146.7, - corner: 132, - cutout: "notch", - cutoutBounds: rect(340, 0, 490, 108), - safeTop: 141, - safeBottom: 102, - sourceUrl: "https://support.apple.com/en-hk/126470", - }), -]; - -type TabletInput = { - id: string; - model: string; - year: number; - width: number; - height: number; - logicalWidth: number; - logicalHeight: number; - dpr: number; - diagonal: number; - bodyWidth: number; - bodyHeight: number; - corner?: number; - classic?: boolean; - sourceUrl?: string; -}; - -function tablet(input: TabletInput): DeviceProfile { - return { - id: `apple-${input.id}`, - brand: "Apple", - platform: "iPadOS", - category: "ipad", - family: "iPad", - model: input.model, - slug: input.id, - releaseYear: input.year, - supportedOrientations: ["portrait", "landscape"], - chassis: { - physicalWidthMm: input.bodyWidth, - physicalHeightMm: input.bodyHeight, - screenDiagonalInches: input.diagonal, - bezelStyle: input.classic ? "classic" : "edge-to-edge", - }, - display: { - physicalWidthPx: input.width, - physicalHeightPx: input.height, - logicalWidthPt: input.logicalWidth, - logicalHeightPt: input.logicalHeight, - devicePixelRatio: input.dpr, - aspectRatio: input.width / input.height, - orientation: "portrait", - cornerRadiusPx: input.corner ?? 52, - }, - cutout: { type: "none" }, - safeArea: { insetsPx: { top: 72, right: 54, bottom: 60, left: 54 }, source: "derived" }, - overlays: { - statusBar: rect(0, 0, input.width, 72), - homeIndicator: input.classic - ? undefined - : rect(input.width * 0.41, input.height - 34, input.width * 0.18, 12), - }, - wallpaperBehavior: { - systemMayZoom: true, - systemMayExtend: true, - notes: ["Portrait and landscape compositions are stored independently in this editor."], - }, - metadata: { - sourceUrls: [input.sourceUrl ?? IPAD_SOURCE], - lastVerified: VERIFIED, - accuracy: "high", - notes: ["Published resolution with derived safe-area geometry."], - }, - }; -} - -const ipads: DeviceProfile[] = [ - tablet({ - id: "ipad-9", - model: "iPad (9th gen)", - year: 2021, - width: 1620, - height: 2160, - logicalWidth: 810, - logicalHeight: 1080, - dpr: 2, - diagonal: 10.2, - bodyWidth: 174.1, - bodyHeight: 250.6, - corner: 0, - classic: true, - }), - tablet({ - id: "ipad-mini-6", - model: "iPad mini (6th gen)", - year: 2021, - width: 1488, - height: 2266, - logicalWidth: 744, - logicalHeight: 1133, - dpr: 2, - diagonal: 8.3, - bodyWidth: 134.8, - bodyHeight: 195.4, - corner: 54, - }), - tablet({ - id: "ipad-pro-11-3", - model: "iPad Pro 11-inch (3rd gen)", - year: 2021, - width: 1668, - height: 2388, - logicalWidth: 834, - logicalHeight: 1194, - dpr: 2, - diagonal: 11, - bodyWidth: 178.5, - bodyHeight: 247.6, - }), - tablet({ - id: "ipad-pro-12-9-5", - model: "iPad Pro 12.9-inch (5th gen)", - year: 2021, - width: 2048, - height: 2732, - logicalWidth: 1024, - logicalHeight: 1366, - dpr: 2, - diagonal: 12.9, - bodyWidth: 214.9, - bodyHeight: 280.6, - }), - tablet({ - id: "ipad-10", - model: "iPad (10th gen)", - year: 2022, - width: 1640, - height: 2360, - logicalWidth: 820, - logicalHeight: 1180, - dpr: 2, - diagonal: 10.9, - bodyWidth: 179.5, - bodyHeight: 248.6, - }), - tablet({ - id: "ipad-air-5", - model: "iPad Air (5th gen)", - year: 2022, - width: 1640, - height: 2360, - logicalWidth: 820, - logicalHeight: 1180, - dpr: 2, - diagonal: 10.9, - bodyWidth: 178.5, - bodyHeight: 247.6, - }), - tablet({ - id: "ipad-pro-11-4", - model: "iPad Pro 11-inch (4th gen)", - year: 2022, - width: 1668, - height: 2388, - logicalWidth: 834, - logicalHeight: 1194, - dpr: 2, - diagonal: 11, - bodyWidth: 178.5, - bodyHeight: 247.6, - }), - tablet({ - id: "ipad-pro-12-9-6", - model: "iPad Pro 12.9-inch (6th gen)", - year: 2022, - width: 2048, - height: 2732, - logicalWidth: 1024, - logicalHeight: 1366, - dpr: 2, - diagonal: 12.9, - bodyWidth: 214.9, - bodyHeight: 280.6, - }), - tablet({ - id: "ipad-mini-a17-pro", - model: "iPad mini (A17 Pro)", - year: 2024, - width: 1488, - height: 2266, - logicalWidth: 744, - logicalHeight: 1133, - dpr: 2, - diagonal: 8.3, - bodyWidth: 134.8, - bodyHeight: 195.4, - corner: 54, - }), - tablet({ - id: "ipad-air-11-m2", - model: "iPad Air 11-inch (M2)", - year: 2024, - width: 1640, - height: 2360, - logicalWidth: 820, - logicalHeight: 1180, - dpr: 2, - diagonal: 11, - bodyWidth: 178.5, - bodyHeight: 247.6, - }), - tablet({ - id: "ipad-air-13-m2", - model: "iPad Air 13-inch (M2)", - year: 2024, - width: 2048, - height: 2732, - logicalWidth: 1024, - logicalHeight: 1366, - dpr: 2, - diagonal: 13, - bodyWidth: 214.9, - bodyHeight: 280.6, - }), - tablet({ - id: "ipad-pro-11-m4", - model: "iPad Pro 11-inch (M4)", - year: 2024, - width: 1668, - height: 2420, - logicalWidth: 834, - logicalHeight: 1210, - dpr: 2, - diagonal: 11, - bodyWidth: 177.5, - bodyHeight: 249.7, - corner: 48, - }), - tablet({ - id: "ipad-pro-13-m4", - model: "iPad Pro 13-inch (M4)", - year: 2024, - width: 2064, - height: 2752, - logicalWidth: 1032, - logicalHeight: 1376, - dpr: 2, - diagonal: 13, - bodyWidth: 215.5, - bodyHeight: 281.6, - corner: 48, - }), - tablet({ - id: "ipad-a16", - model: "iPad (A16)", - year: 2025, - width: 1640, - height: 2360, - logicalWidth: 820, - logicalHeight: 1180, - dpr: 2, - diagonal: 11, - bodyWidth: 179.5, - bodyHeight: 248.6, - }), - tablet({ - id: "ipad-air-11-m3", - model: "iPad Air 11-inch (M3)", - year: 2025, - width: 1640, - height: 2360, - logicalWidth: 820, - logicalHeight: 1180, - dpr: 2, - diagonal: 11, - bodyWidth: 178.5, - bodyHeight: 247.6, - }), - tablet({ - id: "ipad-air-13-m3", - model: "iPad Air 13-inch (M3)", - year: 2025, - width: 2048, - height: 2732, - logicalWidth: 1024, - logicalHeight: 1366, - dpr: 2, - diagonal: 13, - bodyWidth: 214.9, - bodyHeight: 280.6, - }), - tablet({ - id: "ipad-pro-11-m5", - model: "iPad Pro 11-inch (M5)", - year: 2025, - width: 1668, - height: 2420, - logicalWidth: 834, - logicalHeight: 1210, - dpr: 2, - diagonal: 11, - bodyWidth: 177.5, - bodyHeight: 249.7, - corner: 48, - sourceUrl: "https://support.apple.com/en-hk/125406", - }), - tablet({ - id: "ipad-pro-13-m5", - model: "iPad Pro 13-inch (M5)", - year: 2025, - width: 2064, - height: 2752, - logicalWidth: 1032, - logicalHeight: 1376, - dpr: 2, - diagonal: 13, - bodyWidth: 215.5, - bodyHeight: 281.6, - corner: 48, - }), - tablet({ - id: "ipad-air-11-m4", - model: "iPad Air 11-inch (M4)", - year: 2026, - width: 1640, - height: 2360, - logicalWidth: 820, - logicalHeight: 1180, - dpr: 2, - diagonal: 11, - bodyWidth: 178.5, - bodyHeight: 247.6, - sourceUrl: "https://support.apple.com/en-hk/126471", - }), - tablet({ - id: "ipad-air-13-m4", - model: "iPad Air 13-inch (M4)", - year: 2026, - width: 2048, - height: 2732, - logicalWidth: 1024, - logicalHeight: 1366, - dpr: 2, - diagonal: 13, - bodyWidth: 214.9, - bodyHeight: 280.6, - }), -]; - -type MacInput = { - id: string; - model: string; - year: number; - category: "macbook" | "imac" | "display"; - width: number; - height: number; - diagonal: number; - bodyWidth: number; - bodyHeight: number; - notch: boolean; - sourceUrl: string; -}; - -function mac(input: MacInput): DeviceProfile { - const menuHeight = Math.round(input.height * 0.035); - const dockHeight = Math.round(input.height * 0.09); - const notchWidth = Math.round(input.width * 0.085); - return { - id: `apple-${input.id}`, - brand: "Apple", - platform: "macOS", - category: input.category, - family: - input.category === "display" ? "Display" : input.category === "imac" ? "iMac" : "MacBook", - model: input.model, - slug: input.id, - releaseYear: input.year, - supportedOrientations: ["landscape"], - chassis: { - physicalWidthMm: input.bodyWidth, - physicalHeightMm: input.bodyHeight, - screenDiagonalInches: input.diagonal, - bezelStyle: input.category === "macbook" ? "laptop" : "desktop-display", - }, - display: { - physicalWidthPx: input.width, - physicalHeightPx: input.height, - logicalWidthPt: Math.round(input.width / 2), - logicalHeightPt: Math.round(input.height / 2), - devicePixelRatio: 2, - aspectRatio: input.width / input.height, - orientation: "landscape", - cornerRadiusPx: input.notch ? 38 : 12, - }, - cutout: { - type: input.notch ? "notch" : "none", - boundsPx: input.notch - ? rect((input.width - notchWidth) / 2, 0, notchWidth, menuHeight) - : undefined, - }, - safeArea: { - insetsPx: { top: menuHeight, right: 24, bottom: dockHeight, left: 24 }, - source: "derived", - }, - overlays: { - menuBar: rect(0, 0, input.width, menuHeight), - dock: rect( - input.width * 0.28, - input.height - dockHeight, - input.width * 0.44, - dockHeight * 0.78, - ), - }, - wallpaperBehavior: { - systemMayZoom: false, - systemMayExtend: false, - notes: [ - "macOS Fill, Fit, Stretch, and Center behavior is approximated by the editor fit modes.", - ], - }, - metadata: { - sourceUrls: [input.sourceUrl], - lastVerified: VERIFIED, - accuracy: "high", - notes: [ - "Published native display resolution with approximate menu bar, Dock, and notch regions.", - ], - }, - }; -} - -const macs: DeviceProfile[] = [ - mac({ - id: "macbook-pro-14-m1-pro-max", - model: "MacBook Pro 14-inch (M1 Pro/Max)", - year: 2021, - category: "macbook", - width: 3024, - height: 1964, - diagonal: 14.2, - bodyWidth: 312.6, - bodyHeight: 221.2, - notch: true, - sourceUrl: MACBOOK_PRO_SOURCE, - }), - mac({ - id: "macbook-pro-16-m1-pro-max", - model: "MacBook Pro 16-inch (M1 Pro/Max)", - year: 2021, - category: "macbook", - width: 3456, - height: 2234, - diagonal: 16.2, - bodyWidth: 355.7, - bodyHeight: 248.1, - notch: true, - sourceUrl: MACBOOK_PRO_SOURCE, - }), - mac({ - id: "imac-24-m1", - model: "iMac 24-inch (M1)", - year: 2021, - category: "imac", - width: 4480, - height: 2520, - diagonal: 23.5, - bodyWidth: 547, - bodyHeight: 461, - notch: false, - sourceUrl: IMAC_SOURCE, - }), - mac({ - id: "macbook-air-13-m2", - model: "MacBook Air 13-inch (M2)", - year: 2022, - category: "macbook", - width: 2560, - height: 1664, - diagonal: 13.6, - bodyWidth: 304.1, - bodyHeight: 215, - notch: true, - sourceUrl: MACBOOK_AIR_SOURCE, - }), - mac({ - id: "macbook-pro-13-m2", - model: "MacBook Pro 13-inch (M2)", - year: 2022, - category: "macbook", - width: 2560, - height: 1600, - diagonal: 13.3, - bodyWidth: 304.1, - bodyHeight: 212.4, - notch: false, - sourceUrl: MACBOOK_PRO_SOURCE, - }), - mac({ - id: "studio-display", - model: "Studio Display", - year: 2022, - category: "display", - width: 5120, - height: 2880, - diagonal: 27, - bodyWidth: 623, - bodyHeight: 478, - notch: false, - sourceUrl: DISPLAY_SOURCE, - }), - mac({ - id: "macbook-air-15-m2", - model: "MacBook Air 15-inch (M2)", - year: 2023, - category: "macbook", - width: 2880, - height: 1864, - diagonal: 15.3, - bodyWidth: 340.4, - bodyHeight: 237.6, - notch: true, - sourceUrl: MACBOOK_AIR_SOURCE, - }), - mac({ - id: "macbook-pro-14-m2-pro-max", - model: "MacBook Pro 14-inch (M2 Pro/Max)", - year: 2023, - category: "macbook", - width: 3024, - height: 1964, - diagonal: 14.2, - bodyWidth: 312.6, - bodyHeight: 221.2, - notch: true, - sourceUrl: MACBOOK_PRO_SOURCE, - }), - mac({ - id: "macbook-pro-16-m2-pro-max", - model: "MacBook Pro 16-inch (M2 Pro/Max)", - year: 2023, - category: "macbook", - width: 3456, - height: 2234, - diagonal: 16.2, - bodyWidth: 355.7, - bodyHeight: 248.1, - notch: true, - sourceUrl: MACBOOK_PRO_SOURCE, - }), - mac({ - id: "macbook-pro-14-m3", - model: "MacBook Pro 14-inch (M3)", - year: 2023, - category: "macbook", - width: 3024, - height: 1964, - diagonal: 14.2, - bodyWidth: 312.6, - bodyHeight: 221.2, - notch: true, - sourceUrl: MACBOOK_PRO_SOURCE, - }), - mac({ - id: "macbook-pro-14-m3-pro-max", - model: "MacBook Pro 14-inch (M3 Pro/Max)", - year: 2023, - category: "macbook", - width: 3024, - height: 1964, - diagonal: 14.2, - bodyWidth: 312.6, - bodyHeight: 221.2, - notch: true, - sourceUrl: MACBOOK_PRO_SOURCE, - }), - mac({ - id: "macbook-pro-16-m3-pro-max", - model: "MacBook Pro 16-inch (M3 Pro/Max)", - year: 2023, - category: "macbook", - width: 3456, - height: 2234, - diagonal: 16.2, - bodyWidth: 355.7, - bodyHeight: 248.1, - notch: true, - sourceUrl: MACBOOK_PRO_SOURCE, - }), - mac({ - id: "imac-24-m3", - model: "iMac 24-inch (M3)", - year: 2023, - category: "imac", - width: 4480, - height: 2520, - diagonal: 23.5, - bodyWidth: 547, - bodyHeight: 461, - notch: false, - sourceUrl: IMAC_SOURCE, - }), - mac({ - id: "macbook-air-13-m3", - model: "MacBook Air 13-inch (M3)", - year: 2024, - category: "macbook", - width: 2560, - height: 1664, - diagonal: 13.6, - bodyWidth: 304.1, - bodyHeight: 215, - notch: true, - sourceUrl: MACBOOK_AIR_SOURCE, - }), - mac({ - id: "macbook-air-15-m3", - model: "MacBook Air 15-inch (M3)", - year: 2024, - category: "macbook", - width: 2880, - height: 1864, - diagonal: 15.3, - bodyWidth: 340.4, - bodyHeight: 237.6, - notch: true, - sourceUrl: MACBOOK_AIR_SOURCE, - }), - mac({ - id: "macbook-pro-14-m4", - model: "MacBook Pro 14-inch (M4)", - year: 2024, - category: "macbook", - width: 3024, - height: 1964, - diagonal: 14.2, - bodyWidth: 312.6, - bodyHeight: 221.2, - notch: true, - sourceUrl: MACBOOK_PRO_SOURCE, - }), - mac({ - id: "macbook-pro-14-m4-pro-max", - model: "MacBook Pro 14-inch (M4 Pro/Max)", - year: 2024, - category: "macbook", - width: 3024, - height: 1964, - diagonal: 14.2, - bodyWidth: 312.6, - bodyHeight: 221.2, - notch: true, - sourceUrl: MACBOOK_PRO_SOURCE, - }), - mac({ - id: "macbook-pro-16-m4-pro-max", - model: "MacBook Pro 16-inch (M4 Pro/Max)", - year: 2024, - category: "macbook", - width: 3456, - height: 2234, - diagonal: 16.2, - bodyWidth: 355.7, - bodyHeight: 248.1, - notch: true, - sourceUrl: MACBOOK_PRO_SOURCE, - }), - mac({ - id: "imac-24-m4", - model: "iMac 24-inch (M4)", - year: 2024, - category: "imac", - width: 4480, - height: 2520, - diagonal: 23.5, - bodyWidth: 547, - bodyHeight: 461, - notch: false, - sourceUrl: IMAC_SOURCE, - }), - mac({ - id: "macbook-air-13-m4", - model: "MacBook Air 13-inch (M4)", - year: 2025, - category: "macbook", - width: 2560, - height: 1664, - diagonal: 13.6, - bodyWidth: 304.1, - bodyHeight: 215, - notch: true, - sourceUrl: MACBOOK_AIR_SOURCE, - }), - mac({ - id: "macbook-air-15-m4", - model: "MacBook Air 15-inch (M4)", - year: 2025, - category: "macbook", - width: 2880, - height: 1864, - diagonal: 15.3, - bodyWidth: 340.4, - bodyHeight: 237.6, - notch: true, - sourceUrl: MACBOOK_AIR_SOURCE, - }), - mac({ - id: "macbook-pro-14-m5", - model: "MacBook Pro 14-inch (M5)", - year: 2025, - category: "macbook", - width: 3024, - height: 1964, - diagonal: 14.2, - bodyWidth: 312.6, - bodyHeight: 221.2, - notch: true, - sourceUrl: MACBOOK_PRO_SOURCE, - }), - mac({ - id: "macbook-neo", - model: "MacBook Neo", - year: 2026, - category: "macbook", - width: 2408, - height: 1506, - diagonal: 13, - bodyWidth: 297.5, - bodyHeight: 206.4, - notch: false, - sourceUrl: MACBOOK_NEO_SOURCE, - }), - mac({ - id: "macbook-air-13-m5", - model: "MacBook Air 13-inch (M5)", - year: 2026, - category: "macbook", - width: 2560, - height: 1664, - diagonal: 13.6, - bodyWidth: 304.1, - bodyHeight: 215, - notch: true, - sourceUrl: MACBOOK_AIR_SOURCE, - }), - mac({ - id: "macbook-air-15-m5", - model: "MacBook Air 15-inch (M5)", - year: 2026, - category: "macbook", - width: 2880, - height: 1864, - diagonal: 15.3, - bodyWidth: 340.4, - bodyHeight: 237.6, - notch: true, - sourceUrl: MACBOOK_AIR_SOURCE, - }), - mac({ - id: "macbook-pro-14-m5-pro-max", - model: "MacBook Pro 14-inch (M5 Pro/Max)", - year: 2026, - category: "macbook", - width: 3024, - height: 1964, - diagonal: 14.2, - bodyWidth: 312.6, - bodyHeight: 221.2, - notch: true, - sourceUrl: MACBOOK_PRO_SOURCE, - }), - mac({ - id: "macbook-pro-16-m5-pro-max", - model: "MacBook Pro 16-inch (M5 Pro/Max)", - year: 2026, - category: "macbook", - width: 3456, - height: 2234, - diagonal: 16.2, - bodyWidth: 355.7, - bodyHeight: 248.1, - notch: true, - sourceUrl: MACBOOK_PRO_SOURCE, - }), -]; - -export const APPLE_DEVICE_PROFILES: DeviceProfile[] = [...iphones, ...ipads, ...macs]; +export const APPLE_DEVICE_PROFILE_ID_ALIASES: Readonly> = Object.freeze( + Object.fromEntries( + profileGroups.flatMap((group) => + group.previousIds.map((previousId) => [previousId, group.profile.id] as const), + ), + ), +); diff --git a/src/data/devices/index.ts b/src/data/devices/index.ts index 2b5d86b..7243dc3 100644 --- a/src/data/devices/index.ts +++ b/src/data/devices/index.ts @@ -1,10 +1,60 @@ -import { APPLE_DEVICE_PROFILES } from "./apple/profiles"; +import { APPLE_DEVICE_PROFILES, APPLE_DEVICE_PROFILE_ID_ALIASES } from "./apple/profiles"; import { validateDeviceProfiles } from "../../core/validation"; +import type { DeviceProfile } from "../../types/device"; validateDeviceProfiles(APPLE_DEVICE_PROFILES); export const deviceProfiles = APPLE_DEVICE_PROFILES; +export const defaultDeviceProfileId = "apple-iphone-15-pro"; + +export function normalizeDeviceProfileId(id: string) { + return APPLE_DEVICE_PROFILE_ID_ALIASES[id] ?? id; +} export function getDeviceProfile(id: string) { - return deviceProfiles.find((profile) => profile.id === id) ?? deviceProfiles[0]; + const normalizedId = normalizeDeviceProfileId(id); + return ( + deviceProfiles.find((profile) => profile.id === normalizedId) ?? + deviceProfiles.find((profile) => profile.id === defaultDeviceProfileId) ?? + deviceProfiles[0] + ); +} + +export function getKnownDeviceProfileIds() { + return new Set([ + ...deviceProfiles.map((profile) => profile.id), + ...Object.keys(APPLE_DEVICE_PROFILE_ID_ALIASES), + ]); +} + +export function getDeviceReleaseYearLabel(profile: DeviceProfile) { + const years = profile.compatibleModels.map((item) => item.releaseYear); + const earliest = Math.min(...years); + const latest = Math.max(...years); + return earliest === latest ? String(latest) : `${earliest}–${latest}`; +} + +export function getDeviceDiagonalLabel(profile: DeviceProfile) { + const diagonals = [ + ...new Set(profile.compatibleModels.map((item) => item.screenDiagonalInches)), + ].sort((a, b) => a - b); + return diagonals.map((value) => `${value}″`).join(" / "); +} + +export function getDeviceSearchText(profile: DeviceProfile) { + const { display } = profile; + return [ + profile.model, + ...profile.compatibleModels.flatMap((item) => [ + item.name, + item.releaseYear, + item.screenDiagonalInches, + ]), + display.physicalWidthPx, + display.physicalHeightPx, + display.logicalWidthPt, + display.logicalHeightPt, + ] + .join(" ") + .toLocaleLowerCase("en"); } diff --git a/src/i18n/i18n.tsx b/src/i18n/i18n.tsx index d30fdef..028e768 100644 --- a/src/i18n/i18n.tsx +++ b/src/i18n/i18n.tsx @@ -70,7 +70,9 @@ const zhHant: Record = { "Current device": "目前裝置", Show: "顯示", "Available models": "可選型號", + "Available configurations": "可選畫布配置", "{count} results": "{count} 個結果", + "{count} models": "{count} 個型號", Recent: "最近使用", "{group} models": "{group} 型號", "{model}, {year}, {width} by {height}": "{model},{year},{width} × {height}", @@ -140,6 +142,7 @@ const zhHant: Record = { "Choose an image": "選擇圖片", "Device information": "裝置資訊", Model: "型號", + "Compatible models": "相容型號", "Physical resolution": "實體解析度", "Logical viewport": "邏輯視窗", "Pixel density": "像素密度", @@ -166,6 +169,8 @@ const zhHant: Record = { "解析度取自公開規格;安全區域幾何為推算值。", "Published native display resolution with approximate menu bar, Dock, and notch regions.": "原生解析度取自公開規格;選單列、Dock 與瀏海區域為概略值。", + "Grouped options share the same wallpaper canvas and obstruction geometry.": + "合併選項只包含桌布畫布及遮擋幾何相同的型號。", "Export format": "匯出格式", Quality: "品質", Filename: "檔名", @@ -213,7 +218,7 @@ const zhHant: Record = { "Legal, privacy, and data use.": "條款、私隱與資料使用。", "Plain-language terms, limitations, and source rules for using Wallpect.": "以清晰文字說明 Wallpect 的使用條款、限制及資料來源規則。", - "Effective date: 2 August 2026": "生效日期:2026 年 8 月 2 日", + "Effective date: 10 August 2026": "生效日期:2026 年 8 月 10 日", "Terms of use": "使用條款", "By using Wallpect, you agree to use it lawfully and not to disrupt, bypass security controls, overload, or misuse the service. If you do not agree, do not use the service.": "使用 Wallpect 即表示你同意合法使用本服務,不會干擾、規避安全控制、令服務過載或以其他方式濫用服務。如你不同意,請勿使用本服務。", @@ -242,12 +247,13 @@ const zhHant: Record = { "Official reference links": "官方參考連結", "Apple website terms": "Apple 網站使用條款", "Apple trademark guidelines": "Apple 商標使用指引", - "Hong Kong privacy guidance": "香港私隱指引", "Cloudflare privacy policy": "Cloudflare 私隱政策", "Report a problem on GitHub": "在 GitHub 回報問題", "This notice explains the current beta implementation and is general information, not legal advice.": "本聲明用於說明目前測試版的實作,只屬一般資料,並非法律意見。", "Apple-first profiles included": "個以 Apple 為主的裝置設定檔", + "Consolidated preview configurations": "個合併預覽配置", + "Named Apple models covered": "個已涵蓋 Apple 型號", "Accuracy levels": "精確度等級", "Cross-checked with official specifications and measured data.": "已與官方規格及量測資料交叉核對。", diff --git a/src/state/editor-context.tsx b/src/state/editor-context.tsx index 26a3e77..3d9d521 100644 --- a/src/state/editor-context.tsx +++ b/src/state/editor-context.tsx @@ -9,7 +9,13 @@ import { type ReactNode, type SetStateAction, } from "react"; -import { deviceProfiles, getDeviceProfile } from "../data/devices"; +import { + defaultDeviceProfileId, + deviceProfiles, + getDeviceProfile, + getKnownDeviceProfileIds, + normalizeDeviceProfileId, +} from "../data/devices"; import { generateFilename } from "../core/export"; import { decodeBundledImage } from "../core/image-decoder"; import { loadRecentDeviceIds, saveRecentDeviceIds } from "../core/preferences"; @@ -64,6 +70,15 @@ type EditorContextValue = { const EditorContext = createContext(null); const validDeviceIds = new Set(deviceProfiles.map((profile) => profile.id)); +const knownDeviceIds = getKnownDeviceProfileIds(); + +function loadCanonicalRecentDeviceIds() { + const loaded = loadRecentDeviceIds(localStorage, knownDeviceIds); + const canonical = loaded + .map(normalizeDeviceProfileId) + .filter((id, index, values) => validDeviceIds.has(id) && values.indexOf(id) === index); + return saveRecentDeviceIds(localStorage, canonical, validDeviceIds); +} function transformKey(deviceId: string, orientation: Orientation) { return `${deviceId}:${orientation}`; @@ -71,7 +86,7 @@ function transformKey(deviceId: string, orientation: Orientation) { export function EditorProvider({ children }: { children: ReactNode }) { const { t } = useI18n(); - const [deviceId, setDeviceIdState] = useState("apple-iphone-15-pro"); + const [deviceId, setDeviceIdState] = useState(defaultDeviceProfileId); const profile = useMemo(() => getDeviceProfile(deviceId), [deviceId]); const [orientation, setOrientationState] = useState(profile.display.orientation); const [image, setImage] = useState(null); @@ -83,9 +98,7 @@ export function EditorProvider({ children }: { children: ReactNode }) { const [overlays, setOverlays] = useState(DEFAULT_OVERLAYS); const [mobileTab, setMobileTab] = useState("device"); const [announcement, setAnnouncement] = useState(""); - const [recentDeviceIds, setRecentDeviceIds] = useState(() => - loadRecentDeviceIds(localStorage, validDeviceIds), - ); + const [recentDeviceIds, setRecentDeviceIds] = useState(loadCanonicalRecentDeviceIds); const orientedDevice = useMemo(() => orientDevice(profile, orientation), [orientation, profile]); const key = transformKey(deviceId, orientation); const transform = transforms[key] ?? DEFAULT_TRANSFORM; diff --git a/src/styles/index.css b/src/styles/index.css index ce5831d..f29bfdf 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -564,7 +564,7 @@ summary:focus-visible, align-items: center; gap: 9px; width: 100%; - min-height: 64px; + min-height: 70px; padding: 9px 10px; border: 1px solid transparent; border-radius: 8px; @@ -599,10 +599,13 @@ summary:focus-visible, gap: 2px; } .device-option strong { + display: -webkit-box; overflow: hidden; font-size: 13px; + line-height: 1.25; text-overflow: ellipsis; - white-space: nowrap; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; } .device-option small { color: var(--muted); @@ -1656,14 +1659,37 @@ input:checked + .switch::after { .dialog-body a svg { width: 14px; } +.data-counts { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} .data-count { display: flex; - align-items: baseline; - gap: 10px; + min-width: 0; + flex-direction: column; + gap: 2px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--canvas); } .data-count strong { color: var(--blue); font-size: 34px; + line-height: 1; +} +.data-count span { + line-height: 1.35; +} +.compatible-models ul { + max-height: 180px; + margin: 10px 0 0; + padding-left: 20px; + overflow: auto; +} +.compatible-models li + li { + margin-top: 4px; } .accuracy-list { display: grid; diff --git a/src/types/device.ts b/src/types/device.ts index 87086be..4c986bf 100644 --- a/src/types/device.ts +++ b/src/types/device.ts @@ -16,6 +16,12 @@ export interface Rect { height: number; } +export interface CompatibleDeviceModel { + name: string; + releaseYear: number; + screenDiagonalInches: number; +} + export interface DeviceProfile { id: string; brand: "Apple"; @@ -25,6 +31,7 @@ export interface DeviceProfile { model: string; slug: string; releaseYear: number; + compatibleModels: CompatibleDeviceModel[]; supportedOrientations: Orientation[]; chassis: { physicalWidthMm: number; diff --git a/tests/e2e/wallpect.spec.ts b/tests/e2e/wallpect.spec.ts index 27a5295..8b06dbf 100644 --- a/tests/e2e/wallpect.spec.ts +++ b/tests/e2e/wallpect.spec.ts @@ -64,7 +64,9 @@ test("@desktop local upload stays private and all export formats keep exact dime .locator(".left-rail") .getByPlaceholder("搜尋型號、年份或解析度…") .fill("iPhone 14 Pro"); - await page.getByRole("button", { name: /iPhone 14 Pro,2022/ }).click(); + await page + .getByRole("button", { name: /^iPhone 14 Pro \/ 15 \/ 15 Pro \/ 16,2022–2024/ }) + .click(); await page.getByRole("button", { name: "完整顯示" }).first().click(); await page.getByRole("slider", { name: "縮放", exact: true }).fill("118"); const safeArea = page.getByRole("checkbox", { @@ -212,7 +214,7 @@ test("@desktop exposes accurate privacy, terms, disclaimer, and source notices", await expect(legalDialog.getByRole("heading", { name: "條款、私隱與資料使用。" })).toBeVisible(); await expect(legalDialog.getByRole("heading", { name: "使用條款" })).toBeVisible(); await expect(legalDialog.getByRole("heading", { name: "資料來源與方法" })).toBeVisible(); - await expect(legalDialog).toContainText("生效日期:2026 年 8 月 2 日"); + await expect(legalDialog).toContainText("生效日期:2026 年 8 月 10 日"); await expect(legalDialog).toContainText("不使用 Apple 或第三方裝置資料 API"); await expect(legalDialog).toContainText("並非法律意見"); await expect(legalDialog.getByRole("link", { name: /Apple 網站使用條款/ })).toHaveAttribute( diff --git a/tests/integration/editor-controls.test.tsx b/tests/integration/editor-controls.test.tsx index ac7adb7..88da7a4 100644 --- a/tests/integration/editor-controls.test.tsx +++ b/tests/integration/editor-controls.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import { AdjustmentControls } from "../../src/components/editor-controls/AdjustmentControls"; import { DeviceSelector } from "../../src/components/device-selector/DeviceSelector"; import { OverlayPanel } from "../../src/components/overlays/OverlayPanel"; @@ -17,11 +17,15 @@ function renderWithEditor(node: React.ReactNode) { } describe("editor controls", () => { + beforeEach(() => localStorage.clear()); + it("searches and selects a device profile", async () => { const user = userEvent.setup(); renderWithEditor(); await user.type(screen.getByPlaceholderText("搜尋型號、年份或解析度…"), "14 Pro"); - const option = screen.getByRole("button", { name: /iPhone 14 Pro,2022/ }); + const option = screen.getByRole("button", { + name: /^iPhone 14 Pro \/ 15 \/ 15 Pro \/ 16,2022–2024/, + }); await user.click(option); expect(option).toHaveAttribute("aria-pressed", "true"); }); @@ -32,28 +36,45 @@ describe("editor controls", () => { const search = screen.getByPlaceholderText("搜尋型號、年份或解析度…"); await user.type(search, "1290"); - expect(screen.getByText("4 個結果")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /iPhone 15 Pro Max,2023/ })).toBeInTheDocument(); + expect(screen.getByText("1 個結果")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /iPhone 14 Pro Max.*2022–2024/ }), + ).toBeInTheDocument(); await user.type(search, "{Escape}"); expect(search).toHaveValue(""); - expect(screen.getByText("27 個結果")).toBeInTheDocument(); + expect(screen.getByText("19 個結果")).toBeInTheDocument(); }); it("shows the selected device again after browsing another category", async () => { const user = userEvent.setup(); renderWithEditor(); - await user.click(screen.getByRole("button", { name: "iPad · 20" })); - expect(screen.getByText("20 個結果")).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: /目前裝置.*iPhone 15 Pro/ })); + await user.click(screen.getByRole("button", { name: "iPad · 11" })); + expect(screen.getByText("11 個結果")).toBeInTheDocument(); + await user.click( + screen.getByRole("button", { name: /目前裝置.*iPhone 14 Pro \/ 15 \/ 15 Pro \/ 16/ }), + ); - expect(screen.getByRole("button", { name: "iPhone · 27" })).toHaveAttribute( + expect(screen.getByRole("button", { name: "iPhone · 19" })).toHaveAttribute( "aria-pressed", "true", ); }); + it("migrates recent-device aliases to one consolidated profile ID", () => { + localStorage.setItem( + "wallpect:recent-devices:v1", + JSON.stringify(["apple-iphone-14-pro", "apple-iphone-15-pro"]), + ); + + renderWithEditor(); + + expect(JSON.parse(localStorage.getItem("wallpect:recent-devices:v1") ?? "null")).toEqual([ + "apple-iphone-15-pro", + ]); + }); + it("renders MacBooks as laptops instead of desktop displays", async () => { const user = userEvent.setup(); const { container } = renderWithEditor( @@ -63,7 +84,7 @@ describe("editor controls", () => { , ); - await user.click(screen.getByRole("button", { name: "Mac · 27" })); + await user.click(screen.getByRole("button", { name: "Mac · 17" })); await user.click(screen.getByRole("button", { name: /MacBook Neo,2026/ })); expect(container.querySelector(".laptop-base")).toBeInTheDocument(); diff --git a/tests/unit/profiles.test.ts b/tests/unit/profiles.test.ts index 519ab90..39c3fd0 100644 --- a/tests/unit/profiles.test.ts +++ b/tests/unit/profiles.test.ts @@ -1,50 +1,61 @@ import { describe, expect, it } from "vitest"; -import { deviceProfiles } from "../../src/data/devices"; +import { deviceProfiles, getDeviceProfile, normalizeDeviceProfileId } from "../../src/data/devices"; import { validateDeviceProfile, validateDeviceProfiles } from "../../src/core/validation"; +function profileForModel(name: string) { + return deviceProfiles.find((profile) => + profile.compatibleModels.some((item) => item.name === name), + ); +} + describe("Apple device profiles", () => { - it("covers every requested Apple display model from the target years", () => { - expect(deviceProfiles.filter((profile) => profile.category === "iphone")).toHaveLength(27); - expect(deviceProfiles.filter((profile) => profile.category === "ipad")).toHaveLength(20); + it("covers common Apple display families through consolidated wallpaper configurations", () => { + expect(deviceProfiles.filter((profile) => profile.category === "iphone")).toHaveLength(19); + expect(deviceProfiles.filter((profile) => profile.category === "ipad")).toHaveLength(11); expect( deviceProfiles.filter((profile) => ["macbook", "imac", "display"].includes(profile.category)), - ).toHaveLength(27); + ).toHaveLength(17); + expect( + deviceProfiles.reduce((count, profile) => count + profile.compatibleModels.length, 0), + ).toBe(191); - for (const id of [ - "apple-iphone-13-pro-max", - "apple-iphone-se-3", - "apple-iphone-16-pro-max", - "apple-iphone-air", - "apple-iphone-17e", - "apple-ipad-9", - "apple-ipad-pro-12-9-6", - "apple-ipad-mini-a17-pro", - "apple-ipad-pro-13-m5", - "apple-ipad-air-13-m4", - "apple-macbook-pro-16-m1-pro-max", - "apple-macbook-pro-13-m2", - "apple-macbook-neo", - "apple-macbook-air-15-m5", - "apple-macbook-pro-16-m5-pro-max", + for (const model of [ + "iPhone (1st gen)", + "iPhone 8", + "iPhone 17e", + "iPad mini (5th gen)", + "iPad Pro 13-inch (M5)", + "MacBook Air 13-inch (M1)", + "MacBook Neo", + "iMac Retina 5K 27-inch (2020)", + "Studio Display", + "Studio Display XDR", + "Pro Display XDR", ]) { - expect( - deviceProfiles.some((profile) => profile.id === id), - id, - ).toBe(true); + expect(profileForModel(model), model).toBeDefined(); } }); - it("keeps same-size models distinct when their display configuration differs", () => { - const iphone14 = deviceProfiles.find((profile) => profile.id === "apple-iphone-14"); - const iphone14Pro = deviceProfiles.find((profile) => profile.id === "apple-iphone-14-pro"); - const iphone15 = deviceProfiles.find((profile) => profile.id === "apple-iphone-15"); - const iphone15Pro = deviceProfiles.find((profile) => profile.id === "apple-iphone-15-pro"); + it("combines identical wallpaper canvases but keeps obstruction geometry distinct", () => { + const iphone14Pro = profileForModel("iPhone 14 Pro"); + const iphone15Pro = profileForModel("iPhone 15 Pro"); + const iphone12 = profileForModel("iPhone 12"); + const iphone13 = profileForModel("iPhone 13"); - expect(iphone14?.chassis.screenDiagonalInches).toBe(iphone14Pro?.chassis.screenDiagonalInches); - expect(iphone14?.display.physicalHeightPx).not.toBe(iphone14Pro?.display.physicalHeightPx); - expect(iphone14?.cutout.type).toBe("notch"); - expect(iphone14Pro?.cutout.type).toBe("dynamic-island"); - expect(iphone15?.chassis.physicalWidthMm).not.toBe(iphone15Pro?.chassis.physicalWidthMm); + expect(iphone14Pro?.id).toBe(iphone15Pro?.id); + expect(iphone12?.display.physicalWidthPx).toBe(iphone13?.display.physicalWidthPx); + expect(iphone12?.display.physicalHeightPx).toBe(iphone13?.display.physicalHeightPx); + expect(iphone12?.id).not.toBe(iphone13?.id); + expect(iphone12?.cutout.boundsPx?.width).not.toBe(iphone13?.cutout.boundsPx?.width); + }); + + it("maps previously stored profile IDs to their consolidated configuration", () => { + expect(normalizeDeviceProfileId("apple-iphone-14-pro")).toBe("apple-iphone-15-pro"); + expect(getDeviceProfile("apple-iphone-14-pro").id).toBe("apple-iphone-15-pro"); + expect(getDeviceProfile("apple-ipad-air-13-m4").id).toBe("apple-ipad-pro-12-9-5"); + expect(getDeviceProfile("apple-macbook-pro-16-m5-pro-max").id).toBe( + "apple-macbook-pro-16-m1-pro-max", + ); }); it("validates the complete data set", () => { @@ -54,12 +65,16 @@ describe("Apple device profiles", () => { ); }); - it("keeps all device ids and slugs unique", () => { + it("keeps all profile and compatible-model identifiers unique", () => { expect(new Set(deviceProfiles.map((profile) => profile.id)).size).toBe(deviceProfiles.length); expect(new Set(deviceProfiles.map((profile) => profile.slug)).size).toBe(deviceProfiles.length); + const modelNames = deviceProfiles.flatMap((profile) => + profile.compatibleModels.map((item) => item.name.toLocaleLowerCase("en")), + ); + expect(new Set(modelNames).size).toBe(modelNames.length); }); - it("rejects unsafe or malformed source and geometry data", () => { + it("rejects unsafe or malformed source, model, and geometry data", () => { const unsafeSource = structuredClone(deviceProfiles[0]); unsafeSource.metadata.sourceUrls = ["javascript:alert(1)"]; expect(validateDeviceProfile(unsafeSource)).toContain( @@ -78,6 +93,12 @@ describe("Apple device profiles", () => { "lastVerified must be a real YYYY-MM-DD date", ); + const missingModels = structuredClone(deviceProfiles[0]); + missingModels.compatibleModels = []; + expect(validateDeviceProfile(missingModels)).toContain( + "at least one compatible model is required", + ); + const outOfBoundsOverlay = structuredClone(deviceProfiles[0]); outOfBoundsOverlay.overlays.statusBar = { x: 0, @@ -90,10 +111,25 @@ describe("Apple device profiles", () => { ); }); - it("rejects duplicate slugs independently of ids", () => { + it("rejects duplicate slugs and unconsolidated wallpaper configurations", () => { const first = structuredClone(deviceProfiles[0]); - const second = structuredClone(deviceProfiles[1]); - second.slug = first.slug; - expect(() => validateDeviceProfiles([first, second])).toThrow("duplicate slug"); + const duplicateSlug = structuredClone(deviceProfiles[1]); + duplicateSlug.slug = first.slug; + expect(() => validateDeviceProfiles([first, duplicateSlug])).toThrow("duplicate slug"); + + const duplicateConfiguration = structuredClone(first); + duplicateConfiguration.id = "apple-duplicate-configuration"; + duplicateConfiguration.slug = "duplicate-configuration"; + duplicateConfiguration.model = "Duplicate configuration"; + duplicateConfiguration.compatibleModels = [ + { + name: "Duplicate configuration model", + releaseYear: duplicateConfiguration.releaseYear, + screenDiagonalInches: duplicateConfiguration.chassis.screenDiagonalInches, + }, + ]; + expect(() => validateDeviceProfiles([first, duplicateConfiguration])).toThrow( + "duplicates wallpaper configuration", + ); }); });