@hayasaka7/haya-pet 0.1.0

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.
Files changed (131) hide show
  1. package/.gitattributes +34 -0
  2. package/.github/workflows/release.yml +61 -0
  3. package/LICENSE +21 -0
  4. package/README.md +247 -0
  5. package/apps/cli/src/haya-pet.js +395 -0
  6. package/apps/cli/test/haya-pet.test.mjs +339 -0
  7. package/apps/companion/README.md +83 -0
  8. package/apps/companion/package.json +17 -0
  9. package/apps/companion/src/main/display-manager.js +71 -0
  10. package/apps/companion/src/main/index.js +349 -0
  11. package/apps/companion/src/main/lock-file.js +52 -0
  12. package/apps/companion/src/main/panel-placement.js +45 -0
  13. package/apps/companion/src/main/pet-loader.js +2 -0
  14. package/apps/companion/src/main/position-store.js +3 -0
  15. package/apps/companion/src/main/preload.cjs +13 -0
  16. package/apps/companion/src/main/state-file.js +2 -0
  17. package/apps/companion/src/main/terminal-helper-client.js +79 -0
  18. package/apps/companion/src/main/terminal-locator.js +44 -0
  19. package/apps/companion/src/main/tray-menu.js +79 -0
  20. package/apps/companion/src/main/window-options.js +66 -0
  21. package/apps/companion/src/renderer/index.html +18 -0
  22. package/apps/companion/src/renderer/interaction-controller.js +114 -0
  23. package/apps/companion/src/renderer/pet-window.js +275 -0
  24. package/apps/companion/src/renderer/session-bubbles.js +138 -0
  25. package/apps/companion/src/renderer/styles.css +225 -0
  26. package/apps/companion/src/renderer/task-talk-window.js +141 -0
  27. package/apps/companion/test/display-manager.test.mjs +48 -0
  28. package/apps/companion/test/interaction-controller.test.mjs +107 -0
  29. package/apps/companion/test/panel-placement.test.mjs +60 -0
  30. package/apps/companion/test/position-store.test.mjs +54 -0
  31. package/apps/companion/test/state-file.test.mjs +52 -0
  32. package/apps/companion/test/terminal-helper-client.test.mjs +68 -0
  33. package/apps/companion/test/terminal-locator.test.mjs +35 -0
  34. package/apps/companion/test/tray-menu.test.mjs +45 -0
  35. package/apps/companion/test/window-options.test.mjs +62 -0
  36. package/apps/pet-preview/index.html +42 -0
  37. package/apps/pet-preview/src/preview-app.js +123 -0
  38. package/apps/pet-preview/src/preview-state.js +70 -0
  39. package/apps/pet-preview/src/preview.css +125 -0
  40. package/apps/pet-preview/test/preview-state.test.mjs +62 -0
  41. package/assets/fallback-pet/README.md +16 -0
  42. package/assets/fallback-pet/pet.json +13 -0
  43. package/docs/architecture.md +144 -0
  44. package/docs/known-issues.md +49 -0
  45. package/docs/publishing.md +48 -0
  46. package/docs/screenshots/README.md +7 -0
  47. package/docs/screenshots/folder-collapsed.png +0 -0
  48. package/docs/screenshots/hero.png +0 -0
  49. package/docs/screenshots/pet-overlay.png +0 -0
  50. package/docs/screenshots/session-bubbles.png +0 -0
  51. package/docs/screenshots/tray-menu.png +0 -0
  52. package/docs/troubleshooting.md +36 -0
  53. package/native/README.md +80 -0
  54. package/native/linux-window-helper/README.md +29 -0
  55. package/native/mac-window-helper/README.md +30 -0
  56. package/native/win-window-helper/Program.cs +312 -0
  57. package/native/win-window-helper/README.md +53 -0
  58. package/native/win-window-helper/win-window-helper.csproj +12 -0
  59. package/package.json +35 -0
  60. package/packages/adapters/src/adapter-info.js +61 -0
  61. package/packages/adapters/src/capabilities.js +39 -0
  62. package/packages/adapters/src/heuristics.js +114 -0
  63. package/packages/adapters/src/output-observer.js +164 -0
  64. package/packages/adapters/src/routing.js +86 -0
  65. package/packages/adapters/test/adapter-info.test.mjs +35 -0
  66. package/packages/adapters/test/capabilities.test.mjs +44 -0
  67. package/packages/adapters/test/heuristics.test.mjs +42 -0
  68. package/packages/adapters/test/output-observer.test.mjs +142 -0
  69. package/packages/adapters/test/routing.test.mjs +93 -0
  70. package/packages/app-state/src/state-file.js +53 -0
  71. package/packages/app-state/src/state.js +80 -0
  72. package/packages/app-state/test/state.test.mjs +36 -0
  73. package/packages/cli-core/src/companion-launcher.js +69 -0
  74. package/packages/cli-core/src/pty-runner.js +96 -0
  75. package/packages/cli-core/src/run-command.js +353 -0
  76. package/packages/cli-core/src/strip-ansi.js +16 -0
  77. package/packages/cli-core/test/companion-launcher.test.mjs +98 -0
  78. package/packages/cli-core/test/run-command.test.mjs +177 -0
  79. package/packages/cli-core/test/strip-ansi.test.mjs +27 -0
  80. package/packages/daemon-core/src/daemon-runtime.js +49 -0
  81. package/packages/daemon-core/src/ipc-server.js +180 -0
  82. package/packages/daemon-core/src/ipc-transport.js +70 -0
  83. package/packages/daemon-core/src/singleton.js +46 -0
  84. package/packages/daemon-core/test/daemon-runtime.test.mjs +65 -0
  85. package/packages/daemon-core/test/ipc-server.test.mjs +70 -0
  86. package/packages/daemon-core/test/ipc-transport.test.mjs +72 -0
  87. package/packages/daemon-core/test/singleton.test.mjs +32 -0
  88. package/packages/pet-core/src/animation-state.js +84 -0
  89. package/packages/pet-core/src/animator.js +26 -0
  90. package/packages/pet-core/src/atlas.js +81 -0
  91. package/packages/pet-core/src/discovery.js +90 -0
  92. package/packages/pet-core/src/manifest.js +112 -0
  93. package/packages/pet-core/src/validation.js +43 -0
  94. package/packages/pet-core/test/animation-state.test.mjs +47 -0
  95. package/packages/pet-core/test/animator.test.mjs +31 -0
  96. package/packages/pet-core/test/atlas.test.mjs +81 -0
  97. package/packages/pet-core/test/discovery.test.mjs +93 -0
  98. package/packages/pet-core/test/manifest.test.mjs +93 -0
  99. package/packages/pet-core/test/validation.test.mjs +69 -0
  100. package/packages/platform-core/src/capabilities.js +49 -0
  101. package/packages/platform-core/src/paths.js +75 -0
  102. package/packages/platform-core/src/platform.js +15 -0
  103. package/packages/platform-core/test/platform.test.mjs +84 -0
  104. package/packages/protocol/src/messages.js +156 -0
  105. package/packages/protocol/test/messages.test.mjs +112 -0
  106. package/packages/session-core/src/bubble-linger.js +47 -0
  107. package/packages/session-core/src/bubble-view.js +79 -0
  108. package/packages/session-core/src/pet-state.js +56 -0
  109. package/packages/session-core/src/priority.js +56 -0
  110. package/packages/session-core/src/registry.js +144 -0
  111. package/packages/session-core/src/summaries.js +54 -0
  112. package/packages/session-core/test/bubble-linger.test.mjs +96 -0
  113. package/packages/session-core/test/bubble-view.test.mjs +79 -0
  114. package/packages/session-core/test/pet-state.test.mjs +118 -0
  115. package/packages/session-core/test/priority.test.mjs +53 -0
  116. package/packages/session-core/test/registry.test.mjs +161 -0
  117. package/packages/session-core/test/summaries.test.mjs +38 -0
  118. package/packages/task-core/src/approvals.js +91 -0
  119. package/packages/task-core/src/controls.js +61 -0
  120. package/packages/task-core/src/replies.js +80 -0
  121. package/packages/task-core/src/task-events.js +101 -0
  122. package/packages/task-core/src/task-status.js +93 -0
  123. package/packages/task-core/src/task-store.js +74 -0
  124. package/packages/task-core/test/approvals.test.mjs +61 -0
  125. package/packages/task-core/test/controls.test.mjs +61 -0
  126. package/packages/task-core/test/replies.test.mjs +51 -0
  127. package/packages/task-core/test/task-events.test.mjs +67 -0
  128. package/packages/task-core/test/task-status.test.mjs +49 -0
  129. package/packages/task-core/test/task-store.test.mjs +65 -0
  130. package/test/harness.mjs +22 -0
  131. package/test/run-tests.mjs +47 -0
@@ -0,0 +1,68 @@
1
+ import assert from "node:assert/strict";
2
+ import { EventEmitter } from "node:events";
3
+ import { test } from "../../../test/harness.mjs";
4
+ import { createHelperClient } from "../src/main/terminal-helper-client.js";
5
+
6
+ // Fake helper process that answers the line-delimited JSON protocol the way the
7
+ // real native helper does, so the client plumbing is testable without a binary.
8
+ function fakeSpawn(responder) {
9
+ const stdout = new EventEmitter();
10
+ const stdin = {
11
+ write(data) {
12
+ const request = JSON.parse(data);
13
+ queueMicrotask(() => {
14
+ stdout.emit("data", Buffer.from(`${JSON.stringify(responder(request))}\n`));
15
+ });
16
+ return true;
17
+ },
18
+ end() {}
19
+ };
20
+ const child = new EventEmitter();
21
+ child.stdout = stdout;
22
+ child.stdin = stdin;
23
+ child.kill = () => {};
24
+ return () => child;
25
+ }
26
+
27
+ test("requests capabilities and correlates the response by id", async () => {
28
+ const client = createHelperClient({
29
+ spawn: fakeSpawn((req) => ({ id: req.id, ok: true, capabilities: { locate: true } })),
30
+ command: "helper"
31
+ });
32
+
33
+ const caps = await client.capabilities();
34
+ assert.equal(caps.ok, true);
35
+ assert.equal(caps.capabilities.locate, true);
36
+ await client.close();
37
+ });
38
+
39
+ test("locate forwards pid and resolves the window", async () => {
40
+ const seen = [];
41
+ const client = createHelperClient({
42
+ spawn: fakeSpawn((req) => {
43
+ seen.push(req);
44
+ return { id: req.id, ok: true, window: { x: 10, y: 20, width: 800, height: 600 } };
45
+ }),
46
+ command: "helper"
47
+ });
48
+
49
+ const result = await client.locate(1234, 5678);
50
+ assert.equal(result.ok, true);
51
+ assert.equal(result.window.width, 800);
52
+ assert.equal(seen[0].op, "locate");
53
+ assert.equal(seen[0].pid, 1234);
54
+ assert.equal(seen[0].terminalPid, 5678);
55
+ await client.close();
56
+ });
57
+
58
+ test("concurrent requests resolve to their matching responses", async () => {
59
+ const client = createHelperClient({
60
+ spawn: fakeSpawn((req) => ({ id: req.id, ok: true, echo: req.pid })),
61
+ command: "helper"
62
+ });
63
+
64
+ const [a, b] = await Promise.all([client.locate(1), client.locate(2)]);
65
+ assert.equal(a.echo, 1);
66
+ assert.equal(b.echo, 2);
67
+ await client.close();
68
+ });
@@ -0,0 +1,35 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "../../../test/harness.mjs";
3
+ import { getTerminalAttachmentStrategy } from "../src/main/terminal-locator.js";
4
+
5
+ test("uses Windows helper strategy on Windows", () => {
6
+ assert.deepEqual(getTerminalAttachmentStrategy({ platform: "windows", env: {} }), {
7
+ supported: "best-effort",
8
+ strategy: "win32-window-helper",
9
+ reason: "Windows terminal windows require process tree and HWND lookup."
10
+ });
11
+ });
12
+
13
+ test("uses macOS accessibility helper strategy on macOS", () => {
14
+ assert.deepEqual(getTerminalAttachmentStrategy({ platform: "macos", env: {} }), {
15
+ supported: "best-effort",
16
+ strategy: "macos-accessibility-helper",
17
+ reason: "macOS terminal attachment requires Accessibility or window-list permissions."
18
+ });
19
+ });
20
+
21
+ test("uses X11 helper strategy for Linux X11", () => {
22
+ assert.deepEqual(getTerminalAttachmentStrategy({ platform: "linux", env: { DISPLAY: ":0" } }), {
23
+ supported: "best-effort",
24
+ strategy: "x11-window-helper",
25
+ reason: "X11 allows best-effort terminal window discovery."
26
+ });
27
+ });
28
+
29
+ test("uses manual fallback strategy for Linux Wayland", () => {
30
+ assert.deepEqual(getTerminalAttachmentStrategy({ platform: "linux", env: { WAYLAND_DISPLAY: "wayland-0" } }), {
31
+ supported: "fallback",
32
+ strategy: "manual-fallback",
33
+ reason: "Wayland commonly blocks global window positioning and terminal lookup."
34
+ });
35
+ });
@@ -0,0 +1,45 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "../../../test/harness.mjs";
3
+ import { buildTrayMenu } from "../src/main/tray-menu.js";
4
+
5
+ const baseState = {
6
+ petVisible: true,
7
+ displayMode: "hybrid",
8
+ attachBubblesToTerminals: true,
9
+ sessions: [{ sessionId: "s1", label: "Codex · netdisk-server" }],
10
+ pets: [{ id: "example-pet", name: "Example Pet" }]
11
+ };
12
+
13
+ test("includes the documented recovery controls", () => {
14
+ const menu = buildTrayMenu(baseState);
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"]) {
17
+ assert.ok(ids.includes(id), `missing ${id}`);
18
+ }
19
+ });
20
+
21
+ test("toggles the pet label based on visibility", () => {
22
+ assert.equal(buildTrayMenu({ ...baseState, petVisible: true }).find((i) => i.id === "toggle_pet").label, "Hide Pet");
23
+ assert.equal(buildTrayMenu({ ...baseState, petVisible: false }).find((i) => i.id === "toggle_pet").label, "Show Pet");
24
+ });
25
+
26
+ test("checks the current display mode in the submenu", () => {
27
+ const submenu = buildTrayMenu(baseState).find((i) => i.id === "display_mode").submenu;
28
+ const hybrid = submenu.find((i) => i.value === "hybrid");
29
+ const global = submenu.find((i) => i.value === "global");
30
+ assert.equal(hybrid.checked, true);
31
+ assert.equal(global.checked, false);
32
+ });
33
+
34
+ test("lists active sessions or shows an empty placeholder", () => {
35
+ const withSessions = buildTrayMenu(baseState).find((i) => i.id === "sessions").submenu;
36
+ assert.equal(withSessions[0].label, "Codex · netdisk-server");
37
+
38
+ const empty = buildTrayMenu({ ...baseState, sessions: [] }).find((i) => i.id === "sessions").submenu;
39
+ assert.equal(empty[0].enabled, false);
40
+ });
41
+
42
+ test("reflects the attach-bubbles checkbox state", () => {
43
+ assert.equal(buildTrayMenu(baseState).find((i) => i.id === "attach_bubbles").checked, true);
44
+ assert.equal(buildTrayMenu({ ...baseState, attachBubblesToTerminals: false }).find((i) => i.id === "attach_bubbles").checked, false);
45
+ });
@@ -0,0 +1,62 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "../../../test/harness.mjs";
3
+ import { getPlatformCapabilities } from "../../../packages/platform-core/src/capabilities.js";
4
+ import { buildPetWindowOptions } from "../src/main/window-options.js";
5
+
6
+ test("builds non-focus-stealing transparent overlay options", () => {
7
+ const options = buildPetWindowOptions({
8
+ platform: "windows",
9
+ capabilities: getPlatformCapabilities({ platform: "windows", env: {} }),
10
+ bounds: { x: 100, y: 200, width: 192, height: 208 }
11
+ });
12
+
13
+ assert.equal(options.overlayMode, "transparent-overlay");
14
+ assert.deepEqual(options.browserWindow, {
15
+ x: 100,
16
+ y: 200,
17
+ width: 192,
18
+ height: 208,
19
+ transparent: true,
20
+ frame: false,
21
+ alwaysOnTop: true,
22
+ skipTaskbar: true,
23
+ resizable: false,
24
+ hasShadow: false,
25
+ focusable: false,
26
+ backgroundColor: "#00000000"
27
+ });
28
+ });
29
+
30
+ test("uses transparent overlay mode for macOS and Linux X11 capabilities", () => {
31
+ assert.equal(
32
+ buildPetWindowOptions({
33
+ platform: "macos",
34
+ capabilities: getPlatformCapabilities({ platform: "macos", env: {} }),
35
+ bounds: { width: 192, height: 208 }
36
+ }).overlayMode,
37
+ "transparent-overlay"
38
+ );
39
+
40
+ assert.equal(
41
+ buildPetWindowOptions({
42
+ platform: "linux",
43
+ capabilities: getPlatformCapabilities({ platform: "linux", env: { DISPLAY: ":0" } }),
44
+ bounds: { width: 192, height: 208 }
45
+ }).overlayMode,
46
+ "transparent-overlay"
47
+ );
48
+ });
49
+
50
+ test("uses fallback window mode when platform capabilities do not guarantee overlay positioning", () => {
51
+ const options = buildPetWindowOptions({
52
+ platform: "linux",
53
+ capabilities: getPlatformCapabilities({ platform: "linux", env: { WAYLAND_DISPLAY: "wayland-0" } }),
54
+ bounds: { width: 384, height: 416 }
55
+ });
56
+
57
+ assert.equal(options.overlayMode, "fallback-window");
58
+ assert.equal(options.browserWindow.transparent, false);
59
+ assert.equal(options.browserWindow.frame, true);
60
+ assert.equal(options.browserWindow.focusable, true);
61
+ assert.equal(options.browserWindow.skipTaskbar, false);
62
+ });
@@ -0,0 +1,42 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Haya Pet Preview</title>
7
+ <link rel="stylesheet" href="./src/preview.css">
8
+ </head>
9
+ <body>
10
+ <main class="app-shell">
11
+ <section class="toolbar" aria-label="Preview controls">
12
+ <h1 class="brand">Haya Pet Preview</h1>
13
+ <label class="file-input">
14
+ <span>Spritesheet</span>
15
+ <input id="spritesheet-file" type="file" accept="image/png,image/webp">
16
+ </label>
17
+ <div id="action-list" class="action-list" aria-label="Actions"></div>
18
+ </section>
19
+
20
+ <section class="stage" aria-label="Pet frame">
21
+ <canvas id="pet-canvas" width="384" height="416"></canvas>
22
+ </section>
23
+
24
+ <aside class="meta-panel" aria-label="Frame details">
25
+ <button id="play-toggle" class="play-button" type="button">Pause</button>
26
+ <label class="scale-control">
27
+ <span class="control-label">Scale</span>
28
+ <input id="scale" type="range" min="1" max="4" step="1" value="2">
29
+ </label>
30
+ <div>
31
+ <span class="control-label">Action</span>
32
+ <p id="action-meta" class="meta-value">idle</p>
33
+ </div>
34
+ <div>
35
+ <span class="control-label">Frame</span>
36
+ <p id="frame-meta" class="meta-value">frame 1 / 1</p>
37
+ </div>
38
+ </aside>
39
+ </main>
40
+ <script type="module" src="./src/preview-app.js"></script>
41
+ </body>
42
+ </html>
@@ -0,0 +1,123 @@
1
+ import {
2
+ advancePreviewFrame,
3
+ buildPreviewRows,
4
+ createPreviewState,
5
+ selectPreviewAction,
6
+ setPreviewPlaying,
7
+ setPreviewScale
8
+ } from "./preview-state.js";
9
+
10
+ const canvas = document.querySelector("#pet-canvas");
11
+ const context = canvas.getContext("2d");
12
+ const fileInput = document.querySelector("#spritesheet-file");
13
+ const actionList = document.querySelector("#action-list");
14
+ const playToggle = document.querySelector("#play-toggle");
15
+ const scaleInput = document.querySelector("#scale");
16
+ const frameMeta = document.querySelector("#frame-meta");
17
+ const actionMeta = document.querySelector("#action-meta");
18
+
19
+ let state = createPreviewState();
20
+ let spritesheet = null;
21
+ let lastFrameAt = 0;
22
+ let objectUrl = null;
23
+ const previewRows = buildPreviewRows();
24
+
25
+ for (const row of previewRows) {
26
+ const button = document.createElement("button");
27
+ button.type = "button";
28
+ button.className = "action-button";
29
+ button.textContent = row.action;
30
+ button.dataset.action = row.action;
31
+ button.addEventListener("click", () => {
32
+ state = selectPreviewAction(state, row.action);
33
+ render();
34
+ });
35
+ actionList.append(button);
36
+ }
37
+
38
+ fileInput.addEventListener("change", () => {
39
+ const file = fileInput.files?.[0];
40
+ if (!file) {
41
+ return;
42
+ }
43
+
44
+ if (objectUrl) {
45
+ URL.revokeObjectURL(objectUrl);
46
+ }
47
+
48
+ objectUrl = URL.createObjectURL(file);
49
+ const image = new Image();
50
+ image.onload = () => {
51
+ spritesheet = image;
52
+ render();
53
+ };
54
+ image.src = objectUrl;
55
+ });
56
+
57
+ playToggle.addEventListener("click", () => {
58
+ state = setPreviewPlaying(state, !state.playing);
59
+ render();
60
+ });
61
+
62
+ scaleInput.addEventListener("input", () => {
63
+ state = setPreviewScale(state, Number(scaleInput.value));
64
+ render();
65
+ });
66
+
67
+ function tick(timestamp) {
68
+ if (state.playing && timestamp - lastFrameAt > 120) {
69
+ state = advancePreviewFrame(state);
70
+ lastFrameAt = timestamp;
71
+ render();
72
+ }
73
+
74
+ requestAnimationFrame(tick);
75
+ }
76
+
77
+ function render() {
78
+ canvas.width = state.frameRect.width * state.scale;
79
+ canvas.height = state.frameRect.height * state.scale;
80
+ context.imageSmoothingEnabled = false;
81
+ context.clearRect(0, 0, canvas.width, canvas.height);
82
+
83
+ if (spritesheet) {
84
+ context.drawImage(
85
+ spritesheet,
86
+ state.frameRect.x,
87
+ state.frameRect.y,
88
+ state.frameRect.width,
89
+ state.frameRect.height,
90
+ 0,
91
+ 0,
92
+ canvas.width,
93
+ canvas.height
94
+ );
95
+ } else {
96
+ drawEmptyState();
97
+ }
98
+
99
+ for (const button of actionList.querySelectorAll("button")) {
100
+ button.classList.toggle("active", button.dataset.action === state.action);
101
+ }
102
+
103
+ playToggle.textContent = state.playing ? "Pause" : "Play";
104
+ actionMeta.textContent = state.action;
105
+ const row = previewRows.find((previewRow) => previewRow.action === state.action);
106
+ frameMeta.textContent = `frame ${state.frameIndex + 1} / ${row.frameCount}`;
107
+ }
108
+
109
+ function drawEmptyState() {
110
+ context.fillStyle = "#f4f1e8";
111
+ context.fillRect(0, 0, canvas.width, canvas.height);
112
+ context.strokeStyle = "#2f5d62";
113
+ context.lineWidth = 2;
114
+ context.strokeRect(1, 1, canvas.width - 2, canvas.height - 2);
115
+ context.fillStyle = "#24343b";
116
+ context.font = "14px system-ui, sans-serif";
117
+ context.textAlign = "center";
118
+ context.fillText("spritesheet", canvas.width / 2, canvas.height / 2 - 8);
119
+ context.fillText("1536 x 1872", canvas.width / 2, canvas.height / 2 + 14);
120
+ }
121
+
122
+ render();
123
+ requestAnimationFrame(tick);
@@ -0,0 +1,70 @@
1
+ import {
2
+ ACTION_ROWS,
3
+ FRAME_COUNTS,
4
+ getFrameCount,
5
+ getFrameRect
6
+ } from "../../../packages/pet-core/src/atlas.js";
7
+
8
+ export function buildPreviewRows() {
9
+ return Object.entries(ACTION_ROWS).map(([action, row]) => ({
10
+ action,
11
+ row,
12
+ frameCount: FRAME_COUNTS[action]
13
+ }));
14
+ }
15
+
16
+ export function createPreviewState(options = {}) {
17
+ const action = options.action ?? "idle";
18
+ const frameIndex = options.frameIndex ?? 0;
19
+ const scale = options.scale ?? 2;
20
+ const playing = options.playing ?? true;
21
+
22
+ return withFrameRect({
23
+ action,
24
+ frameIndex,
25
+ scale,
26
+ playing
27
+ });
28
+ }
29
+
30
+ export function selectPreviewAction(state, action) {
31
+ return withFrameRect({
32
+ ...state,
33
+ action,
34
+ frameIndex: 0,
35
+ playing: true
36
+ });
37
+ }
38
+
39
+ export function setPreviewScale(state, scale) {
40
+ if (!Number.isFinite(scale) || scale <= 0) {
41
+ throw new RangeError("scale must be greater than 0");
42
+ }
43
+
44
+ return withFrameRect({
45
+ ...state,
46
+ scale
47
+ });
48
+ }
49
+
50
+ export function setPreviewPlaying(state, playing) {
51
+ return withFrameRect({
52
+ ...state,
53
+ playing: Boolean(playing)
54
+ });
55
+ }
56
+
57
+ export function advancePreviewFrame(state) {
58
+ const frameCount = getFrameCount(state.action);
59
+ return withFrameRect({
60
+ ...state,
61
+ frameIndex: (state.frameIndex + 1) % frameCount
62
+ });
63
+ }
64
+
65
+ function withFrameRect(state) {
66
+ return {
67
+ ...state,
68
+ frameRect: getFrameRect(state.action, state.frameIndex)
69
+ };
70
+ }
@@ -0,0 +1,125 @@
1
+ * {
2
+ box-sizing: border-box;
3
+ }
4
+
5
+ body {
6
+ margin: 0;
7
+ min-height: 100vh;
8
+ background: #f7f3ea;
9
+ color: #1e272b;
10
+ font-family: Arial, Helvetica, sans-serif;
11
+ }
12
+
13
+ button,
14
+ input {
15
+ font: inherit;
16
+ }
17
+
18
+ .app-shell {
19
+ display: grid;
20
+ grid-template-columns: minmax(220px, 280px) minmax(360px, 1fr) minmax(180px, 220px);
21
+ gap: 20px;
22
+ min-height: 100vh;
23
+ padding: 20px;
24
+ }
25
+
26
+ .toolbar,
27
+ .meta-panel {
28
+ align-self: stretch;
29
+ border: 1px solid #c9c3b6;
30
+ background: #fffaf1;
31
+ padding: 14px;
32
+ }
33
+
34
+ .brand {
35
+ margin: 0 0 16px;
36
+ font-size: 18px;
37
+ line-height: 1.2;
38
+ }
39
+
40
+ .file-input {
41
+ display: block;
42
+ margin-bottom: 14px;
43
+ }
44
+
45
+ .file-input span,
46
+ .control-label {
47
+ display: block;
48
+ margin-bottom: 6px;
49
+ color: #4b5457;
50
+ font-size: 13px;
51
+ }
52
+
53
+ .file-input input {
54
+ width: 100%;
55
+ }
56
+
57
+ .action-list {
58
+ display: grid;
59
+ grid-template-columns: 1fr;
60
+ gap: 6px;
61
+ }
62
+
63
+ .action-button,
64
+ .play-button {
65
+ min-height: 34px;
66
+ border: 1px solid #7f8c84;
67
+ background: #f7f3ea;
68
+ color: #1e272b;
69
+ cursor: pointer;
70
+ }
71
+
72
+ .action-button.active,
73
+ .play-button {
74
+ border-color: #245c63;
75
+ background: #2f6f73;
76
+ color: #ffffff;
77
+ }
78
+
79
+ .stage {
80
+ display: grid;
81
+ place-items: center;
82
+ min-width: 0;
83
+ overflow: auto;
84
+ border: 1px solid #c9c3b6;
85
+ background:
86
+ linear-gradient(45deg, #ded8cc 25%, transparent 25%),
87
+ linear-gradient(-45deg, #ded8cc 25%, transparent 25%),
88
+ linear-gradient(45deg, transparent 75%, #ded8cc 75%),
89
+ linear-gradient(-45deg, transparent 75%, #ded8cc 75%);
90
+ background-color: #f8f4ec;
91
+ background-position: 0 0, 0 10px, 10px -10px, -10px 0;
92
+ background-size: 20px 20px;
93
+ }
94
+
95
+ canvas {
96
+ width: auto;
97
+ height: auto;
98
+ max-width: 100%;
99
+ image-rendering: pixelated;
100
+ }
101
+
102
+ .meta-panel {
103
+ display: grid;
104
+ align-content: start;
105
+ gap: 16px;
106
+ }
107
+
108
+ .meta-value {
109
+ margin: 0;
110
+ font-size: 15px;
111
+ }
112
+
113
+ .scale-control input {
114
+ width: 100%;
115
+ }
116
+
117
+ @media (max-width: 840px) {
118
+ .app-shell {
119
+ grid-template-columns: 1fr;
120
+ }
121
+
122
+ .stage {
123
+ min-height: 440px;
124
+ }
125
+ }
@@ -0,0 +1,62 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "../../../test/harness.mjs";
3
+ import {
4
+ advancePreviewFrame,
5
+ buildPreviewRows,
6
+ createPreviewState,
7
+ selectPreviewAction
8
+ } from "../src/preview-state.js";
9
+
10
+ test("builds preview rows for every pet atlas action", () => {
11
+ assert.deepEqual(buildPreviewRows().map((row) => row.action), [
12
+ "idle",
13
+ "running-right",
14
+ "running-left",
15
+ "waving",
16
+ "jumping",
17
+ "failed",
18
+ "waiting",
19
+ "running",
20
+ "review"
21
+ ]);
22
+
23
+ assert.deepEqual(buildPreviewRows().find((row) => row.action === "jumping"), {
24
+ action: "jumping",
25
+ row: 4,
26
+ frameCount: 5
27
+ });
28
+ });
29
+
30
+ test("selecting an action resets preview playback to the first frame", () => {
31
+ const state = createPreviewState({ action: "running", frameIndex: 4, scale: 3 });
32
+
33
+ assert.deepEqual(selectPreviewAction(state, "waving"), {
34
+ action: "waving",
35
+ frameIndex: 0,
36
+ frameRect: {
37
+ x: 0,
38
+ y: 624,
39
+ width: 192,
40
+ height: 208
41
+ },
42
+ scale: 3,
43
+ playing: true
44
+ });
45
+ });
46
+
47
+ test("advancing preview frame wraps at the action frame count", () => {
48
+ const state = createPreviewState({ action: "waving", frameIndex: 3 });
49
+
50
+ assert.equal(advancePreviewFrame(state).frameIndex, 0);
51
+ });
52
+
53
+ test("preview state exposes the current atlas frame rectangle", () => {
54
+ const state = createPreviewState({ action: "review", frameIndex: 2 });
55
+
56
+ assert.deepEqual(state.frameRect, {
57
+ x: 384,
58
+ y: 1664,
59
+ width: 192,
60
+ height: 208
61
+ });
62
+ });
@@ -0,0 +1,16 @@
1
+ # Fallback Pet
2
+
3
+ This directory holds the bundled fallback pet manifest. The matching
4
+ `spritesheet.webp` is a binary 1536×1872 Codex-compatible atlas (8 columns ×
5
+ 9 rows of 192×208 cells) and is **not** checked in here.
6
+
7
+ To use a real pet:
8
+
9
+ - Drop a Codex-compatible pet (a folder with `pet.json` + `spritesheet.webp`)
10
+ into `~/.codex/pets` or `~/.haya-pet/pets`, or
11
+ - Add `spritesheet.webp` next to this `pet.json`.
12
+
13
+ If no spritesheet is found, the companion renderer draws labelled placeholder
14
+ frames so interaction and state mapping can still be exercised during
15
+ development. Atlas dimensions are validated against 1536×1872 once the image
16
+ loads (`packages/pet-core/src/validation.js`).
@@ -0,0 +1,13 @@
1
+ {
2
+ "id": "fallback-pet",
3
+ "name": "Fallback Pet",
4
+ "spritesheet": "spritesheet.webp",
5
+ "cellWidth": 192,
6
+ "cellHeight": 208,
7
+ "frameDurationMs": 120,
8
+ "actionFrameDurations": {
9
+ "idle": 160,
10
+ "waving": 90,
11
+ "jumping": 90
12
+ }
13
+ }