diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 9c2264c..487d14b 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -25,6 +25,7 @@ jobs: - run: npm run test:brand-assets - run: npm run test:desktop-workflow - run: npm run test:electron + - run: npm run test:mac-signing - run: npm run test:release-artifacts - run: npm run test:release-tag - run: npm run test:packaged-resources diff --git a/docs/superpowers/plans/2026-07-27-reminder-completion-flow.md b/docs/superpowers/plans/2026-07-27-reminder-completion-flow.md new file mode 100644 index 0000000..ae4a283 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-reminder-completion-flow.md @@ -0,0 +1,256 @@ +# Reminder Completion Flow 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:** Make a completed pet reminder advance directly to the next queued reminder or close immediately when the queue is empty. + +**Architecture:** `ActionCard` will expose an optional `onCompleted` callback that runs only after `controller.complete` succeeds. `CatReminderBubble` will use that callback to clear the action layer and inspect the controller's fresh queue snapshot, preserving the existing standalone confirmation behavior for every caller that omits the callback. + +**Tech Stack:** React 19, TypeScript, Vitest, Testing Library + +## Global Constraints + +- Do not render a completed-status layer inside the pet reminder bubble. +- A queued successor must open at its first-level due actions. +- The final completion must close the bubble and return focus to the pet. +- A rejected completion must keep the current action available for retry. +- Completion must remain single-flight. +- Do not change standalone `ActionCard` behavior when `onCompleted` is omitted. + +--- + +### Task 1: Successful-completion callback + +**Files:** +- Modify: `src/features/reminders/components/ActionCard.tsx` +- Test: `src/features/reminders/components/ActionCard.test.tsx` + +**Interfaces:** +- Consumes: `controller.complete(reminder.id): Promise` +- Produces: optional prop `onCompleted?: (() => void) | undefined` + +- [ ] **Step 1: Write the failing callback tests** + +Add a render helper that accepts `onCompleted`, then cover success and rejection: + +```tsx +test('hands successful completion to its host without rendering confirmation', async () => { + const onCompleted = vi.fn(); + const controller = createAppController(createFakeDependencies({ now: NOW })); + controller.complete = vi.fn(async () => undefined); + render(actionView(controller, reminder, 'zh-CN', onCompleted)); + + fireEvent.click(screen.getByRole('button', { name: '完成了' })); + + await waitFor(() => expect(onCompleted).toHaveBeenCalledTimes(1)); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); +}); + +test('does not notify its host when completion fails', async () => { + const onCompleted = vi.fn(); + const controller = createAppController(createFakeDependencies({ now: NOW })); + controller.complete = vi.fn(async () => { throw new Error('save failed'); }); + render(actionView(controller, reminder, 'zh-CN', onCompleted)); + + fireEvent.click(screen.getByRole('button', { name: '完成了' })); + + await waitFor(() => expect(screen.getByRole('button', { name: '完成了' })).toBeEnabled()); + expect(onCompleted).not.toHaveBeenCalled(); +}); +``` + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run: + +```bash +npm run test:run -- src/features/reminders/components/ActionCard.test.tsx +``` + +Expected: FAIL because `ActionCard` does not accept or invoke `onCompleted`. + +- [ ] **Step 3: Implement the minimal callback** + +Extend `ActionCardProps` and the successful branch: + +```tsx +interface ActionCardProps { + reminder: Reminder; + countdownEndsAt?: number | undefined; + onCountdownStarted?: ((endsAt: number) => void) | undefined; + onCompleted?: (() => void) | undefined; + completionAction?: { + label: string; + onActivate: () => void; + }; +} + +await controller.complete(reminder.id); +if (onCompleted === undefined) setCompleted(true); +else onCompleted(); +``` + +Do not invoke the callback from `catch` or `finally`. + +- [ ] **Step 4: Run the focused tests and verify GREEN** + +Run: + +```bash +npm run test:run -- src/features/reminders/components/ActionCard.test.tsx +``` + +Expected: all `ActionCard` tests pass. + +- [ ] **Step 5: Commit the callback unit** + +```bash +git add src/features/reminders/components/ActionCard.tsx src/features/reminders/components/ActionCard.test.tsx +git commit -m "feat: expose successful reminder completion" +``` + +### Task 2: Direct pet reminder transition + +**Files:** +- Modify: `src/features/cat/components/CatReminderBubble.tsx` +- Test: `src/features/cat/components/CatReminderBubble.test.tsx` + +**Interfaces:** +- Consumes: `ActionCard` prop `onCompleted?: (() => void) | undefined` +- Consumes: `controller.getSnapshot().scheduler.dueQueue` +- Produces: direct queue advancement or `onRequestClose()` + +- [ ] **Step 1: Replace the third-layer tests with failing direct-flow tests** + +Keep the existing stateful queue controller and assert the desired visible behavior: + +```tsx +test('advances directly to the next queued reminder after completion', async () => { + const user = userEvent.setup(); + const controller = statefulQueueController(); + renderBubble({ controller }); + await user.click(screen.getByRole('button', { name: '现在做' })); + + await user.click(screen.getByRole('button', { name: '完成了' })); + + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect(screen.getByRole('dialog')).toHaveAccessibleName('喝水提醒'); + expect(screen.getByRole('button', { name: '现在做' })).toHaveFocus(); +}); + +test('closes immediately after completing the final queued reminder', async () => { + const user = userEvent.setup(); + const onRequestClose = vi.fn(); + const controller = statefulQueueController([reminder('lookAway', 20)]); + renderBubble({ controller, onRequestClose }); + await user.click(screen.getByRole('button', { name: '现在做' })); + + await user.click(screen.getByRole('button', { name: '完成了' })); + + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect(onRequestClose).toHaveBeenCalledTimes(1); + expect(screen.getByRole('button', { name: '猫咪' })).toHaveFocus(); +}); +``` + +Update the single-flight test to expect the bubble transition after its controlled promise resolves instead of expecting a status element. + +- [ ] **Step 2: Run the bubble tests and verify RED** + +Run: + +```bash +npm run test:run -- src/features/cat/components/CatReminderBubble.test.tsx +``` + +Expected: FAIL because the current implementation renders a status layer and explicit continuation control. + +- [ ] **Step 3: Implement direct transition and close** + +Remove `nextReminderIsWaiting`, `leaveCompletedAction`, and `completionAction`. Supply: + +```tsx +onCompleted={() => { + setActionOpen(false); + setActionReminder(undefined); + setActionOccurrenceKey(undefined); + setCountdown(undefined); + setSnoozeOpen(false); + if (controller.getSnapshot().scheduler.dueQueue.length === 0) { + onRequestClose(); + returnFocusRef.current?.focus(); + } +}} +``` + +When a successor remains, clearing `actionOpen` makes `displayedReminder` follow the queue head and the existing focus effect selects its first action. + +- [ ] **Step 4: Run both component suites and verify GREEN** + +Run: + +```bash +npm run test:run -- \ + src/features/reminders/components/ActionCard.test.tsx \ + src/features/cat/components/CatReminderBubble.test.tsx +``` + +Expected: both files pass with no unhandled rejection or React `act` warning. + +- [ ] **Step 5: Commit the pet flow** + +```bash +git add src/features/cat/components/CatReminderBubble.tsx src/features/cat/components/CatReminderBubble.test.tsx +git commit -m "fix: close completed pet reminder layers" +``` + +### Task 3: Release verification and PR update + +**Files:** +- No production files +- Existing PR: `#3` + +**Interfaces:** +- Consumes: the two committed component changes +- Produces: verified `0.2.1` branch state ready for GitHub native packaging + +- [ ] **Step 1: Run the complete desktop release gate** + +Run: + +```bash +npm run check:desktop-release +``` + +Expected: + +- release configuration, workflow, Electron, signing, artifact, tag, and packaged-resource tests pass +- all Vitest application tests pass +- all 40 Playwright tests pass +- web and desktop production builds pass + +- [ ] **Step 2: Push the implementation commits** + +```bash +git push origin feat/cross-platform-desktop-release +``` + +- [ ] **Step 3: Wait for PR `#3` CI** + +Confirm the `Build Desktop` pull-request run completes successfully before merging. + +- [ ] **Step 4: Merge and publish `v0.2.1`** + +Merge PR `#3`, create annotated tag `v0.2.1` on the resulting `main` commit, and push the tag. + +- [ ] **Step 5: Verify native release artifacts** + +Confirm all package jobs and the release job pass, then verify the public Release contains exactly: + +```text +Codex-Pet-Pause-0.2.1-mac-arm64.dmg +Codex-Pet-Pause-0.2.1-mac-x64.dmg +Codex-Pet-Pause-0.2.1-windows-x64.exe +Codex-Pet-Pause-0.2.1-linux-x64.AppImage +Codex-Pet-Pause-0.2.1-linux-x64.deb +``` diff --git a/docs/superpowers/specs/2026-07-27-reminder-completion-flow-design.md b/docs/superpowers/specs/2026-07-27-reminder-completion-flow-design.md new file mode 100644 index 0000000..f1d1c73 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-reminder-completion-flow-design.md @@ -0,0 +1,45 @@ +# Reminder Completion Flow Design + +## Goal + +Remove the extra completion-confirmation layer from the desktop pet reminder flow. +Completing a reminder should either advance directly to the next queued reminder or +close the reminder bubble when the queue is empty. + +## Behavior + +- The user opens a due reminder from the pet and chooses "Do it now." +- The action card keeps countdown and completion controls unchanged. +- Clicking "Complete" remains single-flight while the controller command is pending. +- After a successful completion: + - If another reminder is queued, the bubble immediately shows that reminder's + first-level due actions. + - If the queue is empty, the bubble closes and focus returns to the pet. +- The flow never renders a completed-status layer, a "Continue" button, or a + "Close" button inside the pet reminder bubble. +- If completion fails, the current action remains visible and the completion button + becomes available for retry. + +## Component Boundary + +`ActionCard` will accept an optional successful-completion callback. When supplied, +it invokes the callback after `controller.complete` resolves instead of entering its +local completed-status state. Existing callers that omit the callback retain the +current completion confirmation. + +`CatReminderBubble` will supply that callback, clear its action state, inspect the +controller's fresh queue snapshot, and either reveal the next reminder or request +that the bubble close. + +## Accessibility + +- Advancing the queue focuses the next reminder's first action. +- Closing the final reminder returns focus to the pet. +- A pending completion remains disabled to prevent duplicate commands. + +## Tests + +- A single completed reminder closes immediately without rendering a status layer. +- A completed reminder with a queued successor advances directly to the successor. +- A rejected completion keeps the current action available for retry. +- Existing single-flight completion behavior remains covered. diff --git a/e2e/accessibility.spec.ts b/e2e/accessibility.spec.ts index 331c19d..20e8be0 100644 --- a/e2e/accessibility.spec.ts +++ b/e2e/accessibility.spec.ts @@ -100,9 +100,8 @@ test('completes a reminder and reaches all four views with keyboard only', async await page.keyboard.press('Enter'); await expect(page.getByRole('button', { name: '完成了' })).toBeFocused(); await page.keyboard.press('Enter'); - await expect(page.getByText('喝水已完成')).toBeFocused(); - await page.keyboard.press('Tab'); - await page.keyboard.press('Enter'); + await expect(page.getByRole('dialog')).toHaveCount(0); + await expect(page.getByTestId('cat-stage')).toBeFocused(); for (const [buttonName, headingName] of [ ['提醒', '提醒'], ['宠物', '宠物库'], ['设置', '设置'], ['陪伴', '团子 在陪你'], diff --git a/e2e/localization.spec.ts b/e2e/localization.spec.ts index 36ccaf5..8381d36 100644 --- a/e2e/localization.spec.ts +++ b/e2e/localization.spec.ts @@ -253,7 +253,8 @@ test('supports English settings navigation and a reminder action with the keyboa await page.keyboard.press('Enter'); await expect(page.getByRole('button', { name: 'Done' })).toBeFocused(); await page.keyboard.press('Enter'); - await expect(page.getByText('Drink water completed')).toBeFocused(); + await expect(page.getByRole('dialog')).toHaveCount(0); + await expect(page.getByTestId('cat-stage')).toBeFocused(); }); for (const viewport of [ diff --git a/e2e/persistence.spec.ts b/e2e/persistence.spec.ts index e3139dd..227dc54 100644 --- a/e2e/persistence.spec.ts +++ b/e2e/persistence.spec.ts @@ -361,7 +361,7 @@ test('clears local settings and IndexedDB history after confirmation', async ({ await openWaitingCat(page); await page.getByRole('button', { name: '现在做' }).click(); await page.getByRole('button', { name: '完成了' }).click(); - await page.getByRole('button', { name: '关闭' }).click(); + await expect(page.getByRole('dialog')).toHaveCount(0); await page.getByRole('button', { name: '设置', exact: true }).click(); await page.getByRole('button', { name: '清除全部本地数据' }).click(); await page.getByLabel('我了解本机数据将被删除').check(); diff --git a/e2e/reminders.spec.ts b/e2e/reminders.spec.ts index cb15526..54f2c89 100644 --- a/e2e/reminders.spec.ts +++ b/e2e/reminders.spec.ts @@ -95,8 +95,6 @@ test('supports complete, snooze with 10-minute focus, and skip', async ({ page } await expect(page.getByRole('dialog', { name: '目视远方提醒' })).toBeVisible(); await page.getByRole('button', { name: '现在做' }).click(); await page.getByRole('button', { name: '完成了' }).click(); - await expect(page.getByRole('status', { name: '' }).filter({ hasText: '目视远方已完成' })).toBeVisible(); - await page.getByRole('button', { name: '继续下一项' }).click(); await expect(page.getByRole('dialog', { name: '喝水提醒' })).toBeVisible(); await page.getByRole('button', { name: '稍后提醒' }).click(); diff --git a/package-lock.json b/package-lock.json index 25a4106..4bcbac0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-pet-pause", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-pet-pause", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { "@zip.js/zip.js": "^2.8.34", diff --git a/package.json b/package.json index 0740417..2757bb1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codex-pet-pause", "private": true, - "version": "0.2.0", + "version": "0.2.1", "description": "A playful, local-first break reminder PWA with interactive and Codex-compatible pets.", "license": "MIT", "repository": { @@ -28,6 +28,7 @@ "test": "vitest", "test:run": "vitest run", "test:electron": "vitest run --config vitest.electron.config.js", + "test:mac-signing": "node --test scripts/after-pack.test.mjs", "test:e2e": "playwright test", "check:release-dist": "node scripts/verify-release-dist.mjs", "test:verify-release-dist": "node scripts/test-verify-release-dist.mjs", @@ -62,7 +63,7 @@ "test:release-tag": "node --test scripts/verify-release-tag.test.mjs", "test:packaged-resources": "node --test scripts/verify-packaged-resources.test.mjs", "test:packaged-app": "node --test scripts/verify-packaged-app.test.mjs", - "check:desktop-release": "npm run test:desktop-release-config && npm run test:brand-assets && npm run test:desktop-workflow && npm run test:electron && npm run test:release-artifacts && npm run test:release-tag && npm run test:packaged-resources && npm run test:packaged-app && npm run check:desktop-release-config && npm run check:desktop-workflow && npm run typecheck && npm run test:run && npm run build && npm run test:e2e && npm run desktop:build && npm run desktop:smoke:packaged-resources" + "check:desktop-release": "npm run test:desktop-release-config && npm run test:brand-assets && npm run test:desktop-workflow && npm run test:electron && npm run test:mac-signing && npm run test:release-artifacts && npm run test:release-tag && npm run test:packaged-resources && npm run test:packaged-app && npm run check:desktop-release-config && npm run check:desktop-workflow && npm run typecheck && npm run test:run && npm run build && npm run test:e2e && npm run desktop:build && npm run desktop:smoke:packaged-resources" }, "dependencies": { "@zip.js/zip.js": "^2.8.34", @@ -100,6 +101,7 @@ "appId": "io.elevenlabs.codexpetpause", "productName": "Codex Pet Pause", "asar": true, + "afterPack": "scripts/after-pack.mjs", "files": [ "dist/**/*", "electron/**/*", diff --git a/scripts/after-pack.mjs b/scripts/after-pack.mjs new file mode 100644 index 0000000..8832adc --- /dev/null +++ b/scripts/after-pack.mjs @@ -0,0 +1,22 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { Arch } from 'builder-util'; + +const execFileAsync = promisify(execFileCallback); + +export async function afterPack( + context, + { execFile = execFileAsync } = {}, +) { + if (context.electronPlatformName !== 'darwin' || context.arch !== Arch.arm64) { + return; + } + + const appPath = join( + context.appOutDir, + `${context.packager.appInfo.productFilename}.app`, + ); + await execFile('xattr', ['-cr', appPath]); + await execFile('codesign', ['--force', '--deep', '--sign', '-', appPath]); +} diff --git a/scripts/after-pack.test.mjs b/scripts/after-pack.test.mjs new file mode 100644 index 0000000..a62ab01 --- /dev/null +++ b/scripts/after-pack.test.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Arch } from 'builder-util'; +import { afterPack } from './after-pack.mjs'; + +function context(electronPlatformName, arch) { + return { + electronPlatformName, + arch, + appOutDir: '/tmp/codex-pet-output', + packager: { + appInfo: { + productFilename: 'Codex Pet Pause', + }, + }, + }; +} + +test('deep-signs only the unpacked macOS ARM64 application', async () => { + const calls = []; + const execFile = async (...args) => { + calls.push(args); + }; + + await afterPack(context('darwin', Arch.arm64), { execFile }); + + assert.deepEqual(calls, [ + ['xattr', ['-cr', '/tmp/codex-pet-output/Codex Pet Pause.app']], + [ + 'codesign', + ['--force', '--deep', '--sign', '-', '/tmp/codex-pet-output/Codex Pet Pause.app'], + ], + ]); +}); + +test('does not change macOS x64, Windows, or Linux packages', async () => { + const calls = []; + const execFile = async (...args) => { + calls.push(args); + }; + + await afterPack(context('darwin', Arch.x64), { execFile }); + await afterPack(context('win32', Arch.x64), { execFile }); + await afterPack(context('linux', Arch.x64), { execFile }); + + assert.deepEqual(calls, []); +}); diff --git a/scripts/verify-desktop-release-config.mjs b/scripts/verify-desktop-release-config.mjs index db31c0d..9674317 100644 --- a/scripts/verify-desktop-release-config.mjs +++ b/scripts/verify-desktop-release-config.mjs @@ -31,6 +31,9 @@ function hasExactlyArchitectures(architectures, expectedArchitectures) { export function verifyDesktopReleaseConfig(packageJson, fileExists = existsSync) { const failures = []; const build = packageJson.build ?? {}; + if (build.afterPack !== 'scripts/after-pack.mjs') { + failures.push('build.afterPack must preserve the macOS ARM64 signing repair'); + } if ( packageJson.author?.name !== expected.authorName || packageJson.author?.email !== expected.authorEmail diff --git a/scripts/verify-desktop-release-config.test.mjs b/scripts/verify-desktop-release-config.test.mjs index e9dfdd8..0ea69f1 100644 --- a/scripts/verify-desktop-release-config.test.mjs +++ b/scripts/verify-desktop-release-config.test.mjs @@ -14,6 +14,7 @@ const validPackage = { build: { appId: 'io.elevenlabs.codexpetpause', productName: 'Codex Pet Pause', + afterPack: 'scripts/after-pack.mjs', mac: { icon: 'build/icons/icon.icns', target: [{ target: 'dmg' }], @@ -53,6 +54,16 @@ test('accepts the approved desktop release contract', () => { ); }); +test('requires the ARM64 signing repair hook', () => { + const missingHook = structuredClone(validPackage); + delete missingHook.build.afterPack; + + assert.ok( + verifyDesktopReleaseConfig(missingHook, () => true) + .some((failure) => failure.includes('afterPack')), + ); +}); + test('requires the approved privacy-preserving Linux maintainer identity', () => { const missingAuthorEmail = structuredClone(validPackage); delete missingAuthorEmail.author.email; diff --git a/scripts/verify-release-artifacts.test.mjs b/scripts/verify-release-artifacts.test.mjs index cc6fa3e..365710d 100644 --- a/scripts/verify-release-artifacts.test.mjs +++ b/scripts/verify-release-artifacts.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; @@ -24,6 +25,15 @@ const platformSidecars = { ], }; const invalidLinuxSidecar = 'Codex-Pet-Pause-0.2.0-linux-x64.AppImage.blockmap'; +const currentVersion = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +).version; +const currentInstallerSet = completeInstallerSet.map( + (fileName) => fileName.replace('0.2.0', currentVersion), +); +const currentMacSidecars = platformSidecars.mac.map( + (fileName) => fileName.replace('0.2.0', currentVersion), +); test('accepts one complete 0.2.0 installer set', () => { assert.deepEqual(verifyReleaseArtifacts(completeInstallerSet, '0.2.0'), []); @@ -185,7 +195,7 @@ test('the executable --platform option validates only that platform', async () = const script = fileURLToPath(new URL('./verify-release-artifacts.mjs', import.meta.url)); try { - for (const fileName of completeInstallerSet.slice(0, 2)) { + for (const fileName of currentInstallerSet.slice(0, 2)) { await writeFile(join(directory, fileName), 'package'); } @@ -209,8 +219,8 @@ test('the executable --target option validates only one isolated matrix target', const script = fileURLToPath(new URL('./verify-release-artifacts.mjs', import.meta.url)); try { - await writeFile(join(directory, completeInstallerSet[0]), 'package'); - await writeFile(join(directory, platformSidecars.mac[0]), 'sidecar'); + await writeFile(join(directory, currentInstallerSet[0]), 'package'); + await writeFile(join(directory, currentMacSidecars[0]), 'sidecar'); await writeFile(join(directory, 'builder-effective-config.yaml'), 'metadata'); const targetResult = spawnSync( @@ -237,15 +247,15 @@ test('the executable rejects zero-byte and non-regular expected artifacts', asyn const script = fileURLToPath(new URL('./verify-release-artifacts.mjs', import.meta.url)); try { - await writeFile(join(directory, completeInstallerSet[0]), ''); - await mkdir(join(directory, completeInstallerSet[1])); + await writeFile(join(directory, currentInstallerSet[0]), ''); + await mkdir(join(directory, currentInstallerSet[1])); const result = spawnSync(process.execPath, [script, directory, '--platform', 'mac'], { encoding: 'utf8', }); assert.notEqual(result.status, 0); - assert.match(result.stderr, /Codex-Pet-Pause-0\.2\.0-mac-arm64\.dmg/); - assert.match(result.stderr, /Codex-Pet-Pause-0\.2\.0-mac-x64\.dmg/); + assert.ok(result.stderr.includes(currentInstallerSet[0])); + assert.ok(result.stderr.includes(currentInstallerSet[1])); } finally { await rm(directory, { recursive: true, force: true }); } @@ -256,7 +266,7 @@ test('the executable rejects an unexpected zero-byte stale package', async () => const script = fileURLToPath(new URL('./verify-release-artifacts.mjs', import.meta.url)); try { - for (const fileName of completeInstallerSet.slice(0, 2)) { + for (const fileName of currentInstallerSet.slice(0, 2)) { await writeFile(join(directory, fileName), 'package'); } await writeFile(join(directory, 'Codex-Pet-Pause-0.1.0-mac-x64.dmg'), ''); diff --git a/src/app/AppShell.test.tsx b/src/app/AppShell.test.tsx index 291fc74..d4f9036 100644 --- a/src/app/AppShell.test.tsx +++ b/src/app/AppShell.test.tsx @@ -60,6 +60,19 @@ test('provides a skip link, status area, and navigation between four focused vie expect(screen.getByRole('button', { name: '摸摸 Momo' })).toBe(cat); }); +test('desktop settings route keeps the full shell, opens settings, and hides the duplicate pet', () => { + window.history.replaceState({}, '', '/?mode=web&view=settings&hidePet=1'); + const controller = createAppController(createFakeDependencies({ now: 0 })); + + renderShell(controller); + + expect(screen.getByRole('navigation', { name: '主要导航' })).toBeVisible(); + expect(screen.getByRole('button', { name: '设置' })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByRole('heading', { name: '设置' })).toBeVisible(); + expect(screen.queryByRole('button', { name: '摸摸 Momo' })).not.toBeInTheDocument(); + window.history.replaceState({}, '', '/'); +}); + test('returns the viewport to the top when the shell opens and views change', async () => { const user = userEvent.setup(); const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined); diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index a5f194e..0b7e678 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -15,7 +15,10 @@ type AppView = 'companion' | 'reminders' | 'pet' | 'settings'; export function AppShell() { const { t } = useI18n(); - const [activeView, setActiveView] = useState('companion'); + const launchQuery = new URLSearchParams(window.location.search); + const hidePetFromHost = launchQuery.get('hidePet') === '1'; + const initialView: AppView = launchQuery.get('view') === 'settings' ? 'settings' : 'companion'; + const [activeView, setActiveView] = useState(initialView); const snapshot = useAppSnapshot(); const pwaSnapshot = useSyncExternalStore(pwaStatus.subscribe, pwaStatus.getSnapshot, pwaStatus.getSnapshot); const enabledReminderCount = snapshot.settings.reminders.filter((reminder) => reminder.enabled).length; @@ -60,9 +63,11 @@ export function AppShell() { offlineReady={pwaSnapshot.offlineReady} /> - {activeImportedPet === undefined - ? - : } + {!hidePetFromHost && ( + activeImportedPet === undefined + ? + : + )}