From 7e667844642e452155f9035a905b2a1b7f393fe9 Mon Sep 17 00:00:00 2001 From: kyeunga25 <49793173+kyeunga25@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:05:00 +0800 Subject: [PATCH] (release): Wallpect v0.4.0 --- .github/workflows/ci.yml | 4 + CHANGELOG.md | 46 +- README.md | 37 +- README.zh-Hant.md | 37 +- SECURITY.md | 17 + docs/ARCHITECTURE.md | 98 +-- docs/DEVICE_PROFILE_GUIDE.md | 4 +- docs/LEGAL_PRIVACY_AND_DATA_POLICY.md | 18 +- package-lock.json | 559 +++++++-------- package.json | 13 +- public/THIRD_PARTY_NOTICES.txt | 70 ++ public/sw.js | 2 +- scripts/check-public-safety.mjs | 82 +++ src/app/App.tsx | 46 +- .../device-selector/DeviceSelector.tsx | 303 +++++---- .../editor-controls/AdjustmentControls.tsx | 332 +++++++-- .../editor-controls/CompositionFeedback.tsx | 142 ++++ .../ContentPositionControls.tsx | 123 ++++ src/components/export/DeviceInformation.tsx | 15 + src/components/export/ExportPanel.tsx | 133 ++-- src/components/info/InfoDialog.tsx | 5 +- src/components/mobile/MobilePanel.tsx | 56 +- src/components/overlays/OverlayPanel.tsx | 40 +- src/components/preview/DevicePreview.tsx | 140 +++- src/components/preview/WallpaperCanvas.tsx | 72 +- src/components/ui.tsx | 18 +- src/components/upload/UploadPanel.tsx | 35 +- src/core/fit.ts | 204 +++++- src/core/image-decoder.ts | 2 + src/core/image-quality.ts | 59 ++ src/data/devices/index.ts | 64 +- src/i18n/i18n.tsx | 233 ++++++- src/state/editor-context.tsx | 194 +++++- src/state/transform-history.ts | 105 +++ src/styles/index.css | 523 ++++++++++++++- src/types/editor.ts | 1 + tests/e2e/wallpect.spec.ts | 622 ++++++++++++++++- tests/integration/editor-controls.test.tsx | 634 +++++++++++++++++- tests/unit/device-search.test.ts | 41 ++ tests/unit/fit.test.ts | 66 ++ tests/unit/image-quality.test.ts | 38 ++ tests/unit/transform-history.test.ts | 80 +++ wrangler.toml | 7 + 43 files changed, 4547 insertions(+), 773 deletions(-) create mode 100644 SECURITY.md create mode 100644 public/THIRD_PARTY_NOTICES.txt create mode 100644 scripts/check-public-safety.mjs create mode 100644 src/components/editor-controls/CompositionFeedback.tsx create mode 100644 src/components/editor-controls/ContentPositionControls.tsx create mode 100644 src/core/image-quality.ts create mode 100644 src/state/transform-history.ts create mode 100644 tests/unit/device-search.test.ts create mode 100644 tests/unit/image-quality.test.ts create mode 100644 tests/unit/transform-history.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 337efd6..d0fd125 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,11 +18,15 @@ jobs: node-version: 22 cache: npm - run: npm ci + - run: npm audit --audit-level=high - run: npm run check - run: npm run format:check - run: npm run worker:check env: WRANGLER_LOG_PATH: /tmp/wallpect-wrangler-ci.log + WRANGLER_LOG_SANITIZE: "true" + WRANGLER_SEND_ERROR_REPORTS: "false" + WRANGLER_SEND_METRICS: "false" browser: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index bfb482a..df0d6c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,49 @@ All notable changes to Wallpect are recorded in this file. The format follows [K ## [Unreleased] +## [0.4.0] - 2026-08-11 + +### Added + +- Added a bounded 50-step undo/redo history for each device and orientation, with continuous drag, pinch, wheel, slider, and number-field edits grouped into meaningful steps. +- Added visible composition-history controls, `Command`/`Ctrl` keyboard shortcuts, and one-pixel directional nudging on desktop and mobile. +- Added an accessible original-composition comparison that leaves the edited transform and export result unchanged, then returns to the edit automatically when adjustment resumes. +- Added a distraction-free focus preview that enlarges the existing Canvas on desktop and mobile, preserves the active composition, and restores the editor by button or `Escape`. +- Added an accessible mobile workspace-size control that gives long editing panels more room and restores the balanced preview without changing the composition. +- Added an immediate composition check beside Fill/Fit that explains the current crop in plain language on desktop and mobile. +- Added an undoable “Show whole image” action for noticeable or heavy crop, and moved the composition check to the start of Adjust so the result is visible before manual controls. +- Added a first-screen quick-position row for centering or moving an image by 10% of the active canvas, while retaining exact X/Y fields and one-pixel nudging. +- Added undoable content-position controls that expose useful left/centre/right or top/middle/bottom alignments only when the image geometry has room to move, giving touch and keyboard users an alternative to repeated dragging. +- Added a live-value Precise controls disclosure for exact X/Y, one-pixel nudging, and rotation on desktop and mobile. +- Added source-pixel enlargement feedback to the composition check, device information, and exact-output summary using the same geometry as preview and export. +- Added progressive export options so exact output and the primary download stay visible before optional format, filename, quality, and guide controls. +- Added a one-tap preview orientation control that remains available in the mobile editing flow and distraction-free focus preview. + +### Changed + +- Made device search span iPhone, iPad, Mac, and Apple display configurations without requiring users to choose the correct category first. +- Kept desktop and mobile device selectors aligned with the selected device family and improved pasted-resolution and multi-term matching. +- Kept desktop adjustment controls in reach by collapsing the full device browser until requested and automatically returning to the compact device summary after selection; the dedicated mobile device panel remains fully expanded. +- Increased key mobile editor controls to comfortable 44 px touch targets while preserving the compact scrollable workspace. +- Replaced the non-interactive mobile sheet grabber with explicit “More controls” and “More preview” actions, and kept the header within 320 px-wide viewports with 44 px language and menu targets. +- Made Adjust the mobile starting panel and return target after successful image or device selection; removing the image returns to Image. +- Reordered the desktop reference rail to keep the common export action within the first 1280 × 720 viewport while retaining complete device information below it. +- Kept solid, transparent, and blurred-extension backgrounds close to Fill/Fit and the new content-position controls so uncovered areas can be handled immediately on desktop and mobile. +- Made estimated crop account for zoom, normalized position, and rotation by clipping the transformed image bounds against the wallpaper canvas. +- Replaced the generic loaded-image icon with the active local thumbnail and reset all per-device/orientation transforms and histories when the image is replaced or removed. +- Made zoom, rotation, and export-quality tracks reflect their actual range position instead of a fixed fill, with matching unit-aware accessible values. +- Reordered common adjustment controls ahead of advanced precision fields and folded the latter by default to reduce scanning and scrolling without removing editing accuracy. +- Made mode-specific overlay changes open their matching Lock Screen/Desktop or Safe area preview automatically, while keeping the composition grid and device cutout independent of preview mode. + +### Security + +- Kept local thumbnails on bundled same-origin or browser-generated `blob:` URLs; enlargement feedback and content positioning use dimensions and rendering geometry without pixel analysis, persistence, or image network requests. +- Added a deterministic public-safety check for high-confidence credentials, private paths, personal email addresses, Cloudflare resource identifiers, sensitive file types, and generated/private state without printing matched values. +- Added deployed third-party licence notices and a privacy-safe security reporting policy that uses GitHub private vulnerability reporting instead of a public email address. +- Disabled Wrangler usage metrics, deployment dependency metadata instrumentation, and Worker observability explicitly in the project configuration. +- Updated the affected Vite, jsdom, and Wrangler development dependency chains so both production-only and complete dependency audits report no known vulnerabilities. +- Added a CI gate for high- or critical-severity dependency advisories and made the Wrangler CI privacy settings explicit. + ## [0.3.0] - 2026-08-10 ### Added @@ -114,7 +157,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.3.0...HEAD +[Unreleased]: https://github.com/kyeunga25/wallpect/compare/v0.4.0...HEAD +[0.4.0]: https://github.com/kyeunga25/wallpect/compare/v0.3.0...v0.4.0 [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 diff --git a/README.md b/README.md index a54deb6..118bb0c 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,25 @@ Wallpect helps you compose an image for a selected Apple device, inspect likely ### Highlights - 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 +- Devices with the same wallpaper canvas and obstruction geometry share one option; one search spans iPhone, iPad, and Mac model names, years, diagonals, and resolutions +- The desktop device browser stays compact after selection so composition controls remain in reach; the mobile device tab keeps the complete searchable catalogue visible - Local PNG, JPEG, and WebP decoding, limited to 30 MB, 12,000 px on the longest edge, and 72 megapixels +- The bundled demo is synthetic project artwork with its content-authenticity provenance preserved; it contains no Apple product asset or user image +- The image card shows the active local thumbnail; replacing an image clears old composition histories for every device and orientation so a previous image's position is not reused accidentally - Portrait and landscape compositions stored independently for each device +- Per-device/orientation undo and redo with grouped gestures, keyboard shortcuts, and one-pixel position nudging +- First-screen quick positioning to center an image or move it by 10% of the active canvas without entering X/Y values +- Contextual **Content position** controls expose useful left/centre/right or top/middle/bottom alignments when the image has room to move, bringing people, logos, or text into view without repeated dragging; every alignment remains undoable +- A live-value **Precise controls** disclosure keeps exact X/Y, one-pixel nudging, and rotation available without crowding the everyday adjustment flow +- Zoom, rotation, and export-quality tracks fill to the actual current value and expose the same value with its unit to assistive technology +- One-tap original-composition comparison without replacing edited values or export output +- Distraction-free focus preview that temporarily hides editing panels without changing the composition +- One-tap portrait/landscape switching directly on the preview, including while Adjust or focus preview remains active +- Mode-specific guide switches automatically open the matching Lock Screen/Desktop or Safe area preview so every change is immediately visible; the composition grid and device cutout remain mode-independent +- A mobile “More controls / More preview” switch for balancing long editing panels against the live preview, including compact 320 px-wide screens +- Mobile opens directly on Adjust and returns there after a local image or device is selected, avoiding repeated switches between a long catalogue and composition controls +- First-in-panel plain-language crop and source-pixel enlargement feedback on desktop and mobile, with undoable **Show whole image** and content-position actions for either preserving the whole source or choosing which part to show; solid, transparent, and blurred-extension choices remain nearby for uncovered areas +- Exact output and the primary download remain in the first desktop viewport at common MacBook heights; format, filename, and guide controls remain available under Export options - Solid, transparent, or blurred-extension backgrounds - Shared transform and Canvas rendering calculations for preview and export - Responsive desktop and mobile workspaces @@ -39,12 +55,13 @@ Wallpect helps you compose an image for a selected Apple device, inspect likely ## Try it 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. +2. Use the bundled sample or choose an image from your device. The image-card thumbnail confirms what is being edited. Choosing another image starts a fresh composition instead of reusing the previous image's offsets or undo history. +3. Select a device profile and orientation. On desktop, use **Change** to reopen the full device browser whenever needed. Mobile returns to **Adjust** after an image or device is selected; use the rotate action on the preview to switch orientation without leaving the current editing view. +4. Check the current crop and estimated enlargement at the start of **Adjust**. If important content is heavily cropped, use **Show whole image** for an undoable automatic fit or **Content position** to reveal the source's left/centre/right or top/middle/bottom without repeated dragging. Both use dimensions and shared rendering geometry only; they do not analyse the image. Choose the nearby solid, transparent, or blurred-extension background for uncovered areas. Use **Quick position** to center the image or move it in 10% steps when composing manually. Open **Precise controls** for exact X/Y values, one-pixel nudging, and rotation; its summary keeps the current values visible while folded. Drag and undo/redo remain directly available. On mobile, use **More controls** when a long panel needs space, compare the original framing, or enter focus preview as needed. +5. In **Overlays**, change Safe area, lock-screen, menu-bar, or Dock guides directly; Wallpect opens the matching preview mode so you can see the result immediately. The composition grid and device cutout stay in the current mode. +6. Use **Download wallpaper** below the exact output to keep the current format. Open **Export options** first when you need JPEG, WebP, a custom filename, or exported guides. -Your image stays in browser memory and is released when it is replaced, the page is refreshed, or the page is closed. Only the selected locale (`wallpect:locale:v1`) and up to four recent device identifiers (`wallpect:recent-devices:v1`) are saved in versioned `localStorage`. Normal requests for public site files still pass through Cloudflare, but they do not contain your selected image bytes. +Your image and its thumbnail stay in browser memory and are released when the image is replaced or removed, the page is refreshed, or the page is closed. The enlargement estimate is calculated only from image dimensions, the selected canvas, and the shared rendering geometry; it does not inspect or transmit image pixels. Only the selected locale (`wallpect:locale:v1`) and up to four recent device identifiers (`wallpect:recent-devices:v1`) are saved in versioned `localStorage`. Normal requests for public site files still pass through Cloudflare, but they do not contain your selected image bytes. The website provides directly accessible Traditional Chinese and English privacy, terms, disclaimer, and data-source notices. Maintenance and third-party source/API rules are documented in the [Terms, Privacy, and Data Policy](docs/LEGAL_PRIVACY_AND_DATA_POLICY.md). @@ -134,7 +151,7 @@ You can self-deploy a fork to your own Cloudflare account without a database, st 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. +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 checked-in configuration explicitly disables Wrangler usage metrics, deployment dependency metadata instrumentation, and Worker observability. 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 @@ -147,7 +164,7 @@ Wrangler credentials stay outside the repository. Never add API tokens, account | `src/i18n` | Locale selection, persistence, and translations | | `tests` | Unit, integration, and end-to-end coverage | -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. +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. Composition check derives the visible area from those same image dimensions, canvas dimensions, and transforms without creating another rendering state. See the [architecture document](docs/ARCHITECTURE.md) for the runtime boundary, local-data lifecycle, storage migration, offline behavior, and verification invariants. @@ -159,6 +176,8 @@ See the [architecture document](docs/ARCHITECTURE.md) for the runtime boundary, - [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) +- [Security policy](SECURITY.md) +- [Production third-party licence notices](public/THIRD_PARTY_NOTICES.txt) - [Changelog](CHANGELOG.md) ## Contributing @@ -167,7 +186,7 @@ Issues and focused pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTIN ## License and disclaimer -Wallpect is available under the [MIT License](LICENSE). +Wallpect is available under the [MIT License](LICENSE). Third-party code included in the production browser bundle is listed with its applicable licence text in the [third-party notices](public/THIRD_PARTY_NOTICES.txt). 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 index dca9239..b4803da 100644 --- a/README.zh-Hant.md +++ b/README.zh-Hant.md @@ -26,9 +26,25 @@ Wallpect 讓你按所選 Apple 裝置調整圖片構圖、檢查可能遮擋內 ### 主要功能 - 47 個不重複的畫布配置:19 個 iPhone、11 個 iPad、17 個 Mac/Apple 顯示器配置,共覆蓋 191 個 2007–2026 年具名型號 -- 相同 wallpaper 畫布及遮擋幾何的裝置共用一個選項;所有型號名稱、年份、吋數及解析度仍可搜尋 +- 相同 wallpaper 畫布及遮擋幾何的裝置共用一個選項;可直接跨 iPhone、iPad、Mac 分類搜尋所有型號名稱、年份、吋數及解析度 +- 桌面版選定裝置後會收起完整裝置瀏覽器,讓構圖控制維持在順手位置;流動版裝置分頁則繼續顯示完整可搜尋目錄 - 在本機解碼 PNG、JPEG 與 WebP,限制為 30 MB、最長邊 12,000 px 及 7,200 萬像素 +- 內建示範圖是合成的項目素材,並保留內容真實性 provenance;當中不含 Apple 產品素材或使用者圖片 +- 圖片卡直接顯示目前本機圖片的縮圖;更換圖片會清空所有裝置及方向的舊構圖歷史,避免意外沿用上一張圖的位置 - 每部裝置分別保存直向及橫向構圖 +- 每個裝置與方向各自提供復原/重做歷史,並支援合併連續手勢、鍵盤快捷鍵及 1 px 位置微調 +- 首屏提供快速定位,可一按置中,或按目前畫布的 10% 上下左右移動,毋須先理解 X/Y 數值 +- 圖片有可用裁切空間時會顯示「內容定位」,可一按展示左/中/右或上/中/下內容;適合把人物、標誌或文字帶回畫面,毋須反覆拖曳,而且每次定位均可復原 +- 「精確微調」收合區會持續顯示目前數值,並保留精確 X/Y、1 px 微調及旋轉,避免日常調整流程過度擁擠 +- 縮放、旋轉及匯出品質滑桿會按真實數值填色,並向輔助技術提供相同且帶單位的數值 +- 一按比較原始構圖,不會取代編輯數值或影響匯出結果 +- 桌面與手機均可進入專注預覽,暫時隱藏編輯面板而不改變構圖 +- 可直接在預覽上一按切換直向及橫向,包括保持手機「調整」分頁或專注預覽期間 +- 切換模式專屬的輔助線時,會自動開啟相應的鎖定畫面/桌面或安全區域預覽,讓每次變更即時可見;構圖格線及裝置開孔則不會強制改變模式 +- 手機可用「更多控制/更多預覽」平衡長編輯面板與即時預覽,並支援 320px 寬的小螢幕 +- 手機預設直接顯示「調整」;選好本機圖片或裝置後亦會自動返回調整,減少在長目錄與編輯控制之間重複切換 +- 桌面與手機「調整」面板會先顯示裁切程度、原圖像素是否足夠及容易理解的構圖建議;重要內容被大量裁切時,可按「顯示整張圖片」重設縮放、位置及旋轉,或使用內容定位選擇要展示的部分,而且仍可復原;純色、透明及模糊延伸背景保持在相鄰流程,方便立即處理留白 +- 桌面版在常見 MacBook 高度把精確輸出及下載保持在首屏;格式、檔名與輔助線仍可從「匯出設定」展開 - 純色、透明或模糊延伸背景 - 預覽與匯出共用相同的變換及 Canvas 渲染計算 - 響應式桌面與流動版工作區 @@ -39,12 +55,13 @@ Wallpect 讓你按所選 Apple 裝置調整圖片構圖、檢查可能遮擋內 ## 立即試用 1. 開啟 [wallpect.k-y.cc](https://wallpect.k-y.cc)。 -2. 使用內建示範圖片,或從裝置選擇圖片。 -3. 選擇裝置設定檔及方向。 -4. 調整構圖,並開啟需要檢查的輔助線。 -5. 選擇 PNG、JPEG 或 WebP,再按顯示的解析度匯出。 +2. 使用內建示範圖片,或從裝置選擇圖片;圖片卡的縮圖會確認目前正在編輯哪一張圖。換成另一張圖片時,Wallpect 會開始全新的構圖,不會沿用上一張圖片的偏移或復原歷史。 +3. 選擇裝置設定檔及方向;桌面版可隨時按「更換」重新開啟完整裝置瀏覽器。手機選好圖片或裝置後會返回「調整」,亦可直接按預覽上的旋轉操作切換方向,毋須離開目前編輯畫面。 +4. 在「調整」開首的「構圖概況」確認目前裁切程度及估算放大倍率;重要內容被大量裁切時,可按「顯示整張圖片」自動完整顯示,或用「內容定位」一按展示圖片的左/中/右或上/中/下部分。兩種操作都可復原,且只使用尺寸與共用渲染幾何,不會分析圖片。再從相鄰的純色、透明或模糊延伸處理留白。如要自行構圖,可用「快速定位」一按置中或按畫布的 10% 移動;需要更高精度時展開「精確微調」使用 X/Y、1 px 微調及旋轉,收合時仍可看到目前數值。拖曳及復原/重做保持直接可用。手機遇到較長面板時可按「更多控制」,也可比較原始構圖或進入專注預覽放大檢查。 +5. 在「覆疊」調整安全區域、鎖定畫面、選單列或 Dock 輔助線時,Wallpect 會自動開啟相應預覽模式,讓你立即看到結果;構圖格線及裝置開孔會保留目前模式。 +6. 直接按精確輸出下方的「下載桌布」便會使用目前格式;如需 JPEG、WebP、自訂檔名或輔助線,先展開「匯出設定」。 -圖片只會保存在瀏覽器記憶體,當圖片被取代、頁面重新整理或關閉時便會釋放。只有介面語言(`wallpect:locale:v1`)及最多四個最近使用的裝置識別碼(`wallpect:recent-devices:v1`)會儲存在版本化 `localStorage`;公開網站檔案的正常請求仍會經過 Cloudflare,但不包含你選擇的圖片 bytes。 +圖片及其縮圖只會保存在瀏覽器記憶體,當圖片被取代、移除、頁面重新整理或關閉時便會釋放。放大倍率只由圖片尺寸、所選畫布及共用渲染幾何計算,不會分析或傳送圖片像素。只有介面語言(`wallpect:locale:v1`)及最多四個最近使用的裝置識別碼(`wallpect:recent-devices:v1`)會儲存在版本化 `localStorage`;公開網站檔案的正常請求仍會經過 Cloudflare,但不包含你選擇的圖片 bytes。 網站內可直接查閱繁中/英文的私隱、使用條款、免責及資料來源說明。維護與新增第三方來源/API 的規則記錄於 [條款、私隱與資料政策](docs/LEGAL_PRIVACY_AND_DATA_POLICY.md)。 @@ -134,7 +151,7 @@ Wallpect 以不含 Worker script 的 Cloudflare Workers Static Assets 模式部 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)則說明版本化發布流程。 +Wrangler 憑證應保留在 repository 之外。不要把 API token、帳戶/zone 識別碼、deployment ID、私人網域、正式數據、本機路徑、log,或含私人內容的截圖加入版本控制。已提交的設定會明確關閉 Wrangler usage metrics、deployment dependency metadata instrumentation 及 Worker observability。[部署與自行託管指南](docs/DEPLOYMENT.md)提供安全設定範本、Workers Builds、Custom Domain、部署驗證及回退步驟;[發布指南](docs/RELEASING.md)則說明版本化發布流程。 ## 架構 @@ -147,7 +164,7 @@ Wrangler 憑證應保留在 repository 之外。不要把 API token、帳戶/z | `src/i18n` | 語言選擇、偏好保存及翻譯 | | `tests` | 單元、整合及端對端測試 | -預覽與匯出均會呼叫 `renderWallpaper`。平移值使用目標 Canvas 的標準化座標,因此縮小預覽與完整解析度匯出可保持相同構圖。 +預覽與匯出均會呼叫 `renderWallpaper`。平移值使用目標 Canvas 的標準化座標,因此縮小預覽與完整解析度匯出可保持相同構圖;「構圖概況」只從同一組圖片尺寸、畫布及變換即時計算可見範圍,不會建立另一份渲染狀態。 完整 runtime 邊界、本機資料生命週期、偏好遷移、離線行為及驗證不變條件見[架構文件](docs/ARCHITECTURE.md)。 @@ -159,6 +176,8 @@ Wrangler 憑證應保留在 repository 之外。不要把 API token、帳戶/z - [部署與自行託管指南](docs/DEPLOYMENT.md) - [發布指南](docs/RELEASING.md) - [條款、私隱與資料政策](docs/LEGAL_PRIVACY_AND_DATA_POLICY.md) +- [安全政策](SECURITY.md) +- [Production 第三方授權通知](public/THIRD_PARTY_NOTICES.txt) - [版本記錄](CHANGELOG.md) ## 參與貢獻 @@ -167,7 +186,7 @@ Wrangler 憑證應保留在 repository 之外。不要把 API token、帳戶/z ## 授權及聲明 -Wallpect 採用 [MIT License](LICENSE)。 +Wallpect 採用 [MIT License](LICENSE)。Production browser bundle 內的第三方程式碼及適用授權文字見[第三方授權通知](public/THIRD_PARTY_NOTICES.txt)。 Wallpect 是獨立工具,與 Apple Inc. 無關,亦未獲其背書。Apple 產品名稱只用於識別相容的裝置設定檔。 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d1c7ef0 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,17 @@ +# Wallpect 安全政策 / Security policy + +## 私密回報 + +請使用 GitHub 的[私密漏洞回報](https://github.com/kyeunga25/wallpect/security/advisories/new)提交未公開的安全或私隱問題。不要在公開 issue、pull request、截圖或測試資料中加入憑證、使用者圖片、真實應用資料、私人路徑或其他個人資料。 + +如問題涉及已洩露的 secret,請先撤銷或輪換該 secret,再提交只包含重現所需最少資料的私密報告。一般功能問題可使用公開 issue,但不得附上未公開漏洞細節。 + +目前只支援最新 GitHub Release 與 `main`。修正經過測試及發佈後,維護者會在不披露使用者或報告者資料的前提下提供必要的版本說明。 + +## Private reporting + +Use GitHub's [private vulnerability reporting](https://github.com/kyeunga25/wallpect/security/advisories/new) for undisclosed security or privacy issues. Do not place credentials, user images, production data, private paths, or other personal data in public issues, pull requests, screenshots, or fixtures. + +If a secret has been exposed, revoke or rotate it first, then submit only the minimum information needed to reproduce the issue. Public issues remain appropriate for ordinary bugs that contain no sensitive or undisclosed security detail. + +Only the latest GitHub Release and `main` are supported. Once a fix is tested and released, maintainers will publish the necessary version information without disclosing user or reporter data. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 23ea031..6a4a6d9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -24,35 +24,38 @@ flowchart LR ### 模組職責 -| 路徑 | 職責 | -| ------------------ | ---------------------------------------------------------- | -| `src/components` | UI、輸入控制、裝置預覽及對話框 | -| `src/state` | Editor session state、各裝置/方向的構圖及 UI announcement | -| `src/core` | 圖片驗證/解碼、偏好遷移、幾何、渲染及匯出 | -| `src/data/devices` | UI 以外、經 schema 驗證的裝置設定檔 | -| `src/i18n` | 繁體中文預設、簡體中文/英文翻譯及語言偏好 | -| `public` | 靜態 headers、service worker、示範圖片及公開資源 | -| `tests` | 單元、整合及 production-mode browser contract | -| `scripts` | 不會進入 runtime bundle 的 deterministic quality gate | +| 路徑 | 職責 | +| ------------------ | ---------------------------------------------------------------- | +| `src/components` | UI、輸入控制、裝置預覽及對話框 | +| `src/state` | Editor session state、各裝置/方向的構圖、歷史及 UI announcement | +| `src/core` | 圖片驗證/解碼、偏好遷移、幾何、渲染及匯出 | +| `src/data/devices` | UI 以外、經 schema 驗證的裝置設定檔 | +| `src/i18n` | 繁體中文預設、簡體中文/英文翻譯及語言偏好 | +| `public` | 靜態 headers、service worker、示範圖片及公開資源 | +| `tests` | 單元、整合及 production-mode browser contract | +| `scripts` | 不會進入 runtime bundle 的 deterministic quality gate | + +桌面右欄按常用工作流程顯示覆疊、精確輸出/下載,再提供完整裝置資料;格式、檔名與輔助線置於原生 `details` 內作漸進揭露。流動版預設顯示「調整」,只有使用者成功選圖或選擇裝置的事件才會把分頁帶回「調整」,移除圖片則返回「圖片」。這些導覽與揭露狀態只屬目前頁面的 UI,不得持久保存、改變構圖,或建立另一套匯出邏輯。 ### 必須維持的不變條件 -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`。舊 storage key 及 0.3.0 前的個別型號 ID 會在讀取時清理、映射到標準配置、去除重複並寫回。 -5. 裝置資料留在 `src/data/devices`;具名型號只可在完整 wallpaper 幾何相同時合併,推算或估算的幾何不得標示為 `verified`。 -6. 缺少的離線導覽可回退到已快取的 `/`,但缺少的 script、style 或其他非導覽資源必須失敗,不得回傳 HTML shell。 +1. 預覽與匯出均呼叫 `renderWallpaper`;構圖平移採用目標 Canvas 的標準化座標,因此相同比例的預覽與完整輸出保持一致。快速定位及內容定位亦只可更新同一組標準化 transform,並進入相同歷史,不得建立另一套位置狀態;內容定位只可從旋轉後的渲染邊界計算可用對齊點,不得讀取或分析圖片像素。原始構圖比較只可替換預覽傳入的 transform,匯出仍必須使用目前編輯 transform;專注預覽只可改變工作區版面,不得另建渲染或匯出狀態。「構圖概況」只可從目前圖片、畫布及 transform 衍生裁切與放大倍率;放大倍率必須使用 `renderWallpaper` 共用的幾何計算,不得分析像素、持久保存結果或改變 renderer 輸入。 +2. 每個裝置與方向的構圖歷史最多保存 50 步;連續拖曳、捏合、滾輪、滑桿或數值輸入只可提交為一個有意義的步驟。更換或移除圖片必須清除所有裝置與方向的 transform 及歷史,讓新圖片從預設構圖開始。 +3. 匯出 Canvas 只在操作期間存在,完成後會縮回 `1 × 1`;下載與上傳建立的 Object URL 必須撤銷。 +4. 圖片 bytes、檔名、Canvas 像素、thumbnail URL、transform 及構圖歷史不得寫入 `localStorage`、Cache Storage、網絡請求或分析服務。本機縮圖只可使用上傳時建立的 `blob:` URL;內建示範則使用同源靜態 URL。 +5. 可持久保存的資料只有 `wallpect:locale:v1` 及最多四個有效裝置 ID 的 `wallpect:recent-devices:v1`。舊 storage key 及 0.3.0 前的個別型號 ID 會在讀取時清理、映射到標準配置、去除重複並寫回。 +6. 裝置資料留在 `src/data/devices`;具名型號只可在完整 wallpaper 幾何相同時合併,推算或估算的幾何不得標示為 `verified`。 +7. 缺少的離線導覽可回退到已快取的 `/`,但缺少的 script、style 或其他非導覽資源必須失敗,不得回傳 HTML shell。 ### 資料生命週期 -| 資料 | 建立 | 保留 | 清除/更新 | -| -------------- | ------------------------------------ | ------------------------------------------------ | -------------------------------------------- | -| 使用者圖片 | `decodeImageFile` 由本機 `File` 解碼 | React state 及瀏覽器圖片物件 | 更換、移除、重新整理或關閉頁面時釋放 | -| 構圖與匯出設定 | 使用者操作 | 目前頁面的 React state | 重新整理或關閉頁面 | -| 語言 | 使用者切換 | `wallpect:locale:v1` | 改寫或清除網站資料 | -| 最近裝置 | 使用者選擇裝置 | `wallpect:recent-devices:v1`,最多四個有效 ID | 改寫或清除網站資料 | -| 公開 app shell | 首次 production 載入 | `wallpect-v3` Cache Storage 及一般 browser cache | 新 cache 版本啟用時移除舊 `wallpect-*` cache | +| 資料 | 建立 | 保留 | 清除/更新 | +| -------------------- | ------------------------------------ | ------------------------------------------------ | -------------------------------------------- | +| 使用者圖片及縮圖 | `decodeImageFile` 由本機 `File` 解碼 | React state、瀏覽器圖片物件及暫時 `blob:` URL | 更換、移除、重新整理或關閉頁面時釋放 | +| 構圖、歷史與匯出設定 | 使用者操作 | 目前頁面的 React state | 更換/移除圖片時重設;重新整理或關閉時清除 | +| 語言 | 使用者切換 | `wallpect:locale:v1` | 改寫或清除網站資料 | +| 最近裝置 | 使用者選擇裝置 | `wallpect:recent-devices:v1`,最多四個有效 ID | 改寫或清除網站資料 | +| 公開 app shell | 首次 production 載入 | `wallpect-v4` Cache Storage 及一般 browser cache | 新 cache 版本啟用時移除舊 `wallpect-*` cache | ### 驗證門檻 @@ -60,7 +63,7 @@ flowchart LR - `npm run format:check`:格式驗證。 - `npm run worker:check`:Static Assets dry-run,不部署。 - `npm run test:e2e`:以 production build/preview 驗證 Chromium、WebKit、Edge 及 Firefox 對應流程。 -- E2E 必須覆蓋 PNG/JPEG/WebP 精確尺寸、上傳後零 HTTP(S) egress、Object URL 釋放、離線導覽、鍵盤操作、流動版無水平溢出及法律/私隱入口。 +- E2E 必須覆蓋 PNG/JPEG/WebP 精確尺寸、上傳後零 HTTP(S) egress、Object URL 釋放、離線導覽、鍵盤與內容定位、1280×720 首屏下載可見度、流動版流程銜接與無水平溢出,以及法律/私隱入口。 ## English @@ -75,7 +78,7 @@ The diagram above intentionally has no data path from the selected image, Canvas | Path | Responsibility | | ------------------ | ------------------------------------------------------------------------------------------- | | `src/components` | UI, input controls, device previews, and dialogs | -| `src/state` | Editor session state, per-device/orientation composition, and announcements | +| `src/state` | Editor session state, per-device/orientation composition/history, and announcements | | `src/core` | Image validation/decoding, preference migration, geometry, rendering, and export | | `src/data/devices` | Schema-validated device profiles outside UI components | | `src/i18n` | Traditional Chinese default, Simplified Chinese/English translations, and locale preference | @@ -83,24 +86,41 @@ The diagram above intentionally has no data path from the selected image, Canvas | `tests` | Unit, integration, and production-mode browser contracts | | `scripts` | Deterministic quality gates excluded from the runtime bundle | +The desktop `DeviceSelector` may collapse its catalogue after a selection so the adjacent +composition controls remain reachable without scrolling through every device. The mobile device +tab uses the same data and search component in its expanded form. This presentation state is local +to each component instance and must not enter editor persistence or alter the selected profile. + +The mobile workspace may switch between balanced and control-priority grid rows. This UI-only state +lives in `Workspace`, changes only the available preview and panel heights, and must not alter or +persist the image, transform, selected profile, export settings, or renderer inputs. The control is +a labelled native button; a decorative drag handle must not imply unsupported resizing. + +The desktop right rail follows the common path from overlays to exact output/download, then detailed +device data; format, filename, and guide controls use native `details` for progressive disclosure. +Mobile opens on Adjust. Only a successful user image/device selection event may return the active tab +to Adjust, while image removal returns to Image. These navigation and disclosure states are session UI +only: they must not persist, alter a composition, or create a second export path. + ### Required invariants -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 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. +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. Quick and content positioning may update only that same normalized transform and must enter the same history; neither may create a second position state. Content alignment may derive useful anchors only from the rotated render bounds and must not read or analyse image pixels. Original-composition comparison may replace only the preview transform; export must continue using the active edited transform. Focus preview may change only workspace layout and must not create separate rendering or export state. Composition check may only derive crop and enlargement from the active image, canvas, and transform. Enlargement must use geometry shared with `renderWallpaper`; it must not inspect pixels, persist results, or alter renderer inputs. +2. Each device-orientation composition history retains at most 50 steps. Continuous drag, pinch, wheel, slider, or number-input edits must commit as one meaningful step. Replacing or removing an image must clear transforms and histories for every device and orientation so the next image starts from its default composition. +3. 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. +4. Image bytes, filenames, Canvas pixels, thumbnail URLs, transforms, and composition history must not enter `localStorage`, Cache Storage, network requests, or analytics. A local thumbnail may use only the upload's `blob:` URL; the bundled sample uses a same-origin static URL. +5. 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. +6. 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`. +7. 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 -| Data | Created | Retained | Cleared or updated | -| --------------------------- | ------------------------------------------------ | ---------------------------------------------------- | ---------------------------------------------------------- | -| Selected image | Decoded from a local `File` by `decodeImageFile` | React state and a browser image object | Replace, remove, refresh, or close | -| Composition/export settings | User interaction | Current-page React state | Refresh or close | -| Locale | User selection | `wallpect:locale:v1` | Overwrite or clear site data | -| Recent devices | Device selection | `wallpect:recent-devices:v1`, up to four valid IDs | Overwrite or clear site data | -| Public app shell | First production load | `wallpect-v3` Cache Storage and normal browser cache | Old `wallpect-*` caches removed when a new cache activates | +| Data | Created | Retained | Cleared or updated | +| ----------------------------------- | ------------------------------------------------ | ----------------------------------------------------- | ---------------------------------------------------------- | +| Selected image and thumbnail | Decoded from a local `File` by `decodeImageFile` | React state, browser image, and temporary `blob:` URL | Replace, remove, refresh, or close | +| Composition/history/export settings | User interaction | Current-page React state | Reset on replace/remove; clear on refresh or close | +| Locale | User selection | `wallpect:locale:v1` | Overwrite or clear site data | +| Recent devices | Device selection | `wallpect:recent-devices:v1`, up to four valid IDs | Overwrite or clear site data | +| Public app shell | First production load | `wallpect-v4` Cache Storage and normal browser cache | Old `wallpect-*` caches removed when a new cache activates | ### Verification gates @@ -108,4 +128,4 @@ The diagram above intentionally has no data path from the selected image, Canvas - `npm run format:check`: formatting validation. - `npm run worker:check`: Static Assets dry-run without deployment. - `npm run test:e2e`: production build/preview coverage across the mapped Chromium, WebKit, Edge, and Firefox flows. -- E2E must cover exact PNG/JPEG/WebP dimensions, zero HTTP(S) egress after upload, Object URL release, offline navigation, keyboard editing, mobile overflow, and legal/privacy entry points. +- E2E must cover exact PNG/JPEG/WebP dimensions, zero HTTP(S) egress after upload, Object URL release, offline navigation, keyboard and content positioning, first-view download visibility at 1280×720, mobile workflow handoff and overflow, and legal/privacy entry points. diff --git a/docs/DEVICE_PROFILE_GUIDE.md b/docs/DEVICE_PROFILE_GUIDE.md index ab5107b..f344a19 100644 --- a/docs/DEVICE_PROFILE_GUIDE.md +++ b/docs/DEVICE_PROFILE_GUIDE.md @@ -20,7 +20,7 @@ Mac mini、Mac Studio 與 Mac Pro 沒有內建螢幕;它們應按實際連接 只有相同螢幕吋數或相同實體解析度並不足以合併。例如 iPhone 12 與 iPhone 13 的輸出同為 `1170 × 2532`,但瀏海幾何不同,因此保留兩個配置。相反,iPhone 14 Pro、iPhone 15、iPhone 15 Pro 與 iPhone 16 的畫布及遮擋幾何相同,所以共用一個選項。 -每個配置的 `compatibleModels` 保存完整型號名稱、推出年份及對角線;搜尋會讀取所有別名、年份、吋數、實體及邏輯解析度。`releaseYear` 代表該配置內最新型號的年份,只用於排序。合併配置的 `chassis` 是相對外框的代表值,不應被解讀為每個相容型號都具有完全相同的機身尺寸。 +每個配置的 `compatibleModels` 保存完整型號名稱、推出年份及對角線;單一搜尋欄會跨 iPhone、iPad、Mac 與 Apple 顯示器讀取所有別名、年份、吋數、實體及邏輯解析度。搜尋會正規化大小寫、空格、標點及 `×`/`x` 解析度寫法,並容許以不同次序輸入多個關鍵字。`releaseYear` 代表該配置內最新型號的年份,只用於排序。合併配置的 `chassis` 是相對外框的代表值,不應被解讀為每個相容型號都具有完全相同的機身尺寸。 ### 舊 ID 遷移 @@ -68,7 +68,7 @@ Models may share one option only when all wallpaper-relevant data matches: 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. -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. +Each configuration's `compatibleModels` records every full model name, release year, and diagonal. One search spans iPhone, iPad, Mac, and Apple displays and indexes all aliases, years, diagonals, physical resolutions, and logical resolutions. Search normalizes case, whitespace, punctuation, and `×`/`x` resolution forms, and accepts multiple terms in any order. `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. ### Previous-ID migration diff --git a/docs/LEGAL_PRIVACY_AND_DATA_POLICY.md b/docs/LEGAL_PRIVACY_AND_DATA_POLICY.md index bcf5112..f284c9a 100644 --- a/docs/LEGAL_PRIVACY_AND_DATA_POLICY.md +++ b/docs/LEGAL_PRIVACY_AND_DATA_POLICY.md @@ -1,7 +1,7 @@ # Wallpect 條款、私隱與資料政策 -生效日期:2026 年 8 月 10 日 -適用版本:0.3.0 +生效日期:2026 年 8 月 11 日 +適用版本:0.4.0 > 本文件記錄 Wallpect 測試版目前的產品行為及維護規則,只屬一般資料,並非法律意見。若加入收費、帳戶、廣告、分析、個人資料表格、使用者內容託管或新司法管轄區,必須先接受獨立法律審閱。 @@ -26,6 +26,8 @@ Wallpect 前端不設定廣告 Cookie、不載入客戶端分析、不建立帳戶、不收集表格資料,也不把使用者選擇的圖片傳送到 Cloudflare。正常瀏覽公開網站仍會產生網絡請求;不應把「圖片不上傳」誤寫成「託管供應商完全不處理任何網絡資料」。 +Repository 設定會關閉 Wrangler usage metrics、deployment dependency metadata instrumentation 及 Worker observability。這不會阻止 Cloudflare 按其服務條款及私隱政策處理傳送公開網站所需的一般網絡與路由資料。 + 0.3.0 會在首次讀取時把舊 `wallpect:recent-devices` key 及 0.3.0 前按個別型號保存的裝置 ID,映射到新的整合配置 ID、移除重複值,並把最多四個有效 ID 寫入 `wallpect:recent-devices:v1`;舊 key 隨後會被移除。遷移不會加入圖片、檔名、構圖或其他資料;若 storage 不可用,編輯器仍可運作而不保存偏好。 參考:[Cloudflare Privacy Policy](https://www.cloudflare.com/policies/privacy/)。 @@ -49,8 +51,10 @@ Apple 網站條款限制複製、重新發佈、鏡像及自動擷取其內容 - `Wallpect` 不包含 Apple 商標,品牌視覺不使用 Apple Logo、產品圖片、官方圖示或仿製 Apple 網站 trade dress。 - Apple、iPhone、iPad、Mac、MacBook、iMac 及相關名稱只用於識別相容裝置設定檔,顯著程度低於 Wallpect 品牌。 - Wallpect 是獨立工具,未獲 Apple Inc. 授權、贊助或背書。 +- 內建示範圖是合成的項目素材,並保留其內容真實性 provenance;當中不含 Apple 產品素材或使用者圖片。 - 使用者圖片的權利不會因本機處理而轉移給 Wallpect;使用者仍須確保自己有權使用圖片。 - Wallpect 程式碼按 repository 的 [MIT License](../LICENSE) 發布;MIT License 不授予第三方商標、資料或使用者圖片的權利。 +- Production browser bundle 內第三方套件的授權文字見[第三方授權通知](../public/THIRD_PARTY_NOTICES.txt);build 與測試工具仍受各自套件附帶的授權規管。 參考:[Apple 使用商標和版權的指引](https://www.apple.com/legal/intellectual-property/guidelinesfor3rdparties.html)。 @@ -85,8 +89,8 @@ Apple 網站條款限制複製、重新發佈、鏡像及自動擷取其內容 # Wallpect Terms, Privacy, and Data Policy -Effective date: 10 August 2026 -Applies to version: 0.3.0 +Effective date: 11 August 2026 +Applies to version: 0.4.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. @@ -107,6 +111,8 @@ On first read, version 0.3.0 maps both the legacy `wallpect:recent-devices` key 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. +The repository configuration disables Wrangler usage metrics, deployment dependency metadata instrumentation, and Worker observability. This does not prevent Cloudflare from processing ordinary network and routing data needed to deliver the public site under its service terms and privacy policy. + Reference: [Cloudflare Privacy Policy](https://www.cloudflare.com/policies/privacy/). ## 3. Device data and source rules @@ -115,6 +121,10 @@ Profiles are manually curated from linked public `https://support.apple.com` spe 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). +The bundled demo is synthetic project artwork with its content-authenticity provenance preserved; it contains no Apple product asset or user image. + +Wallpect source is released under the repository's [MIT License](../LICENSE). Licence text for third-party packages included in the production browser bundle is published in the [third-party notices](../public/THIRD_PARTY_NOTICES.txt); build and test tools remain governed by the licences distributed with their packages. + ## 4. Third-party and API gate Any new source, SDK, or API must document its provider, official terms or licence, permitted fields and uses, redistribution and attribution rules, retention and withdrawal handling, rate limits, schema validation, privacy impact, secret storage, and image-egress boundary before merge. Unknown usage or redistribution rights block adoption. diff --git a/package-lock.json b/package-lock.json index e7b955a..4d3229c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wallpect", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wallpect", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "lucide-react": "1.24.0", @@ -22,17 +22,17 @@ "@types/node": "24.10.1", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", - "@vitejs/plugin-react": "6.0.3", + "@vitejs/plugin-react": "6.0.5", "eslint": "10.7.0", "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-refresh": "0.5.3", - "jsdom": "29.1.1", + "jsdom": "30.0.1", "prettier": "3.9.5", "typescript": "5.9.3", "typescript-eslint": "8.63.0", - "vite": "8.1.4", + "vite": "8.2.1", "vitest": "4.1.10", - "wrangler": "4.114.0" + "wrangler": "4.120.1" }, "engines": { "node": ">=22" @@ -46,56 +46,58 @@ "license": "MIT" }, "node_modules/@asamuzakjp/css-color": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", - "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@csstools/css-calc": "^3.2.0", - "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", - "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1" + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.13.0 || >=24.0.0" } }, - "node_modules/@asamuzakjp/generational-cache": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", - "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "20 || >=22" } }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -386,9 +388,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260722.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260722.1.tgz", - "integrity": "sha512-vZOP8vIS3NwnuaO+gz0FZ7kIGeiO3bZmxV35Ph9zOXKSREhDFlH7wQ7mkCdhW3O4jnXsew+XT7b+DNEI2CcJGQ==", + "version": "1.20260804.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260804.1.tgz", + "integrity": "sha512-191/PPEFicRK2wK69eXzSjnLgHL79k7zR2VUpyIr8rhFgGns1b5bTHgnBuUrgU2LPbEtwbE5eL2hJ0uhLAFEow==", "cpu": [ "x64" ], @@ -403,9 +405,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260722.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260722.1.tgz", - "integrity": "sha512-EmIQymihDq6WNdER4+LF8Qn80yqayBUpJ+tkOO7wmY8pmgfyXjIUFNXotl21AHovTeu2seR7HdVUgeN/BilCWw==", + "version": "1.20260804.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260804.1.tgz", + "integrity": "sha512-aI2cAFLsrNkSz3kLSQrgrO9ICTfY7jb2h0jgaWDE9mQLQQDfpXeYrKWt07tTgMmeNP79o9w3NZe3aqtt8mTGHQ==", "cpu": [ "arm64" ], @@ -420,9 +422,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260722.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260722.1.tgz", - "integrity": "sha512-jvZ3k9fxcnEn04s80CgIYxQfpOyAiz/8qC42DP8EBa9tR27qWyg9wmm31zIobVlrgBZn/+8NfdP73avRGcQOjQ==", + "version": "1.20260804.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260804.1.tgz", + "integrity": "sha512-KBCjxBIlN2jucfQGaTK4EgmPsWzQgYR/zYhPfi3mkWwdoTyG1dgrt2aizKps/SYse85ci/SOxojKk0/K7vstPw==", "cpu": [ "x64" ], @@ -437,9 +439,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260722.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260722.1.tgz", - "integrity": "sha512-BOSB55SMNdy+DA5uj2WirgiNanpHGis5PVvXH1wSfvjRKr4JGgWK+EZzxz0RFUo6QjjQQC/NimEzNZ7va7jmKg==", + "version": "1.20260804.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260804.1.tgz", + "integrity": "sha512-7lswfarBZ7xkHpTFrNb74ExwOltIalVaASc+GOYfCNtnwFxK/JZSVByfm/hnZEBtrHU7fMY2z7dYT64rwS0pHg==", "cpu": [ "arm64" ], @@ -454,9 +456,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260722.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260722.1.tgz", - "integrity": "sha512-sYM8YgUpKnRz2xjvdJLX1Ojzoi4MlA4gk8WTTExhGydjYB2UTs5NIbv0ZmpKgMoK9io3ixgmiW56ZnTbcWOdiA==", + "version": "1.20260804.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260804.1.tgz", + "integrity": "sha512-GOgRWYtxISN5rAWfx3K7zubt3xEn0/ZPFrbcLL+GwT4ouCKyNoHqfT1cPNB6Nx7t8BHEjnuTG7gkHO4Wd5ORdg==", "cpu": [ "x64" ], @@ -515,9 +517,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", - "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", "dev": true, "funding": [ { @@ -539,9 +541,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", - "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", "dev": true, "funding": [ { @@ -556,7 +558,7 @@ "license": "MIT", "dependencies": { "@csstools/color-helpers": "^6.1.0", - "@csstools/css-calc": "^3.2.1" + "@csstools/css-calc": "^3.3.0" }, "engines": { "node": ">=20.19.0" @@ -590,9 +592,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", - "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", "dev": true, "funding": [ { @@ -634,33 +636,10 @@ "node": ">=20.19.0" } }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "dev": true, "license": "MIT", "optional": true, @@ -1947,29 +1926,10 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@oxc-project/types": { - "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", "dev": true, "license": "MIT", "funding": { @@ -2022,9 +1982,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", "cpu": [ "arm64" ], @@ -2039,9 +1999,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", "cpu": [ "arm64" ], @@ -2056,9 +2016,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", "cpu": [ "x64" ], @@ -2073,9 +2033,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", "cpu": [ "x64" ], @@ -2090,9 +2050,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", "cpu": [ "arm" ], @@ -2107,9 +2067,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", "cpu": [ "arm64" ], @@ -2127,9 +2087,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", "cpu": [ "arm64" ], @@ -2147,9 +2107,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", "cpu": [ "ppc64" ], @@ -2167,9 +2127,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", "cpu": [ "s390x" ], @@ -2187,9 +2147,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", "cpu": [ "x64" ], @@ -2207,9 +2167,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", "cpu": [ "x64" ], @@ -2227,9 +2187,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", "cpu": [ "arm64" ], @@ -2243,29 +2203,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", "cpu": [ "arm64" ], @@ -2280,9 +2221,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", "cpu": [ "x64" ], @@ -2317,9 +2258,9 @@ } }, "node_modules/@speed-highlight/core": { - "version": "1.2.17", - "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", - "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", "dev": true, "license": "CC0-1.0" }, @@ -2420,17 +2361,6 @@ "@testing-library/dom": ">=7.21.4" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -2752,9 +2682,9 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", - "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", "dev": true, "license": "MIT", "dependencies": { @@ -3771,39 +3701,39 @@ "license": "MIT" }, "node_modules/jsdom": { - "version": "29.1.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", - "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^5.1.11", - "@asamuzakjp/dom-selector": "^7.1.1", + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.3", - "@exodus/bytes": "^1.15.0", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.3.5", + "lru-cache": "^11.5.2", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.1", - "undici": "^7.25.0", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.1", + "whatwg-url": "^17.1.0", "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "canvas": "^3.0.0" + "canvas": "^3.2.3" }, "peerDependenciesMeta": { "canvas": { @@ -3821,6 +3751,21 @@ "node": "20 || >=22" } }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3903,9 +3848,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -3919,23 +3864,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -3954,9 +3899,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -3975,9 +3920,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -3996,9 +3941,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -4017,9 +3962,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -4038,9 +3983,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], @@ -4062,9 +4007,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], @@ -4086,9 +4031,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], @@ -4110,9 +4055,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], @@ -4134,9 +4079,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -4155,9 +4100,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -4249,26 +4194,33 @@ } }, "node_modules/miniflare": { - "version": "4.20260722.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260722.0.tgz", - "integrity": "sha512-LW6ABMhCx/yIEFBLC/DO4yAhdm2T/G7jp7pr5T2kj895+CCIaHZqpMXdW9O6YE48LcYcCJChwWc8aEs1vpbTXw==", + "version": "5.20260804.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260804.0-alpha.tgz", + "integrity": "sha512-UDegnTukIpJCI/lNyQXU7B8kYFLcMZihlxqqOocnc62tV8AO6XofmFmZm3ZLDXTWZpkZnLLJ2sKCSlTIEXO3dQ==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", - "undici": "7.28.0", - "workerd": "1.20260722.1", + "undici": "7.29.0", + "workerd": "1.20260804.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, - "bin": { - "miniflare": "bootstrap.js" - }, "engines": { "node": ">=22.0.0" } }, + "node_modules/miniflare/node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -4293,9 +4245,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -4492,9 +4444,9 @@ } }, "node_modules/postcss": { - "version": "8.5.19", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", - "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -4512,7 +4464,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4626,13 +4578,13 @@ } }, "node_modules/rolldown": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.139.0", + "@oxc-project/types": "=0.143.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -4642,21 +4594,20 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.5", - "@rolldown/binding-darwin-arm64": "1.1.5", - "@rolldown/binding-darwin-x64": "1.1.5", - "@rolldown/binding-freebsd-x64": "1.1.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", - "@rolldown/binding-linux-arm64-gnu": "1.1.5", - "@rolldown/binding-linux-arm64-musl": "1.1.5", - "@rolldown/binding-linux-ppc64-gnu": "1.1.5", - "@rolldown/binding-linux-s390x-gnu": "1.1.5", - "@rolldown/binding-linux-x64-gnu": "1.1.5", - "@rolldown/binding-linux-x64-musl": "1.1.5", - "@rolldown/binding-openharmony-arm64": "1.1.5", - "@rolldown/binding-wasm32-wasi": "1.1.5", - "@rolldown/binding-win32-arm64-msvc": "1.1.5", - "@rolldown/binding-win32-x64-msvc": "1.1.5" + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" } }, "node_modules/saxes": { @@ -4996,13 +4947,13 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=22.19.0" } }, "node_modules/undici-types": { @@ -5064,16 +5015,16 @@ } }, "node_modules/vite": { - "version": "8.1.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", - "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", + "lightningcss": "^1.33.0", "picomatch": "^4.0.5", - "postcss": "^8.5.16", - "rolldown": "~1.1.4", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "bin": { @@ -5090,7 +5041,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", + "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -5338,9 +5289,9 @@ } }, "node_modules/workerd": { - "version": "1.20260722.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260722.1.tgz", - "integrity": "sha512-NycKuc1x2onvsRfGGpM093vRlLFU2zHDAM0+APpccfg4+gZxDGCH27RmdDvkeBuoZyYqgLo3oAfF6re4mvC3vQ==", + "version": "1.20260804.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260804.1.tgz", + "integrity": "sha512-b0P38g5/ssemwWxd/mafNYggEZ0ere7PCiUH6RCHkgqjRhhXTP45nDiL2L3iIvi8uF2IjNrGpVgMXEunCaeY/w==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -5351,17 +5302,17 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260722.1", - "@cloudflare/workerd-darwin-arm64": "1.20260722.1", - "@cloudflare/workerd-linux-64": "1.20260722.1", - "@cloudflare/workerd-linux-arm64": "1.20260722.1", - "@cloudflare/workerd-windows-64": "1.20260722.1" + "@cloudflare/workerd-darwin-64": "1.20260804.1", + "@cloudflare/workerd-darwin-arm64": "1.20260804.1", + "@cloudflare/workerd-linux-64": "1.20260804.1", + "@cloudflare/workerd-linux-arm64": "1.20260804.1", + "@cloudflare/workerd-windows-64": "1.20260804.1" } }, "node_modules/wrangler": { - "version": "4.114.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.114.0.tgz", - "integrity": "sha512-M65P25t5UHA1TIJfgZXDcj+YzVobgKdRguM2QPz0xnxLFuOcuE3ErgllDht0iaho7MS4o0g/Bb4YK2+GT+bibg==", + "version": "4.120.1", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.120.1.tgz", + "integrity": "sha512-rQJ38rY02XiVITbSBWHC7oap3M2evcXgsC4CdlIX4WQENUZMMnue9j2vXO4aIi39KR+jDH+UHK8JNqp1+UjkRQ==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { @@ -5369,10 +5320,10 @@ "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", - "miniflare": "4.20260722.0", + "miniflare": "5.20260804.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260722.1" + "workerd": "1.20260804.1" }, "bin": { "cf-wrangler": "bin/cf-wrangler.js", @@ -5386,7 +5337,7 @@ "fsevents": "2.3.3" }, "peerDependencies": { - "@cloudflare/workers-types": "^5.20260722.1" + "@cloudflare/workers-types": "^5.20260804.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { diff --git a/package.json b/package.json index 5e1df79..9b775c9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "wallpect", "private": true, - "version": "0.3.0", + "version": "0.4.0", "description": "Privacy-first wallpaper preview and fitting tool for Apple devices.", "license": "MIT", "homepage": "https://wallpect.k-y.cc", @@ -21,13 +21,14 @@ "build": "tsc -b && vite build", "preview": "vite preview", "lint": "eslint . --max-warnings=0", + "public-safety:check": "node scripts/check-public-safety.mjs", "format": "prettier --write .", "format:check": "prettier --check .", "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", "performance:check": "node scripts/check-build-budget.mjs", - "check": "npm run lint && npm test && npm run build && npm run performance:check", + "check": "npm run lint && npm run public-safety:check && npm test && npm run build && npm run performance:check", "worker:dev": "wrangler dev", "worker:check": "wrangler deploy --dry-run", "worker:preview": "wrangler versions upload", @@ -47,16 +48,16 @@ "@types/node": "24.10.1", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", - "@vitejs/plugin-react": "6.0.3", + "@vitejs/plugin-react": "6.0.5", "eslint": "10.7.0", "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-refresh": "0.5.3", - "jsdom": "29.1.1", + "jsdom": "30.0.1", "prettier": "3.9.5", "typescript": "5.9.3", "typescript-eslint": "8.63.0", - "vite": "8.1.4", + "vite": "8.2.1", "vitest": "4.1.10", - "wrangler": "4.114.0" + "wrangler": "4.120.1" } } diff --git a/public/THIRD_PARTY_NOTICES.txt b/public/THIRD_PARTY_NOTICES.txt new file mode 100644 index 0000000..ccfcaa7 --- /dev/null +++ b/public/THIRD_PARTY_NOTICES.txt @@ -0,0 +1,70 @@ +Wallpect Third-Party Notices +Last reviewed: 11 August 2026 + +This notice covers third-party code included in Wallpect's production browser bundle. +Build and test tools are not shipped as part of the browser application and remain subject to the +licence files distributed with their respective packages. + +1. React 19.2.7, React DOM 19.2.7, and Scheduler 0.27.0 + +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +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. + +2. Lucide React 1.24.0 + +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +Some Lucide icons are derived from the Feather project and use the MIT License: + +Copyright (c) 2013-present Cole Bemis + +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/public/sw.js b/public/sw.js index b449635..eb6ca39 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ const CACHE_PREFIX = "wallpect-"; -const CACHE = `${CACHE_PREFIX}v3`; +const CACHE = `${CACHE_PREFIX}v4`; const SHELL = ["/", "/assets/sample-aurora-wallpaper.png"]; self.addEventListener("install", (event) => { diff --git a/scripts/check-public-safety.mjs b/scripts/check-public-safety.mjs new file mode 100644 index 0000000..6969986 --- /dev/null +++ b/scripts/check-public-safety.mjs @@ -0,0 +1,82 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; + +const trackedAndNewFiles = execFileSync( + "git", + ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], + { encoding: "utf8" }, +) + .split("\0") + .filter(Boolean); + +const forbiddenPaths = [ + { label: "environment file", pattern: /(^|\/)\.env(?:\.|$)/i }, + { label: "local Worker variables", pattern: /(^|\/)\.dev\.vars$/i }, + { label: "private key or certificate", pattern: /\.(?:pem|key|p12|pfx|jks|keystore)$/i }, + { label: "database or export", pattern: /\.(?:sqlite3?|db|dump|sql|bak)$/i }, + { label: "private Worker state", pattern: /(^|\/)\.wrangler\//i }, + { + label: "generated output", + pattern: /(^|\/)(?:dist|coverage|playwright-report|test-results)\//i, + }, +]; + +const forbiddenContent = [ + { + label: "private key", + pattern: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/, + }, + { label: "AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ }, + { label: "GitHub token", pattern: /\b(?:github_pat_|gh[pousr]_)[A-Za-z0-9_]{20,}\b/ }, + { label: "OpenAI-style secret", pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/ }, + { label: "Slack token", pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ }, + { label: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{30,}\b/ }, + { label: "credential in URL", pattern: /https?:\/\/[^/\s:@]+:[^@\s/]+@/i }, + { + label: "absolute macOS user path", + pattern: /(?:^|[\s"'`(])\/Users\/[^/<>{}\s]+\//m, + }, + { + label: "absolute Linux user path", + pattern: /(?:^|[\s"'`(])\/home\/[^/<>{}\s]+\//m, + }, + { label: "absolute Windows user path", pattern: /[A-Za-z]:\\Users\\[^\\\s]+\\/ }, + { + label: "embedded email address", + pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/, + }, + { + label: "Cloudflare resource identifier", + pattern: /\b(?:account_id|zone_id|database_id|namespace_id)\s*[:=]\s*["'][a-f0-9]{16,}["']/i, + }, + { + label: "assigned secret-like value", + pattern: + /\b(?:api[_-]?key|api[_-]?token|access[_-]?token|auth[_-]?token|client[_-]?secret|private[_-]?key|database[_-]?url|password)\s*[:=]\s*["'][^"'\s]{12,}["']/i, + }, +]; + +const findings = []; + +for (const file of trackedAndNewFiles) { + for (const rule of forbiddenPaths) { + if (rule.pattern.test(file)) findings.push(`${rule.label}: ${file}`); + } + + const bytes = readFileSync(file); + if (bytes.includes(0)) continue; + const content = bytes.toString("utf8"); + for (const rule of forbiddenContent) { + if (rule.pattern.test(content)) findings.push(`${rule.label}: ${file}`); + } +} + +if (findings.length) { + console.error( + "Public-safety check failed. Review these files without printing sensitive values:", + ); + for (const finding of [...new Set(findings)].sort()) console.error(`- ${finding}`); + process.exitCode = 1; +} else { + console.log(`Public-safety check passed for ${trackedAndNewFiles.length} reviewed files.`); +} diff --git a/src/app/App.tsx b/src/app/App.tsx index 021b045..8766954 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,4 +1,4 @@ -import { Component, useState, type ErrorInfo, type ReactNode } from "react"; +import { Component, useCallback, useEffect, useState, type ErrorInfo, type ReactNode } from "react"; import { AdjustmentControls } from "../components/editor-controls/AdjustmentControls"; import { DeviceSelector } from "../components/device-selector/DeviceSelector"; import { DeviceInformation } from "../components/export/DeviceInformation"; @@ -42,26 +42,58 @@ class ErrorBoundary extends Component< function Workspace() { const [info, setInfo] = useState(null); - const { announcement } = useEditor(); + const [previewFocused, setPreviewFocused] = useState(false); + const [mobilePanelExpanded, setMobilePanelExpanded] = useState(false); + const { announcement, setAnnouncement } = useEditor(); const { t } = useI18n(); + const changePreviewFocus = useCallback( + (focused: boolean) => { + setPreviewFocused(focused); + setAnnouncement(t(focused ? "Preview focus enabled." : "Editor controls restored.")); + }, + [setAnnouncement, t], + ); + const changeMobilePanelSize = useCallback( + (expanded: boolean) => { + setMobilePanelExpanded(expanded); + setAnnouncement( + t(expanded ? "Editing panel expanded." : "Balanced mobile workspace restored."), + ); + }, + [setAnnouncement, t], + ); + + useEffect(() => { + if (!previewFocused) return; + const handleEscape = (event: globalThis.KeyboardEvent) => { + if (event.key !== "Escape" || document.querySelector("dialog[open]")) return; + event.preventDefault(); + changePreviewFocus(false); + }; + window.addEventListener("keydown", handleEscape); + return () => window.removeEventListener("keydown", handleEscape); + }, [changePreviewFocus, previewFocused]); + return ( -
+
- +
- +
setInfo(null)} />
diff --git a/src/components/device-selector/DeviceSelector.tsx b/src/components/device-selector/DeviceSelector.tsx index 6d93602..d9c816c 100644 --- a/src/components/device-selector/DeviceSelector.tsx +++ b/src/components/device-selector/DeviceSelector.tsx @@ -1,9 +1,19 @@ -import { Check, Monitor, Search, Smartphone, Tablet, X } from "lucide-react"; +import { + Check, + ChevronDown, + ChevronUp, + Monitor, + Search, + Smartphone, + Tablet, + X, +} from "lucide-react"; import { useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import { getDeviceDiagonalLabel, getDeviceReleaseYearLabel, - getDeviceSearchText, + getDeviceSearchRank, + matchesDeviceSearch, } from "../../data/devices"; import { useEditor } from "../../state/editor-context"; import { useI18n } from "../../i18n/i18n"; @@ -25,21 +35,43 @@ function groupForProfile(profile: DeviceProfile): DeviceGroup { return "mac"; } +function groupLabel(group: DeviceGroup) { + if (group === "iphone") return "iPhone"; + if (group === "ipad") return "iPad"; + return "Mac"; +} + function DeviceGlyph({ group }: { group: DeviceGroup }) { if (group === "iphone") return ; if (group === "ipad") return ; return ; } -export function DeviceSelector() { +export function DeviceSelector({ collapsible = false }: { collapsible?: boolean }) { const { t } = useI18n(); - const { profiles, profile, deviceId, setDeviceId, orientation, setOrientation, recentDeviceIds } = - useEditor(); - const initialGroup = groupForProfile(profile); - const [group, setGroup] = useState(initialGroup); + const { + profiles, + profile, + deviceId, + setDeviceId, + orientation, + setOrientation, + recentDeviceIds, + setMobileTab, + } = useEditor(); + const [browseContext, setBrowseContext] = useState(() => ({ + deviceId, + group: groupForProfile(profile), + })); + const group = + browseContext.deviceId === deviceId ? browseContext.group : groupForProfile(profile); const [query, setQuery] = useState(""); + const [browserOpen, setBrowserOpen] = useState(!collapsible); const listRef = useRef(null); + const browserId = useId(); const resultCountId = useId(); + const searchScopeId = useId(); + const isSearching = Boolean(query.trim()); const groupCounts = useMemo( () => ({ iphone: profiles.filter((item) => belongs(item, "iphone")).length, @@ -49,15 +81,16 @@ export function DeviceSelector() { [profiles], ); const filtered = useMemo(() => { - const normalizedQuery = query.trim().toLowerCase(); return profiles - .filter( - (item) => - belongs(item, group) && - (!normalizedQuery || getDeviceSearchText(item).includes(normalizedQuery)), - ) - .sort((a, b) => b.releaseYear - a.releaseYear); - }, [group, profiles, query]); + .filter((item) => (isSearching ? matchesDeviceSearch(item, query) : belongs(item, group))) + .sort((a, b) => { + if (isSearching) { + const rankDifference = getDeviceSearchRank(a, query) - getDeviceSearchRank(b, query); + if (rankDifference) return rankDifference; + } + return b.releaseYear - a.releaseYear || a.model.localeCompare(b.model); + }); + }, [group, isSearching, profiles, query]); const recentProfiles = useMemo( () => recentDeviceIds @@ -69,19 +102,29 @@ export function DeviceSelector() { useLayoutEffect(() => { const list = listRef.current; const selected = list?.querySelector('[aria-pressed="true"]'); - if (!list || !selected || query) return; + if (!list || !selected || isSearching) return; const selectedTopWithinList = selected.offsetTop - list.offsetTop; list.scrollTop = Math.max(0, selectedTopWithinList - 4); - }, [deviceId, filtered.length, group, query]); + }, [browserOpen, deviceId, filtered.length, group, isSearching]); + + const selectDevice = (item: DeviceProfile) => { + setBrowseContext({ deviceId: item.id, group: groupForProfile(item) }); + setDeviceId(item.id); + setMobileTab("adjust"); + if (collapsible) setBrowserOpen(false); + }; return ( - { - setGroup(next); - setQuery(""); - }} - label={t("Device category")} - options={DEVICE_GROUPS.map((value) => ({ - value, - label: `${value === "iphone" ? "iPhone" : value === "ipad" ? "iPad" : "Mac"} · ${groupCounts[value]}`, - }))} - /> -
{profile.supportedOrientations.length > 1 ? (
{t("Orientation")} diff --git a/src/components/editor-controls/AdjustmentControls.tsx b/src/components/editor-controls/AdjustmentControls.tsx index 450548a..3dee02f 100644 --- a/src/components/editor-controls/AdjustmentControls.tsx +++ b/src/components/editor-controls/AdjustmentControls.tsx @@ -1,15 +1,35 @@ -import { Focus, RotateCcw } from "lucide-react"; +import { useRef } from "react"; +import { + ArrowDown, + ArrowLeft, + ArrowRight, + ArrowUp, + Focus, + Redo2, + RotateCcw, + Undo2, +} from "lucide-react"; +import { clampTransform, clampTransformForImage } from "../../core/fit"; import { pixelsToNormalized, normalizedToPixels } from "../../core/transforms"; import { useI18n } from "../../i18n/i18n"; import { useEditor } from "../../state/editor-context"; -import type { BackgroundMode, FitMode } from "../../types/editor"; -import { PanelSection, RangeControl, Segmented } from "../ui"; +import type { BackgroundMode, FitMode, ImageTransform } from "../../types/editor"; +import { IconButton, PanelSection, RangeControl, Segmented } from "../ui"; +import { CompositionFeedback } from "./CompositionFeedback"; +import { ContentPositionControls } from "./ContentPositionControls"; export function AdjustmentControls() { const { t } = useI18n(); const { + image, transform, setTransform, + beginTransformEdit, + commitTransformEdit, + canUndoTransform, + canRedoTransform, + undoTransform, + redoTransform, orientedDevice, backgroundMode, setBackgroundMode, @@ -20,8 +40,34 @@ export function AdjustmentControls() { } = useEditor(); const xPixels = normalizedToPixels(transform.translateX, orientedDevice.outputWidth); const yPixels = normalizedToPixels(transform.translateY, orientedDevice.outputHeight); + const backgroundControlRef = useRef(null); + const clampForCurrentImage = (next: ImageTransform) => + image && image.width > 0 && image.height > 0 + ? clampTransformForImage( + image.width, + image.height, + orientedDevice.outputWidth, + orientedDevice.outputHeight, + next, + ) + : clampTransform(next); + const revealBackgroundControl = () => { + backgroundControlRef.current?.scrollIntoView?.({ block: "nearest" }); + }; + const nudgePosition = (axis: "x" | "y", pixels: number) => { + const dimension = axis === "x" ? orientedDevice.outputWidth : orientedDevice.outputHeight; + setTransform((current) => + clampForCurrentImage({ + ...current, + [axis === "x" ? "translateX" : "translateY"]: + current[axis === "x" ? "translateX" : "translateY"] + + pixelsToNormalized(pixels, dimension), + }), + ); + }; return ( + value={transform.fitMode} onChange={(fitMode) => setTransform((current) => ({ ...current, fitMode }))} @@ -31,68 +77,8 @@ export function AdjustmentControls() { { value: "contain", label: t("Fit") }, ]} /> - setTransform((current) => ({ ...current, scale: value / 100 }))} - /> -
- {t("Position")} - - -
- setTransform((current) => ({ ...current, rotationDeg }))} - /> -
+ +
{t("Background")} value={backgroundMode} @@ -123,6 +109,80 @@ export function AdjustmentControls() {

)}
+
+
+ {t("Quick position")} + {t("10% per tap")} +
+
+ + + + + +
+
+ setTransform((current) => ({ ...current, scale: value / 100 }))} + onInteractionStart={beginTransformEdit} + onInteractionEnd={commitTransformEdit} + />
+
+ + +
+

+ {t("Fit, zoom, position, and rotation history is kept for this device and orientation.")} +

+
+ + {t("Precise controls")} + {t("Exact X/Y, one-pixel nudging, and rotation")} + + X {xPixels} · Y {yPixels} · {transform.rotationDeg}° + + +
+
+ {t("Position")} + + +
+
+
+ {t("Fine position")} + {t("1 px per tap")} +
+
+ nudgePosition("x", -1)}> + + + nudgePosition("y", -1)}> + + + nudgePosition("y", 1)}> + + + nudgePosition("x", 1)}> + + +
+
+ setTransform((current) => ({ ...current, rotationDeg }))} + onInteractionStart={beginTransformEdit} + onInteractionEnd={commitTransformEdit} + /> +
+
); } diff --git a/src/components/editor-controls/CompositionFeedback.tsx b/src/components/editor-controls/CompositionFeedback.tsx new file mode 100644 index 0000000..313ae8b --- /dev/null +++ b/src/components/editor-controls/CompositionFeedback.tsx @@ -0,0 +1,142 @@ +import { CheckCircle2, CircleAlert, Maximize2 } from "lucide-react"; +import { calculateCropPercent } from "../../core/fit"; +import { + calculateImageEnlargement, + formatImageEnlargement, + getImageResolutionSummary, + type ImageResolutionTone, +} from "../../core/image-quality"; +import { useI18n } from "../../i18n/i18n"; +import { useEditor } from "../../state/editor-context"; + +type CompositionTone = ImageResolutionTone; + +interface CompositionSummary { + label: string; + message: string; + tone: CompositionTone; +} + +interface CompositionFeedbackProps { + onShowWholeImage: () => void; +} + +const TONE_RANK: Record = { good: 0, caution: 1, warning: 2 }; + +function getCompositionSummary(crop: number, fitMode: "cover" | "contain"): CompositionSummary { + if (crop <= 1) { + return { + label: "Whole image visible", + message: + fitMode === "contain" + ? "The whole image is visible; choose Solid, Transparent, or Blur below for uncovered areas." + : "The image fills the wallpaper while keeping nearly all of its content visible.", + tone: "good", + }; + } + if (crop <= 15) { + return { + label: "Light crop", + message: "Only a small part sits outside the wallpaper; fine positioning may be enough.", + tone: "good", + }; + } + if (crop <= 40) { + return { + label: "Noticeable crop", + message: "Some content sits outside the wallpaper; reposition the subject or use Fit.", + tone: "caution", + }; + } + return { + label: "Heavy crop", + message: "A large part sits outside the wallpaper; use Fit if important content is missing.", + tone: "warning", + }; +} + +export function CompositionFeedback({ onShowWholeImage }: CompositionFeedbackProps) { + const { t } = useI18n(); + const { image, orientedDevice, transform, setTransform, setAnnouncement } = useEditor(); + if (!image || image.width <= 0 || image.height <= 0) return null; + const crop = calculateCropPercent( + image.width, + image.height, + orientedDevice.outputWidth, + orientedDevice.outputHeight, + transform, + ); + const enlargement = calculateImageEnlargement( + image.width, + image.height, + orientedDevice.outputWidth, + orientedDevice.outputHeight, + transform, + ); + const cropSummary = getCompositionSummary(crop, transform.fitMode); + const resolutionSummary = getImageResolutionSummary(enlargement); + const tone = + TONE_RANK[resolutionSummary.tone] > TONE_RANK[cropSummary.tone] + ? resolutionSummary.tone + : cropSummary.tone; + const CropIcon = cropSummary.tone === "warning" ? CircleAlert : CheckCircle2; + const ResolutionIcon = resolutionSummary.tone === "good" ? CheckCircle2 : CircleAlert; + const formattedEnlargement = formatImageEnlargement(enlargement); + const shouldOfferWholeImage = crop > 15; + + function showWholeImage() { + setTransform((current) => ({ + ...current, + fitMode: "contain", + scale: 1, + translateX: 0, + translateY: 0, + rotationDeg: 0, + })); + setAnnouncement(t("The whole image is now visible.")); + window.requestAnimationFrame(onShowWholeImage); + } + + return ( +
+
+ + + + {t(cropSummary.label)} + {crop.toFixed(1)}% + +
+

{t(cropSummary.message)}

+ {shouldOfferWholeImage ? ( + <> +

{t("Resets zoom, position, and rotation. You can undo this.")}

+ + + ) : null} +
+ + + + {t(resolutionSummary.label)} + + {formattedEnlargement ?? t("No enlargement")} + + +
+

{t(resolutionSummary.message)}

+
+ ); +} diff --git a/src/components/editor-controls/ContentPositionControls.tsx b/src/components/editor-controls/ContentPositionControls.tsx new file mode 100644 index 0000000..76f1829 --- /dev/null +++ b/src/components/editor-controls/ContentPositionControls.tsx @@ -0,0 +1,123 @@ +import { + alignImageToCanvas, + calculateImageAlignmentOffsets, + type ImageAlignment, + type ImageAlignmentAxis, + type ImageAlignmentOffsets, +} from "../../core/fit"; +import { useI18n } from "../../i18n/i18n"; +import { useEditor } from "../../state/editor-context"; +import { Segmented } from "../ui"; + +type AlignmentValue = ImageAlignment | "custom"; + +const MIN_ALIGNMENT_TRAVEL_PX = 4; +const ALIGNMENTS: ImageAlignment[] = ["start", "center", "end"]; +const HORIZONTAL_LABELS = ["Left side", "Center", "Right side"] as const; +const VERTICAL_LABELS = ["Top", "Middle", "Bottom"] as const; + +function hasUsefulTravel(offsets: ImageAlignmentOffsets, dimension: number) { + return Math.abs(offsets.start - offsets.end) * dimension >= MIN_ALIGNMENT_TRAVEL_PX; +} + +function activeAlignment( + value: number, + offsets: ImageAlignmentOffsets, + dimension: number, +): AlignmentValue { + const tolerance = Math.max(0.5 / dimension, Number.EPSILON); + return ( + ALIGNMENTS.find((alignment) => Math.abs(value - offsets[alignment]) <= tolerance) ?? "custom" + ); +} + +export function ContentPositionControls() { + const { t } = useI18n(); + const { image, orientedDevice, transform, setTransform, setAnnouncement } = useEditor(); + if (!image || image.width <= 0 || image.height <= 0) return null; + + const offsets = calculateImageAlignmentOffsets( + image.width, + image.height, + orientedDevice.outputWidth, + orientedDevice.outputHeight, + transform, + ); + const horizontalAvailable = hasUsefulTravel(offsets.horizontal, orientedDevice.outputWidth); + const verticalAvailable = hasUsefulTravel(offsets.vertical, orientedDevice.outputHeight); + if (!horizontalAvailable && !verticalAvailable) return null; + + function positionImage(axis: ImageAlignmentAxis, alignment: ImageAlignment, label: string) { + setTransform((current) => + alignImageToCanvas( + image!.width, + image!.height, + orientedDevice.outputWidth, + orientedDevice.outputHeight, + current, + axis, + alignment, + ), + ); + setAnnouncement(t("Image positioned to show the {position}.", { position: t(label) })); + } + + return ( +
+
+ {t("Content position")} + {t("Snap into view")} +
+

+ {t("Bring an important person, logo, or text into view without repeated dragging.")} +

+ {horizontalAvailable ? ( +
+ {t("Horizontal")} + + value={activeAlignment( + transform.translateX, + offsets.horizontal, + orientedDevice.outputWidth, + )} + onChange={(alignment) => { + if (alignment === "custom") return; + const index = ALIGNMENTS.indexOf(alignment); + positionImage("horizontal", alignment, HORIZONTAL_LABELS[index]); + }} + label={t("Horizontal content position")} + options={ALIGNMENTS.map((value, index) => ({ + value, + label: t(HORIZONTAL_LABELS[index]), + }))} + /> +
+ ) : null} + {verticalAvailable ? ( +
+ {t("Vertical")} + + value={activeAlignment( + transform.translateY, + offsets.vertical, + orientedDevice.outputHeight, + )} + onChange={(alignment) => { + if (alignment === "custom") return; + const index = ALIGNMENTS.indexOf(alignment); + positionImage("vertical", alignment, VERTICAL_LABELS[index]); + }} + label={t("Vertical content position")} + options={ALIGNMENTS.map((value, index) => ({ + value, + label: t(VERTICAL_LABELS[index]), + }))} + /> +
+ ) : null} +

+ {t("You can still drag the preview or use precise controls for a custom position.")} +

+
+ ); +} diff --git a/src/components/export/DeviceInformation.tsx b/src/components/export/DeviceInformation.tsx index 2e03e1b..52f8031 100644 --- a/src/components/export/DeviceInformation.tsx +++ b/src/components/export/DeviceInformation.tsx @@ -1,5 +1,6 @@ import { CircleAlert, ExternalLink } from "lucide-react"; import { calculateCropPercent } from "../../core/fit"; +import { calculateImageEnlargement, formatImageEnlargement } from "../../core/image-quality"; import { useI18n } from "../../i18n/i18n"; import { useEditor } from "../../state/editor-context"; import { PanelSection } from "../ui"; @@ -16,6 +17,16 @@ export function DeviceInformation() { transform, ) : 0; + const enlargement = image + ? calculateImageEnlargement( + image.width, + image.height, + orientedDevice.outputWidth, + orientedDevice.outputHeight, + transform, + ) + : null; + const formattedEnlargement = enlargement === null ? null : formatImageEnlargement(enlargement); const insets = orientedDevice.safeInsets; return ( @@ -51,6 +62,10 @@ export function DeviceInformation() {
{t("Estimated crop")}
{crop.toFixed(1)}%
+
+
{t("Estimated enlargement")}
+
{image ? (formattedEnlargement ?? t("No enlargement")) : "—"}
+
{t("Safe-area insets")}
diff --git a/src/components/export/ExportPanel.tsx b/src/components/export/ExportPanel.tsx index c938986..ffb7ea3 100644 --- a/src/components/export/ExportPanel.tsx +++ b/src/components/export/ExportPanel.tsx @@ -6,6 +6,11 @@ import { exportWallpaper, generateFilename, } from "../../core/export"; +import { + calculateImageEnlargement, + formatImageEnlargement, + getImageResolutionSummary, +} from "../../core/image-quality"; import { useI18n } from "../../i18n/i18n"; import { useEditor } from "../../state/editor-context"; import type { ExportFormat } from "../../types/editor"; @@ -50,6 +55,19 @@ export function ExportPanel({ compact = false }: { compact?: boolean }) { profile.slug, ], ); + const imageEnlargement = image + ? calculateImageEnlargement( + image.width, + image.height, + orientedDevice.outputWidth, + orientedDevice.outputHeight, + transform, + ) + : null; + const imageResolution = + imageEnlargement === null ? null : getImageResolutionSummary(imageEnlargement); + const formattedEnlargement = + imageEnlargement === null ? null : formatImageEnlargement(imageEnlargement); function setFormat(format: ExportFormat) { setExportSettings((current) => ({ @@ -107,44 +125,6 @@ export function ExportPanel({ compact = false }: { compact?: boolean }) { const content = ( <> - - value={exportSettings.format} - onChange={setFormat} - label={t("Export format")} - options={[ - { value: "png", label: "PNG" }, - { value: "jpeg", label: "JPEG" }, - { value: "webp", label: "WebP" }, - ]} - /> - {exportSettings.format !== "png" ? ( - - setExportSettings((current) => ({ ...current, quality: quality / 100 })) - } - /> - ) : null} -
@@ -152,18 +132,14 @@ export function ExportPanel({ compact = false }: { compact?: boolean }) { {orientedDevice.outputWidth} × {orientedDevice.outputHeight} px + {imageResolution ? ( + + {t("Source image")} · {t(imageResolution.label)} + {formattedEnlargement ? ` · ${formattedEnlargement}` : ""} + + ) : null}
- - ) : null} + {mobileTab !== "export" ? ( + + ) : null} +
-
+
{mobileTab === "image" ? : null} {mobileTab === "device" ? : null} {mobileTab === "adjust" ? : null} diff --git a/src/components/overlays/OverlayPanel.tsx b/src/components/overlays/OverlayPanel.tsx index 390920e..7506ac8 100644 --- a/src/components/overlays/OverlayPanel.tsx +++ b/src/components/overlays/OverlayPanel.tsx @@ -10,6 +10,7 @@ import { } from "lucide-react"; import { useI18n } from "../../i18n/i18n"; import { useEditor } from "../../state/editor-context"; +import type { OverlaySettings } from "../../types/editor"; import { PanelSection, Toggle } from "../ui"; const items = [ @@ -22,9 +23,24 @@ const items = [ ["grid", "Composition grid", "Rule-of-thirds guide", Grid3X3], ] as const; +const LOCK_PREVIEW_OVERLAYS = new Set([ + "statusBar", + "clock", + "bottomActions", + "homeIndicator", + "menuBar", + "dock", +]); + +function matchingPreviewMode(key: keyof OverlaySettings): "lock" | "safe" | null { + if (key === "safeArea") return "safe"; + return LOCK_PREVIEW_OVERLAYS.has(key) ? "lock" : null; +} + export function OverlayPanel() { const { t } = useI18n(); - const { profile, overlays, setOverlays } = useEditor(); + const { profile, overlays, setOverlays, previewMode, setPreviewMode, setAnnouncement } = + useEditor(); const visibleItems = profile.platform === "macOS" ? ([ @@ -35,6 +51,22 @@ export function OverlayPanel() { ["grid", "Composition grid", "Rule-of-thirds guide", Grid3X3], ] as const) : items; + const changeOverlay = (key: keyof OverlaySettings, checked: boolean) => { + setOverlays((current) => ({ ...current, [key]: checked })); + const nextPreviewMode = matchingPreviewMode(key); + if (!nextPreviewMode || previewMode === nextPreviewMode) return; + setPreviewMode(nextPreviewMode); + const modeLabel = t( + nextPreviewMode === "safe" + ? "Safe area" + : profile.platform === "macOS" + ? "Desktop" + : "Lock Screen", + ); + setAnnouncement( + t("Opened {mode} preview so this guide change is visible.", { mode: modeLabel }), + ); + }; return ( setOverlays((current) => ({ ...current, [key]: checked }))} + onChange={(checked) => changeOverlay(key, checked)} label={t(label)} description={t(description)} /> @@ -66,7 +98,9 @@ export function OverlayPanel() { ))}

- {t("Guides affect preview only unless “Export preview with guides” is enabled.")} + {t( + "Guide changes open the matching preview mode so the result is visible. They affect preview only unless “Export preview with guides” is enabled.", + )}

); diff --git a/src/components/preview/DevicePreview.tsx b/src/components/preview/DevicePreview.tsx index 2a0fe4e..97b0702 100644 --- a/src/components/preview/DevicePreview.tsx +++ b/src/components/preview/DevicePreview.tsx @@ -1,4 +1,15 @@ -import { Camera, Flashlight, Frame, Hand, Maximize, Monitor, Move, ZoomIn } from "lucide-react"; +import { + Camera, + Eye, + Flashlight, + Hand, + Maximize2, + Minimize2, + Monitor, + Move, + RotateCw, + ZoomIn, +} from "lucide-react"; import type { CSSProperties } from "react"; import { orientRect } from "../../core/transforms"; import { useI18n } from "../../i18n/i18n"; @@ -141,17 +152,37 @@ function MacOverlay() { ); } -export function DevicePreview() { +export function DevicePreview({ + previewFocused, + onPreviewFocusChange, +}: { + previewFocused: boolean; + onPreviewFocusChange: (focused: boolean) => void; +}) { const { t } = useI18n(); - const { profile, orientedDevice, previewMode, setPreviewMode, frameVisible, setFrameVisible } = - useEditor(); + const { + image, + profile, + orientedDevice, + orientation, + setOrientation, + previewMode, + setPreviewMode, + frameVisible, + setFrameVisible, + showOriginalComposition, + setShowOriginalComposition, + } = 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}%`; + const alternateOrientation = profile.supportedOrientations.find( + (supportedOrientation) => supportedOrientation !== orientation, + ); return ( -
+
- setFrameVisible(value === "frame")} - label={t("Device frame visibility")} - options={[ - { value: "frame", label: t("Frame") }, - { value: "screen", label: t("Screen only") }, - ]} - /> +
+ setFrameVisible(value === "frame")} + label={t("Device frame visibility")} + options={[ + { value: "frame", label: t("Frame") }, + { value: "screen", label: t("Screen only") }, + ]} + /> + +
-
- - {t("Drag to reposition")} - - {t("Scroll or pinch to zoom")} - - {t("Arrow keys to nudge")} +
+ {showOriginalComposition ? ( + <> + + {t("Original composition")} + {t("Preview only — edited values and export remain unchanged.")} + + ) : ( + <> + + {t("Drag to reposition")} + + {t("Scroll or pinch to zoom")} + + {t("Arrow keys to nudge")} + + )}
- + ) : null} +
); } diff --git a/src/components/preview/WallpaperCanvas.tsx b/src/components/preview/WallpaperCanvas.tsx index 41112a2..6e02764 100644 --- a/src/components/preview/WallpaperCanvas.tsx +++ b/src/components/preview/WallpaperCanvas.tsx @@ -1,8 +1,9 @@ import { useEffect, useRef, type KeyboardEvent, type PointerEvent, type WheelEvent } from "react"; -import { calculatePinchScale, clampTransform } from "../../core/fit"; +import { calculatePinchScale, clampTransform, clampTransformForImage } from "../../core/fit"; import { renderWallpaper } from "../../core/renderer"; import { useI18n } from "../../i18n/i18n"; import { useEditor } from "../../state/editor-context"; +import { DEFAULT_TRANSFORM } from "../../types/editor"; type DragState = { pointerId: number; x: number; y: number } | null; type PinchState = { distance: number; scale: number } | null; @@ -19,15 +20,41 @@ export function WallpaperCanvas() { const dragRef = useRef(null); const pointersRef = useRef(new Map()); const pinchRef = useRef(null); + const wheelCommitTimerRef = useRef(null); const { image, transform, + showOriginalComposition, setTransform, + beginTransformEdit, + commitTransformEdit, backgroundMode, backgroundColor, + orientedDevice, resetTransform, centerTransform, } = useEditor(); + const previewTransform = showOriginalComposition ? DEFAULT_TRANSFORM : transform; + const clampForCurrentImage = (next: typeof transform) => + image && image.width > 0 && image.height > 0 + ? clampTransformForImage( + image.width, + image.height, + orientedDevice.outputWidth, + orientedDevice.outputHeight, + next, + ) + : clampTransform(next); + + useEffect( + () => () => { + if (wheelCommitTimerRef.current !== null) { + window.clearTimeout(wheelCommitTimerRef.current); + } + commitTransformEdit(); + }, + [commitTransformEdit], + ); useEffect(() => { const canvas = canvasRef.current; @@ -48,7 +75,7 @@ export function WallpaperCanvas() { width, height, image, - transform, + transform: previewTransform, backgroundMode, backgroundColor, }); @@ -57,9 +84,14 @@ export function WallpaperCanvas() { observer.observe(wrap); draw(); return () => observer.disconnect(); - }, [backgroundColor, backgroundMode, image, transform]); + }, [backgroundColor, backgroundMode, image, previewTransform]); function pointerDown(event: PointerEvent) { + if (wheelCommitTimerRef.current !== null) { + window.clearTimeout(wheelCommitTimerRef.current); + wheelCommitTimerRef.current = null; + } + if (pointersRef.current.size === 0) beginTransformEdit(); event.currentTarget.setPointerCapture(event.pointerId); pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }); dragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }; @@ -83,7 +115,7 @@ export function WallpaperCanvas() { pinchRef.current.distance, distance, ); - setTransform((current) => ({ ...current, scale: nextScale })); + setTransform((current) => clampForCurrentImage({ ...current, scale: nextScale })); return; } if (!previous || previous.pointerId !== event.pointerId) return; @@ -92,7 +124,7 @@ export function WallpaperCanvas() { const deltaY = (event.clientY - previous.y) / bounds.height; dragRef.current = { ...previous, x: event.clientX, y: event.clientY }; setTransform((current) => - clampTransform({ + clampForCurrentImage({ ...current, translateX: current.translateX + deltaX, translateY: current.translateY + deltaY, @@ -105,12 +137,23 @@ export function WallpaperCanvas() { pinchRef.current = null; const remaining = [...pointersRef.current.entries()][0]; dragRef.current = remaining ? { pointerId: remaining[0], ...remaining[1] } : null; + if (!remaining) commitTransformEdit(); } function wheel(event: WheelEvent) { event.preventDefault(); + beginTransformEdit(); const factor = Math.exp(-event.deltaY * 0.0015); - setTransform((current) => clampTransform({ ...current, scale: current.scale * factor })); + setTransform((current) => clampForCurrentImage({ ...current, scale: current.scale * factor })); + if (wheelCommitTimerRef.current !== null) { + window.clearTimeout(wheelCommitTimerRef.current); + } + if (pointersRef.current.size === 0) { + wheelCommitTimerRef.current = window.setTimeout(() => { + wheelCommitTimerRef.current = null; + commitTransformEdit(); + }, 180); + } } function keyDown(event: KeyboardEvent) { @@ -123,24 +166,24 @@ export function WallpaperCanvas() { event.preventDefault(); if (event.key === "ArrowLeft") setTransform((current) => - clampTransform({ ...current, translateX: current.translateX - step }), + clampForCurrentImage({ ...current, translateX: current.translateX - step }), ); if (event.key === "ArrowRight") setTransform((current) => - clampTransform({ ...current, translateX: current.translateX + step }), + clampForCurrentImage({ ...current, translateX: current.translateX + step }), ); if (event.key === "ArrowUp") setTransform((current) => - clampTransform({ ...current, translateY: current.translateY - step }), + clampForCurrentImage({ ...current, translateY: current.translateY - step }), ); if (event.key === "ArrowDown") setTransform((current) => - clampTransform({ ...current, translateY: current.translateY + step }), + clampForCurrentImage({ ...current, translateY: current.translateY + step }), ); if (["+", "="].includes(event.key)) - setTransform((current) => clampTransform({ ...current, scale: current.scale * 1.05 })); + setTransform((current) => clampForCurrentImage({ ...current, scale: current.scale * 1.05 })); if (event.key === "-") - setTransform((current) => clampTransform({ ...current, scale: current.scale / 1.05 })); + setTransform((current) => clampForCurrentImage({ ...current, scale: current.scale / 1.05 })); if (["r", "R"].includes(event.key)) resetTransform(); if (event.key === "0") centerTransform(); } @@ -151,8 +194,11 @@ export function WallpaperCanvas() { className="wallpaper-canvas-wrap" tabIndex={0} role="application" + data-composition-view={showOriginalComposition ? "original" : "edited"} aria-label={t( - "Wallpaper canvas. Drag to reposition, scroll to zoom, use arrow keys to move.", + showOriginalComposition + ? "Original composition preview. Start editing to return to your edited composition." + : "Wallpaper canvas. Drag to reposition, scroll to zoom, use arrow keys to move.", )} onPointerDown={pointerDown} onPointerMove={pointerMove} diff --git a/src/components/ui.tsx b/src/components/ui.tsx index 3b46e0d..1741a84 100644 --- a/src/components/ui.tsx +++ b/src/components/ui.tsx @@ -97,6 +97,8 @@ export function RangeControl({ step, unit, onChange, + onInteractionStart, + onInteractionEnd, }: { label: string; value: number; @@ -105,24 +107,38 @@ export function RangeControl({ step: number; unit: string; onChange: (value: number) => void; + onInteractionStart?: () => void; + onInteractionEnd?: () => void; }) { + const displayValue = Math.round(value * 100) / 100; + const rawProgress = max > min ? ((value - min) / (max - min)) * 100 : 0; + const progress = Math.round(Math.min(100, Math.max(0, rawProgress)) * 100) / 100; return ( ); diff --git a/src/components/upload/UploadPanel.tsx b/src/components/upload/UploadPanel.tsx index 93ef3e2..5bc5783 100644 --- a/src/components/upload/UploadPanel.tsx +++ b/src/components/upload/UploadPanel.tsx @@ -1,4 +1,4 @@ -import { ImagePlus, LockKeyhole, Replace, Trash2, UploadCloud } from "lucide-react"; +import { LockKeyhole, Replace, Trash2, UploadCloud } from "lucide-react"; import { useId, useRef, useState, type DragEvent } from "react"; import { decodeImageFile } from "../../core/image-decoder"; import { useI18n } from "../../i18n/i18n"; @@ -17,7 +17,7 @@ export function UploadPanel() { const inputRef = useRef(null); const loadingRef = useRef(false); const { t } = useI18n(); - const { image, setImage, setAnnouncement } = useEditor(); + const { image, setImage, setAnnouncement, setMobileTab } = useEditor(); const [error, setError] = useState(""); const [dragging, setDragging] = useState(false); const [loading, setLoading] = useState(false); @@ -29,8 +29,16 @@ export function UploadPanel() { setError(""); try { const asset = await decodeImageFile(file); - setImage(asset); - setAnnouncement(t("{name} loaded locally.", { name: file.name })); + const replaced = setImage(asset); + setMobileTab("adjust"); + setAnnouncement( + t( + replaced + ? "{name} loaded locally. Composition reset for the new image." + : "{name} loaded locally.", + { name: file.name }, + ), + ); } catch (reason) { setError(t(reason instanceof Error ? reason.message : "The image could not be loaded.")); } finally { @@ -47,6 +55,12 @@ export function UploadPanel() { void load(event.dataTransfer.files[0]); } + function remove() { + setImage(null); + setMobileTab("image"); + setAnnouncement(t("Image removed. Composition cleared.")); + } + return ( {image ? (
-