Skip to content

Commit 901d0f9

Browse files
committed
feat: release Codex Pet Pause 0.2.0
1 parent 64b4da2 commit 901d0f9

20 files changed

Lines changed: 1343 additions & 71 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Codex Pet Pause makes gentle breaks feel less mechanical. A companion on your de
2525

2626
## Codex pet compatibility
2727

28-
Codex Pet Pause accepts compatible Codex pet v1 and v2 files. A compatible pet commonly contains `pet.json` and `spritesheet.webp`; select the matching pair from the same pet directory when importing. Pets are imported locally into this browser and are never uploaded by this application.
28+
Codex Pet Pause accepts compatible Codex pet v1 and v2 files. Import either one ZIP containing one Codex pet (one pet per ZIP), or the matching `pet.json` and WebP atlas as loose files. ZIPs may contain outer folders, are extracted only in this browser, and are never uploaded. A ZIP may contain at most one pet, must be no larger than 32 MiB compressed or expanded, and may contain at most 128 entries. The existing pet manifest and atlas limits are 64 KiB and 16 MiB.
2929

3030
## Quick start
3131

README.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Codex Pet Pause 让温和的休息不再机械。一位桌面伙伴会帮助你
2525

2626
## Codex 宠物兼容性
2727

28-
Codex Pet Pause 支持兼容的 Codex 宠物 v1 和 v2 文件。兼容宠物通常包含 `pet.json` `spritesheet.webp`;导入时请从同一宠物目录选择相匹配的一对文件。宠物仅导入到当前浏览器本地,本应用永远不会上传它们
28+
Codex Pet Pause 支持兼容的 Codex 宠物 v1 和 v2 文件。可以导入仅含一个 Codex 宠物的 ZIP(一个 ZIP 中只含一个宠物),也可以选择配套的 `pet.json` 与 WebP 图集作为松散文件。ZIP 可以带外层目录,只会在当前浏览器中解压,不会上传。每个 ZIP 只能包含一个宠物,压缩前后均不得超过 32 MiB,条目不得超过 128 个;清单与图集仍分别受 64 KiB 和 16 MiB 限制
2929

3030
## 快速开始
3131

docs/ASSET-PROVENANCE.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,24 @@
1-
# Built-in cat sound provenance
1+
# Built-in cat asset provenance
2+
3+
## Visual provenance
4+
5+
The built-in Neko Pause Cat atlas is `public/assets/cat/neko-pause-cat.webp`.
6+
7+
The 2026-07-24 closed-eye restyle used a user-supplied contact sheet only as a
8+
style reference for thick rounded brown/tan closed-eye arcs. No reference cell
9+
or pixel region was copied into the production atlas. The accepted result is a
10+
deterministic masked recolor of the Neko Pause Cat's existing eye geometry.
11+
12+
- Approved atlas SHA-256: `25b40839f320f013a371634d5ac192ea2a9157ef5af4de8a7564bdf1642da1d3`
13+
- The masked comparison changed 1,220 color pixels, with zero changes outside
14+
the approved masks and zero alpha changes.
15+
- The public regression baseline is
16+
`e2e/fixtures/neko-pause-cat-atlas-baseline.json`; the browser test verifies
17+
atlas occupancy, protected-cell hashes, and the approved brown eye arcs.
18+
- The idle fallback PNG remains unchanged because the neutral cell was not
19+
modified.
20+
21+
## Sound provenance
222

323
## Source and attribution
424

e2e/accessibility.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ for (const theme of ['light', 'dark'] as const) {
163163

164164
await help.click();
165165
await expect(help).toHaveAttribute('aria-expanded', 'true');
166-
await expect(page.getByText(/Codex /)).toBeVisible();
166+
await expect(page.getByText(/ Codex ZIP /)).toBeVisible();
167167
await expectNoHighImpactViolations(page);
168168
});
169169
}
@@ -180,7 +180,7 @@ test('keeps expanded import help unclipped and axe-clean at 390px', async ({ pag
180180
await help.click();
181181
await expect(help).toHaveAttribute('aria-expanded', 'true');
182182

183-
const disclosure = page.getByText(/Codex /);
183+
const disclosure = page.getByText(/ Codex ZIP /);
184184
await expect(disclosure).toBeVisible();
185185
const descriptionBounds = await description.boundingBox();
186186
const disclosureBounds = await disclosure.boundingBox();

e2e/cat-asset.spec.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { readFileSync } from 'node:fs';
2+
import { test, expect } from '@playwright/test';
3+
4+
type AtlasBaseline = {
5+
occupancy: number[][];
6+
protectedCells: Record<string, string>;
7+
};
8+
9+
const baseline = JSON.parse(
10+
readFileSync(
11+
new URL(
12+
'./fixtures/neko-pause-cat-atlas-baseline.json',
13+
import.meta.url,
14+
),
15+
'utf8',
16+
),
17+
) as AtlasBaseline;
18+
19+
test('built-in cat atlas preserves approved cells and uses brown closed-eye arcs', async ({
20+
page,
21+
}) => {
22+
await page.goto('/');
23+
24+
const result = await page.evaluate(async (protectedCells) => {
25+
const CELL_WIDTH = 192;
26+
const CELL_HEIGHT = 208;
27+
const COLUMNS = 8;
28+
const ROWS = 11;
29+
const CLOSED_EYE_RECTS = {
30+
'0/2-left': { row: 0, column: 2, x: 45, y: 48, width: 48, height: 42 },
31+
'0/2-right': { row: 0, column: 2, x: 99, y: 48, width: 48, height: 42 },
32+
'6/4-closed': { row: 6, column: 4, x: 42, y: 48, width: 52, height: 44 },
33+
// Source inspection places these arcs at y=94..106, not inside the
34+
// original y=48..89 estimate.
35+
'8/5-left': { row: 8, column: 5, x: 45, y: 82, width: 48, height: 42 },
36+
'8/5-right': { row: 8, column: 5, x: 105, y: 82, width: 48, height: 42 },
37+
} as const;
38+
39+
const response = await fetch('/assets/cat/neko-pause-cat.webp', {
40+
cache: 'no-store',
41+
});
42+
if (!response.ok) {
43+
throw new Error(`Failed to load cat atlas: ${response.status}`);
44+
}
45+
46+
const bitmap = await createImageBitmap(await response.blob());
47+
const canvas = document.createElement('canvas');
48+
canvas.width = bitmap.width;
49+
canvas.height = bitmap.height;
50+
const context = canvas.getContext('2d', { willReadFrequently: true });
51+
if (!context) {
52+
throw new Error('Canvas 2D context unavailable');
53+
}
54+
context.drawImage(bitmap, 0, 0);
55+
bitmap.close();
56+
57+
const hexDigest = async (pixels: Uint8ClampedArray) =>
58+
Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', pixels)))
59+
.map((value) => value.toString(16).padStart(2, '0'))
60+
.join('');
61+
62+
const occupancy: number[][] = [];
63+
const changedProtectedCells: string[] = [];
64+
for (let row = 0; row < ROWS; row += 1) {
65+
const occupancyRow: number[] = [];
66+
for (let column = 0; column < COLUMNS; column += 1) {
67+
const cell = context.getImageData(
68+
column * CELL_WIDTH,
69+
row * CELL_HEIGHT,
70+
CELL_WIDTH,
71+
CELL_HEIGHT,
72+
);
73+
let occupied = 0;
74+
for (let offset = 3; offset < cell.data.length; offset += 4) {
75+
if (cell.data[offset] !== 0) {
76+
occupied += 1;
77+
}
78+
}
79+
occupancyRow.push(occupied);
80+
81+
const key = `${row}/${column}`;
82+
const approvedHash = protectedCells[key];
83+
if (approvedHash) {
84+
const computedHash = await hexDigest(cell.data);
85+
if (computedHash !== approvedHash) {
86+
changedProtectedCells.push(key);
87+
}
88+
}
89+
}
90+
occupancy.push(occupancyRow);
91+
}
92+
93+
const isBrownArc = (red: number, green: number, blue: number, alpha: number) =>
94+
alpha >= 192 &&
95+
red >= 55 &&
96+
red <= 170 &&
97+
green >= 35 &&
98+
green <= 125 &&
99+
blue >= 20 &&
100+
blue <= 90 &&
101+
red > green &&
102+
green >= blue;
103+
104+
const inspectClosedEye = (rect: (typeof CLOSED_EYE_RECTS)[keyof typeof CLOSED_EYE_RECTS]) => {
105+
const pixels = context.getImageData(
106+
rect.column * CELL_WIDTH + rect.x,
107+
rect.row * CELL_HEIGHT + rect.y,
108+
rect.width,
109+
rect.height,
110+
).data;
111+
const brown = new Uint8Array(rect.width * rect.height);
112+
let lightFill = false;
113+
114+
for (let index = 0; index < brown.length; index += 1) {
115+
const offset = index * 4;
116+
const red = pixels[offset] ?? 0;
117+
const green = pixels[offset + 1] ?? 0;
118+
const blue = pixels[offset + 2] ?? 0;
119+
const alpha = pixels[offset + 3] ?? 0;
120+
brown[index] = Number(isBrownArc(red, green, blue, alpha));
121+
lightFill ||= alpha >= 192 && red >= 205 && green >= 205 && blue >= 205;
122+
}
123+
124+
let largestConnectedArc = 0;
125+
const visited = new Uint8Array(brown.length);
126+
for (let start = 0; start < brown.length; start += 1) {
127+
if (brown[start] === 0 || visited[start] === 1) {
128+
continue;
129+
}
130+
const stack = [start];
131+
visited[start] = 1;
132+
let connected = 0;
133+
while (stack.length > 0) {
134+
const index = stack.pop();
135+
if (index === undefined) {
136+
break;
137+
}
138+
connected += 1;
139+
const x = index % rect.width;
140+
const y = Math.floor(index / rect.width);
141+
const neighbors = [
142+
x > 0 ? index - 1 : -1,
143+
x + 1 < rect.width ? index + 1 : -1,
144+
y > 0 ? index - rect.width : -1,
145+
y + 1 < rect.height ? index + rect.width : -1,
146+
];
147+
for (const neighbor of neighbors) {
148+
if (
149+
neighbor >= 0 &&
150+
brown[neighbor] === 1 &&
151+
visited[neighbor] === 0
152+
) {
153+
visited[neighbor] = 1;
154+
stack.push(neighbor);
155+
}
156+
}
157+
}
158+
largestConnectedArc = Math.max(largestConnectedArc, connected);
159+
}
160+
161+
return {
162+
brownArc: largestConnectedArc >= 12,
163+
lightFill,
164+
};
165+
};
166+
167+
return {
168+
dimensions: { width: canvas.width, height: canvas.height },
169+
occupancy,
170+
changedProtectedCells,
171+
closedEyes: Object.fromEntries(
172+
Object.entries(CLOSED_EYE_RECTS).map(([key, rect]) => [
173+
key,
174+
inspectClosedEye(rect),
175+
]),
176+
),
177+
};
178+
}, baseline.protectedCells);
179+
180+
expect(result.dimensions).toEqual({ width: 1536, height: 2288 });
181+
expect(result.occupancy).toEqual(baseline.occupancy);
182+
expect(result.changedProtectedCells).toEqual([]);
183+
expect(result.closedEyes).toEqual({
184+
'0/2-left': { brownArc: true, lightFill: false },
185+
'0/2-right': { brownArc: true, lightFill: false },
186+
'6/4-closed': { brownArc: true, lightFill: false },
187+
'8/5-left': { brownArc: true, lightFill: false },
188+
'8/5-right': { brownArc: true, lightFill: false },
189+
});
190+
});

0 commit comments

Comments
 (0)