diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..803ffe3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +All notable changes to Wallpect are recorded in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 0.1.0 - 2026-07-16 + +### Added + +- Privacy-first browser-only wallpaper composition and exact-resolution PNG, JPEG, and WebP export. +- 74 Apple display profiles: 27 iPhone, 20 iPad, and 27 Mac/display profiles. +- Complete requested coverage for iPhone 13 and later, iPad models introduced in 2021 or later, and Macs with built-in displays introduced in 2021 or later, current through July 2026. +- Traditional Chinese, Simplified Chinese, and English interfaces. +- Desktop and mobile editor layouts, device search, recent devices, orientation controls, safe-area guides, and system-overlay previews. +- Automated unit, integration, browser, privacy, and exact-output checks. +- MIT license, contribution guide, bilingual project documentation, and Cloudflare Pages deployment configuration. + +### Changed + +- Device previews now use profile-specific screen corner geometry and distinguish MacBook laptop frames from desktop Mac/display frames. +- The device picker now exposes model year, diagonal size, physical resolution, accuracy level, category counts, and searchable profile metadata. +- Apple device sources and review dates were refreshed on 2026-07-16. + +### Security + +- 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2c2baf4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,89 @@ +# Contributing to Wallpect + +[繁體中文](#繁體中文) · [English](#english) + +## 繁體中文 + +感謝你協助改善 Wallpect。請讓每個 issue 或 pull request 專注於一項清楚的問題,並說明對用戶可見的影響。 + +### 開始之前 + +- 使用 Node.js 22 或更新版本。 +- 執行 `npm ci` 安裝鎖定版本的依賴。 +- 不要加入圖片上傳 API、圖片分析、帳戶追蹤或其他把用戶圖片傳離瀏覽器的功能。 +- 預覽與匯出必須共用相同的變換及渲染計算。 +- 裝置資料必須位於 `src/data/devices`,不要寫入 UI 元件。 +- 推算或估算的設定檔資料不得標示為 `verified`。 + +### 開發流程 + +1. 建立範圍清晰的分支及變更。 +2. 為行為修正或新邏輯加入相應測試。 +3. 修改裝置設定檔時,提供來源、檢查日期、證據類型及準確度等級。 +4. 不要提交私人桌布、Apple 專有素材、憑證、環境檔或建置產物。 +5. 執行以下檢查: + +```bash +npm run lint +npm test +npm run build +``` + +涉及完整編輯流程、響應式版面或瀏覽器行為時,亦應執行: + +```bash +npm run test:e2e +``` + +### Pull request 清單 + +- 清楚說明問題、處理方式及用戶影響。 +- 列出已執行的測試及結果。 +- UI 變更附上不含私人圖片的前後畫面。 +- 裝置資料變更連結到可靠來源,並依照[準確度政策](docs/ACCURACY_POLICY.md)標示資料。 +- 確認上傳圖片仍只留在瀏覽器中。 + +一般錯誤或功能建議可使用 GitHub Issues。涉及未公開漏洞或可能洩露資料的問題,請勿在公開 issue 中附上敏感細節。 + +## English + +Thank you for helping improve Wallpect. Keep each issue or pull request focused on one clear problem, and explain the user-visible impact. + +### Before you start + +- Use Node.js 22 or newer. +- Run `npm ci` to install the locked dependency versions. +- Do not add image upload APIs, image analytics, account tracking, or any feature that sends a user's image out of the browser. +- Preview and export must share the same transform and rendering calculations. +- Keep device data in `src/data/devices`, outside UI components. +- Never label derived or estimated profile data as `verified`. + +### Development workflow + +1. Create a focused branch and change set. +2. Add tests for behavior fixes or new logic. +3. For device profiles, include sources, review dates, evidence types, and accuracy levels. +4. Do not commit private wallpapers, proprietary Apple assets, credentials, environment files, or generated build output. +5. Run the core checks: + +```bash +npm run lint +npm test +npm run build +``` + +Also run the end-to-end suite when changing the complete editor flow, responsive layout, or browser behavior: + +```bash +npm run test:e2e +``` + +### Pull request checklist + +- Explain the problem, approach, and user impact. +- List the tests run and their results. +- Include before/after images for UI changes, without private imagery. +- Link device-data changes to reliable sources and follow the [accuracy policy](docs/ACCURACY_POLICY.md). +- Confirm that uploaded image content still stays in the browser. + +Use GitHub Issues for ordinary bugs and feature requests. Do not place sensitive details in a public issue when reporting an undisclosed vulnerability or possible data exposure. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..37220da --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 kyeunga25 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index f2d9bd9..4456dc1 100644 --- a/README.md +++ b/README.md @@ -1,127 +1,117 @@ +
+ # Wallpect -Wallpect 是一個以隱私為優先的桌布預覽與適配工具。 +**Privacy-first wallpaper preview and fitting for Apple devices.** -上傳圖片、選擇 Apple 裝置設定檔,即可檢查精確解析度、長寬比、裁切範圍、開孔與安全區域覆疊,並以目標裝置的解析度匯出桌布。介面首次載入時預設使用繁體中文,也可切換至簡體中文或英文;語言偏好只會儲存在瀏覽器本機。 +[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) +[![License: MIT](https://img.shields.io/badge/License-MIT-2563eb.svg)](LICENSE) +[![Live app](https://img.shields.io/badge/Live-wallpect.k--y.cc-16a34a.svg)](https://wallpect.k-y.cc) -- 圖片只留在瀏覽器中 -- 精確的輸出尺寸 -- 以資料驅動裝置設定檔 -- 預覽與匯出共用相同的渲染邏輯 -- 清楚標示資料準確度 +
-## MVP 已包含功能 +![Wallpect desktop workspace showing an iPhone wallpaper preview and export controls](docs/screenshots/wallpect-desktop.png) -- 12 個代表性 iPhone、5 個 iPad,以及 6 個 Mac/顯示器設定檔 -- 在本機解碼 PNG、JPEG 與 WebP,安全限制為 30 MB/最長邊 12,000 px -- 直接拖曳、雙指或滾動縮放、鍵盤微調、數值平移、旋轉,以及填滿/完整顯示 -- 純色、透明與模糊延伸背景 -- 純桌布、鎖定畫面/桌面與安全區域預覽模式 -- 以資料驅動的開孔、安全區域、鎖定畫面、選單列與 Dock 覆疊 -- 每部支援裝置分別保存直向/橫向構圖 -- 以 Canvas 匯出精確解析度的 PNG、JPEG 與 WebP -- 響應式桌面與流動版工作區 -- 預設繁體中文,並可切換簡體中文或英文;偏好設定保存在本機 -- 首次載入正式版本後可離線快取應用程式外殼 +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. -## 本機開發 +## What you can do -需求:Node.js 22 或以上版本,以及 npm。 +| Inspect | Compose | Export | +| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| Check aspect ratio, cutouts, Safe Area, Lock Screen, menu bar, and Dock guides. | Drag, zoom, pan, rotate, fill, fit, or extend an image against a device preview. | Download PNG, JPEG, or WebP at the selected profile's physical pixel dimensions. | -```bash -npm install -npm run dev -``` +### Highlights -品質檢查: +- 74 profiles: 27 iPhone, 20 iPad, and 27 Mac/display profiles, covering every requested display family introduced from 2021 through July 2026 +- Local PNG, JPEG, and WebP decoding with file, dimension, and pixel-count limits +- Portrait and landscape compositions stored independently for each device +- Solid, transparent, or blurred-extension backgrounds +- Shared transform and Canvas rendering calculations for preview and export +- Responsive desktop and mobile workspaces +- Traditional Chinese by default, with Simplified Chinese and English options +- Offline app-shell caching after the first production visit +- Explicit accuracy levels for published, derived, and estimated device data -```bash -npm run lint -npm test -npm run build -npm run test:e2e -``` - -端對端測試涵蓋 Chromium、WebKit、本機安裝的 Microsoft Edge,以及 Firefox。 +## Try it -## 架構 - -```text -src/components UI 面板與裝置預覽介面 -src/state 編輯器狀態、各裝置的構圖變換與偏好設定 -src/core 適配、變換、驗證、Canvas 渲染與匯出邏輯 -src/data/devices 資料驅動的 Apple 裝置設定檔 -src/i18n 預設語言、偏好保存與介面翻譯 -src/types 領域模型 -tests/unit 計算與資料驗證測試 -tests/integration 元件互動測試 -tests/e2e 從上傳至匯出的 Playwright 流程 -``` +1. Open [wallpect.k-y.cc](https://wallpect.k-y.cc). +2. Use the bundled sample or choose an image from your device. +3. Select a device profile and orientation. +4. Adjust the composition and enable the guides you want to inspect. +5. Choose PNG, JPEG, or WebP and export at the displayed resolution. -預覽與匯出均會呼叫 `renderWallpaper`。平移值會儲存為目標畫布的標準化比例,因此縮小的預覽與完整解析度匯出可保持相同構圖。 +Your image stays in browser memory and is released when it is replaced or the page is closed. Only preferences such as the recent device identifier may be saved in `localStorage`. -## 私隱與限制 +

+ Wallpect mobile workspace showing the device selector and wallpaper preview +

-上傳圖片的像素、名稱與 Canvas 資料不會傳送到伺服器。最近使用的裝置識別碼可能儲存在 `localStorage`;圖片與 object URL 只存在記憶體中,在圖片被取代或頁面關閉時釋放。 +## Accuracy, by design -Wallpect 會按所選設定檔輸出精確像素尺寸,但 iOS、iPadOS 與 macOS 仍可能套用系統層級的縮放、延伸桌布、透視、景深或填滿效果。 +Wallpect separates published display facts from geometry that must be measured or derived: -Wallpect 是獨立工具,與 Apple Inc. 無關,亦未獲其背書。 +- The selected profile defines the export pixel dimensions and aspect ratio. +- Device frames and system overlays are composition guides, not official Apple renderings. +- Profiles declare an accuracy level; the initial data set is intentionally marked `high`, not `verified`, because much of the obstruction geometry is derived. +- iOS, iPadOS, and macOS may still apply system-level zoom, extension, depth, or fill behavior. -## 安全控制 +See the [accuracy policy](docs/ACCURACY_POLICY.md) for the complete definition and reporting requirements. -- 沒有圖片上傳端點、帳號系統、分析服務或第三方圖片處理 -- 圖片進入編輯器前會驗證 MIME、檔案大小、最長邊與總像素數 -- Cloudflare Pages 使用嚴格的 Content Security Policy 與瀏覽器權限政策 -- 同源 Service Worker 快取只使用 Wallpect 專屬快取名稱 -- 正式版本停用 source map -- Git 會排除本機環境檔、憑證、證書、建置輸出與測試產物 +## Local development -## 部署 +Requirements: Node.js 22 or newer and npm. -專案已設定使用 Cloudflare Pages: +```bash +git clone https://github.com/kyeunga25/wallpect.git +cd wallpect +npm ci +npm run dev +``` -- 建置指令:`npm run build` -- 輸出目錄:`dist` -- 預定正式網域:`wallpect.k-y.cc` -- 預定開發網域:`dev.wallpect.k-y.cc` +Run the core quality checks: -請參閱 [部署指南](docs/DEPLOYMENT.md)、[裝置設定檔指南](docs/DEVICE_PROFILE_GUIDE.md)、[準確度政策](docs/ACCURACY_POLICY.md),以及已使用中文撰寫的[產品需求與實作計劃](docs/WALLPECT_PRODUCT_REQUIREMENTS_AND_IMPLEMENTATION_PLAN.md)。 +```bash +npm run lint +npm test +npm run build +``` ---- +The Playwright suite additionally covers Chromium, WebKit, Microsoft Edge, and Firefox: -## English +```bash +npm run test:e2e +``` -Wallpect is a privacy-first wallpaper preview and fitting tool. +## Architecture -Upload an image, select an Apple device profile, inspect its exact resolution, aspect ratio, crop area, cutout, and safe-area overlays, then export a wallpaper at the target device resolution. The interface defaults to Traditional Chinese on first load and can be switched to Simplified Chinese or English; the preference stays in the browser. +| Path | Responsibility | +| ------------------ | ------------------------------------------------------------- | +| `src/components` | Editor panels, controls, and device preview UI | +| `src/state` | Editor state, per-device transforms, and preferences | +| `src/core` | Fitting, transforms, validation, Canvas rendering, and export | +| `src/data/devices` | Data-driven Apple device profiles outside the UI | +| `src/i18n` | Locale selection, persistence, and translations | +| `tests` | Unit, integration, and end-to-end coverage | -### Principles +Preview and export both call `renderWallpaper`. Pan offsets use the target canvas's normalized coordinate space, so a scaled preview and full-resolution export preserve the same composition. -- Images stay in the browser -- Exact output dimensions -- Device profiles are data-driven -- Preview and export share the same rendering logic -- Accuracy levels are disclosed +## Documentation -### Local development +- [Accuracy policy](docs/ACCURACY_POLICY.md) +- [Device profile guide](docs/DEVICE_PROFILE_GUIDE.md) +- [Deployment guide](docs/DEPLOYMENT.md) +- [Product requirements and implementation plan](docs/WALLPECT_PRODUCT_REQUIREMENTS_AND_IMPLEMENTATION_PLAN.md) +- [Implementation checklist](TODO.md) +- [Changelog](CHANGELOG.md) -Requirements: Node.js 22+ and npm. +## Contributing -```bash -npm install -npm run dev -``` +Issues and focused pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) before changing device data, rendering behavior, or privacy-sensitive code. -Quality checks: +## License and disclaimer -```bash -npm run lint -npm test -npm run build -npm run test:e2e -``` +Wallpect is available under the [MIT License](LICENSE). -The end-to-end suite covers Chromium, WebKit, the locally installed Microsoft Edge, and Firefox. See the Chinese sections above for the complete feature, architecture, privacy, security, and deployment reference. +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. diff --git a/README.zh-Hant.md b/README.zh-Hant.md new file mode 100644 index 0000000..2952101 --- /dev/null +++ b/README.zh-Hant.md @@ -0,0 +1,117 @@ +
+ +# Wallpect + +**以私隱為先的 Apple 裝置桌布預覽與適配工具。** + +[立即使用](https://wallpect.k-y.cc) · [English](README.md) · [技術文件](#技術文件) + +[![CI](https://github.com/kyeunga25/wallpect/actions/workflows/ci.yml/badge.svg)](https://github.com/kyeunga25/wallpect/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-2563eb.svg)](LICENSE) +[![正式網站](https://img.shields.io/badge/正式網站-wallpect.k--y.cc-16a34a.svg)](https://wallpect.k-y.cc) + +
+ +![Wallpect 桌面工作區,顯示 iPhone 桌布預覽及匯出控制](docs/screenshots/wallpect-desktop.png) + +Wallpect 讓你按所選 Apple 裝置調整圖片構圖、檢查可能遮擋內容的系統介面,並以設定檔的精確像素尺寸匯出桌布。圖片解碼、編輯、預覽及匯出全部在瀏覽器內完成;沒有圖片上傳端點、帳戶系統或第三方圖片分析。 + +## 可以做甚麼 + +| 檢查 | 構圖 | 匯出 | +| ------------------------------------------------------------ | -------------------------------------------------- | -------------------------------------------------- | +| 檢查長寬比、開孔、安全區域、鎖定畫面、選單列及 Dock 輔助線。 | 拖曳、縮放、平移、旋轉、填滿、完整顯示或延伸圖片。 | 按所選設定檔的實體像素尺寸下載 PNG、JPEG 或 WebP。 | + +### 主要功能 + +- 74 個設定檔:27 個 iPhone、20 個 iPad,以及 27 個 Mac/顯示器,涵蓋 2021 年至 2026 年 7 月要求支援的所有顯示系列 +- 在本機解碼 PNG、JPEG 與 WebP,並限制檔案大小、圖像尺寸及總像素數 +- 每部裝置分別保存直向及橫向構圖 +- 純色、透明或模糊延伸背景 +- 預覽與匯出共用相同的變換及 Canvas 渲染計算 +- 響應式桌面與流動版工作區 +- 預設繁體中文,並支援簡體中文及英文 +- 首次載入正式版本後可離線快取應用程式外殼 +- 清楚標示公開、推算及估算裝置資料的準確度 + +## 立即試用 + +1. 開啟 [wallpect.k-y.cc](https://wallpect.k-y.cc)。 +2. 使用內建示範圖片,或從裝置選擇圖片。 +3. 選擇裝置設定檔及方向。 +4. 調整構圖,並開啟需要檢查的輔助線。 +5. 選擇 PNG、JPEG 或 WebP,再按顯示的解析度匯出。 + +圖片只會保存在瀏覽器記憶體,當圖片被取代或頁面關閉時便會釋放。只有最近使用的裝置識別碼等偏好可能儲存在 `localStorage`。 + +

+ Wallpect 流動版工作區,顯示裝置選擇器及桌布預覽 +

+ +## 準確度原則 + +Wallpect 將公開的顯示資料與需要量度或推算的幾何資料分開處理: + +- 所選設定檔決定匯出像素尺寸及長寬比。 +- 裝置外框及系統覆疊只是構圖輔助線,並非 Apple 官方渲染畫面。 +- 每個設定檔均標示準確度;初始資料集刻意標記為 `high` 而非 `verified`,因為大部分遮擋幾何均為推算值。 +- iOS、iPadOS 及 macOS 仍可能套用系統層級的縮放、延伸、景深或填滿行為。 + +完整定義及資料問題回報要求請參閱[準確度政策](docs/ACCURACY_POLICY.md)。 + +## 本機開發 + +需求:Node.js 22 或更新版本,以及 npm。 + +```bash +git clone https://github.com/kyeunga25/wallpect.git +cd wallpect +npm ci +npm run dev +``` + +執行主要品質檢查: + +```bash +npm run lint +npm test +npm run build +``` + +Playwright 測試另外涵蓋 Chromium、WebKit、Microsoft Edge 及 Firefox: + +```bash +npm run test:e2e +``` + +## 架構 + +| 路徑 | 職責 | +| ------------------ | ------------------------------------------- | +| `src/components` | 編輯器面板、控制項及裝置預覽介面 | +| `src/state` | 編輯器狀態、各裝置的構圖變換及偏好 | +| `src/core` | 適配、變換、驗證、Canvas 渲染及匯出 | +| `src/data/devices` | 放在 UI 以外、以資料驅動的 Apple 裝置設定檔 | +| `src/i18n` | 語言選擇、偏好保存及翻譯 | +| `tests` | 單元、整合及端對端測試 | + +預覽與匯出均會呼叫 `renderWallpaper`。平移值使用目標 Canvas 的標準化座標,因此縮小預覽與完整解析度匯出可保持相同構圖。 + +## 技術文件 + +- [準確度政策](docs/ACCURACY_POLICY.md) +- [裝置設定檔指南](docs/DEVICE_PROFILE_GUIDE.md) +- [部署指南](docs/DEPLOYMENT.md) +- [產品需求與實作計劃](docs/WALLPECT_PRODUCT_REQUIREMENTS_AND_IMPLEMENTATION_PLAN.md) +- [實作清單](TODO.md) +- [版本記錄](CHANGELOG.md) + +## 參與貢獻 + +歡迎提交 issue 及範圍清晰的 pull request。修改裝置資料、渲染行為或涉及私隱的程式碼前,請先閱讀 [CONTRIBUTING.md](CONTRIBUTING.md)。 + +## 授權及聲明 + +Wallpect 採用 [MIT License](LICENSE)。 + +Wallpect 是獨立工具,與 Apple Inc. 無關,亦未獲其背書。Apple 產品名稱只用於識別相容的裝置設定檔。 diff --git a/TODO.md b/TODO.md index 536ed9a..6adec1c 100644 --- a/TODO.md +++ b/TODO.md @@ -16,7 +16,7 @@ - [x] 在 Chromium、WebKit、Microsoft Edge 與 Firefox 執行自動相容性檢查 - [x] 加入預設繁體中文,並支援簡體中文與英文切換 - [x] 完成提交前的依賴、機密、瀏覽器介面與安全 header 審查 -- [ ] 建立 Cloudflare Pages 專案並綁定 `wallpect.k-y.cc`(需要部署權限) +- [x] 建立 Cloudflare Pages 專案並綁定 `wallpect.k-y.cc` - [ ] 完成實體裝置 Safari、低記憶體流動裝置、CMYK JPEG 與 EXIF 手動 QA ## English @@ -35,5 +35,5 @@ - [x] Run automated compatibility checks in Chromium, WebKit, Microsoft Edge, and Firefox - [x] Default to Traditional Chinese and support Simplified Chinese and English switching - [x] Complete pre-commit dependency, secret, browser-surface, and security-header review -- [ ] Create the Cloudflare Pages project and bind `wallpect.k-y.cc` (requires deployment access) +- [x] Create the Cloudflare Pages project and bind `wallpect.k-y.cc` - [ ] Complete physical-device Safari, low-memory mobile, CMYK JPEG, and EXIF manual QA diff --git a/docs/DEVICE_PROFILE_GUIDE.md b/docs/DEVICE_PROFILE_GUIDE.md index c85ef9f..9183c60 100644 --- a/docs/DEVICE_PROFILE_GUIDE.md +++ b/docs/DEVICE_PROFILE_GUIDE.md @@ -4,6 +4,8 @@ 裝置設定檔是位於 `src/data/devices/apple/profiles.ts` 的純 TypeScript 記錄。預覽介面不含針對個別型號的分支;只要加入有效的設定檔,它便會自動出現在搜尋、選擇器、覆疊、資訊與匯出功能中。 +目前資料集包含 27 個 iPhone、20 個 2021 年或之後的 iPad,以及 27 個 2021 年或之後、具備內建螢幕的 Mac/Apple 顯示器設定檔。Mac mini、Mac Studio 與 Mac Pro 沒有內建螢幕,其桌布解析度由外接顯示器決定,因此不建立虛構的主機螢幕設定檔。 + ### 必填欄位 每個設定檔必須包含: @@ -45,6 +47,8 @@ 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. +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. + ### Required fields Every profile must include: diff --git a/docs/screenshots/wallpect-desktop.png b/docs/screenshots/wallpect-desktop.png new file mode 100644 index 0000000..da7567c Binary files /dev/null and b/docs/screenshots/wallpect-desktop.png differ diff --git a/docs/screenshots/wallpect-mobile.png b/docs/screenshots/wallpect-mobile.png new file mode 100644 index 0000000..1aeb0fc Binary files /dev/null and b/docs/screenshots/wallpect-mobile.png differ diff --git a/package-lock.json b/package-lock.json index 5c2a8e4..200be92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "wallpect", "version": "0.1.0", + "license": "MIT", "dependencies": { "lucide-react": "1.24.0", "react": "19.2.7", @@ -31,6 +32,9 @@ "typescript-eslint": "8.63.0", "vite": "8.1.4", "vitest": "4.1.10" + }, + "engines": { + "node": ">=22" } }, "node_modules/@adobe/css-tools": { diff --git a/package.json b/package.json index 3329af5..130912c 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,19 @@ "name": "wallpect", "private": true, "version": "0.1.0", + "description": "Privacy-first wallpaper preview and fitting tool for Apple devices.", + "license": "MIT", + "homepage": "https://wallpect.k-y.cc", + "repository": { + "type": "git", + "url": "git+https://github.com/kyeunga25/wallpect.git" + }, + "bugs": { + "url": "https://github.com/kyeunga25/wallpect/issues" + }, + "engines": { + "node": ">=22" + }, "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/header/Header.tsx b/src/components/header/Header.tsx index c9decb5..9aaa853 100644 --- a/src/components/header/Header.tsx +++ b/src/components/header/Header.tsx @@ -30,7 +30,7 @@ export function Header({ onOpenInfo }: { onOpenInfo: (view: Exclude onOpenInfo("privacy")}> {t("Privacy")} - + GitHub diff --git a/src/components/preview/DevicePreview.tsx b/src/components/preview/DevicePreview.tsx index 68b8792..2a0fe4e 100644 --- a/src/components/preview/DevicePreview.tsx +++ b/src/components/preview/DevicePreview.tsx @@ -146,7 +146,10 @@ export function DevicePreview() { const { profile, orientedDevice, previewMode, setPreviewMode, frameVisible, setFrameVisible } = useEditor(); const isMac = profile.platform === "macOS"; + const isMacBook = profile.category === "macbook"; const ratio = orientedDevice.outputWidth / orientedDevice.outputHeight; + const cornerX = `${(profile.display.cornerRadiusPx / orientedDevice.outputWidth) * 100}%`; + const cornerY = `${(profile.display.cornerRadiusPx / orientedDevice.outputHeight) * 100}%`; return (
@@ -171,8 +174,14 @@ export function DevicePreview() { />
@@ -184,18 +193,23 @@ export function DevicePreview() {
- {isMac && frameVisible ? ( + {isMac && !isMacBook && frameVisible ? (
) : null} - {isMac && frameVisible ? ( + {isMac && !isMacBook && frameVisible ? (
) : null} + {isMacBook && frameVisible ? ( +
+ +
+ ) : null} {!isMac && frameVisible ? ( <> diff --git a/src/data/devices/apple/profiles.ts b/src/data/devices/apple/profiles.ts index fc907a0..769f7ce 100644 --- a/src/data/devices/apple/profiles.ts +++ b/src/data/devices/apple/profiles.ts @@ -1,10 +1,13 @@ import type { DeviceProfile, Rect } from "../../../types/device"; -const VERIFIED = "2026-07-13"; -const PHONE_SOURCE = "https://support.apple.com/specs/iphone"; -const IPAD_SOURCE = "https://support.apple.com/specs/ipad"; -const MAC_SOURCE = "https://support.apple.com/specs/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"; function rect(x: number, y: number, width: number, height: number): Rect { return { x, y, width, height }; @@ -13,7 +16,6 @@ function rect(x: number, y: number, width: number, height: number): Rect { type PhoneInput = { id: string; model: string; - slug: string; year: number; width: number; height: number; @@ -27,6 +29,8 @@ type PhoneInput = { cutoutBounds?: Rect; safeTop: number; safeBottom: number; + dpr?: number; + sourceUrl?: string; }; function phone(input: PhoneInput): DeviceProfile { @@ -38,7 +42,7 @@ function phone(input: PhoneInput): DeviceProfile { category: "iphone", family: "iPhone", model: input.model, - slug: input.slug, + slug: input.id, releaseYear: input.year, supportedOrientations: ["portrait", "landscape"], chassis: { @@ -52,7 +56,7 @@ function phone(input: PhoneInput): DeviceProfile { physicalHeightPx: input.height, logicalWidthPt: input.logicalWidth, logicalHeightPt: input.logicalHeight, - devicePixelRatio: input.width / input.logicalWidth, + devicePixelRatio: input.dpr ?? input.width / input.logicalWidth, aspectRatio: input.width / input.height, orientation: "portrait", cornerRadiusPx: input.corner, @@ -87,7 +91,7 @@ function phone(input: PhoneInput): DeviceProfile { notes: ["System wallpaper placement can vary by iOS version and user settings."], }, metadata: { - sourceUrls: [PHONE_SOURCE], + sourceUrls: [input.sourceUrl ?? PHONE_SOURCE], lastVerified: VERIFIED, accuracy: "high", notes: [ @@ -101,7 +105,6 @@ const iphones: DeviceProfile[] = [ phone({ id: "iphone-se-2", model: "iPhone SE (2nd gen)", - slug: "iphone-se-2", year: 2020, width: 750, height: 1334, @@ -118,7 +121,6 @@ const iphones: DeviceProfile[] = [ phone({ id: "iphone-12-mini", model: "iPhone 12 mini", - slug: "iphone-12-mini", year: 2020, width: 1080, height: 2340, @@ -136,7 +138,6 @@ const iphones: DeviceProfile[] = [ phone({ id: "iphone-12", model: "iPhone 12", - slug: "iphone-12", year: 2020, width: 1170, height: 2532, @@ -154,7 +155,6 @@ const iphones: DeviceProfile[] = [ phone({ id: "iphone-12-pro-max", model: "iPhone 12 Pro Max", - slug: "iphone-12-pro-max", year: 2020, width: 1284, height: 2778, @@ -172,7 +172,6 @@ const iphones: DeviceProfile[] = [ phone({ id: "iphone-13-mini", model: "iPhone 13 mini", - slug: "iphone-13-mini", year: 2021, width: 1080, height: 2340, @@ -186,11 +185,11 @@ const iphones: DeviceProfile[] = [ cutoutBounds: rect(350, 0, 380, 102), safeTop: 150, safeBottom: 102, + dpr: 3, }), phone({ id: "iphone-13", model: "iPhone 13", - slug: "iphone-13", year: 2021, width: 1170, height: 2532, @@ -205,10 +204,94 @@ const iphones: DeviceProfile[] = [ 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", - slug: "iphone-14-pro", year: 2022, width: 1179, height: 2556, @@ -226,7 +309,6 @@ const iphones: DeviceProfile[] = [ phone({ id: "iphone-14-pro-max", model: "iPhone 14 Pro Max", - slug: "iphone-14-pro-max", year: 2022, width: 1290, height: 2796, @@ -244,7 +326,6 @@ const iphones: DeviceProfile[] = [ phone({ id: "iphone-15", model: "iPhone 15", - slug: "iphone-15", year: 2023, width: 1179, height: 2556, @@ -262,7 +343,6 @@ const iphones: DeviceProfile[] = [ phone({ id: "iphone-15-pro", model: "iPhone 15 Pro", - slug: "iphone-15-pro", year: 2023, width: 1179, height: 2556, @@ -280,7 +360,6 @@ const iphones: DeviceProfile[] = [ phone({ id: "iphone-15-plus", model: "iPhone 15 Plus", - slug: "iphone-15-plus", year: 2023, width: 1290, height: 2796, @@ -298,7 +377,6 @@ const iphones: DeviceProfile[] = [ phone({ id: "iphone-15-pro-max", model: "iPhone 15 Pro Max", - slug: "iphone-15-pro-max", year: 2023, width: 1290, height: 2796, @@ -313,11 +391,188 @@ const iphones: DeviceProfile[] = [ 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; - slug: string; year: number; width: number; height: number; @@ -327,24 +582,27 @@ type TabletInput = { diagonal: number; bodyWidth: number; bodyHeight: number; + corner?: number; + classic?: boolean; + sourceUrl?: string; }; function tablet(input: TabletInput): DeviceProfile { return { - id: `apple-${input.slug}`, + id: `apple-${input.id}`, brand: "Apple", platform: "iPadOS", category: "ipad", family: "iPad", model: input.model, - slug: input.slug, + slug: input.id, releaseYear: input.year, supportedOrientations: ["portrait", "landscape"], chassis: { physicalWidthMm: input.bodyWidth, physicalHeightMm: input.bodyHeight, screenDiagonalInches: input.diagonal, - bezelStyle: "edge-to-edge", + bezelStyle: input.classic ? "classic" : "edge-to-edge", }, display: { physicalWidthPx: input.width, @@ -354,13 +612,15 @@ function tablet(input: TabletInput): DeviceProfile { devicePixelRatio: input.dpr, aspectRatio: input.width / input.height, orientation: "portrait", - cornerRadiusPx: 52, + 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: rect(input.width * 0.41, input.height - 34, input.width * 0.18, 12), + homeIndicator: input.classic + ? undefined + : rect(input.width * 0.41, input.height - 34, input.width * 0.18, 12), }, wallpaperBehavior: { systemMayZoom: true, @@ -368,7 +628,7 @@ function tablet(input: TabletInput): DeviceProfile { notes: ["Portrait and landscape compositions are stored independently in this editor."], }, metadata: { - sourceUrls: [IPAD_SOURCE], + sourceUrls: [input.sourceUrl ?? IPAD_SOURCE], lastVerified: VERIFIED, accuracy: "high", notes: ["Published resolution with derived safe-area geometry."], @@ -378,8 +638,23 @@ function tablet(input: TabletInput): DeviceProfile { 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)", - slug: "ipad-mini-6", year: 2021, width: 1488, height: 2266, @@ -389,10 +664,37 @@ const ipads: DeviceProfile[] = [ 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)", - slug: "ipad-10", year: 2022, width: 1640, height: 2360, @@ -404,8 +706,61 @@ const ipads: DeviceProfile[] = [ 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)", - slug: "ipad-air-11-m2", year: 2024, width: 1640, height: 2360, @@ -417,8 +772,21 @@ const ipads: DeviceProfile[] = [ 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)", - slug: "ipad-pro-11-m4", year: 2024, width: 1668, height: 2420, @@ -428,10 +796,11 @@ const ipads: DeviceProfile[] = [ diagonal: 11, bodyWidth: 177.5, bodyHeight: 249.7, + corner: 48, }), tablet({ + id: "ipad-pro-13-m4", model: "iPad Pro 13-inch (M4)", - slug: "ipad-pro-13-m4", year: 2024, width: 2064, height: 2752, @@ -441,12 +810,108 @@ const ipads: DeviceProfile[] = [ 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; - slug: string; year: number; category: "macbook" | "imac" | "display"; width: number; @@ -455,22 +920,22 @@ type MacInput = { bodyWidth: number; bodyHeight: number; notch: boolean; + sourceUrl: string; }; function mac(input: MacInput): DeviceProfile { - const source = input.category === "display" ? DISPLAY_SOURCE : MAC_SOURCE; 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.slug}`, + 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.slug, + slug: input.id, releaseYear: input.year, supportedOrientations: ["landscape"], chassis: { @@ -516,7 +981,7 @@ function mac(input: MacInput): DeviceProfile { ], }, metadata: { - sourceUrls: [source], + sourceUrls: [input.sourceUrl], lastVerified: VERIFIED, accuracy: "high", notes: [ @@ -528,8 +993,177 @@ function mac(input: MacInput): DeviceProfile { 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)", - slug: "macbook-air-13-m3", year: 2024, category: "macbook", width: 2560, @@ -538,10 +1172,11 @@ const macs: DeviceProfile[] = [ bodyWidth: 304.1, bodyHeight: 215, notch: true, + sourceUrl: MACBOOK_AIR_SOURCE, }), mac({ + id: "macbook-air-15-m3", model: "MacBook Air 15-inch (M3)", - slug: "macbook-air-15-m3", year: 2024, category: "macbook", width: 2880, @@ -550,11 +1185,12 @@ const macs: DeviceProfile[] = [ bodyWidth: 340.4, bodyHeight: 237.6, notch: true, + sourceUrl: MACBOOK_AIR_SOURCE, }), mac({ - model: "MacBook Pro 14-inch", - slug: "macbook-pro-14", - year: 2023, + id: "macbook-pro-14-m4", + model: "MacBook Pro 14-inch (M4)", + year: 2024, category: "macbook", width: 3024, height: 1964, @@ -562,11 +1198,25 @@ const macs: DeviceProfile[] = [ bodyWidth: 312.6, bodyHeight: 221.2, notch: true, + sourceUrl: MACBOOK_PRO_SOURCE, }), mac({ - model: "MacBook Pro 16-inch", - slug: "macbook-pro-16", - year: 2023, + 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, @@ -574,11 +1224,12 @@ const macs: DeviceProfile[] = [ bodyWidth: 355.7, bodyHeight: 248.1, notch: true, + sourceUrl: MACBOOK_PRO_SOURCE, }), mac({ - model: "iMac 24-inch", - slug: "imac-24", - year: 2023, + id: "imac-24-m4", + model: "iMac 24-inch (M4)", + year: 2024, category: "imac", width: 4480, height: 2520, @@ -586,18 +1237,111 @@ const macs: DeviceProfile[] = [ bodyWidth: 547, bodyHeight: 461, notch: false, + sourceUrl: IMAC_SOURCE, }), mac({ - model: "Studio Display", - slug: "studio-display", - year: 2022, - category: "display", - width: 5120, - height: 2880, - diagonal: 27, - bodyWidth: 623, - bodyHeight: 478, + 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, }), ]; diff --git a/src/styles/index.css b/src/styles/index.css index 724635c..db0c74a 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -861,6 +861,10 @@ input[type="range"]::-webkit-slider-thumb { border-radius: 12px; background: linear-gradient(145deg, #30363d, #07090c 50%, #333942); } +.is-macbook .device-shell { + padding: 8px; + border-radius: 11px 11px 6px 6px; +} .screen-only .device-shell { padding: 0; border-color: transparent; @@ -875,14 +879,14 @@ input[type="range"]::-webkit-slider-thumb { } .screen-bezel { overflow: hidden; - border-radius: 11.2% / 5.2%; + border-radius: var(--screen-corner-x, 11.2%) / var(--screen-corner-y, 5.2%); background: #000; } .is-ipad .screen-bezel { - border-radius: 4%; + border-radius: var(--screen-corner-x, 4%) / var(--screen-corner-y, 4%); } .is-mac .screen-bezel { - border-radius: 6px; + border-radius: var(--screen-corner-x, 6px) / var(--screen-corner-y, 6px); } .device-screen { position: relative; @@ -1147,6 +1151,27 @@ input[type="range"]::-webkit-slider-thumb { border-radius: 50%; background: #30363d; } +.laptop-base { + position: absolute; + top: calc(100% - 2px); + left: -5%; + width: 110%; + height: 5.5%; + min-height: 7px; + border-radius: 2px 2px 10px 10px; + background: linear-gradient(180deg, #8e969e 0, #363d45 42%, #171b20 100%); + box-shadow: 0 5px 8px rgba(0, 0, 0, 0.38); + clip-path: polygon(4% 0, 96% 0, 100% 52%, 96% 100%, 4% 100%, 0 52%); +} +.laptop-base i { + position: absolute; + top: 0; + left: 43%; + width: 14%; + height: 42%; + border-radius: 0 0 8px 8px; + background: rgba(8, 10, 13, 0.72); +} .stage-hint { display: flex; align-items: center; @@ -1806,6 +1831,9 @@ input:checked + .switch::after { .is-mac .device-shell { padding: 5px 5px 14px; } + .is-macbook .device-shell { + padding: 5px; + } .computer-chin { height: 13px; } diff --git a/tests/integration/editor-controls.test.tsx b/tests/integration/editor-controls.test.tsx index 3ef35b0..d26dd66 100644 --- a/tests/integration/editor-controls.test.tsx +++ b/tests/integration/editor-controls.test.tsx @@ -4,6 +4,7 @@ import { 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"; +import { DevicePreview } from "../../src/components/preview/DevicePreview"; import { I18nProvider } from "../../src/i18n/i18n"; import { EditorProvider } from "../../src/state/editor-context"; @@ -31,28 +32,44 @@ describe("editor controls", () => { const search = screen.getByPlaceholderText("搜尋型號、年份或解析度…"); await user.type(search, "1290"); - expect(screen.getByText("3 個結果")).toBeInTheDocument(); + expect(screen.getByText("4 個結果")).toBeInTheDocument(); expect(screen.getByRole("option", { name: /iPhone 15 Pro Max,2023/ })).toBeInTheDocument(); await user.type(search, "{Escape}"); expect(search).toHaveValue(""); - expect(screen.getByText("12 個結果")).toBeInTheDocument(); + expect(screen.getByText("27 個結果")).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 · 5" })); - expect(screen.getByText("5 個結果")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "iPad · 20" })); + expect(screen.getByText("20 個結果")).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: /目前裝置.*iPhone 15 Pro/ })); - expect(screen.getByRole("button", { name: "iPhone · 12" })).toHaveAttribute( + expect(screen.getByRole("button", { name: "iPhone · 27" })).toHaveAttribute( "aria-pressed", "true", ); }); + it("renders MacBooks as laptops instead of desktop displays", async () => { + const user = userEvent.setup(); + const { container } = renderWithEditor( + <> + + + , + ); + + await user.click(screen.getByRole("button", { name: "Mac · 27" })); + await user.click(screen.getByRole("option", { name: /MacBook Neo,2026/ })); + + expect(container.querySelector(".laptop-base")).toBeInTheDocument(); + expect(container.querySelector(".computer-stand")).not.toBeInTheDocument(); + }); + it("updates fit, zoom, and background controls", async () => { const user = userEvent.setup(); renderWithEditor(); diff --git a/tests/unit/profiles.test.ts b/tests/unit/profiles.test.ts index 9448139..9f7521a 100644 --- a/tests/unit/profiles.test.ts +++ b/tests/unit/profiles.test.ts @@ -3,12 +3,48 @@ import { deviceProfiles } from "../../src/data/devices"; import { validateDeviceProfile, validateDeviceProfiles } from "../../src/core/validation"; describe("Apple device profiles", () => { - it("meets MVP representative profile counts", () => { - expect(deviceProfiles.filter((profile) => profile.category === "iphone")).toHaveLength(12); - expect(deviceProfiles.filter((profile) => profile.category === "ipad")).toHaveLength(5); + 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); expect( deviceProfiles.filter((profile) => ["macbook", "imac", "display"].includes(profile.category)), - ).toHaveLength(6); + ).toHaveLength(27); + + 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", + ]) { + expect( + deviceProfiles.some((profile) => profile.id === id), + id, + ).toBe(true); + } + }); + + 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"); + + 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); }); it("validates the complete data set", () => { diff --git a/tests/unit/transforms.test.ts b/tests/unit/transforms.test.ts index 4e25d38..d727049 100644 --- a/tests/unit/transforms.test.ts +++ b/tests/unit/transforms.test.ts @@ -12,7 +12,7 @@ describe("device orientation transforms", () => { }); it("does not swap native landscape Macs", () => { - const profile = getDeviceProfile("apple-macbook-pro-14"); + const profile = getDeviceProfile("apple-macbook-pro-14-m1-pro-max"); expect(orientDevice(profile, "landscape").outputWidth).toBe(3024); }); @@ -26,7 +26,7 @@ describe("device orientation transforms", () => { }); it("keeps a native-landscape MacBook notch at the top center", () => { - const profile = getDeviceProfile("apple-macbook-pro-14"); + const profile = getDeviceProfile("apple-macbook-pro-14-m1-pro-max"); expect( orientRect( profile.cutout.boundsPx,