Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- `@botharness/pixel-avatar` 0.10.1: opt-in `speechMouthVersion: 2` replaces the round fully open speech mouth with a flatter, gently curved opening. Half-open, closed and saved layers stay identical; default/version-1 speech output, recipes and golden fixtures are unchanged.
- `@botharness/pixel-avatar` 0.10.0: animal species. `cat`, `dog`, `fox`, `rabbit` and `bear` (`AVATAR_ANIMAL_SPECIES`, asset version 4 only) draw a muzzle in a light fur tone, an animal nose instead of the human nose, and their own ears, with suggested fur colors in `AVATAR_SPECIES_SWATCHES`. The skin color is the fur color. An optional `pattern` (`solid`, `tabby`, `spots`, `patches`, `colorpoint`) is drawn in fur tones, so it follows recoloring, and the new `pattern` Custom Part slot (`patternPart`, asset version 4 only, so `PART_SLOTS` and `REPLACE_PART_SLOTS` gain `pattern`) replaces it with a drawn one. `pattern: 'solid'` canonicalizes to no pattern. On an animal, ear headpieces and ear accessories stay saved but are hidden (`hiddenChoices`). Version 2 seeds never pick an animal, so they are unchanged, as is every earlier recipe.
- `@botharness/pixel-avatar` 0.9.0: asset version 4, every hair piece colored and built-in headpieces. `bangsColor`, `backHairColor` and `strandColor` join the side hair colors, each defaulting to `hairColor`. A new single `strand` (`ahoge`, `curl`, `double`) stands up from the crown. The headpiece slot holds a built-in headpiece (`catears`, `bunnyears`, `horseears`, `horns`, `halo`, `wings`) or a Custom Part, alongside any accessory. A headpiece's base sits behind the hair, the rest in front, and the far side hides when turned. `withPieces` raises a recipe to version 4 and moves a headpiece accessory into the empty headpiece slot; call it only when the Human edits. `withBuiltInHeadpiece` and `builtInHeadpiece` set and read the built-in headpiece, and `headpieceStart` flattens the worn headpiece into a Custom Part (base on the back layer) that renders identically facing front. Every earlier recipe renders unchanged.
- `@botharness/pixel-avatar` 0.8.0: drawing tool algorithms for Custom Parts. `partLinePoints` (Bresenham, with 0°/45°/90° snapping), `partRectPoints` (outline, with a square option), `gradientPartLayer` (a linear gradient over the start cell's 4-connected region that steps through tones with 4×4 or 2×2 ordered dithering via `ditherThreshold`), `noisePartLayer` (seeded ±1 tone texture) and `shadePartLayer` (moves each colored cell on a stroke one tone, once). Every tool mirrors across the centerline, and each produces ordinary cells, so a part's content and identity never depend on the tool or the seed. Rendering is unchanged.
Expand Down
27 changes: 27 additions & 0 deletions docs/assets/pr/13/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Speech-mouth comparison

`mouth-comparison.png` and `mouth-comparison.webm` show the owning BotPixel build with preset 1 (the same saved face used for the real DSH Grace reading qualification). The static comparison is half-open / legacy full opening / version-2 full opening, at 96px and 192px. The continuous silent recording plays both versions at an identical fixed preview cadence.

This is an isolated source-generated preview, not a real DSH runtime or performance measurement. Only version 2's fully open layer changes: five pixels wide and two rows high, with the bottom row narrowing to three pixels. The horizontal center matches the old three-by-three opening. Half-open, closed, saved and default/version-1 output remain identical, including head-turn layers. No recipe or golden fixture migration is involved. Real DSH integration qualification is recorded separately when available.

## Actual DSH qualification

`dsh-before.png` / `dsh-after.png` and their continuous WebM recordings use the same isolated Profile and saved Grace face, Chinese/light, 1559×865, and identical complete new Bot-authored text. Host baseline is `e4926548`, integrated with main `78aba544`: Before uses formal pixel-avatar 0.5.0; After uses the local source candidate `e6f2c6c` with version 2 selected only for Companion rendering. Both real replies were verified canonically and all saved/closed/half-open/open states were observed (227 / 199 actual DOM samples). See `dsh-qualification.json`.

The candidate was packed with 0.5.0 metadata before preparing the 0.5.1 release; it was not published. Candidate Host typecheck and build pass on this latest-main baseline. An earlier attempt against old Host code exposed an avatar-editor type mismatch; latest main's existing 0.5 compatibility resolved it without a mouth-specific editor patch. Background history, exact speech frame, arrival times and walking positions differ: these screenshots show actual speech scenes, not a frame-matched isolated full-opening pair. The standalone comparison above supplies that exact shape view. Recordings are silent original official MCP output, remuxed without reencoding, interpolation or dubbing; they do not measure performance or qualify audio. Human review and formal downstream installation remain separate gates.

## Current 0.10.1 candidate — actual default-face speech and dismissal

Owner source ac9e25740e7ef33d1409b124d9bab23d16732ced is packed locally as0.10.1, unpublished. A fixed Host composition fc356883/main66d2767a selects speechMouthVersion2 only for Companion rendering in an uncommitted QA worktree. Typecheck, focused public renderer regression and build pass. The older Host preview's existing editor types reject the new package; no unrelated editor workaround is added to this change.

current-default-speaking-dark.png and current-default-restored-dark.png show actual DEFAULT_RECIPE speech and the saved face after a real header-close/Enter action. current-default-speech.webm(84.867s) observes all four real mouth states and verifies an exact canonical model reply; its source-navigation misclick cannot qualify dismissal. The separate current-default-dismiss.webm(83.433s) observes three speaking states, targets the exact native close control, removes the message and restores saved expression in the same document. The strict repetitive-text assertion timed out because the model's actual917-character reply differed from the request; a read-only postflight confirms that canonical Bot reply and unchanged saved recipe. Both original continuous recordings retain QA waiting; no stitching, synthetic events or forced animation. No console errors; owned browsers close.

See current-dsh-qualification.json for retained failures and scope. This is current-package DEFAULT_RECIPE runtime qualification, not a new formal installation, a frame-matched baseline comparison, performance evidence or visual acceptance of every new animal/hair/pattern combination. Human acceptance of the flatter shape and release approval remain open.

## Current candidate — actual stale feed and return

Fixed Host composition `fc356883` / main `66d2767a`, locally packed owning `ac9e257` / unpublished Avatar0.10.1 and Companion-only version-2 opt-in. A new exact3219-character canonical Bot reply drives partial reveal. A loopback forwarding fixture destroys/refuses only the actual Companion SSE; the installed Host/model and other routes remain live. All118 stale DOM samples show saved mouth, including a six-second hold. The existing committed text continues reveal; mouth feedback stays saved. Returning the actual feed resumes live speech on the same single card and document with the original text prefix retained; saved recipe is unchanged.

`current-speaking-before-cut.png`, `current-saved-during-stale.png`, `current-resumed-same-card.png` and `current-stale-return.webm` record this actual path. The continuous silent official MCP clip is 48.767seconds, container remux only. Both deliberate connection errors are retained in `current-stale-qualification.json`; owned browser and proxy closed.

An initial script incorrectly required already committed text to freeze during stale synchronization, beyond the specified voice/mouth cleanup. That failed report/video remain private and excluded; the corrected protocol changes no product code or timer. This is a narrow current candidate mouth lifecycle observation, not standalone PR1180, audible cleanup, genuine backgrounding, full-network outage, performance, every new asset, shape approval or a published package.
Binary file added docs/assets/pr/13/current-default-dismiss.webm
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/pr/13/current-default-speech.webm
Binary file not shown.
47 changes: 47 additions & 0 deletions docs/assets/pr/13/current-dsh-qualification.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"ownerCommit": "ac9e25740e7ef33d1409b124d9bab23d16732ced",
"hostBase": "fc356883152ac5d86b11993216084dbda60be4d4",
"hostMain": "66d2767a",
"hostChanges": "Uncommitted QA-only packed dependency and Companion-only speechMouthVersion 2",
"package": "0.10.1 local packed candidate, unpublished",
"dsh": "0.2.0-rc.1",
"face": "Current owning package DEFAULT_RECIPE, canonically saved through existing authenticated appearance command",
"viewport": { "width": 1559, "height": 865 },
"theme": "dark",
"language": "Chinese",
"firstClip": {
"durationSeconds": 84.867,
"domSamples": 170,
"realCanonicalBotReplyExactText": true,
"mouthStates": ["saved", "closed", "half-open", "open"],
"flatterOpenArtworkReadFromActualDom": true,
"liveRevealObserved": true,
"restoredSavedExpressionObserved": true,
"nativeCloseQualified": false
},
"secondClip": {
"durationSeconds": 83.433,
"realCanonicalBotReplyConfirmedInReadOnlyPostflight": true,
"canonicalReplyLength": 917,
"strictQaExpectedTextMatched": false,
"realHeaderCloseFocusedAndNativeEnterPressed": true,
"threeSpeakingStatesBeforeClose": true,
"pendingMessageRemoved": true,
"savedExpressionRestored": true,
"sameDocumentTimeOrigin": true,
"savedRecipeUnchangedBeforeAndAfter": true
},
"bothClips": {
"capture": "Continuous silent official Chrome DevTools MCP; VP9; container remux only",
"consoleErrors": 0,
"ownedBrowsersClosed": true
},
"retainedFailuresAndLimits": [
"First recording's QA selector focused source navigation, not header close. Its restoration does not qualify a native dismissal. Its final read-only check used non-existing detail and received404; corrected get postflight proves saved recipe unchanged.",
"The first exact-dismiss setup refused to interrupt a live daemon. Inspection found only an owned blank page; it was closed before the separate retry. No message was sent by that failed setup.",
"The second recording proves native dismissal and saved restoration. Its strict text wait timed out: the model's actual917-character canonical reply differed from the requested repetitive text. A separate read-only postflight established the canonical Bot reply; neither the message nor the failed result was rewritten.",
"Both clips retain their original continuous duration, including QA waiting; they are not stitched together or called entirely passing scripts.",
"This runtime qualification covers DEFAULT_RECIPE only. New animal, hair and pattern combinations are source-regression covered, not visually qualified here.",
"No synthetic SSE, forced mouth state, recipe migration, timing interpolation, added audio, publication, formal downstream dependency installation or performance claim. Human shape acceptance and explicit release approval remain separate."
]
}
Binary file added docs/assets/pr/13/current-resumed-same-card.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/pr/13/current-saved-during-stale.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
65 changes: 65 additions & 0 deletions docs/assets/pr/13/current-stale-qualification.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
{
"ownerCommit": "ac9e25740e7ef33d1409b124d9bab23d16732ced",
"hostBase": "fc356883152ac5d86b11993216084dbda60be4d4",
"hostMain": "66d2767a",
"package": "0.10.1 locally packed, unpublished; Companion-only version2 QA opt-in",
"dsh": "0.2.0-rc.1",
"viewport": {
"width": 1559,
"height": 865
},
"language": "Chinese",
"theme": "dark",
"capture": "Continuous silent official Chrome DevTools MCP; container remux only",
"durationSeconds": 48.767,
"actualCanonicalReply": {
"length": 3219,
"sha256": "8d04f411ab5602e806115a4c2e404a0bdf096190ecf150b20b0c059a3bcdc36d",
"exactRequestedText": true
},
"actualSharedCompanionStreamsDestroyed": 1,
"domSamples": 236,
"staleSamples": 118,
"staleMouthStates": ["saved"],
"staleObservationMilliseconds": 6859.699999988079,
"beforeCut": {
"sync": "live",
"mouthStates": ["closed"],
"cards": 1,
"revealedCharacters": 193
},
"firstStale": {
"sync": "stale",
"mouthStates": ["saved"],
"cards": 1,
"revealedCharacters": 286
},
"staleHeld": {
"sync": "stale",
"mouthStates": ["saved"],
"cards": 1,
"revealedCharacters": 404
},
"recovered": {
"sync": "live",
"mouthStates": ["half-open"],
"cards": 1,
"revealedCharacters": 454
},
"sameDocumentTimeOrigin": true,
"originalCardPrefixRetained": true,
"oneCardThroughout": true,
"savedRecipeUnchanged": true,
"expectedConnectionErrors": [
"Failed to load resource: net::ERR_INCOMPLETE_CHUNKED_ENCODING",
"Failed to load resource: net::ERR_EMPTY_RESPONSE"
],
"ownedBrowserClosed": true,
"proxyClosed": true,
"limits": [
"Only the actual Companion SSE is cut/refused through an authenticated loopback forwarding hop; other installed Host routes and model remain live. Not full-network outage or genuine backgrounding.",
"Current locally packed unpublished candidate in the fixed integrated Host composition; does not certify standalone PR1180 or formal dependency adoption.",
"Already committed text continues local reveal during stale synchronization; saved mouth is retained. The original failed test incorrectly required text freezing; its original report and video are preserved privately and excluded from the complete verdict. No product change or synthetic stale/mouth event.",
"Not audible voice-cleanup, fast-drag/throw listening, performance, every avatar asset combination, Human shape acceptance or release approval."
]
}
Binary file added docs/assets/pr/13/current-stale-return.webm
Binary file not shown.
Binary file added docs/assets/pr/13/dsh-after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/pr/13/dsh-after.webm
Binary file not shown.
Binary file added docs/assets/pr/13/dsh-before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/pr/13/dsh-before.webm
Binary file not shown.
35 changes: 35 additions & 0 deletions docs/assets/pr/13/dsh-qualification.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"capture": "Continuous silent official Chrome DevTools MCP in real isolated DSH0.2.0-rc.1; remux only, no reencoding",
"proofs": [
{
"variant": "before",
"hostCode": "e4926548",
"pixelAvatar": "formal 0.5.0",
"actualCanonicalBotReply": true,
"bodyLength": 420,
"samples": 227,
"mouthStates": ["saved", "closed", "half-open", "open"],
"viewport": [1559, 865],
"locale": "zh-CN",
"theme": "light"
},
{
"variant": "after",
"hostCode": "e4926548",
"pixelAvatar": "local e6f2c6c candidate with speechMouthVersion2",
"actualCanonicalBotReply": true,
"bodyLength": 420,
"samples": 199,
"mouthStates": ["saved", "closed", "half-open", "open"],
"viewport": [1559, 865],
"locale": "zh-CN",
"theme": "light"
}
],
"limits": [
"No synthetic SSE, DOM mouth forcing or Session rewrites",
"Same Profile, face, exact text, viewport and theme; arrivals, speech frame, background history and roaming position are not pixel-matched",
"The installed candidate was built from e6f2c6c with package metadata0.5.0, before preparing0.5.1; no npm publication is implied",
"Not audio or performance qualification"
]
}
Binary file added docs/assets/pr/13/mouth-comparison.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/pr/13/mouth-comparison.webm
Binary file not shown.
1 change: 1 addition & 0 deletions packages/avatar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ const svg = pixelAvatarSvg(recipe); // 32×32 viewBox, crisp edges, no ids or sc
- `AVATAR_ANIMAL_SPECIES`, `AVATAR_PATTERNS`, `isAnimalSpecies`: animal species in asset version 4. Each has a muzzle, an animal nose and its own ears, uses the skin color as fur, and takes an optional fur `pattern` drawn in fur tones. A drawn `pattern` Custom Part replaces the built-in one. Ear headpieces and ear accessories are kept but hidden on an animal.
- `faceCells`, `pixelSymbolCells`, `symbolArtCells`: pixels for [`@botharness/pixel-morph`](../morph).
- `pixelAvatarSvg(recipe, { turns, classPrefix })`: optional pre-rendered head turns, and rig layer classes (`<prefix>-body`, `-head`, `-face`, `-gaze`, `-blink`) for CSS animation.
- `pixelAvatarSvg(recipe, { mouthLayers: true, speechMouthVersion: 2 })`: saved/closed/half-open/open speech layers, with a flatter fully open mouth. Omit `speechMouthVersion` (or use `1`) for the original round opening. Version 2 only changes the open layer, including each head turn; the accepted half-open, closed and saved face remain identical. This is a rendering-only speech artwork version, not a stored recipe migration. `pixelFigure` takes the same options. Text pacing and layer opacity stay with the consumer.
2 changes: 1 addition & 1 deletion packages/avatar/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@botharness/pixel-avatar",
"version": "0.10.0",
"version": "0.10.1",
"description": "Seeded, editable 32×32 chibi pixel avatars with state symbols that morph in and out",
"keywords": [
"agent-ui",
Expand Down
3 changes: 2 additions & 1 deletion packages/avatar/src/figure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export type PixelGrid = Grid;
export type PixelMouthState = 'saved' | 'closed' | 'half-open' | 'open';
export interface PixelFigureOptions {
mouthLayers?: boolean;
speechMouthVersion?: 1 | 2;
}
const blank = (): Grid => Array.from({ length: N }, () => Array<Cell>(N).fill(undefined));
const ellipse =
Expand Down Expand Up @@ -1463,7 +1464,7 @@ function renderFigure(
['saved', MOUTHS[recipe.mouth]],
['closed', ['KKK']],
['half-open', ['KKK', 'MMM']],
['open', ['.K.', 'KMK', '.K.']],
['open', options.speechMouthVersion === 2 ? ['KMMMK', '.MMM.'] : ['.K.', 'KMK', '.K.']],
] satisfies readonly [PixelMouthState, readonly string[]][]
)
.map(
Expand Down
5 changes: 2 additions & 3 deletions packages/avatar/src/svg.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import type { PixelCell } from '@botharness/pixel-morph';
import { pixelFigure } from './figure.js';
import { pixelFigure, type PixelFigureOptions } from './figure.js';
import { isPixelAvatarRecipe, type PixelAvatarRecipe } from './recipe.js';

const YAW = { front: 0, left: -25, right: 25 } as const;

/** Head-turn offsets (degrees) the thinking animation cycles through. */
export const AVATAR_TURNS = [-14, -7, 7, 14] as const;

export interface PixelAvatarSvgOptions {
mouthLayers?: boolean;
export interface PixelAvatarSvgOptions extends PixelFigureOptions {
/** Extra yaw offsets to pre-render as hidden `[data-avatar-turn]` layers. */
turns?: readonly number[];
/** Class prefix for the rig layers (`-body`, `-head`, `-face`, `-gaze`, `-blink`). */
Expand Down
32 changes: 32 additions & 0 deletions packages/avatar/test/speech.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { describe, expect, it } from 'vitest';
import {
AVATAR_ANIMAL_SPECIES,
AVATAR_PARTS,
AVATAR_PATTERNS,
AVATAR_PRESETS,
AVATAR_TURNS,
DEFAULT_RECIPE,
detailedRecipe,
pixelAvatarSvg,
withSpecies,
type PixelAvatarRecipe,
} from '../src/index.js';

Expand Down Expand Up @@ -41,4 +44,33 @@ describe('optional speech mouth rig', () => {
expect(JSON.stringify(recipe)).toBe(snapshot);
}
});

it('opts into a flatter full opening without changing the accepted speech layers or saved artwork', () => {
const recipes = [
DEFAULT_RECIPE,
...AVATAR_PRESETS,
detailedRecipe(DEFAULT_RECIPE),
...AVATAR_ANIMAL_SPECIES.flatMap((species) =>
AVATAR_PATTERNS.map((pattern) => ({ ...withSpecies(DEFAULT_RECIPE, species), pattern })),
),
];
for (const recipe of recipes) {
const options = { turns: AVATAR_TURNS, mouthLayers: true };
const original = pixelAvatarSvg(recipe, options);
const legacy = pixelAvatarSvg(recipe, { ...options, speechMouthVersion: 1 });
const natural = pixelAvatarSvg(recipe, { ...options, speechMouthVersion: 2 });
expect(legacy).toBe(original);
expect(natural).not.toBe(original);
const withoutOpen = (svg: string) =>
svg.replace(/<g data-avatar-mouth="open" opacity="0">.*?<\/g>/gu, '');
expect(withoutOpen(natural)).toBe(withoutOpen(original));
expect(pixelAvatarSvg(recipe, { speechMouthVersion: 2 })).toBe(pixelAvatarSvg(recipe));
}
const svg = pixelAvatarSvg(DEFAULT_RECIPE, { mouthLayers: true, speechMouthVersion: 2 });
const open = svg.match(/<g data-avatar-mouth="open" opacity="0">(.*?)<\/g>/u)![1]!;
expect(open).toContain('<rect x="14" y="20" width="1" height="1" fill="#7a2a38"/>');
expect(open).toContain('<rect x="15" y="20" width="3" height="1" fill="#b8415a"/>');
expect(open).toContain('<rect x="15" y="21" width="3" height="1" fill="#b8415a"/>');
expect(open).not.toContain('y="22"');
});
});
Loading