diff --git a/AGENTS.md b/AGENTS.md index 6592100b73..6b9faa0690 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,7 +149,7 @@ For the full tag dictionary, operational playbook (direct merge / duplicate-titl ## i18n keys -- `apps/web/src/i18n/types.ts` is the typed `Dict`; every key must be defined in all 18 locale files under `apps/web/src/i18n/locales/*.ts` (`ar`, `de`, `en`, `es-ES`, `fa`, `fr`, `hu`, `id`, `ja`, `ko`, `pl`, `pt-BR`, `ru`, `th`, `tr`, `uk`, `zh-CN`, `zh-TW`). Add the key to `types.ts` first; missing translations produce a typecheck error. +- `apps/web/src/i18n/types.ts` is the typed `Dict`; every key must be defined in all 19 locale files under `apps/web/src/i18n/locales/*.ts` (`ar`, `de`, `en`, `es-ES`, `fa`, `fr`, `hu`, `id`, `it`, `ja`, `ko`, `pl`, `pt-BR`, `ru`, `th`, `tr`, `uk`, `zh-CN`, `zh-TW`). Add the key to `types.ts` first; missing translations produce a typecheck error. ## UI animation philosophy diff --git a/apps/web/src/components/pet/PetOverlay.tsx b/apps/web/src/components/pet/PetOverlay.tsx index 8f18cd4fdc..5844f34919 100644 --- a/apps/web/src/components/pet/PetOverlay.tsx +++ b/apps/web/src/components/pet/PetOverlay.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useT } from '../../i18n'; import type { PetConfig } from '../../types'; import { @@ -45,13 +45,33 @@ const EMPTY_TASK_CENTER: PetTaskCenter = { }; interface Position { - // Distances from the right/bottom of the viewport so the overlay - // sticks to the corner across resizes. Saved in localStorage. - right: number; - bottom: number; + // Distance from the horizontal edge of the anchor corner (left for + // left-anchored corners, right for right-anchored corners). + x: number; + // Distance from the vertical edge of the anchor corner (top for + // top-anchored corners, bottom for bottom-anchored corners). + y: number; } -const DEFAULT_POSITION: Position = { right: 24, bottom: 24 }; +const DEFAULT_POSITION: Position = { x: 24, y: 24 }; + +type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; + +// Converts (corner, position) into a React CSS style object with exactly two +// positional properties set and the other two undefined so the browser ignores +// them. The overlay is position:fixed so any unset axis property resolves to +// 'auto' rather than a pixel value, which is what the tests assert for the +// "empty string" checks (jsdom reports '' for unset inline styles). +function cornerStyle(corner: Corner, pos: Position): React.CSSProperties { + const isTop = corner === 'top-left' || corner === 'top-right'; + const isLeft = corner === 'top-left' || corner === 'bottom-left'; + return { + top: isTop ? pos.y : undefined, + bottom: isTop ? undefined : pos.y, + left: isLeft ? pos.x : undefined, + right: isLeft ? undefined : pos.x, + }; +} // How long the pet has to sit untouched before the overlay flips to // the "waiting" animation row. Sized to sit comfortably past a few @@ -93,11 +113,15 @@ function loadPosition(): Position { try { const raw = window.localStorage.getItem(STORAGE_KEY); if (!raw) return DEFAULT_POSITION; - const parsed = JSON.parse(raw) as Partial; - return { - right: typeof parsed.right === 'number' ? parsed.right : DEFAULT_POSITION.right, - bottom: typeof parsed.bottom === 'number' ? parsed.bottom : DEFAULT_POSITION.bottom, - }; + const parsed = JSON.parse(raw) as Partial; + // Migrate legacy { right, bottom } shape to { x, y }. + const x = typeof parsed.x === 'number' ? parsed.x + : typeof parsed.right === 'number' ? parsed.right + : DEFAULT_POSITION.x; + const y = typeof parsed.y === 'number' ? parsed.y + : typeof parsed.bottom === 'number' ? parsed.bottom + : DEFAULT_POSITION.y; + return { x, y }; } catch { return DEFAULT_POSITION; } @@ -139,8 +163,8 @@ export function PetOverlay({ const dragRef = useRef<{ startX: number; startY: number; - startRight: number; - startBottom: number; + startPosX: number; + startPosY: number; moved: boolean; // Last classified gesture direction. Kept on the ref so we don't // trigger a state update + render on every pointermove tick. @@ -319,6 +343,10 @@ export function PetOverlay({ if (!active) return null; + const activeCorner: Corner = pet?.corner ?? 'bottom-right'; + const isLeftAnchored = activeCorner === 'top-left' || activeCorner === 'bottom-left'; + const isTopAnchored = activeCorner === 'top-left' || activeCorner === 'top-right'; + const onPointerDown = (event: React.PointerEvent) => { if (event.button !== 0) return; const target = event.currentTarget; @@ -326,8 +354,8 @@ export function PetOverlay({ dragRef.current = { startX: event.clientX, startY: event.clientY, - startRight: position.right, - startBottom: position.bottom, + startPosX: position.x, + startPosY: position.y, moved: false, direction: null, }; @@ -341,13 +369,15 @@ export function PetOverlay({ const dy = event.clientY - drag.startY; if (!drag.moved && Math.abs(dx) + Math.abs(dy) < 4) return; drag.moved = true; - // Convert pointer movement into right/bottom offsets so the sprite - // tracks the cursor while staying anchored to the corner system. - // The clamp budget (~120px) keeps the 96px sprite plus its drop - // shadow on-screen even when dragged toward the opposite edge. - const nextRight = Math.max(8, Math.min(window.innerWidth - 120, drag.startRight - dx)); - const nextBottom = Math.max(8, Math.min(window.innerHeight - 120, drag.startBottom - dy)); - setPosition({ right: nextRight, bottom: nextBottom }); + // For left-anchored corners, moving right increases x offset. + // For right-anchored corners, moving right decreases x offset. + // For top-anchored corners, moving down increases y offset. + // For bottom-anchored corners, moving down decreases y offset. + const nextX = Math.max(8, Math.min(window.innerWidth - 120, + isLeftAnchored ? drag.startPosX + dx : drag.startPosX - dx)); + const nextY = Math.max(8, Math.min(window.innerHeight - 120, + isTopAnchored ? drag.startPosY + dy : drag.startPosY - dy)); + setPosition({ x: nextX, y: nextY }); // Classify the gesture direction once it clears the jitter floor // and one axis clearly dominates the other. The animation then @@ -429,9 +459,9 @@ export function PetOverlay({ className="pet-overlay" role="complementary" aria-label={t('pet.overlayAria')} + data-corner={activeCorner} style={{ - right: position.right, - bottom: position.bottom, + ...cornerStyle(activeCorner, position), // The accent drives the halo, the bubble border, and the focus // ring on the action buttons via CSS custom property cascade. ['--pet-accent' as string]: active.accent, diff --git a/apps/web/src/components/pet/PetSettings.tsx b/apps/web/src/components/pet/PetSettings.tsx index b13850ff34..0f63963538 100644 --- a/apps/web/src/components/pet/PetSettings.tsx +++ b/apps/web/src/components/pet/PetSettings.tsx @@ -603,6 +603,28 @@ export function PetSettings({ cfg, setCfg }: Props) {

+
+ {t('pet.fieldCorner')} +
+ {(['top-left', 'top-right', 'bottom-left', 'bottom-right'] as const).map((c) => ( + + ))} +
+
+ {activeTab === 'builtIn' ? (
{bundledPets.length === 0 ? ( diff --git a/apps/web/src/i18n/locales/ar.ts b/apps/web/src/i18n/locales/ar.ts index 58b669abfe..11747742c9 100644 --- a/apps/web/src/i18n/locales/ar.ts +++ b/apps/web/src/i18n/locales/ar.ts @@ -1299,6 +1299,11 @@ export const ar: Dict = { 'pet.communitySyncFailed': 'فشلت المزامنة: {error}', 'pet.codexBundled': 'مدمج', 'pet.codexBundledTitle': 'يأتي مع Open Design - لا حاجة للتحميل.', + 'pet.fieldCorner': 'الزاوية', + 'pet.corner.top-left': 'أعلى يسار', + 'pet.corner.top-right': 'أعلى يمين', + 'pet.corner.bottom-left': 'أسفل يسار', + 'pet.corner.bottom-right': 'أسفل يمين', 'settings.notifications': 'الإشعارات', 'settings.notificationsHint': 'صوت وإشعار سطح المكتب عند اكتمال المهمة', diff --git a/apps/web/src/i18n/locales/de.ts b/apps/web/src/i18n/locales/de.ts index d721e79558..c153dd82d5 100644 --- a/apps/web/src/i18n/locales/de.ts +++ b/apps/web/src/i18n/locales/de.ts @@ -1237,6 +1237,11 @@ export const de: Dict = { 'pet.communitySyncFailed': 'Sync fehlgeschlagen: {error}', 'pet.codexBundled': 'Mitgeliefert', 'pet.codexBundledTitle': 'Wird mit Open Design ausgeliefert — kein Download nötig.', + 'pet.fieldCorner': 'Ecke', + 'pet.corner.top-left': 'Oben links', + 'pet.corner.top-right': 'Oben rechts', + 'pet.corner.bottom-left': 'Unten links', + 'pet.corner.bottom-right': 'Unten rechts', 'settings.notifications': 'Benachrichtigungen', 'settings.notificationsHint': 'Ton und Desktop-Benachrichtigung beim Abschluss von Aufgaben', diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index 0c192d56f2..35156dee85 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -1922,6 +1922,11 @@ export const en: Dict = { 'pet.communitySyncFailed': 'Sync failed: {error}', 'pet.codexBundled': 'Bundled', 'pet.codexBundledTitle': 'Ships with Open Design — no download needed.', + 'pet.fieldCorner': 'Corner', + 'pet.corner.top-left': 'Top left', + 'pet.corner.top-right': 'Top right', + 'pet.corner.bottom-left': 'Bottom left', + 'pet.corner.bottom-right': 'Bottom right', 'settings.notifications': 'Notifications', 'settings.notificationsHint': 'Sound and desktop notification on task completion', diff --git a/apps/web/src/i18n/locales/es-ES.ts b/apps/web/src/i18n/locales/es-ES.ts index 31c7f6ff7d..161ea8a4fd 100644 --- a/apps/web/src/i18n/locales/es-ES.ts +++ b/apps/web/src/i18n/locales/es-ES.ts @@ -1188,6 +1188,11 @@ export const esES: Dict = { 'pet.communitySyncFailed': 'Error al sincronizar: {error}', 'pet.codexBundled': 'Incluida', 'pet.codexBundledTitle': 'Viene con Open Design — sin descarga.', + 'pet.fieldCorner': 'Esquina', + 'pet.corner.top-left': 'Arriba izquierda', + 'pet.corner.top-right': 'Arriba derecha', + 'pet.corner.bottom-left': 'Abajo izquierda', + 'pet.corner.bottom-right': 'Abajo derecha', 'settings.notifications': 'Notificaciones', 'settings.notificationsHint': 'Sonido y notificación al completar la tarea', diff --git a/apps/web/src/i18n/locales/fa.ts b/apps/web/src/i18n/locales/fa.ts index 7c1e9566c2..54a7211b36 100644 --- a/apps/web/src/i18n/locales/fa.ts +++ b/apps/web/src/i18n/locales/fa.ts @@ -1342,6 +1342,11 @@ export const fa: Dict = { 'pet.communitySyncFailed': 'خطا در همگام‌سازی: {error}', 'pet.codexBundled': 'همراه', 'pet.codexBundledTitle': 'همراه Open Design ارائه می‌شود — نیازی به دانلود نیست.', + 'pet.fieldCorner': 'گوشه', + 'pet.corner.top-left': 'بالا چپ', + 'pet.corner.top-right': 'بالا راست', + 'pet.corner.bottom-left': 'پایین چپ', + 'pet.corner.bottom-right': 'پایین راست', 'settings.notifications': 'اعلان‌ها', 'settings.notificationsHint': 'صدا و اعلان دسکتاپ هنگام تکمیل وظیفه', diff --git a/apps/web/src/i18n/locales/fr.ts b/apps/web/src/i18n/locales/fr.ts index e448b57671..37960a54ff 100644 --- a/apps/web/src/i18n/locales/fr.ts +++ b/apps/web/src/i18n/locales/fr.ts @@ -1299,6 +1299,11 @@ export const fr: Dict = { 'pet.communitySyncFailed': 'Échec de la synchronisation : {error}', 'pet.codexBundled': 'Fourni', 'pet.codexBundledTitle': 'Livré avec Open Design — aucun téléchargement nécessaire.', + 'pet.fieldCorner': 'Coin', + 'pet.corner.top-left': 'Haut gauche', + 'pet.corner.top-right': 'Haut droite', + 'pet.corner.bottom-left': 'Bas gauche', + 'pet.corner.bottom-right': 'Bas droite', 'settings.notifications': 'Notifications', 'settings.notificationsHint': 'Son et notification bureau à la fin d\'une tâche', diff --git a/apps/web/src/i18n/locales/hu.ts b/apps/web/src/i18n/locales/hu.ts index d5dd62fbe9..343556009d 100644 --- a/apps/web/src/i18n/locales/hu.ts +++ b/apps/web/src/i18n/locales/hu.ts @@ -1309,6 +1309,11 @@ export const hu: Dict = { 'pet.communitySyncFailed': 'A szinkronizálás sikertelen: {error}', 'pet.codexBundled': 'Beépített', 'pet.codexBundledTitle': 'Az Open Designgal érkezik — letöltés nem szükséges.', + 'pet.fieldCorner': 'Sarok', + 'pet.corner.top-left': 'Bal felső', + 'pet.corner.top-right': 'Jobb felső', + 'pet.corner.bottom-left': 'Bal alsó', + 'pet.corner.bottom-right': 'Jobb alsó', 'settings.notifications': 'Értesítések', 'settings.notificationsHint': 'Hang és asztali értesítés a feladat befejezésekor', diff --git a/apps/web/src/i18n/locales/id.ts b/apps/web/src/i18n/locales/id.ts index baa135fb5c..fc7167e317 100644 --- a/apps/web/src/i18n/locales/id.ts +++ b/apps/web/src/i18n/locales/id.ts @@ -1450,6 +1450,11 @@ export const id: Dict = { 'pet.communitySyncFailed': 'Sinkronisasi gagal: {error}', 'pet.codexBundled': 'Bawaan', 'pet.codexBundledTitle': 'Disertakan dengan Open Design - tidak perlu diunduh.', + 'pet.fieldCorner': 'Sudut', + 'pet.corner.top-left': 'Kiri atas', + 'pet.corner.top-right': 'Kanan atas', + 'pet.corner.bottom-left': 'Kiri bawah', + 'pet.corner.bottom-right': 'Kanan bawah', 'settings.notifications': 'Notifikasi', 'settings.notificationsHint': 'Atur suara dan notifikasi desktop.', diff --git a/apps/web/src/i18n/locales/it.ts b/apps/web/src/i18n/locales/it.ts index 87cf4cc70f..1399e83ffc 100644 --- a/apps/web/src/i18n/locales/it.ts +++ b/apps/web/src/i18n/locales/it.ts @@ -1184,6 +1184,11 @@ export const it: Dict = { 'pet.communitySyncFailed': 'Sincronizzazione fallita: {error}', 'pet.codexBundled': 'Fornito', 'pet.codexBundledTitle': 'Fornito con Open Design — nessun download necessario.', + 'pet.fieldCorner': 'Angolo', + 'pet.corner.top-left': 'In alto a sinistra', + 'pet.corner.top-right': 'In alto a destra', + 'pet.corner.bottom-left': 'In basso a sinistra', + 'pet.corner.bottom-right': 'In basso a destra', 'settings.notifications': 'Notifiche', 'settings.notificationsHint': 'Suono e notifica desktop alla fine di un\'attività', diff --git a/apps/web/src/i18n/locales/ja.ts b/apps/web/src/i18n/locales/ja.ts index 34403f1719..b377d501e5 100644 --- a/apps/web/src/i18n/locales/ja.ts +++ b/apps/web/src/i18n/locales/ja.ts @@ -1236,6 +1236,11 @@ export const ja: Dict = { 'pet.communitySyncFailed': '同期に失敗しました: {error}', 'pet.codexBundled': '同梱', 'pet.codexBundledTitle': 'Open Design に同梱 — ダウンロード不要。', + 'pet.fieldCorner': 'コーナー', + 'pet.corner.top-left': '左上', + 'pet.corner.top-right': '右上', + 'pet.corner.bottom-left': '左下', + 'pet.corner.bottom-right': '右下', 'settings.notifications': '通知', 'settings.notificationsHint': 'タスク完了時の効果音とデスクトップ通知', diff --git a/apps/web/src/i18n/locales/ko.ts b/apps/web/src/i18n/locales/ko.ts index dbdb7eb325..3b2355619e 100644 --- a/apps/web/src/i18n/locales/ko.ts +++ b/apps/web/src/i18n/locales/ko.ts @@ -1349,6 +1349,11 @@ export const ko: Dict = { 'pet.communitySyncFailed': '동기화 실패: {error}', 'pet.codexBundled': '내장', 'pet.codexBundledTitle': 'Open Design 에 내장 — 다운로드 불필요.', + 'pet.fieldCorner': '모서리', + 'pet.corner.top-left': '왼쪽 상단', + 'pet.corner.top-right': '오른쪽 상단', + 'pet.corner.bottom-left': '왼쪽 하단', + 'pet.corner.bottom-right': '오른쪽 하단', 'settings.notifications': '알림', 'settings.notificationsHint': '작업 완료 시 효과음 및 데스크톱 알림', diff --git a/apps/web/src/i18n/locales/pl.ts b/apps/web/src/i18n/locales/pl.ts index 569a451fbb..7a2269dfcd 100644 --- a/apps/web/src/i18n/locales/pl.ts +++ b/apps/web/src/i18n/locales/pl.ts @@ -1299,6 +1299,11 @@ export const pl: Dict = { 'pet.communitySyncFailed': 'Błąd synchronizacji: {error}', 'pet.codexBundled': 'W zestawie', 'pet.codexBundledTitle': 'Dostarczany z Open Design — bez pobierania.', + 'pet.fieldCorner': 'Narożnik', + 'pet.corner.top-left': 'Lewy górny', + 'pet.corner.top-right': 'Prawy górny', + 'pet.corner.bottom-left': 'Lewy dolny', + 'pet.corner.bottom-right': 'Prawy dolny', 'settings.notifications': 'Powiadomienia', 'settings.notificationsHint': 'Dźwięk i powiadomienie pulpitu po zakończeniu zadania', diff --git a/apps/web/src/i18n/locales/pt-BR.ts b/apps/web/src/i18n/locales/pt-BR.ts index 5954fb3e0b..eebc42d80b 100644 --- a/apps/web/src/i18n/locales/pt-BR.ts +++ b/apps/web/src/i18n/locales/pt-BR.ts @@ -1340,6 +1340,11 @@ export const ptBR: Dict = { 'pet.communitySyncFailed': 'Falha na sincronização: {error}', 'pet.codexBundled': 'Incluído', 'pet.codexBundledTitle': 'Já vem com o Open Design — sem download.', + 'pet.fieldCorner': 'Canto', + 'pet.corner.top-left': 'Superior esquerdo', + 'pet.corner.top-right': 'Superior direito', + 'pet.corner.bottom-left': 'Inferior esquerdo', + 'pet.corner.bottom-right': 'Inferior direito', 'settings.notifications': 'Notificações', 'settings.notificationsHint': 'Som e notificação na conclusão da tarefa', diff --git a/apps/web/src/i18n/locales/ru.ts b/apps/web/src/i18n/locales/ru.ts index 8bed3d4674..b2d7cfd5a2 100644 --- a/apps/web/src/i18n/locales/ru.ts +++ b/apps/web/src/i18n/locales/ru.ts @@ -1340,6 +1340,11 @@ export const ru: Dict = { 'pet.communitySyncFailed': 'Ошибка синхронизации: {error}', 'pet.codexBundled': 'Встроен', 'pet.codexBundledTitle': 'Поставляется с Open Design — загрузка не нужна.', + 'pet.fieldCorner': 'Угол', + 'pet.corner.top-left': 'Верхний левый', + 'pet.corner.top-right': 'Верхний правый', + 'pet.corner.bottom-left': 'Нижний левый', + 'pet.corner.bottom-right': 'Нижний правый', 'settings.notifications': 'Уведомления', 'settings.notificationsHint': 'Звук и уведомление при завершении задачи', diff --git a/apps/web/src/i18n/locales/th.ts b/apps/web/src/i18n/locales/th.ts index 33de3fb8c0..ddfc468ea3 100644 --- a/apps/web/src/i18n/locales/th.ts +++ b/apps/web/src/i18n/locales/th.ts @@ -1260,6 +1260,11 @@ export const th: Dict = { 'pet.communitySyncFailed': 'ซิงค์เข้าล้มซะแล้ว มีเออเร่อแบบนี้ขึ้นเลย: {error}', 'pet.codexBundled': 'รวมมามัดชุดใหญ่', 'pet.codexBundledTitle': 'เตรียมมาให้กับ Open Design แล้ว — ไม่ต้องดาวน์โหลดใหม่', + 'pet.fieldCorner': 'มุม', + 'pet.corner.top-left': 'บนซ้าย', + 'pet.corner.top-right': 'บนขวา', + 'pet.corner.bottom-left': 'ล่างซ้าย', + 'pet.corner.bottom-right': 'ล่างขวา', 'settings.notifications': 'การรับส่งข้อมูลการแจ้งเตือน', 'settings.notificationsHint': 'มีระบบใช้บอกทั้งส่งเตือนเดสก์ท็อป และเปิดเสียงที่รันรับใช้เข้าแบบทำงานลุล่วง', diff --git a/apps/web/src/i18n/locales/tr.ts b/apps/web/src/i18n/locales/tr.ts index c7c4e02496..d02c5cf6e6 100644 --- a/apps/web/src/i18n/locales/tr.ts +++ b/apps/web/src/i18n/locales/tr.ts @@ -1286,6 +1286,11 @@ export const tr: Dict = { 'pet.communitySyncFailed': 'Eşitleme başarısız: {error}', 'pet.codexBundled': 'Yerleşik', 'pet.codexBundledTitle': 'Open Design ile birlikte gelir — indirme gerekmez.', + 'pet.fieldCorner': 'Köşe', + 'pet.corner.top-left': 'Sol üst', + 'pet.corner.top-right': 'Sağ üst', + 'pet.corner.bottom-left': 'Sol alt', + 'pet.corner.bottom-right': 'Sağ alt', 'settings.notifications': 'Bildirimler', 'settings.notificationsHint': 'Görev tamamlandığında ses ve masaüstü bildirimi', diff --git a/apps/web/src/i18n/locales/uk.ts b/apps/web/src/i18n/locales/uk.ts index 31b1608df9..996daff224 100644 --- a/apps/web/src/i18n/locales/uk.ts +++ b/apps/web/src/i18n/locales/uk.ts @@ -1341,6 +1341,11 @@ export const uk: Dict = { 'pet.communitySyncFailed': 'Синхронізація не вдалася: {error}', 'pet.codexBundled': 'Упаковано', 'pet.codexBundledTitle': 'Поставляється з Open Design — завантаження не потрібне.', + 'pet.fieldCorner': 'Кут', + 'pet.corner.top-left': 'Верхній лівий', + 'pet.corner.top-right': 'Верхній правий', + 'pet.corner.bottom-left': 'Нижній лівий', + 'pet.corner.bottom-right': 'Нижній правий', 'settings.notifications': 'Сповіщення', 'settings.notificationsHint': 'Звук та сповіщення робочого столу при завершенні завдання', diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index f66f5a61ed..9da291b609 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -1888,6 +1888,11 @@ export const zhCN: Dict = { 'pet.communitySyncFailed': '同步失败:{error}', 'pet.codexBundled': '内置', 'pet.codexBundledTitle': 'Open Design 内置宠物,无需下载。', + 'pet.fieldCorner': '角落', + 'pet.corner.top-left': '左上', + 'pet.corner.top-right': '右上', + 'pet.corner.bottom-left': '左下', + 'pet.corner.bottom-right': '右下', 'settings.notifications': '通知', 'settings.notificationsHint': '任务完成时的声音和桌面通知', diff --git a/apps/web/src/i18n/locales/zh-TW.ts b/apps/web/src/i18n/locales/zh-TW.ts index 2fe4c46d7c..f0663a3ba8 100644 --- a/apps/web/src/i18n/locales/zh-TW.ts +++ b/apps/web/src/i18n/locales/zh-TW.ts @@ -1459,6 +1459,11 @@ export const zhTW: Dict = { 'pet.communitySyncFailed': '同步失敗:{error}', 'pet.codexBundled': '內建', 'pet.codexBundledTitle': 'Open Design 內建寵物,無需下載。', + 'pet.fieldCorner': '角落', + 'pet.corner.top-left': '左上', + 'pet.corner.top-right': '右上', + 'pet.corner.bottom-left': '左下', + 'pet.corner.bottom-right': '右下', 'settings.notifications': '通知', 'settings.notificationsHint': '任務完成時的音效和桌面通知', diff --git a/apps/web/src/i18n/types.ts b/apps/web/src/i18n/types.ts index dcf62dda31..ed9e45758e 100644 --- a/apps/web/src/i18n/types.ts +++ b/apps/web/src/i18n/types.ts @@ -2128,6 +2128,12 @@ export interface Dict { 'pet.communitySyncFailed': string; 'pet.codexBundled': string; 'pet.codexBundledTitle': string; + // Corner anchor picker + 'pet.fieldCorner': string; + 'pet.corner.top-left': string; + 'pet.corner.top-right': string; + 'pet.corner.bottom-left': string; + 'pet.corner.bottom-right': string; // Sketch editor 'sketch.toolSelect': string; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 8faf9894e0..a2ec868dd3 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -17059,6 +17059,12 @@ body.entry-resizing { cursor: col-resize; user-select: none; } --pet-accent: var(--accent); } .pet-overlay > * { pointer-events: auto; } +/* Left-anchored corners: align bubble to the left so it stays visually + attached to the sprite rather than floating off to the right. */ +.pet-overlay[data-corner='top-left'], +.pet-overlay[data-corner='bottom-left'] { + align-items: flex-start; +} .pet-sprite { position: relative; @@ -17177,6 +17183,29 @@ body.entry-resizing { cursor: col-resize; user-select: none; } border-bottom: 1px solid var(--pet-accent); transform: rotate(45deg); } +/* Left-anchored corners: move the tail to the left side of the bubble. */ +.pet-overlay[data-corner='top-left'] .pet-bubble::after, +.pet-overlay[data-corner='bottom-left'] .pet-bubble::after { + right: auto; + left: 18px; +} +/* Top-anchored corners: reverse column so sprite stays pinned at the top edge + and bubble grows downward (column-reverse makes sprite the visual bottom child, + which in a top-pinned container means sprite stays at pos.y). */ +.pet-overlay[data-corner='top-left'], +.pet-overlay[data-corner='top-right'] { + flex-direction: column-reverse; +} +/* Top-anchored corners: move the tail to the top of the bubble. */ +.pet-overlay[data-corner='top-left'] .pet-bubble::after, +.pet-overlay[data-corner='top-right'] .pet-bubble::after { + bottom: auto; + top: -6px; + border-bottom: 0; + border-right: 0; + border-top: 1px solid var(--pet-accent); + border-left: 1px solid var(--pet-accent); +} .pet-bubble-name { font-weight: 600; font-size: 12px; diff --git a/apps/web/src/state/config.ts b/apps/web/src/state/config.ts index 961e1ff147..90e584a4f4 100644 --- a/apps/web/src/state/config.ts +++ b/apps/web/src/state/config.ts @@ -38,6 +38,7 @@ export const DEFAULT_PET: PetConfig = { adopted: false, enabled: false, petId: 'mochi', + corner: 'bottom-right', custom: { name: 'Buddy', glyph: '🦄', @@ -267,13 +268,25 @@ export const KNOWN_PROVIDERS: KnownProvider[] = [ }, ]; +const VALID_CORNERS: ReadonlySet = new Set([ + 'top-left', + 'top-right', + 'bottom-left', + 'bottom-right', +]); + function normalizePet(input: Partial | undefined): PetConfig { if (!input) return { ...DEFAULT_PET, custom: { ...DEFAULT_PET.custom } }; // Merge stored values onto defaults so newly-added fields land safely // when an older config is rehydrated. + const corner = + typeof input.corner === 'string' && VALID_CORNERS.has(input.corner) + ? (input.corner as PetConfig['corner']) + : DEFAULT_PET.corner; return { ...DEFAULT_PET, ...input, + corner, custom: { ...DEFAULT_PET.custom, ...(input.custom ?? {}) }, }; } diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index c75ef65965..c9be875979 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -292,6 +292,9 @@ export interface PetConfig { // Free-form custom pet definition. Always present so the customize panel // has stable state to bind against, even when a built-in is active. custom: PetCustom; + // Which viewport corner the pet is anchored to. Older persisted configs + // without this field are normalised to 'bottom-right' at read time. + corner: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; } export interface AppConfig { diff --git a/apps/web/tests/components/PetOverlay.test.tsx b/apps/web/tests/components/PetOverlay.test.tsx index 92cf9f5404..2db8de234d 100644 --- a/apps/web/tests/components/PetOverlay.test.tsx +++ b/apps/web/tests/components/PetOverlay.test.tsx @@ -10,6 +10,7 @@ const pet: PetConfig = { adopted: true, enabled: true, petId: 'custom', + corner: 'bottom-right', custom: { name: 'YoRHa Sit-2B', glyph: 'N', diff --git a/apps/web/tests/components/SettingsDialog.execution.test.tsx b/apps/web/tests/components/SettingsDialog.execution.test.tsx index e33dfe07a1..84051be623 100644 --- a/apps/web/tests/components/SettingsDialog.execution.test.tsx +++ b/apps/web/tests/components/SettingsDialog.execution.test.tsx @@ -1999,6 +1999,7 @@ describe('SettingsDialog pets interactions', () => { adopted: true, enabled: true, petId: 'custom', + corner: 'bottom-right' as const, custom: { name: 'Buddy', glyph: '🦄', diff --git a/apps/web/tests/components/pet-corner.test.tsx b/apps/web/tests/components/pet-corner.test.tsx new file mode 100644 index 0000000000..373947e7f2 --- /dev/null +++ b/apps/web/tests/components/pet-corner.test.tsx @@ -0,0 +1,132 @@ +// @vitest-environment jsdom + +import { cleanup, render } from '@testing-library/react'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { PetOverlay } from '../../src/components/pet/PetOverlay'; +import type { PetConfig } from '../../src/types'; +import { DEFAULT_PET } from '../../src/state/config'; + +// Node 26 exposes `localStorage` as a global (but returns undefined) which +// prevents vitest's jsdom populateGlobal from overriding it. Stub it so the +// config persistence tests can call loadConfig/saveConfig normally. +const store = new Map(); +beforeAll(() => { + vi.stubGlobal('localStorage', { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { store.set(key, value); }, + removeItem: (key: string) => { store.delete(key); }, + clear: () => { store.clear(); }, + }); +}); + +function makePet(overrides?: Partial): PetConfig { + return { + adopted: true, + enabled: true, + petId: 'custom', + corner: 'bottom-right', + custom: { + name: 'Tester', + glyph: '🐱', + accent: '#c96442', + greeting: 'Hi!', + }, + ...overrides, + }; +} + +afterEach(() => { + cleanup(); +}); + +describe('pet corner anchor — default behavior', () => { + it('DEFAULT_PET has corner set to bottom-right', () => { + expect(DEFAULT_PET.corner).toBe('bottom-right'); + }); + + it('renders overlay with bottom/right inline styles when corner is bottom-right', () => { + const { container } = render(); + const overlay = container.querySelector('.pet-overlay') as HTMLElement | null; + expect(overlay).not.toBeNull(); + // bottom/right should be set (truthy numeric or px value); top/left should not be set + expect(overlay!.style.bottom).not.toBe(''); + expect(overlay!.style.right).not.toBe(''); + expect(overlay!.style.top).toBe(''); + expect(overlay!.style.left).toBe(''); + }); +}); + +describe('pet corner anchor — corner changes', () => { + it('applies top/right styles for top-right corner', () => { + const { container } = render(); + const overlay = container.querySelector('.pet-overlay') as HTMLElement | null; + expect(overlay).not.toBeNull(); + expect(overlay!.style.top).not.toBe(''); + expect(overlay!.style.right).not.toBe(''); + expect(overlay!.style.bottom).toBe(''); + expect(overlay!.style.left).toBe(''); + }); + + it('applies top/left styles for top-left corner', () => { + const { container } = render(); + const overlay = container.querySelector('.pet-overlay') as HTMLElement | null; + expect(overlay).not.toBeNull(); + expect(overlay!.style.top).not.toBe(''); + expect(overlay!.style.left).not.toBe(''); + expect(overlay!.style.bottom).toBe(''); + expect(overlay!.style.right).toBe(''); + }); + + it('applies bottom/left styles for bottom-left corner', () => { + const { container } = render(); + const overlay = container.querySelector('.pet-overlay') as HTMLElement | null; + expect(overlay).not.toBeNull(); + expect(overlay!.style.bottom).not.toBe(''); + expect(overlay!.style.left).not.toBe(''); + expect(overlay!.style.top).toBe(''); + expect(overlay!.style.right).toBe(''); + }); +}); + +describe('pet corner anchor — config persistence', () => { + it('normalizePet preserves a stored corner value', async () => { + // Dynamic import so we can test the normalizer in isolation + const { loadConfig, saveConfig } = await import('../../src/state/config'); + + // Seed localStorage with a stored config that has corner: top-left + const stored = { + ...DEFAULT_PET, + corner: 'top-left' as const, + }; + localStorage.setItem( + 'open-design:config', + JSON.stringify({ pet: stored }), + ); + + const loaded = loadConfig(); + expect(loaded.pet?.corner).toBe('top-left'); + + // Confirm saveConfig round-trips the corner field + const next = { ...loaded, pet: { ...loaded.pet!, corner: 'top-right' as const } }; + saveConfig(next); + const reloaded = loadConfig(); + expect(reloaded.pet?.corner).toBe('top-right'); + + localStorage.removeItem('open-design:config'); + }); + + it('normalizePet defaults corner to bottom-right when absent from stored config', async () => { + const { loadConfig } = await import('../../src/state/config'); + + localStorage.setItem( + 'open-design:config', + JSON.stringify({ pet: { adopted: true, enabled: true, petId: 'mochi' } }), + ); + + const loaded = loadConfig(); + expect(loaded.pet?.corner).toBe('bottom-right'); + + localStorage.removeItem('open-design:config'); + }); +}); diff --git a/docs/codex-pets.md b/docs/codex-pets.md index f69b8413a9..8896a31d6c 100644 --- a/docs/codex-pets.md +++ b/docs/codex-pets.md @@ -82,11 +82,30 @@ Notes: back to a sensible default (folder name → display name, empty description, etc.). +## Corner anchor + +The pet overlay anchors to one of four screen corners. Pet Settings exposes a +four-way picker (`top-left`, `top-right`, `bottom-left`, `bottom-right`); +default is `bottom-right`. The choice is persisted in localStorage as +`PetConfig.corner` and is web-only — there is no daemon or CLI surface for +pet configuration today, consistent with the other web-local prefs (`theme`, +`accentColor`). Legacy configs without the field hydrate to `bottom-right` +through `normalizePet`. + +The chosen corner drives inline positioning in `PetOverlay`: +`cornerStyle()` returns only the two relevant CSS edges (`top`/`bottom` +plus `left`/`right`) so the other axes are left undefined and the existing +drag math still works. The speech-bubble alignment and tail position are +overridden via `[data-corner='top-left']` / `[data-corner='bottom-left']` +CSS rules in `apps/web/src/index.css` so the bubble and its tail render on +the correct side of the sprite for left-anchored corners. + ## Related code - Daemon registry + manifest validation: `apps/daemon/src/codex-pets.ts` - HTTP routes (list + spritesheet): `apps/daemon/src/server.ts` - Web list / adopt UI: `apps/web/src/components/pet/PetSettings.tsx` +- Pet overlay positioning: `apps/web/src/components/pet/PetOverlay.tsx` - Shared response types: `packages/contracts/src/api/registry.ts` - Vendored skill source: `skills/hatch-pet/` - Community catalog sync script: `scripts/sync-community-pets.ts` diff --git a/e2e/ui/pet-corner-anchor.test.ts b/e2e/ui/pet-corner-anchor.test.ts new file mode 100644 index 0000000000..369f711ac7 --- /dev/null +++ b/e2e/ui/pet-corner-anchor.test.ts @@ -0,0 +1,411 @@ +// Playwright spec: pet overlay corner anchor positioning and bubble-tail alignment. +// +// Red-on-main signal: the bubble-tail assertion (left: 18px for left-anchored +// corners) is the regression guard. On main, .pet-overlay[data-corner='top-left'] +// .pet-bubble::after has no `left` override so the tail stays right-anchored. +// The assert verifies that computed `left` is a small positive value (~18px) for +// left-anchored corners, and computed `right` is small for right-anchored corners. +// +// Run from e2e/: pnpm exec playwright test -c playwright.config.ts pet-corner-anchor +// Ports: OD_PORT=18021, OD_WEB_PORT=18022 + +import { expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; + +const STORAGE_KEY = 'open-design:config'; +// Separate key from the main config to avoid poisoning other suites. +const PET_POSITION_KEY = 'open-design:pet-position'; + +// The 120px clamp in PetOverlay means the overlay can sit up to ~120px away +// from an edge. The viewport in Desktop Chrome is 1280x720. An overlay that +// starts 24px from its anchor edge, with a 96px sprite, has its bounding box +// well inside 200px of the anchor corner. The sprite itself is 96px wide. +const QUAD_THRESHOLD = 200; + +// The bubble ::after tail is pinned at 18px from the anchor-side edge. We allow +// a small rounding budget (1px) when comparing the resolved computed value. +const TAIL_OFFSET_PX = 18; +const TAIL_TOLERANCE_PX = 2; + +type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; + +// Seeds localStorage so the pet is adopted and visible. `corner` sets the +// initial anchor. Called via addInitScript so it runs before page load. +async function seedPet(page: Page, corner: Corner = 'bottom-right') { + await page.addInitScript( + ({ configKey, posKey, cfg }) => { + window.localStorage.setItem(configKey, JSON.stringify(cfg)); + // Remove any lingering position state so each test starts at the + // default 24px offset — prevents cross-test bleed via position storage. + window.localStorage.removeItem(posKey); + }, + { + configKey: STORAGE_KEY, + posKey: PET_POSITION_KEY, + cfg: { + mode: 'daemon', + apiKey: '', + baseUrl: 'https://api.anthropic.com', + model: 'claude-sonnet-4-5', + agentId: 'mock', + skillId: null, + designSystemId: null, + onboardingCompleted: true, + agentModels: {}, + pet: { + adopted: true, + enabled: true, + petId: 'custom', + corner, + custom: { + name: 'Corner Tester', + glyph: '🧪', + accent: '#c96442', + greeting: 'Testing corners.', + }, + }, + }, + }, + ); +} + +// Opens the Settings dialog and navigates to the Pets section. Returns the +// dialog locator. The app may open the dialog directly on button click (when +// onboarding is complete and agent is configured) or via an avatar popover +// menu (when it shows the profile/settings popover first). We handle both paths. +async function openPetSettings(page: Page) { + const settingsButton = page.getByRole('button', { name: /open settings/i }); + await settingsButton.click(); + + const dialog = page.getByRole('dialog'); + const settingsMenu = page.locator('.avatar-popover[role="menu"]'); + + // Wait for whichever comes first: the dialog or the popover menu. + await Promise.race([ + dialog.waitFor({ state: 'visible' }), + settingsMenu.waitFor({ state: 'visible' }), + ]); + + // If the popover appeared first, click Settings inside it to open the dialog. + if (await settingsMenu.isVisible().catch(() => false)) { + await settingsMenu.getByRole('button', { name: /^Settings$/i }).click(); + await expect(dialog).toBeVisible(); + } + + const petsNav = dialog.locator('.settings-nav-item', { + has: page.locator('strong', { hasText: /^Pets$/i }), + }).first(); + await petsNav.click(); + await expect(dialog.locator('.pet-corner-picker')).toBeVisible(); + return dialog; +} + +// Reads the resolved pixel offset of the ::after tail from the LEFT edge. In +// Chromium, getComputedStyle always returns a resolved px value even for `auto` +// properties. When the CSS rule sets `left: 18px`, this returns ~18. When +// `left` is `auto` (right-anchored default), this returns a large value +// (roughly bubbleWidth - 18px - tailWidth, i.e. > 100px for a 240px bubble). +async function getBubbleTailLeftPx(page: Page): Promise { + return page.evaluate(() => { + const bubble = document.querySelector('.pet-bubble'); + if (!bubble) return -1; + const raw = window.getComputedStyle(bubble, '::after').left; + return parseFloat(raw); + }); +} + +// Reads the resolved pixel offset of the ::after tail from the RIGHT edge. +// When `right: 18px` is set, returns ~18. When `right` is `auto`, returns a +// large value. +async function getBubbleTailRightPx(page: Page): Promise { + return page.evaluate(() => { + const bubble = document.querySelector('.pet-bubble'); + if (!bubble) return -1; + const raw = window.getComputedStyle(bubble, '::after').right; + return parseFloat(raw); + }); +} + +// Returns the data-corner attribute on the overlay. +async function getOverlayCorner(page: Page): Promise { + return page.evaluate(() => { + return document.querySelector('.pet-overlay')?.getAttribute('data-corner') ?? 'missing'; + }); +} + +// ------------------------------------------------------------------ tests --- + +test.describe('pet corner anchor: quadrant positioning', () => { + // Default corner: bottom-right + test('bottom-right: overlay is in bottom-right viewport quadrant', async ({ page }) => { + await seedPet(page, 'bottom-right'); + await page.goto('/'); + const overlay = page.locator('.pet-overlay'); + await expect(overlay).toBeVisible(); + + const box = await overlay.boundingBox(); + const viewport = page.viewportSize()!; + expect(box).not.toBeNull(); + // Sprite leading edge (left side) should be in the right half. + expect(box!.x).toBeGreaterThan(viewport.width / 2); + // Sprite top edge should be in the bottom half. + expect(box!.y).toBeGreaterThan(viewport.height / 2); + // Sprite trailing edge should be near the right viewport edge. + expect(box!.x + box!.width).toBeGreaterThan(viewport.width - QUAD_THRESHOLD); + // Sprite bottom edge should be near the bottom viewport edge. + expect(box!.y + box!.height).toBeGreaterThan(viewport.height - QUAD_THRESHOLD); + }); + + test('top-left: overlay is in top-left viewport quadrant', async ({ page }) => { + await seedPet(page, 'top-left'); + await page.goto('/'); + const overlay = page.locator('.pet-overlay'); + await expect(overlay).toBeVisible(); + + const box = await overlay.boundingBox(); + expect(box).not.toBeNull(); + // Left edge should be near the left edge of the viewport. + expect(box!.x).toBeLessThan(QUAD_THRESHOLD); + // Top edge should be near the top of the viewport. + expect(box!.y).toBeLessThan(QUAD_THRESHOLD); + + expect(await getOverlayCorner(page)).toBe('top-left'); + + // The sprite must stay pinned near the top edge — not pushed down by the + // bubble. Without flex-direction: column-reverse the sprite would be the + // first child and the bubble would push it ~80-200px below pos.y. + const sprite = page.locator('.pet-sprite'); + const spriteBox = await sprite.boundingBox(); + expect(spriteBox).not.toBeNull(); + expect(spriteBox!.y).toBeLessThan(QUAD_THRESHOLD); + }); + + test('top-right: overlay is in top-right viewport quadrant', async ({ page }) => { + await seedPet(page, 'top-right'); + await page.goto('/'); + const overlay = page.locator('.pet-overlay'); + await expect(overlay).toBeVisible(); + + const box = await overlay.boundingBox(); + const viewport = page.viewportSize()!; + expect(box).not.toBeNull(); + // Right edge close to the right side. + expect(box!.x + box!.width).toBeGreaterThan(viewport.width - QUAD_THRESHOLD); + // Top edge close to the top. + expect(box!.y).toBeLessThan(QUAD_THRESHOLD); + + expect(await getOverlayCorner(page)).toBe('top-right'); + + // The sprite must stay pinned near the top edge — not pushed down by the + // bubble. Without flex-direction: column-reverse the sprite would be the + // first child and the bubble would push it ~80-200px below pos.y. + const sprite = page.locator('.pet-sprite'); + const spriteBox = await sprite.boundingBox(); + expect(spriteBox).not.toBeNull(); + expect(spriteBox!.y).toBeLessThan(QUAD_THRESHOLD); + }); + + test('bottom-left: overlay is in bottom-left viewport quadrant', async ({ page }) => { + await seedPet(page, 'bottom-left'); + await page.goto('/'); + const overlay = page.locator('.pet-overlay'); + await expect(overlay).toBeVisible(); + + const box = await overlay.boundingBox(); + const viewport = page.viewportSize()!; + expect(box).not.toBeNull(); + // Left edge close to the left side. + expect(box!.x).toBeLessThan(QUAD_THRESHOLD); + // Bottom edge close to the bottom. + expect(box!.y + box!.height).toBeGreaterThan(viewport.height - QUAD_THRESHOLD); + + expect(await getOverlayCorner(page)).toBe('bottom-left'); + }); +}); + +test.describe('pet corner anchor: bubble-tail side (regression guard for left-anchor alignment)', () => { + // The left-anchor CSS fix is the critical regression guard. + // + // On main (without the fix): + // .pet-overlay[data-corner='top-left'] .pet-bubble::after has NO left override. + // getComputedStyle '::after' left resolves to a LARGE pixel value (bubble + // width minus the fixed right offset, roughly 200+px). + // + // On the branch (with the fix): + // .pet-overlay[data-corner='top-left'] .pet-bubble::after { right:auto; left:18px } + // getComputedStyle '::after' left resolves to ~18px — small, near the left edge. + // + // We assert `left < TAIL_OFFSET_PX + TAIL_TOLERANCE_PX` for left-anchored + // corners. This goes RED on main because computed left would be ~200px there. + + for (const corner of ['top-left', 'bottom-left'] as const) { + test(`${corner}: bubble tail is on the LEFT side (~18px from left)`, async ({ page }) => { + await seedPet(page, corner); + await page.goto('/'); + // The bubble auto-opens on mount; wait for it. + await expect(page.locator('.pet-bubble')).toBeVisible(); + + const tailLeftPx = await getBubbleTailLeftPx(page); + // Left-anchored: the fix places the tail at left:18px. This value is + // small. On main, no override means computed left is a large value (the + // bubble is ~240px wide; computed left would be ~240 - 18 - 12 = ~210px). + expect(tailLeftPx).toBeGreaterThanOrEqual(0); + expect(tailLeftPx).toBeLessThanOrEqual(TAIL_OFFSET_PX + TAIL_TOLERANCE_PX); + + // Conversely, right should be a large resolved value (not 18px) because + // right:auto resolves to the complement of left in a fixed box. + const tailRightPx = await getBubbleTailRightPx(page); + expect(tailRightPx).toBeGreaterThan(TAIL_OFFSET_PX + TAIL_TOLERANCE_PX); + }); + } + + for (const corner of ['top-right', 'bottom-right'] as const) { + test(`${corner}: bubble tail is on the RIGHT side (~18px from right)`, async ({ page }) => { + await seedPet(page, corner); + await page.goto('/'); + await expect(page.locator('.pet-bubble')).toBeVisible(); + + // Right-anchored: the default CSS has right:18px on .pet-bubble::after. + const tailRightPx = await getBubbleTailRightPx(page); + expect(tailRightPx).toBeGreaterThanOrEqual(0); + expect(tailRightPx).toBeLessThanOrEqual(TAIL_OFFSET_PX + TAIL_TOLERANCE_PX); + + // Left should be the large resolved complement. + const tailLeftPx = await getBubbleTailLeftPx(page); + expect(tailLeftPx).toBeGreaterThan(TAIL_OFFSET_PX + TAIL_TOLERANCE_PX); + }); + } +}); + +test.describe('pet corner anchor: settings picker changes corner live', () => { + test('switching to top-left via Pet Settings moves overlay to top-left', async ({ page }) => { + // Start with the default bottom-right so we can observe the change. + await seedPet(page, 'bottom-right'); + + await page.goto('/'); + await expect(page.locator('.pet-overlay')).toBeVisible(); + + // Verify starting position is bottom-right. + expect(await getOverlayCorner(page)).toBe('bottom-right'); + + const dialog = await openPetSettings(page); + + // Click the top-left radio button inside the corner picker. + await dialog + .locator('.pet-corner-picker') + .getByRole('radio', { name: /top.?left/i }) + .click(); + + // The overlay must now carry data-corner='top-left'. + await expect(page.locator('.pet-overlay[data-corner="top-left"]')).toBeVisible(); + + // Verify bounding box is in the top-left quadrant. + const box = await page.locator('.pet-overlay').boundingBox(); + expect(box).not.toBeNull(); + expect(box!.x).toBeLessThan(QUAD_THRESHOLD); + expect(box!.y).toBeLessThan(QUAD_THRESHOLD); + + // Close the dialog and verify the tail is now on the left. + await dialog.getByRole('button', { name: 'Close', exact: true }).click(); + await expect(dialog).toHaveCount(0); + + // Wait for bubble; it may have auto-closed — click sprite to reopen. + const bubbleVisible = await page.locator('.pet-bubble').isVisible().catch(() => false); + if (!bubbleVisible) { + await page.locator('.pet-sprite').click(); + await expect(page.locator('.pet-bubble')).toBeVisible(); + } + + const tailLeftPx = await getBubbleTailLeftPx(page); + expect(tailLeftPx).toBeLessThanOrEqual(TAIL_OFFSET_PX + TAIL_TOLERANCE_PX); + }); +}); + +test.describe('pet corner anchor: persistence across reload', () => { + test('corner choice survives a full page reload', async ({ page }) => { + // Start with bottom-right. Use addInitScript so the first page.goto picks + // it up. On reload, we need the UPDATED corner (top-right) to survive — so + // we verify localStorage was written by the app, then seed a fresh + // localStorage evaluate-side before the reload to simulate what the app + // already persisted (avoiding addInitScript re-running on reload). + const initialCfg = { + mode: 'daemon', + apiKey: '', + baseUrl: 'https://api.anthropic.com', + model: 'claude-sonnet-4-5', + agentId: 'mock', + skillId: null, + designSystemId: null, + onboardingCompleted: true, + agentModels: {}, + pet: { + adopted: true, + enabled: true, + petId: 'custom', + corner: 'bottom-right', + custom: { + name: 'Corner Tester', + glyph: '🧪', + accent: '#c96442', + greeting: 'Testing corners.', + }, + }, + }; + + // Seed the initial config before the first load. + await page.addInitScript( + ({ configKey, posKey, cfg }) => { + // Only seed if not already set to a non-bottom-right corner, so + // addInitScript does not clobber state written during this test. + const existing = (() => { + try { + return JSON.parse(window.localStorage.getItem(configKey) ?? '{}'); + } catch { return {}; } + })(); + if (!existing.pet || existing.pet.corner === 'bottom-right' || !existing.pet.corner) { + window.localStorage.setItem(configKey, JSON.stringify(cfg)); + window.localStorage.removeItem(posKey); + } + }, + { configKey: STORAGE_KEY, posKey: PET_POSITION_KEY, cfg: initialCfg }, + ); + + await page.goto('/'); + await expect(page.locator('.pet-overlay')).toBeVisible(); + + const dialog = await openPetSettings(page); + await dialog + .locator('.pet-corner-picker') + .getByRole('radio', { name: /top.?right/i }) + .click(); + + await expect(page.locator('.pet-overlay[data-corner="top-right"]')).toBeVisible(); + + // Close the dialog — the app should have persisted the new corner choice. + await dialog.getByRole('button', { name: 'Close', exact: true }).click(); + await expect(dialog).toHaveCount(0); + + // Verify the app actually wrote the new corner to localStorage before reload. + const storedCorner = await page.evaluate((key) => { + try { + const cfg = JSON.parse(window.localStorage.getItem(key) ?? '{}'); + return cfg.pet?.corner ?? 'not-found'; + } catch { return 'error'; } + }, STORAGE_KEY); + expect(storedCorner).toBe('top-right'); + + await page.reload(); + await expect(page.locator('.pet-overlay')).toBeVisible(); + + // The corner must survive the reload — addInitScript skips re-seeding + // because the stored corner is now 'top-right' (not bottom-right). + expect(await getOverlayCorner(page)).toBe('top-right'); + + const box = await page.locator('.pet-overlay').boundingBox(); + const viewport = page.viewportSize()!; + expect(box).not.toBeNull(); + // top-right: top edge near top, right edge near right. + expect(box!.y).toBeLessThan(QUAD_THRESHOLD); + expect(box!.x + box!.width).toBeGreaterThan(viewport.width - QUAD_THRESHOLD); + }); +});