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) {
+
{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);
+ });
+});