From a758193be8dd33bebd15fcaeb158bdd7cbf0a45f Mon Sep 17 00:00:00 2001 From: cc Date: Wed, 29 Jul 2026 15:08:21 +0100 Subject: [PATCH] add Petdex import and pet sizing --- .../2026-07-29-petdex-import-and-pet-size.md | 57 +++++++++++ ...07-29-petdex-import-and-pet-size-design.md | 32 ++++++ e2e/helpers/state.ts | 3 +- electron/main.js | 99 +++++++++++++++++++ electron/main.test.js | 30 +++++- electron/petdex-download.js | 27 +++++ electron/petdex-download.test.js | 23 +++++ electron/preload.js | 6 ++ package-lock.json | 4 +- package.json | 2 +- src/app/defaults.ts | 3 +- src/app/model.ts | 4 +- .../cat/components/InteractiveCatStage.tsx | 29 ++++-- src/features/cat/stage/viewport.test.ts | 8 ++ src/features/cat/stage/viewport.ts | 21 ++++ .../pets/components/CodexPetStage.tsx | 29 ++++-- .../pets/components/PetLibrary.test.tsx | 35 ++++++- src/features/pets/components/PetLibrary.tsx | 29 +++++- src/features/settings/SettingsPage.test.tsx | 12 ++- src/features/settings/SettingsPage.tsx | 13 ++- src/i18n/messages.ts | 14 +++ src/infrastructure/settingsRepository.test.ts | 10 +- src/infrastructure/settingsRepository.ts | 10 +- src/types/electron-api.d.ts | 5 + 24 files changed, 462 insertions(+), 43 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-29-petdex-import-and-pet-size.md create mode 100644 docs/superpowers/specs/2026-07-29-petdex-import-and-pet-size-design.md create mode 100644 electron/petdex-download.js create mode 100644 electron/petdex-download.test.js diff --git a/docs/superpowers/plans/2026-07-29-petdex-import-and-pet-size.md b/docs/superpowers/plans/2026-07-29-petdex-import-and-pet-size.md new file mode 100644 index 0000000..f887453 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-petdex-import-and-pet-size.md @@ -0,0 +1,57 @@ +# Petdex Import and Pet Size Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship automatic Petdex ZIP handoff and persistent three-level pet sizing as desktop release 0.2.4. + +**Architecture:** Electron owns the isolated remote browser and temporary download. The renderer reuses the existing ZIP preview pipeline. A schema-backed size preference feeds one shared viewport sizing function used by both pet renderers. + +**Tech Stack:** Electron 35, React 19, TypeScript, Vitest, Playwright + +## Global Constraints + +- Medium must preserve the current desktop pet size. +- Petdex downloads must still require import-preview confirmation. +- Existing manual ZIP and loose-file imports remain available. +- Release version is `0.2.4`. + +--- + +### Task 1: Petdex download bridge + +**Files:** +- Create: `electron/petdex-download.js` +- Modify: `electron/main.js` +- Modify: `electron/preload.js` +- Modify: `src/types/electron-api.d.ts` +- Modify: `src/features/pets/components/PetLibrary.tsx` +- Test: `electron/petdex-download.test.js` +- Test: `electron/main.test.js` +- Test: `src/features/pets/components/PetLibrary.test.tsx` + +- [x] Define and test the Petdex origin, ZIP, and size policies. +- [x] Add the sandboxed Petdex browser and temporary download bridge. +- [x] Feed intercepted archives into the existing preview flow. +- [ ] Run focused Electron and pet-library tests. + +### Task 2: Persistent pet sizing + +**Files:** +- Modify: `src/app/model.ts` +- Modify: `src/app/defaults.ts` +- Modify: `src/infrastructure/settingsRepository.ts` +- Modify: `src/features/cat/stage/viewport.ts` +- Modify: `src/features/cat/components/InteractiveCatStage.tsx` +- Modify: `src/features/pets/components/CodexPetStage.tsx` +- Modify: `src/features/settings/SettingsPage.tsx` +- Modify: `src/i18n/messages.ts` + +- [x] Migrate settings schema 5 with medium as the fallback. +- [x] Add localized small, medium, and large controls. +- [x] Apply shared dimensions to both pet renderers and their interaction geometry. +- [ ] Run focused persistence, viewport, and settings tests. + +### Task 3: Release + +- [ ] Run `npm run check:desktop-release`. +- [ ] Commit, review, merge, tag `v0.2.4`, and verify all five release assets. diff --git a/docs/superpowers/specs/2026-07-29-petdex-import-and-pet-size-design.md b/docs/superpowers/specs/2026-07-29-petdex-import-and-pet-size-design.md new file mode 100644 index 0000000..90a88f2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-petdex-import-and-pet-size-design.md @@ -0,0 +1,32 @@ +# Petdex Import and Pet Size Design + +## Goal + +Remove the manual ZIP handoff after downloading a pet from Petdex and add a +persistent small, medium, or large desktop-pet size preference. + +## Petdex flow + +The pet library opens `https://petdex.dev/` in a sandboxed Electron window with +Node.js disabled. Electron intercepts ZIP downloads, stores each archive in the +application temporary directory, enforces the existing 32 MiB limit, forwards +the bytes to the settings renderer, and deletes the temporary file. + +The renderer feeds the ZIP into the existing archive extraction and validation +pipeline. Automatic import stops at the existing preview; saving or replacing a +pet still requires explicit user confirmation. + +## Size flow + +Settings schema version 5 adds `petSize` with `small`, `medium`, and `large`. +Legacy settings migrate to `medium`. Desktop sizes are 80%, 100%, and 125% of +the existing 140 by 152 pixel pet. Both the built-in cat and imported pets use +the same sizing function for rendering, hit testing, dragging, and reminder +bubble anchoring. + +## Security + +- The Petdex window has `sandbox: true`, `nodeIntegration: false`, and no preload. +- Only ZIP downloads enter the import bridge. +- Existing ZIP path, count, expanded-size, encryption, atlas, and manifest checks remain authoritative. +- Imported pets are never saved without the existing preview confirmation. diff --git a/e2e/helpers/state.ts b/e2e/helpers/state.ts index bf7e21f..2357742 100644 --- a/e2e/helpers/state.ts +++ b/e2e/helpers/state.ts @@ -45,7 +45,8 @@ export async function seedApp( ); const enabled = new Map(presetReminders.map((item) => [item.type, item])); localStorage.setItem('neko-pause:settings', JSON.stringify({ - schemaVersion: 4, + schemaVersion: 5, + petSize: 'medium', locale: seededOptions.locale ?? 'zh-CN', onboardingComplete: true, theme: seededOptions.theme ?? 'light', diff --git a/electron/main.js b/electron/main.js index 3fe97d5..120ec3c 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { randomUUID } from 'node:crypto'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { @@ -6,11 +7,18 @@ import { BrowserWindow, Menu, screen, + shell, Tray, nativeImage, ipcMain, } from 'electron'; import { resolvePetWindowPolicy } from './window-policy.js'; +import { + isAllowedPetdexNavigation, + isSupportedPetArchive, + MAX_PETDEX_ARCHIVE_BYTES, + PETDEX_URL, +} from './petdex-download.js'; const IS_DEV = process.argv.includes('--dev') || process.env.NODE_ENV === 'development'; const EDGE_HANDLE_SIZE = 24; @@ -39,6 +47,7 @@ const stateFilePath = path.join(app.getPath('userData'), STATE_FILE); let petWindow; let settingsWindow; +let petdexWindow; let tray; let dockState = { isDocked: false, @@ -353,6 +362,13 @@ function registerIpcHandlers() { openSettingsWindow(); }); + ipcMain.handle('pet:open-petdex', (event) => { + if (settingsWindow === undefined + || settingsWindow.isDestroyed() + || event.sender.id !== settingsWindow.webContents.id) return; + openPetdexWindow(); + }); + ipcMain.handle('pet:manual-dock', () => { if (!EDGE_DOCKING_ENABLED) return; const currentPetWindow = getCurrentPetWindow(); @@ -380,6 +396,88 @@ function registerIpcHandlers() { }); } +function sendPetdexImport(payload) { + if (settingsWindow === undefined || settingsWindow.isDestroyed()) return; + settingsWindow.webContents.send('pet:petdex-import', payload); +} + +function handlePetdexDownload(_event, item) { + const filename = path.basename(item.getFilename()); + if (!isSupportedPetArchive(filename, item.getMimeType(), item.getURL())) return; + const totalBytes = item.getTotalBytes(); + if (totalBytes > MAX_PETDEX_ARCHIVE_BYTES) { + item.cancel(); + sendPetdexImport({ type: 'error' }); + return; + } + + const temporaryPath = path.join(app.getPath('temp'), `codex-pet-pause-${randomUUID()}.zip`); + item.setSavePath(temporaryPath); + item.once('done', (_doneEvent, state) => { + if (state !== 'completed') { + void fs.promises.rm(temporaryPath, { force: true }); + sendPetdexImport({ type: 'error' }); + return; + } + void (async () => { + try { + const stat = await fs.promises.stat(temporaryPath); + if (stat.size > MAX_PETDEX_ARCHIVE_BYTES) { + sendPetdexImport({ type: 'error' }); + return; + } + const bytes = await fs.promises.readFile(temporaryPath); + const transferable = bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ); + sendPetdexImport({ type: 'archive', name: filename || 'petdex-pet.zip', bytes: transferable }); + } catch { + sendPetdexImport({ type: 'error' }); + } finally { + await fs.promises.rm(temporaryPath, { force: true }); + } + })(); + }); +} + +function openPetdexWindow() { + if (petdexWindow !== undefined && !petdexWindow.isDestroyed()) { + if (petdexWindow.isMinimized()) petdexWindow.restore(); + petdexWindow.focus(); + return; + } + + petdexWindow = new BrowserWindow({ + width: 1100, + height: 760, + title: 'Petdex', + parent: settingsWindow, + autoHideMenuBar: true, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + partition: 'persist:petdex', + }, + }); + const petdexSession = petdexWindow.webContents.session; + petdexSession.on('will-download', handlePetdexDownload); + petdexWindow.webContents.setWindowOpenHandler(({ url }) => { + if (isAllowedPetdexNavigation(url)) { + void petdexWindow?.loadURL(url); + } else if (url.startsWith('https://')) { + void shell.openExternal(url); + } + return { action: 'deny' }; + }); + petdexWindow.loadURL(PETDEX_URL); + petdexWindow.on('closed', () => { + petdexSession.removeListener('will-download', handlePetdexDownload); + petdexWindow = undefined; + }); +} + function createWindow() { const workspace = getPrimaryWorkArea(); const persisted = readPersistedState(); @@ -499,6 +597,7 @@ function openSettingsWindow() { settingsWindow.loadURL(resolveWindowUrl('web&view=settings&hidePet=1')); settingsWindow.on('closed', () => { + if (petdexWindow !== undefined && !petdexWindow.isDestroyed()) petdexWindow.close(); settingsWindow = undefined; }); } diff --git a/electron/main.test.js b/electron/main.test.js index 6a6f442..2aa49f9 100644 --- a/electron/main.test.js +++ b/electron/main.test.js @@ -24,7 +24,17 @@ const electron = vi.hoisted(() => { minimized: false, webContents: { id: nextWebContentsId++, + send: vi.fn(), + setWindowOpenHandler: vi.fn(), + session: { + on: vi.fn(), + removeListener: vi.fn(), + }, }, + close: vi.fn(() => { + window.destroyed = true; + listeners.get('closed')?.(); + }), focus: vi.fn(), getBounds: vi.fn(() => ({ x: options.x ?? 0, @@ -83,6 +93,9 @@ const electron = vi.hoisted(() => { createEmpty: vi.fn(() => icon), createFromPath: vi.fn(() => icon), }, + shell: { + openExternal: vi.fn(), + }, screen: { getCursorScreenPoint: vi.fn(() => ({ x: 0, y: 0 })), getDisplayMatching: vi.fn(() => ({ @@ -147,7 +160,7 @@ describe('Electron process lifecycle', () => { activate(); const currentPetWindow = electron.windows.at(-1); - for (const channel of ['pet:open-settings', 'pet:manual-dock']) { + for (const channel of ['pet:open-settings', 'pet:open-petdex', 'pet:manual-dock']) { const registrations = electron.ipcMain.handle.mock.calls .filter(([registeredChannel]) => registeredChannel === channel); expect(registrations).toHaveLength(1); @@ -191,6 +204,21 @@ describe('Electron process lifecycle', () => { openSettings(); const firstSettingsWindow = electron.windows.at(-1); expect(firstSettingsWindow.options.focusable).toBeUndefined(); + const openPetdex = electron.ipcMain.handle.mock.calls + .find(([channel]) => channel === 'pet:open-petdex')[1]; + openPetdex({ sender: firstSettingsWindow.webContents }); + const firstPetdexWindow = electron.windows.at(-1); + expect(firstPetdexWindow.options).toMatchObject({ + title: 'Petdex', + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + partition: 'persist:petdex', + }, + }); + expect(firstPetdexWindow.webContents.session.on) + .toHaveBeenCalledWith('will-download', expect.any(Function)); firstSettingsWindow.listeners.get('closed')(); openSettings(); diff --git a/electron/petdex-download.js b/electron/petdex-download.js new file mode 100644 index 0000000..1ecf09e --- /dev/null +++ b/electron/petdex-download.js @@ -0,0 +1,27 @@ +export const PETDEX_URL = 'https://petdex.dev/'; +export const MAX_PETDEX_ARCHIVE_BYTES = 32 * 1024 * 1024; + +export function isAllowedPetdexNavigation(rawUrl) { + try { + const url = new URL(rawUrl); + return url.protocol === 'https:' + && (url.hostname === 'petdex.dev' || url.hostname.endsWith('.petdex.dev')); + } catch { + return false; + } +} + +export function isSupportedPetArchive(filename, mimeType = '', rawUrl = '') { + const normalizedFilename = String(filename ?? '').toLowerCase(); + const normalizedMime = String(mimeType ?? '').toLowerCase(); + let pathname = ''; + try { + pathname = new URL(rawUrl).pathname.toLowerCase(); + } catch { + pathname = ''; + } + return normalizedFilename.endsWith('.zip') + || pathname.endsWith('.zip') + || normalizedMime === 'application/zip' + || normalizedMime === 'application/x-zip-compressed'; +} diff --git a/electron/petdex-download.test.js b/electron/petdex-download.test.js new file mode 100644 index 0000000..3dd6727 --- /dev/null +++ b/electron/petdex-download.test.js @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { + isAllowedPetdexNavigation, + isSupportedPetArchive, + MAX_PETDEX_ARCHIVE_BYTES, +} from './petdex-download.js'; + +describe('Petdex download policy', () => { + it('accepts Petdex HTTPS pages and rejects lookalike or unsafe origins', () => { + expect(isAllowedPetdexNavigation('https://petdex.dev/pets/boba')).toBe(true); + expect(isAllowedPetdexNavigation('https://cdn.petdex.dev/boba.zip')).toBe(true); + expect(isAllowedPetdexNavigation('https://petdex.dev.example.com/boba.zip')).toBe(false); + expect(isAllowedPetdexNavigation('http://petdex.dev/pets/boba')).toBe(false); + }); + + it('recognizes ZIP downloads from filename, URL, or MIME type', () => { + expect(isSupportedPetArchive('boba.zip')).toBe(true); + expect(isSupportedPetArchive('download', '', 'https://petdex.dev/files/boba.zip')).toBe(true); + expect(isSupportedPetArchive('download', 'application/zip')).toBe(true); + expect(isSupportedPetArchive('pet.json', 'application/json')).toBe(false); + expect(MAX_PETDEX_ARCHIVE_BYTES).toBe(32 * 1024 * 1024); + }); +}); diff --git a/electron/preload.js b/electron/preload.js index 26cfb36..6575769 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -2,6 +2,12 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('petShell', { openSettings: () => ipcRenderer.invoke('pet:open-settings'), + openPetdex: () => ipcRenderer.invoke('pet:open-petdex'), + onPetdexImport: (callback) => { + const listener = (_event, payload) => callback(payload); + ipcRenderer.on('pet:petdex-import', listener); + return () => ipcRenderer.removeListener('pet:petdex-import', listener); + }, dockNow: () => ipcRenderer.invoke('pet:manual-dock'), dragWindowTo: (x, y) => ipcRenderer.send('pet:drag-window', { x, y }), showContextMenu: (x, y) => ipcRenderer.send('pet:show-context-menu', { x, y }), diff --git a/package-lock.json b/package-lock.json index ff040e9..a63c645 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-pet-pause", - "version": "0.2.3", + "version": "0.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-pet-pause", - "version": "0.2.3", + "version": "0.2.4", "license": "MIT", "dependencies": { "@zip.js/zip.js": "^2.8.34", diff --git a/package.json b/package.json index e901d80..7358439 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codex-pet-pause", "private": true, - "version": "0.2.3", + "version": "0.2.4", "description": "A playful, local-first break reminder PWA with interactive and Codex-compatible pets.", "license": "MIT", "repository": { diff --git a/src/app/defaults.ts b/src/app/defaults.ts index c2c27ed..61e0375 100644 --- a/src/app/defaults.ts +++ b/src/app/defaults.ts @@ -15,10 +15,11 @@ export function createDefaultSettings(now: number, locale: Locale = 'zh-CN'): Ap }); return { - schemaVersion: 4, + schemaVersion: 5, locale, onboardingComplete: false, theme: 'system', + petSize: 'medium', soundEnabled: false, animationsEnabled: true, affinity: 0, diff --git a/src/app/model.ts b/src/app/model.ts index b7b9c39..e92616c 100644 --- a/src/app/model.ts +++ b/src/app/model.ts @@ -4,6 +4,7 @@ import type { PresetReminderType, Reminder, SchedulerState } from '../features/r import type { Locale } from '../i18n/types'; export type ThemeMode = 'light' | 'dark' | 'system'; +export type PetSizePreference = 'small' | 'medium' | 'large'; export interface QuietHours { enabled: boolean; @@ -18,10 +19,11 @@ export interface RuntimeState { } export interface AppSettings { - schemaVersion: 4; + schemaVersion: 5; locale: Locale; onboardingComplete: boolean; theme: ThemeMode; + petSize: PetSizePreference; soundEnabled: boolean; animationsEnabled: boolean; affinity: number; diff --git a/src/features/cat/components/InteractiveCatStage.tsx b/src/features/cat/components/InteractiveCatStage.tsx index 874e6ef..82f68cf 100644 --- a/src/features/cat/components/InteractiveCatStage.tsx +++ b/src/features/cat/components/InteractiveCatStage.tsx @@ -24,10 +24,9 @@ import { CatSprite } from '../sprite/CatSprite'; import type { CatAnimation } from '../sprite/atlas'; import { useReducedMotion } from '../sprite/useReducedMotion'; import { - CAT_COMPACT_SIZE, - CAT_DESKTOP_SIZE, clampCatPosition, defaultCatPosition, + petSizeForViewport, type Point, type Size, } from '../stage/viewport'; @@ -84,10 +83,6 @@ function boundedRandom(random: () => number): number { return Math.min(1, Math.max(0, value)); } -function catSizeForViewport(width: number): Size { - return width < 480 ? CAT_COMPACT_SIZE : CAT_DESKTOP_SIZE; -} - function viewportSize(): Size { return { width: window.innerWidth, height: window.innerHeight }; } @@ -118,10 +113,16 @@ export function InteractiveCatStage({ runtime = browserRuntime }: InteractiveCat const { t } = useI18n(); const reducedMotion = useReducedMotion(); const motionAllowed = snapshot.settings.animationsEnabled && !reducedMotion; - const [catSize, setCatSize] = useState(() => catSizeForViewport(window.innerWidth)); + const desktopApp = window.petShell !== undefined; + const [catSize, setCatSize] = useState(() => ( + petSizeForViewport(window.innerWidth, snapshot.settings.petSize, desktopApp) + )); const catSizeRef = useRef(catSize); const [position, setPosition] = useState(() => ( - defaultCatPosition(viewportSize(), catSizeForViewport(window.innerWidth)) + defaultCatPosition( + viewportSize(), + petSizeForViewport(window.innerWidth, snapshot.settings.petSize, desktopApp), + ) )); const positionRef = useRef(position); const [behavior, setBehavior] = useState(initialCatBehavior); @@ -191,14 +192,19 @@ export function InteractiveCatStage({ runtime = browserRuntime }: InteractiveCat useEffect(() => { const onResize = () => { - const nextSize = catSizeForViewport(window.innerWidth); + const nextSize = petSizeForViewport( + window.innerWidth, + snapshot.settings.petSize, + desktopApp, + ); catSizeRef.current = nextSize; setCatSize(nextSize); updatePosition(positionRef.current); }; + onResize(); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); - }, [updatePosition]); + }, [desktopApp, snapshot.settings.petSize, updatePosition]); useEffect(() => { const onFocusIn = (event: FocusEvent) => { @@ -579,6 +585,9 @@ export function InteractiveCatStage({ runtime = browserRuntime }: InteractiveCat : t('pet.touch', { name: snapshot.settings.cat.name })} style={{ position: 'fixed', + width: `${catSize.width}px`, + minWidth: `${catSize.width}px`, + height: `${catSize.height}px`, transform: `translate3d(${position.x}px, ${position.y}px, 0)`, transition, touchAction: 'none', diff --git a/src/features/cat/stage/viewport.test.ts b/src/features/cat/stage/viewport.test.ts index 8ee8a2d..a12cd9f 100644 --- a/src/features/cat/stage/viewport.test.ts +++ b/src/features/cat/stage/viewport.test.ts @@ -4,6 +4,7 @@ import { CAT_DESKTOP_SIZE, clampCatPosition, defaultCatPosition, + petSizeForViewport, placeBubble, } from './viewport'; @@ -12,6 +13,13 @@ test('exports the fixed desktop and compact cat sizes', () => { expect(CAT_COMPACT_SIZE).toEqual({ width: 112, height: 121 }); }); +test('scales desktop pets to small, medium, and large while preserving the current default', () => { + expect(petSizeForViewport(360, 'small', true)).toEqual({ width: 112, height: 122 }); + expect(petSizeForViewport(360, 'medium', true)).toEqual({ width: 140, height: 152 }); + expect(petSizeForViewport(360, 'large', true)).toEqual({ width: 175, height: 190 }); + expect(petSizeForViewport(390, 'medium')).toEqual(CAT_COMPACT_SIZE); +}); + test('uses the fixed top-right default and clamps small viewports', () => { expect(defaultCatPosition({ width: 1000, height: 700 }, { width: 140, height: 152 })) .toEqual({ x: 836, y: 96 }); diff --git a/src/features/cat/stage/viewport.ts b/src/features/cat/stage/viewport.ts index bc797cf..69f6f3f 100644 --- a/src/features/cat/stage/viewport.ts +++ b/src/features/cat/stage/viewport.ts @@ -8,6 +8,8 @@ export interface Size { height: number; } +export type PetSizePreference = 'small' | 'medium' | 'large'; + export interface Rect extends Point, Size {} export type BubbleSide = 'top' | 'right' | 'bottom' | 'left'; @@ -21,6 +23,25 @@ export interface BubblePlacement { export const CAT_DESKTOP_SIZE: Size = { width: 140, height: 152 }; export const CAT_COMPACT_SIZE: Size = { width: 112, height: 121 }; +const PET_SIZE_SCALE: Record = { + small: 0.8, + medium: 1, + large: 1.25, +}; + +export function petSizeForViewport( + viewportWidth: number, + preference: PetSizePreference, + desktopApp = false, +): Size { + const base = !desktopApp && viewportWidth < 480 ? CAT_COMPACT_SIZE : CAT_DESKTOP_SIZE; + const scale = PET_SIZE_SCALE[preference]; + return { + width: Math.round(base.width * scale), + height: Math.round(base.height * scale), + }; +} + const DEFAULT_RIGHT_MARGIN = 24; const DEFAULT_TOP = 96; const BUBBLE_GAP = 12; diff --git a/src/features/pets/components/CodexPetStage.tsx b/src/features/pets/components/CodexPetStage.tsx index ef8208d..5ff5ad3 100644 --- a/src/features/pets/components/CodexPetStage.tsx +++ b/src/features/pets/components/CodexPetStage.tsx @@ -12,9 +12,8 @@ import { CatReminderBubble } from '../../cat/components/CatReminderBubble'; import { lookDirectionForVector } from '../../cat/domain/behavior'; import { useReducedMotion } from '../../cat/sprite/useReducedMotion'; import { - CAT_COMPACT_SIZE, - CAT_DESKTOP_SIZE, clampCatPosition, + petSizeForViewport, type Point, type Size, } from '../../cat/stage/viewport'; @@ -51,10 +50,6 @@ interface PointerSession { const DRAG_THRESHOLD = 6; const ATTENTION_DEPARTURE_PX = 48; -function petSizeForViewport(width: number): Size { - return width < 480 ? CAT_COMPACT_SIZE : CAT_DESKTOP_SIZE; -} - function viewportSize(): Size { return { width: window.innerWidth, height: window.innerHeight }; } @@ -73,10 +68,16 @@ export function CodexPetStage({ pet, random = Math.random }: CodexPetStageProps) const { t } = useI18n(); const reducedMotion = useReducedMotion(); const motionAllowed = snapshot.settings.animationsEnabled && !reducedMotion; - const [petSize, setPetSize] = useState(() => petSizeForViewport(window.innerWidth)); + const desktopApp = window.petShell !== undefined; + const [petSize, setPetSize] = useState(() => ( + petSizeForViewport(window.innerWidth, snapshot.settings.petSize, desktopApp) + )); const petSizeRef = useRef(petSize); const [position, setPosition] = useState(() => ( - positionFromRatios(snapshot.settings.petPosition, petSizeForViewport(window.innerWidth)) + positionFromRatios( + snapshot.settings.petPosition, + petSizeForViewport(window.innerWidth, snapshot.settings.petSize, desktopApp), + ) )); const positionRef = useRef(position); const [behavior, setBehavior] = useState(initialCodexBehavior); @@ -125,14 +126,19 @@ export function CodexPetStage({ pet, random = Math.random }: CodexPetStageProps) useEffect(() => { const onResize = () => { - const nextSize = petSizeForViewport(window.innerWidth); + const nextSize = petSizeForViewport( + window.innerWidth, + snapshot.settings.petSize, + desktopApp, + ); petSizeRef.current = nextSize; setPetSize(nextSize); updatePosition(positionRef.current); }; + onResize(); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); - }, [updatePosition]); + }, [desktopApp, snapshot.settings.petSize, updatePosition]); useEffect(() => { dispatchBehavior({ type: 'REMINDER_CHANGED', due }); @@ -380,6 +386,9 @@ export function CodexPetStage({ pet, random = Math.random }: CodexPetStageProps) : t('pet.touch', { name: pet.displayName })} style={{ position: 'fixed', + width: `${petSize.width}px`, + minWidth: `${petSize.width}px`, + height: `${petSize.height}px`, transform: `translate3d(${position.x}px, ${position.y}px, 0)`, transition: 'none', touchAction: 'none', diff --git a/src/features/pets/components/PetLibrary.test.tsx b/src/features/pets/components/PetLibrary.test.tsx index d7edf45..eda8572 100644 --- a/src/features/pets/components/PetLibrary.test.tsx +++ b/src/features/pets/components/PetLibrary.test.tsx @@ -77,7 +77,10 @@ async function renderLibrary(options: { }; } -afterEach(() => vi.restoreAllMocks()); +afterEach(() => { + vi.restoreAllMocks(); + delete window.petShell; +}); test('previews and saves a valid pair only after confirmation', async () => { const user = userEvent.setup(); @@ -120,6 +123,36 @@ test('extracts one ZIP and opens the existing preview', async () => { .toHaveTextContent('Murk'); }); +test('opens Petdex in the desktop shell and previews an intercepted ZIP', async () => { + let receiveImport!: (event: + | { type: 'archive'; name: string; bytes: ArrayBuffer } + | { type: 'error' } + ) => void; + const openPetdex = vi.fn(async () => undefined); + window.petShell = { + openPetdex, + onPetdexImport: (callback) => { + receiveImport = callback; + return () => undefined; + }, + }; + const { extractArchive } = await renderLibrary(); + + await userEvent.setup().click(screen.getByRole('button', { + name: '浏览 Petdex 并自动导入', + })); + expect(openPetdex).toHaveBeenCalledOnce(); + + await act(async () => receiveImport({ + type: 'archive', + name: 'murk.zip', + bytes: new TextEncoder().encode('zip').buffer, + })); + + expect(extractArchive).toHaveBeenCalledWith(expect.objectContaining({ name: 'murk.zip' })); + expect(await screen.findByRole('dialog', { name: '导入宠物预览' })).toHaveTextContent('Murk'); +}); + test('accepts one ZIP by drag and drop', async () => { const extractArchive = vi.fn(async () => ({ manifestFile, diff --git a/src/features/pets/components/PetLibrary.tsx b/src/features/pets/components/PetLibrary.tsx index 8e44d8d..47600e0 100644 --- a/src/features/pets/components/PetLibrary.tsx +++ b/src/features/pets/components/PetLibrary.tsx @@ -1,5 +1,6 @@ import { - useEffect, useId, useLayoutEffect, useRef, useState, type ChangeEvent, type DragEvent, type ReactNode, + useCallback, useEffect, useId, useLayoutEffect, useRef, useState, + type ChangeEvent, type DragEvent, type ReactNode, } from 'react'; import { createPortal } from 'react-dom'; import { useAppController, useAppSnapshot } from '../../../app/AppProvider'; @@ -34,7 +35,7 @@ const currentTime = (): number => Date.now(); type LibraryError = | { kind: 'selection-unsupported' | 'selection-incomplete' | 'selection-duplicate' } | { kind: 'import'; code: PetImportErrorCode; details: Readonly> } - | { kind: 'import-generic' | 'save' | 'select' | 'delete' }; + | { kind: 'import-generic' | 'petdex-download' | 'save' | 'select' | 'delete' }; type LibraryMessage = | { kind: 'saved' | 'deleted'; name: string } @@ -92,6 +93,7 @@ function libraryErrorMessage(error: LibraryError, t: Translator): string { case 'selection-duplicate': return t('pet.import.error.duplicateSelection'); case 'import': return importErrorMessage(error.code, error.details, t); case 'import-generic': return t('pet.import.error.generic'); + case 'petdex-download': return t('pet.import.error.petdexDownload'); case 'save': return t('pet.library.saveFailed'); case 'select': return t('pet.library.selectFailed'); case 'delete': return t('pet.library.deleteFailed'); @@ -167,7 +169,7 @@ export function PetLibrary({ queueMicrotask(() => deleteTriggerRef.current?.focus()); }; - const processFiles = async (files: File[]): Promise => { + const processFiles = useCallback(async (files: File[]): Promise => { const request = importRequestRef.current + 1; importRequestRef.current = request; setError(undefined); @@ -219,6 +221,25 @@ export function PetLibrary({ ? { kind: 'import', code: reason.code, details: reason.details } : { kind: 'import-generic' }); } + }, [extractArchive, now, parseImport]); + + useEffect(() => window.petShell?.onPetdexImport?.((event) => { + if (event.type === 'error') { + setError({ kind: 'petdex-download' }); + return; + } + const archive = new File([event.bytes], event.name, { type: 'application/zip' }); + void processFiles([archive]); + }), [processFiles]); + + const openPetdex = (): void => { + setError(undefined); + setMessage(undefined); + if (window.petShell?.openPetdex !== undefined) { + void window.petShell.openPetdex(); + return; + } + window.open('https://petdex.dev/', '_blank', 'noopener,noreferrer'); }; const chooseFiles = (event: ChangeEvent): void => { @@ -330,6 +351,8 @@ export function PetLibrary({ + +

{t('pet.import.petdexHint')}

{ expect(screen.getByRole('heading', { name: 'Settings' })).toBeVisible(); expect(screen.getByRole('group', { name: 'Appearance' })).toBeVisible(); expect(screen.getByRole('radio', { name: 'Use system setting' })).toBeChecked(); + expect(screen.getByRole('group', { name: 'Pet size' })).toBeVisible(); + expect(screen.getByRole('radio', { name: 'Medium' })).toBeChecked(); expect(screen.getByRole('checkbox', { name: 'Reminder sound' })).toBeVisible(); expect(screen.getByText('System notifications are not enabled yet. Allow them to receive reminders while this page is in the background.')).toBeVisible(); expect(screen.getByRole('button', { name: 'Save application settings' })).toBeVisible(); @@ -529,14 +531,20 @@ describe('general settings', () => { expect(readFileSync('index.html', 'utf8')).toContain('Codex Pet Pause · Cat sound: elevenlabs.io'); }); - test('saves theme, sound, and animations', async () => { + test('saves theme, pet size, sound, and animations', async () => { const { user, controller } = await setup('general'); const save = vi.spyOn(controller, 'saveSettings'); await user.click(screen.getByRole('radio', { name: '夜间' })); + await user.click(screen.getByRole('radio', { name: '大' })); await user.click(screen.getByRole('checkbox', { name: '提醒声音' })); await user.click(screen.getByRole('checkbox', { name: '界面动画' })); await user.click(screen.getByRole('button', { name: '保存应用设置' })); - expect(save).toHaveBeenCalledWith(expect.objectContaining({ theme: 'dark', soundEnabled: true, animationsEnabled: false })); + expect(save).toHaveBeenCalledWith(expect.objectContaining({ + theme: 'dark', + petSize: 'large', + soundEnabled: true, + animationsEnabled: false, + })); }); test('reports a session-only save honestly, then clears that warning after persistence recovers', async () => { diff --git a/src/features/settings/SettingsPage.tsx b/src/features/settings/SettingsPage.tsx index a3dc93b..5503438 100644 --- a/src/features/settings/SettingsPage.tsx +++ b/src/features/settings/SettingsPage.tsx @@ -5,7 +5,7 @@ import { createPortal } from 'react-dom'; import { useAppController, useAppSnapshot } from '../../app/AppProvider'; import { isCurrentQuietRuntime } from '../../app/appController'; import type { - AppSettings, AppSnapshot, NotificationStatus, QuietHours, ThemeMode, + AppSettings, AppSnapshot, NotificationStatus, PetSizePreference, QuietHours, ThemeMode, } from '../../app/model'; import { isPresetReminder } from '../reminders/domain/types'; import type { @@ -149,6 +149,7 @@ export function SettingsPage({ section = 'general', now = systemTimestamp }: Set const [quietStart, setQuietStart] = useState(() => minutesToTime(snapshot.settings.quietHours.startMinutes)); const [quietEnd, setQuietEnd] = useState(() => minutesToTime(snapshot.settings.quietHours.endMinutes)); const [theme, setTheme] = useState(snapshot.settings.theme); + const [petSize, setPetSize] = useState(snapshot.settings.petSize); const [soundEnabled, setSoundEnabled] = useState(snapshot.settings.soundEnabled); const [animationsEnabled, setAnimationsEnabled] = useState(snapshot.settings.animationsEnabled); const [error, setError] = useState(); @@ -233,6 +234,7 @@ export function SettingsPage({ section = 'general', now = systemTimestamp }: Set setQuietStart(minutesToTime(settings.quietHours.startMinutes)); setQuietEnd(minutesToTime(settings.quietHours.endMinutes)); setTheme(settings.theme); + setPetSize(settings.petSize); setSoundEnabled(settings.soundEnabled); setAnimationsEnabled(settings.animationsEnabled); }; @@ -336,7 +338,13 @@ export function SettingsPage({ section = 'general', now = systemTimestamp }: Set event.preventDefault(); if (!beginAction()) return; try { - await controller.saveSettings({ ...controller.getSnapshot().settings, theme, soundEnabled, animationsEnabled }); + await controller.saveSettings({ + ...controller.getSnapshot().settings, + theme, + petSize, + soundEnabled, + animationsEnabled, + }); syncDraft(controller.getSnapshot().settings); const result = controller.getSnapshot(); if (result.storageMode === 'temporary' || result.nonBlockingError === 'settings-write-failed') { @@ -458,6 +466,7 @@ export function SettingsPage({ section = 'general', now = systemTimestamp }: Set ))}
{t('settings.theme.heading')}{([['light', 'settings.theme.light'], ['dark', 'settings.theme.dark'], ['system', 'settings.theme.system']] as const).map(([value, label]) => )}
+
{t('settings.petSize.heading')}{([['small', 'settings.petSize.small'], ['medium', 'settings.petSize.medium'], ['large', 'settings.petSize.large']] as const).map(([value, label]) => )}
{t('settings.experience.heading')}

{t('settings.notifications.heading')}

{t(notificationCopy[snapshot.notificationStatus])}

{snapshot.notificationStatus === 'default' && }
{error !== undefined &&

{errorText(error)}

} diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 50d8f45..e637000 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -37,6 +37,10 @@ const zhCN = { 'settings.theme.light': '日间', 'settings.theme.dark': '夜间', 'settings.theme.system': '跟随系统', + 'settings.petSize.heading': '宠物大小', + 'settings.petSize.small': '小', + 'settings.petSize.medium': '中(默认)', + 'settings.petSize.large': '大', 'settings.experience.heading': '提醒体验', 'settings.experience.sound': '提醒声音', 'settings.experience.animations': '界面动画', @@ -204,6 +208,9 @@ const zhCN = { 'pet.reminder.laterOptions': '稍后时间', 'pet.reminder.minutes': ({ minutes }: { minutes: number }) => `${minutes} 分钟`, 'pet.import.heading': '导入 Codex 宠物', + 'pet.import.petdexAction': '浏览 Petdex 并自动导入', + 'pet.import.petdexHint': '在应用内下载 ZIP 后会自动打开安全预览。', + 'pet.import.error.petdexDownload': '无法从 Petdex 获取这个宠物,请重试', 'pet.import.instructions': '选择一个 ZIP,或配套的 JSON 清单和 WebP 图集。', 'pet.import.deviceSaveNote': '确认预览后才会保存到这台设备。', 'pet.import.moreInformation': '查看 Codex 宠物文件提示', @@ -322,6 +329,10 @@ const en: Catalog = { 'settings.theme.light': 'Light', 'settings.theme.dark': 'Dark', 'settings.theme.system': 'Use system setting', + 'settings.petSize.heading': 'Pet size', + 'settings.petSize.small': 'Small', + 'settings.petSize.medium': 'Medium', + 'settings.petSize.large': 'Large', 'settings.experience.heading': 'Reminder experience', 'settings.experience.sound': 'Reminder sound', 'settings.experience.animations': 'Interface animations', @@ -489,6 +500,9 @@ const en: Catalog = { 'pet.reminder.laterOptions': 'Remind me later options', 'pet.reminder.minutes': ({ minutes }) => minutes === 1 ? '1 minute' : `${minutes} minutes`, 'pet.import.heading': 'Import a Codex pet', + 'pet.import.petdexAction': 'Browse Petdex and import automatically', + 'pet.import.petdexHint': 'Downloading a ZIP in the app opens the secure preview automatically.', + 'pet.import.error.petdexDownload': 'Could not get this pet from Petdex. Try again.', 'pet.import.instructions': 'Choose one ZIP, or a matching JSON manifest and WebP atlas.', 'pet.import.deviceSaveNote': 'Files are saved on this device only after you confirm the preview.', 'pet.import.moreInformation': 'More information about Codex pet files', diff --git a/src/infrastructure/settingsRepository.test.ts b/src/infrastructure/settingsRepository.test.ts index 720e265..55b13c2 100644 --- a/src/infrastructure/settingsRepository.test.ts +++ b/src/infrastructure/settingsRepository.test.ts @@ -31,7 +31,7 @@ describe('browser settings repository', () => { const loaded = await createBrowserSettingsRepository(storage).load(); expect(loaded).toMatchObject({ - schemaVersion: 4, + schemaVersion: 5, theme: 'dark', affinity: 23, cat: { name: '团子' }, @@ -77,7 +77,7 @@ describe('browser settings repository', () => { storage.setItem(SETTINGS_KEY, JSON.stringify(old)); const loaded = await createBrowserSettingsRepository(storage).load(); - expect(loaded?.schemaVersion).toBe(4); + expect(loaded?.schemaVersion).toBe(5); expect(loaded?.reminders.find(({ id }) => id === 'lookAway')).toMatchObject({ kind: 'preset', nextDueAt: 123, status: 'due' }); expect(loaded?.reminders.find(({ id }) => id === 'drinkWater')).toMatchObject({ kind: 'preset', nextDueAt: 456, snoozedUntil: 789 }); }); @@ -182,7 +182,7 @@ describe('browser settings repository', () => { storage.setItem(SETTINGS_KEY, JSON.stringify(schema3)); await expect(createBrowserSettingsRepository(storage).load()).resolves.toMatchObject({ - schemaVersion: 4, + schemaVersion: 5, locale: 'zh-CN', runtime: schema3.runtime, reminders: schema3.reminders, @@ -194,7 +194,7 @@ describe('browser settings repository', () => { storage.setItem(SETTINGS_KEY, JSON.stringify({ ...createDefaultSettings(NOW), locale: 'not-a-locale' })); await expect(createBrowserSettingsRepository(storage, 'en').load()) - .resolves.toMatchObject({ schemaVersion: 4, locale: 'en' }); + .resolves.toMatchObject({ schemaVersion: 5, locale: 'en', petSize: 'medium' }); }); test('repairs each invalid field while retaining valid sibling fields', async () => { @@ -204,7 +204,7 @@ describe('browser settings repository', () => { const repo = createBrowserSettingsRepository(storage); storage.setItem(SETTINGS_KEY, JSON.stringify({ ...defaults, - schemaVersion: 4, + schemaVersion: 5, onboardingComplete: 'yes', theme: 'neon', soundEnabled: 1, diff --git a/src/infrastructure/settingsRepository.ts b/src/infrastructure/settingsRepository.ts index 0739df3..ea349e7 100644 --- a/src/infrastructure/settingsRepository.ts +++ b/src/infrastructure/settingsRepository.ts @@ -1,5 +1,7 @@ import { createDefaultSettings } from '../app/defaults'; -import type { AppSettings, RuntimeState, ThemeMode } from '../app/model'; +import type { + AppSettings, PetSizePreference, RuntimeState, ThemeMode, +} from '../app/model'; import type { CatConfig } from '../features/cat/domain/types'; import { DEFAULT_PET_POSITION } from '../features/pets/domain/types'; import type { PetPosition } from '../features/pets/domain/types'; @@ -19,6 +21,7 @@ export const SETTINGS_KEY = 'neko-pause:settings'; type RecordValue = Record; const themes: readonly ThemeMode[] = ['light', 'dark', 'system']; +const petSizes: readonly PetSizePreference[] = ['small', 'medium', 'large']; const presetTypes: readonly PresetReminderType[] = ['lookAway', 'drinkWater', 'standUp', 'takeBreak']; const reservedIds = new Set(presetTypes); const MAX_CUSTOM_REMINDERS = 20; @@ -173,7 +176,7 @@ function presetReminderByType(reminders: readonly Reminder[]): Map void; + openPetdex?: () => Promise; + onPetdexImport?: (callback: (event: + | { type: 'archive'; name: string; bytes: ArrayBuffer } + | { type: 'error' } + ) => void) => () => void; dockNow?: () => void; dragWindowTo?: (x: number, y: number) => void; showContextMenu?: (x?: number, y?: number) => void;