@hayasaka7/haya-pet 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ All notable changes to Haya Pet are documented here. This project adheres to
7
7
  > 0.2.0 npm publish; they are listed under 0.2.1, which is the first version that
8
8
  > ships them.
9
9
 
10
+ ## [0.2.4]
11
+
12
+ ### Fixed
13
+ - Removed the non-functional **"Open Settings"** tray item (it had no handler).
14
+ A settings window is deferred until settings outgrow the tray; every current
15
+ setting already has a tray, CLI, or gesture home.
16
+
17
+ ## [0.2.3]
18
+
19
+ ### Added
20
+ - **Drag-to-resize the pet.** Hovering the pet reveals a small grip at its
21
+ bottom-right corner; drag it to scale the pet between 0.5× and 2× (aspect
22
+ locked), double-click it to reset to 1×. The size persists across restarts,
23
+ like the pet position. Only the pet scales — session bubbles keep their
24
+ readable size and keep anchoring beside it.
25
+
10
26
  ## [0.2.2]
11
27
 
12
28
  ### Fixed
package/README.md CHANGED
@@ -48,6 +48,8 @@ Haya Pet watches all of them and presents one ambient interface:
48
48
  stays click-through outside the pet and bubbles.
49
49
  - 🖱️ **Click / double-click / drag** — click folds/unfolds the bubbles, double-click
50
50
  expands them, drag moves the pet (position persists; bubbles stay on-screen).
51
+ - 📏 **Resizable pet** — hover the pet and drag the corner grip to scale it
52
+ 0.5×–2× for your screen; double-click the grip to reset. The size persists.
51
53
  - 🟢 **Live session bubbles** with per-session status icons and a folder toggle.
52
54
  - 🧠 **Normalized state model** — every client maps to a shared state vocabulary
53
55
  (`thinking`, `running_tool`, `waiting_approval`, `reviewing`, `failed`, …).
@@ -224,6 +226,8 @@ frames so everything still works.
224
226
  | Single click | waves + folds/unfolds the session bubbles |
225
227
  | Double click | jumps + expands the bubbles |
226
228
  | Drag | moves the pet; position is saved (bubbles follow, always on-screen) |
229
+ | Drag corner grip | resizes the pet 0.5×–2× (grip appears on hover); size is saved |
230
+ | Double-click grip | resets the pet to its normal size |
227
231
  | Tray icon → menu | show/hide, display mode, sessions, pets, **reset position**, **Quit** |
228
232
 
229
233
  ## Stop / exit the pet
@@ -11,9 +11,10 @@ import { createProcessSnapshotLister } from "../../../../packages/platform-core/
11
11
  import { getDefaultPaths } from "../../../../packages/platform-core/src/paths.js";
12
12
  import { getPlatformCapabilities } from "../../../../packages/platform-core/src/capabilities.js";
13
13
  import { buildBubbleViews } from "../../../../packages/session-core/src/bubble-view.js";
14
+ import { clampScale } from "../../../../packages/pet-core/src/pet-scale.js";
14
15
  import { buildPetWindowOptions, PET_SIZE } from "./window-options.js";
15
16
  import { resolveSavedPosition } from "./display-manager.js";
16
- import { setSelectedPet, updateGlobalPetPosition } from "./position-store.js";
17
+ import { getPetScale, setPetScale, setSelectedPet, updateGlobalPetPosition } from "./position-store.js";
17
18
  import { buildTrayMenu } from "./tray-menu.js";
18
19
  import { createStateFile } from "./state-file.js";
19
20
  import { discoverPets } from "./pet-loader.js";
@@ -43,6 +44,8 @@ let runtime;
43
44
  let currentWorkArea;
44
45
  let currentDisplayId;
45
46
  let petLocal = { x: 0, y: 0 };
47
+ // User-chosen pet scale (resize grip); the pet occupies PET_SIZE × petScale.
48
+ let petScale = 1;
46
49
  let approvalWatch;
47
50
 
48
51
  // Electron singleton: a second launch forwards to the running instance.
@@ -58,6 +61,7 @@ if (!app.requestSingleInstanceLock()) {
58
61
 
59
62
  async function bootstrap() {
60
63
  positionState = await stateFile.load();
64
+ petScale = clampScale(getPetScale(positionState));
61
65
  pets = await discoverPets(paths.petSearchPaths);
62
66
 
63
67
  // Clients fire no event at the moment the user ACCEPTS a permission prompt
@@ -164,9 +168,17 @@ function createPetWindow() {
164
168
  });
165
169
  }
166
170
 
171
+ function scaledPetSize() {
172
+ return {
173
+ width: Math.round(PET_SIZE.width * petScale),
174
+ height: Math.round(PET_SIZE.height * petScale)
175
+ };
176
+ }
177
+
167
178
  function clampPetLocal(local) {
168
- const maxX = Math.max(0, (currentWorkArea?.width ?? PET_SIZE.width) - PET_SIZE.width);
169
- const maxY = Math.max(0, (currentWorkArea?.height ?? PET_SIZE.height) - PET_SIZE.height);
179
+ const size = scaledPetSize();
180
+ const maxX = Math.max(0, (currentWorkArea?.width ?? size.width) - size.width);
181
+ const maxY = Math.max(0, (currentWorkArea?.height ?? size.height) - size.height);
170
182
  return {
171
183
  x: Math.min(Math.max(local.x ?? 0, 0), maxX),
172
184
  y: Math.min(Math.max(local.y ?? 0, 0), maxY)
@@ -274,6 +286,15 @@ function registerRendererHandlers() {
274
286
  persistPetPosition();
275
287
  return petLocal;
276
288
  });
289
+
290
+ // Resize grip released (or double-clicked to reset): store the new scale and
291
+ // re-clamp the position so a grown pet never sticks out of the work area.
292
+ ipcMain.handle("haya-pet:save-pet-scale", async (_event, scale) => {
293
+ petScale = clampScale(scale);
294
+ petLocal = clampPetLocal(petLocal);
295
+ persistPetPosition();
296
+ return petScale;
297
+ });
277
298
  }
278
299
 
279
300
  function buildSessionPayload() {
@@ -302,7 +323,8 @@ function sendPetConfig() {
302
323
  ? { manifest: selected.manifest, spritesheetUrl: selected.spritesheetUrl }
303
324
  : undefined,
304
325
  overlayMode: capabilities.transparentOverlay === "required" ? "transparent-overlay" : "fallback-window",
305
- petPosition: petLocal
326
+ petPosition: petLocal,
327
+ petScale
306
328
  });
307
329
  }
308
330
 
@@ -318,13 +340,15 @@ function persistPetPosition() {
318
340
 
319
341
  // Store the pet's absolute on-screen top-left so it can be restored on the
320
342
  // right display, mapping the in-window position back to screen coordinates.
321
- positionState = updateGlobalPetPosition(positionState, {
343
+ // The persisted box is the *scaled* size, so display restore clamps correctly.
344
+ const size = scaledPetSize();
345
+ positionState = setPetScale(updateGlobalPetPosition(positionState, {
322
346
  x: currentWorkArea.x + petLocal.x,
323
347
  y: currentWorkArea.y + petLocal.y,
324
- width: PET_SIZE.width,
325
- height: PET_SIZE.height,
348
+ width: size.width,
349
+ height: size.height,
326
350
  displayId: currentDisplayId
327
- });
351
+ }), petScale);
328
352
 
329
353
  // Debounce disk writes during drag (positionSaveDebounce, plan section 27).
330
354
  clearTimeout(persistTimer);
@@ -348,9 +372,10 @@ function focusPet() {
348
372
  function resetPosition() {
349
373
  // Drop the pet back to the bottom-right corner of its work area.
350
374
  const margin = 24;
375
+ const size = scaledPetSize();
351
376
  petLocal = clampPetLocal({
352
- x: (currentWorkArea?.width ?? PET_SIZE.width) - PET_SIZE.width - margin,
353
- y: (currentWorkArea?.height ?? PET_SIZE.height) - PET_SIZE.height - margin
377
+ x: (currentWorkArea?.width ?? size.width) - size.width - margin,
378
+ y: (currentWorkArea?.height ?? size.height) - size.height - margin
354
379
  });
355
380
  sendPetPosition();
356
381
  persistPetPosition();
@@ -5,6 +5,7 @@ const { contextBridge, ipcRenderer } = require("electron");
5
5
  contextBridge.exposeInMainWorld("aiPet", {
6
6
  listSessions: () => ipcRenderer.invoke("haya-pet:list-sessions"),
7
7
  savePetPosition: (local) => ipcRenderer.invoke("haya-pet:save-pet-position", local),
8
+ savePetScale: (scale) => ipcRenderer.invoke("haya-pet:save-pet-scale", scale),
8
9
  setMouseIgnore: (ignore) => ipcRenderer.send("haya-pet:set-mouse-ignore", ignore),
9
10
  onConfig: (handler) => ipcRenderer.on("haya-pet:config", (_event, config) => handler(config)),
10
11
  onSessions: (handler) => ipcRenderer.on("haya-pet:sessions", (_event, payload) => handler(payload)),
@@ -46,7 +46,11 @@ export function buildTrayMenu(state = {}) {
46
46
  checked: Boolean(state.attachBubblesToTerminals)
47
47
  },
48
48
  { id: "reset_position", label: "Reset Position" },
49
- { id: "settings", label: "Open Settings" },
49
+ // Parked until a real settings window exists: every current setting already
50
+ // has a home (tray toggles, `haya-pet hooks`, drag/grip gestures), so the
51
+ // item would be a dead button. Re-enable once settings outgrow the tray
52
+ // (e.g. bubble text size, linger duration) and a handler is wired up.
53
+ // { id: "settings", label: "Open Settings" },
50
54
  { id: "separator", type: "separator" },
51
55
  { id: "quit", label: "Quit" }
52
56
  ];
@@ -7,8 +7,12 @@
7
7
  <link rel="stylesheet" href="styles.css" />
8
8
  </head>
9
9
  <body>
10
- <!-- Layer 1: the global pet overlay. -->
11
- <canvas id="pet-canvas" class="interactive" width="192" height="208"></canvas>
10
+ <!-- Layer 1: the global pet overlay. The wrapper carries the position so the
11
+ hover-revealed resize grip travels with the canvas. -->
12
+ <div id="pet">
13
+ <canvas id="pet-canvas" class="interactive" width="192" height="208"></canvas>
14
+ <div id="pet-resize-grip" class="resize-grip interactive" title="Drag to resize · double-click to reset"></div>
15
+ </div>
12
16
 
13
17
  <!-- Layer 2: ongoing-session progress bubbles + folder toggle. -->
14
18
  <div id="bubbles" class="bubbles"></div>
@@ -3,6 +3,7 @@
3
3
  // is the browser glue that draws frames and forwards pointer gestures.
4
4
 
5
5
  import { CELL_HEIGHT, CELL_WIDTH, getFrameRect } from "../../../../packages/pet-core/src/atlas.js";
6
+ import { clampScale, DEFAULT_SCALE, resolveScaleFromDrag } from "../../../../packages/pet-core/src/pet-scale.js";
6
7
  import { getActionDurationMs, getFrameAt } from "../../../../packages/pet-core/src/animator.js";
7
8
  import {
8
9
  clearDragAction,
@@ -19,10 +20,15 @@ import { createInteractionController } from "./interaction-controller.js";
19
20
  import { createBubbleList } from "./session-bubbles.js";
20
21
 
21
22
  const bridge = window.aiPet;
23
+ const petEl = document.getElementById("pet");
22
24
  const canvas = document.getElementById("pet-canvas");
25
+ const gripEl = document.getElementById("pet-resize-grip");
23
26
  const ctx = canvas.getContext("2d");
24
27
  const panelEl = document.getElementById("bubbles");
25
28
 
29
+ // The sprite's natural cell size; the canvas is this times the user's scale.
30
+ const BASE_SIZE = Object.freeze({ width: CELL_WIDTH, height: CELL_HEIGHT });
31
+
26
32
  const controller = createInteractionController({
27
33
  // Click is deferred so a double-click never also fires a wave. Clicking the
28
34
  // pet folds/unfolds the session bubbles; double-click forces them open.
@@ -50,6 +56,8 @@ let previousSessionStates = {};
50
56
  // The pet lives at this work-area-relative position inside the full-screen
51
57
  // overlay window; dragging moves it via CSS (the window never moves).
52
58
  let petLocal = { x: 0, y: 0 };
59
+ // User-chosen pet scale (resize grip), persisted like the position.
60
+ let petScale = DEFAULT_SCALE;
53
61
  // Linger bookkeeping so a finished session's bubble stays ~2s before vanishing.
54
62
  let lingerState = {};
55
63
  let lingerTimer;
@@ -60,6 +68,10 @@ function setupPet(config) {
60
68
  manifest = config.pet.manifest;
61
69
  }
62
70
 
71
+ if (config?.petScale !== undefined) {
72
+ applyPetScale(config.petScale);
73
+ }
74
+
63
75
  if (config?.petPosition) {
64
76
  applyPetPosition(config.petPosition);
65
77
  }
@@ -75,11 +87,21 @@ function setupPet(config) {
75
87
 
76
88
  function applyPetPosition(pos) {
77
89
  petLocal = clampPetLocal(pos);
78
- canvas.style.left = `${petLocal.x}px`;
79
- canvas.style.top = `${petLocal.y}px`;
90
+ petEl.style.left = `${petLocal.x}px`;
91
+ petEl.style.top = `${petLocal.y}px`;
80
92
  placePanel();
81
93
  }
82
94
 
95
+ // Resizes the canvas's pixel size, so each frame re-renders at the new scale
96
+ // (no CSS stretching). Everything that reads canvas.width/height — drag
97
+ // clamping, panel placement — adapts automatically.
98
+ function applyPetScale(scale) {
99
+ petScale = clampScale(scale);
100
+ canvas.width = Math.round(BASE_SIZE.width * petScale);
101
+ canvas.height = Math.round(BASE_SIZE.height * petScale);
102
+ applyPetPosition(petLocal);
103
+ }
104
+
83
105
  function clampPetLocal(pos) {
84
106
  const maxX = Math.max(0, window.innerWidth - canvas.width);
85
107
  const maxY = Math.max(0, window.innerHeight - canvas.height);
@@ -148,13 +170,13 @@ function draw(action, frameIndex) {
148
170
  function drawPlaceholder(action, frameIndex) {
149
171
  ctx.fillStyle = "rgba(110, 168, 254, 0.85)";
150
172
  ctx.beginPath();
151
- ctx.roundRect ? ctx.roundRect(16, 16, CELL_WIDTH - 32, CELL_HEIGHT - 32, 16) : ctx.rect(16, 16, CELL_WIDTH - 32, CELL_HEIGHT - 32);
173
+ ctx.roundRect ? ctx.roundRect(16, 16, canvas.width - 32, canvas.height - 32, 16) : ctx.rect(16, 16, canvas.width - 32, canvas.height - 32);
152
174
  ctx.fill();
153
175
  ctx.fillStyle = "#001233";
154
176
  ctx.font = "14px system-ui";
155
177
  ctx.textAlign = "center";
156
- ctx.fillText(action, CELL_WIDTH / 2, CELL_HEIGHT / 2);
157
- ctx.fillText(`frame ${frameIndex}`, CELL_WIDTH / 2, CELL_HEIGHT / 2 + 20);
178
+ ctx.fillText(action, canvas.width / 2, canvas.height / 2);
179
+ ctx.fillText(`frame ${frameIndex}`, canvas.width / 2, canvas.height / 2 + 20);
158
180
  }
159
181
 
160
182
  function playOneShot(action) {
@@ -188,6 +210,50 @@ canvas.addEventListener("pointerup", (event) => {
188
210
  }
189
211
  });
190
212
 
213
+ // --- Resize grip: drag to scale the pet, double-click to reset ---
214
+
215
+ let resizeDrag; // { startScale, startPointer } while a grip drag is active
216
+
217
+ gripEl.addEventListener("pointerdown", (event) => {
218
+ gripEl.setPointerCapture(event.pointerId);
219
+ gripEl.classList.add("active");
220
+ resizeDrag = {
221
+ startScale: petScale,
222
+ startPointer: { x: event.clientX, y: event.clientY }
223
+ };
224
+ });
225
+
226
+ gripEl.addEventListener("pointermove", (event) => {
227
+ if (!resizeDrag) {
228
+ return;
229
+ }
230
+ applyPetScale(resolveScaleFromDrag({
231
+ startScale: resizeDrag.startScale,
232
+ startPointer: resizeDrag.startPointer,
233
+ pointer: { x: event.clientX, y: event.clientY },
234
+ baseSize: BASE_SIZE
235
+ }));
236
+ });
237
+
238
+ gripEl.addEventListener("pointerup", () => {
239
+ if (!resizeDrag) {
240
+ return;
241
+ }
242
+ resizeDrag = undefined;
243
+ gripEl.classList.remove("active");
244
+ bridge?.savePetScale?.(petScale);
245
+ });
246
+
247
+ gripEl.addEventListener("pointercancel", () => {
248
+ resizeDrag = undefined;
249
+ gripEl.classList.remove("active");
250
+ });
251
+
252
+ gripEl.addEventListener("dblclick", () => {
253
+ applyPetScale(DEFAULT_SCALE);
254
+ bridge?.savePetScale?.(DEFAULT_SCALE);
255
+ });
256
+
191
257
  // Re-clamp and re-place when the work area changes (display/resolution change).
192
258
  window.addEventListener("resize", () => {
193
259
  applyPetPosition(petLocal);
@@ -247,6 +313,12 @@ let mouseIgnored;
247
313
  let lastPointer = { x: -1, y: -1 };
248
314
 
249
315
  function refreshMouseIgnore(x, y) {
316
+ // While the grip is captured, the pointer can briefly leave it (the pet only
317
+ // approximately tracks the diagonal); flipping click-through mid-drag would
318
+ // drop the pointerup, so hold interaction until the drag ends.
319
+ if (resizeDrag) {
320
+ return;
321
+ }
250
322
  if (!Number.isFinite(x) || !Number.isFinite(y)) {
251
323
  return;
252
324
  }
@@ -30,10 +30,14 @@ body {
30
30
  its right. The window itself is large + transparent, and pointer events are
31
31
  forwarded so only the marked .interactive regions catch clicks — everything
32
32
  else passes through to the desktop (see pet-window.js). */
33
- #pet-canvas {
33
+ #pet {
34
34
  position: absolute;
35
35
  left: 0;
36
36
  top: 0;
37
+ }
38
+
39
+ #pet-canvas {
40
+ display: block;
37
41
  image-rendering: pixelated;
38
42
  cursor: grab;
39
43
  }
@@ -42,6 +46,26 @@ body {
42
46
  cursor: grabbing;
43
47
  }
44
48
 
49
+ /* Resize grip: a small corner triangle that fades in while hovering the pet.
50
+ Dragging it scales the pet (aspect-locked); double-click resets to 1×. */
51
+ .resize-grip {
52
+ position: absolute;
53
+ right: 0;
54
+ bottom: 0;
55
+ width: 14px;
56
+ height: 14px;
57
+ cursor: nwse-resize;
58
+ background: linear-gradient(135deg, transparent 50%, rgba(255, 255, 255, 0.75) 50%);
59
+ border-radius: 2px;
60
+ opacity: 0;
61
+ transition: opacity 0.15s ease;
62
+ }
63
+
64
+ #pet:hover .resize-grip,
65
+ .resize-grip.active {
66
+ opacity: 1;
67
+ }
68
+
45
69
  /* Panel container is inert; its children opt back into pointer events so the
46
70
  gaps between bubbles stay click-through. Its left/top are set by JS
47
71
  (panel-placement) so it always sits beside the pet, fully on-screen. The
@@ -13,9 +13,12 @@ const baseState = {
13
13
  test("includes the documented recovery controls", () => {
14
14
  const menu = buildTrayMenu(baseState);
15
15
  const ids = menu.map((item) => item.id);
16
- for (const id of ["toggle_pet", "display_mode", "sessions", "pets", "attach_bubbles", "reset_position", "settings", "quit"]) {
16
+ for (const id of ["toggle_pet", "display_mode", "sessions", "pets", "attach_bubbles", "reset_position", "quit"]) {
17
17
  assert.ok(ids.includes(id), `missing ${id}`);
18
18
  }
19
+ // "Open Settings" is parked until a settings window exists (every current
20
+ // setting already has a tray/CLI/gesture home) — it must not be shown dead.
21
+ assert.ok(!ids.includes("settings"), "settings item should stay hidden until implemented");
19
22
  });
20
23
 
21
24
  test("toggles the pet label based on visibility", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hayasaka7/haya-pet",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "description": "Generic AI CLI pet runtime foundation.",
6
6
  "license": "MIT",
@@ -66,6 +66,23 @@ export function getSelectedPetId(state) {
66
66
  return state?.globalPet?.selectedPetId;
67
67
  }
68
68
 
69
+ // Pet display scale (resize grip). Range clamping lives in pet-core's
70
+ // pet-scale module; this layer only stores and validates the raw value.
71
+ export function setPetScale(state, scale) {
72
+ return {
73
+ ...state,
74
+ globalPet: {
75
+ ...state.globalPet,
76
+ scale
77
+ }
78
+ };
79
+ }
80
+
81
+ export function getPetScale(state) {
82
+ const scale = state?.globalPet?.scale;
83
+ return Number.isFinite(scale) ? scale : undefined;
84
+ }
85
+
69
86
  export function serializePositionState(state) {
70
87
  return `${JSON.stringify(state, null, 2)}\n`;
71
88
  }
@@ -2,7 +2,11 @@ import assert from "node:assert/strict";
2
2
  import { test } from "../../../test/harness.mjs";
3
3
  import {
4
4
  createDefaultPositionState,
5
+ getPetScale,
5
6
  getSelectedPetId,
7
+ parsePositionState,
8
+ serializePositionState,
9
+ setPetScale,
6
10
  setSelectedPet
7
11
  } from "../src/state.js";
8
12
 
@@ -34,3 +38,36 @@ test("getSelectedPetId tolerates missing state", () => {
34
38
  assert.equal(getSelectedPetId(undefined), undefined);
35
39
  assert.equal(getSelectedPetId({}), undefined);
36
40
  });
41
+
42
+ test("setPetScale stores the scale immutably", () => {
43
+ const state = createDefaultPositionState();
44
+ const next = setPetScale(state, 1.5);
45
+
46
+ assert.equal(getPetScale(next), 1.5);
47
+ assert.equal(getPetScale(state), undefined);
48
+ assert.notEqual(next.globalPet, state.globalPet);
49
+ });
50
+
51
+ test("setPetScale preserves other globalPet fields", () => {
52
+ const state = { ...createDefaultPositionState(), globalPet: { open: true, x: 10, y: 20, manual: true } };
53
+ const next = setPetScale(state, 0.75);
54
+
55
+ assert.equal(next.globalPet.x, 10);
56
+ assert.equal(next.globalPet.manual, true);
57
+ assert.equal(next.globalPet.scale, 0.75);
58
+ });
59
+
60
+ test("getPetScale returns undefined for missing or invalid values", () => {
61
+ assert.equal(getPetScale(undefined), undefined);
62
+ assert.equal(getPetScale(createDefaultPositionState()), undefined);
63
+ assert.equal(getPetScale(setPetScale(createDefaultPositionState(), Number.NaN)), undefined);
64
+ assert.equal(getPetScale({ globalPet: { scale: "big" } }), undefined);
65
+ });
66
+
67
+ test("scale survives a serialize/parse round-trip and old files parse without it", () => {
68
+ const withScale = parsePositionState(serializePositionState(setPetScale(createDefaultPositionState(), 1.25)));
69
+ assert.equal(getPetScale(withScale), 1.25);
70
+
71
+ const legacy = parsePositionState(JSON.stringify({ globalPet: { open: true, x: 1, y: 2 } }));
72
+ assert.equal(getPetScale(legacy), undefined);
73
+ });
@@ -0,0 +1,34 @@
1
+ // Pure math for the pet resize grip: the user drags the pet's bottom-right
2
+ // corner to scale the sprite between MIN_SCALE and MAX_SCALE, aspect locked.
3
+
4
+ export const MIN_SCALE = 0.5;
5
+ export const MAX_SCALE = 2;
6
+ export const DEFAULT_SCALE = 1;
7
+
8
+ export function clampScale(value) {
9
+ if (!Number.isFinite(value)) {
10
+ return DEFAULT_SCALE;
11
+ }
12
+
13
+ return Math.min(Math.max(value, MIN_SCALE), MAX_SCALE);
14
+ }
15
+
16
+ // Maps a grip drag to the next scale: each axis implies a scale from how far
17
+ // the pointer moved relative to the pet's base size, and the two are averaged
18
+ // so the pet follows the pointer along the diagonal while staying aspect-locked.
19
+ export function resolveScaleFromDrag({ startScale, startPointer, pointer, baseSize } = {}) {
20
+ const safeStart = clampScale(startScale);
21
+
22
+ if (
23
+ !Number.isFinite(startPointer?.x) || !Number.isFinite(startPointer?.y) ||
24
+ !Number.isFinite(pointer?.x) || !Number.isFinite(pointer?.y) ||
25
+ !Number.isFinite(baseSize?.width) || !Number.isFinite(baseSize?.height) ||
26
+ baseSize.width <= 0 || baseSize.height <= 0
27
+ ) {
28
+ return safeStart;
29
+ }
30
+
31
+ const scaleX = safeStart + (pointer.x - startPointer.x) / baseSize.width;
32
+ const scaleY = safeStart + (pointer.y - startPointer.y) / baseSize.height;
33
+ return clampScale((scaleX + scaleY) / 2);
34
+ }
@@ -0,0 +1,94 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "../../../test/harness.mjs";
3
+ import {
4
+ clampScale,
5
+ DEFAULT_SCALE,
6
+ MAX_SCALE,
7
+ MIN_SCALE,
8
+ resolveScaleFromDrag
9
+ } from "../src/pet-scale.js";
10
+
11
+ const baseSize = { width: 192, height: 208 };
12
+
13
+ test("scale constants define a sane range around the default", () => {
14
+ assert.equal(DEFAULT_SCALE, 1);
15
+ assert.ok(MIN_SCALE < DEFAULT_SCALE);
16
+ assert.ok(MAX_SCALE > DEFAULT_SCALE);
17
+ });
18
+
19
+ test("clampScale keeps in-range values and clamps out-of-range ones", () => {
20
+ assert.equal(clampScale(1.25), 1.25);
21
+ assert.equal(clampScale(MIN_SCALE), MIN_SCALE);
22
+ assert.equal(clampScale(MAX_SCALE), MAX_SCALE);
23
+ assert.equal(clampScale(0.01), MIN_SCALE);
24
+ assert.equal(clampScale(99), MAX_SCALE);
25
+ });
26
+
27
+ test("clampScale falls back to the default for invalid input", () => {
28
+ assert.equal(clampScale(undefined), DEFAULT_SCALE);
29
+ assert.equal(clampScale(null), DEFAULT_SCALE);
30
+ assert.equal(clampScale(Number.NaN), DEFAULT_SCALE);
31
+ assert.equal(clampScale("big"), DEFAULT_SCALE);
32
+ });
33
+
34
+ test("dragging the grip outward grows the scale", () => {
35
+ const next = resolveScaleFromDrag({
36
+ startScale: 1,
37
+ startPointer: { x: 500, y: 500 },
38
+ pointer: { x: 500 + baseSize.width / 2, y: 500 + baseSize.height / 2 },
39
+ baseSize
40
+ });
41
+
42
+ assert.equal(next, 1.5);
43
+ });
44
+
45
+ test("dragging the grip inward shrinks the scale", () => {
46
+ const next = resolveScaleFromDrag({
47
+ startScale: 1,
48
+ startPointer: { x: 500, y: 500 },
49
+ pointer: { x: 500 - baseSize.width / 4, y: 500 - baseSize.height / 4 },
50
+ baseSize
51
+ });
52
+
53
+ assert.equal(next, 0.75);
54
+ });
55
+
56
+ test("a zero drag keeps the starting scale", () => {
57
+ const next = resolveScaleFromDrag({
58
+ startScale: 1.3,
59
+ startPointer: { x: 10, y: 10 },
60
+ pointer: { x: 10, y: 10 },
61
+ baseSize
62
+ });
63
+
64
+ assert.equal(next, 1.3);
65
+ });
66
+
67
+ test("drag results are clamped to the scale range", () => {
68
+ const grown = resolveScaleFromDrag({
69
+ startScale: 1.8,
70
+ startPointer: { x: 0, y: 0 },
71
+ pointer: { x: 1000, y: 1000 },
72
+ baseSize
73
+ });
74
+ const shrunk = resolveScaleFromDrag({
75
+ startScale: 0.6,
76
+ startPointer: { x: 1000, y: 1000 },
77
+ pointer: { x: 0, y: 0 },
78
+ baseSize
79
+ });
80
+
81
+ assert.equal(grown, MAX_SCALE);
82
+ assert.equal(shrunk, MIN_SCALE);
83
+ });
84
+
85
+ test("invalid drag input falls back to the clamped starting scale", () => {
86
+ const next = resolveScaleFromDrag({
87
+ startScale: 1.2,
88
+ startPointer: { x: Number.NaN, y: 0 },
89
+ pointer: { x: 50, y: 50 },
90
+ baseSize
91
+ });
92
+
93
+ assert.equal(next, 1.2);
94
+ });