fix(ui): keep composer critters proportionate and grounded (#147750)

Remove stretched resting body builds, preserve the shared SVG aspect ratio, and soften hop deformation around a foot-anchored pivot. Keep visitor odds and remaining seeded traits unchanged.

Co-authored-by: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
This commit is contained in:
RoboClaw
2026-09-13 23:40:27 -07:00
committed by GitHub
co-authored by roboclaw-bot steipete
parent cde7fa0068
commit 705500c6fa
6 changed files with 65 additions and 39 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ This is normal. This is OpenClaw.
## What you are looking at
Each new composer opening gets its own cast: color, size, build, claw proportions, accessories, and personality stay stable while you type or change controls. Some are round, some are tall, some have claws that are frankly too big for them. Some nap constantly. Some never sit still. One of them waves a lot.
Each new composer opening gets its own cast: color, size, claw proportions, accessories, and personality stay stable while you type or change controls. Their bodies keep their natural proportions; some are small, some are large, and some have claws that are frankly too big for them. Some nap constantly. Some never sit still. One of them waves a lot.
Hover over a visitor and it will tell you its name.
@@ -79,8 +79,6 @@ export type LobsterPetAccessory =
export type LobsterPetAntennae = "perky" | "droopy";
export type LobsterPetBuild = "round" | "squat" | "slender";
export type LobsterPetClawSize = "dainty" | "regular" | "mighty";
export type LobsterPetLook = {
@@ -93,7 +91,6 @@ export type LobsterPetLook = {
facing: 1 | -1;
personality: LobsterPetPersonalityId;
blinkDelayS: number;
build: LobsterPetBuild;
clawSize: LobsterPetClawSize;
tailFan: boolean;
// Pokemon-style shiny roll (~1 in 512): sparkles plus a saturated sheen,
+4 -24
View File
@@ -5,7 +5,6 @@ import { fnv1aUtf16 } from "../lib/fnv1a.ts";
import type {
LobsterPetAccessory,
LobsterPetAntennae,
LobsterPetBuild,
LobsterPetClawSize,
LobsterPetLook,
LobsterPetPalette,
@@ -70,7 +69,6 @@ export function canonicalLobsterLook(palette: LobsterPetPalette): LobsterPetLook
facing: 1,
personality: "friendly",
blinkDelayS: (paletteHash % 36) / 10,
build: "round",
clawSize: "regular",
tailFan: false,
shiny: false,
@@ -128,27 +126,12 @@ const SCALES: Array<[number, number]> = [
[2.5, 20],
];
const BUILDS: Array<[LobsterPetBuild, number]> = [
["round", 40],
["squat", 30],
["slender", 30],
];
const CLAW_SIZES: Array<[LobsterPetClawSize, number]> = [
["regular", 55],
["dainty", 25],
["mighty", 20],
];
// Builds reshape the whole sprite by stretching its aspect ratio (the svg
// renders with preserveAspectRatio="none"), so eyes, claws, accessories, and
// rare-variant geometry stay aligned for every silhouette.
const LOBSTER_PET_BUILD_MULS: Record<LobsterPetBuild, { w: number; h: number }> = {
round: { w: 1, h: 1 },
squat: { w: 1.14, h: 0.94 },
slender: { w: 0.88, h: 1.1 },
};
const LOBSTER_PET_CLAW_MULS: Record<LobsterPetClawSize, number> = {
dainty: 0.85,
regular: 1,
@@ -203,9 +186,9 @@ export function createLobsterPetLook(seed: number, now: Date = new Date()): Lobs
const facing = rng() < 0.5 ? 1 : -1;
const personality = pickWeighted(rng, PERSONALITY_IDS);
const blinkDelayS = Math.round(randomBetween(rng, 0, 4) * 10) / 10;
// Trait generations append their rolls (shape, then sparkle) so earlier
// seeds keep their palette/personality and only gain new details.
const build = pickWeighted(rng, BUILDS);
// Retain the former body-build draw so removing stretched silhouettes does
// not reroll the remaining seeded traits, including shiny and claw choices.
rng();
const clawSize = pickWeighted(rng, CLAW_SIZES);
const tailFan = rng() < 0.3;
const shiny = rng() < 1 / 512;
@@ -232,7 +215,6 @@ export function createLobsterPetLook(seed: number, now: Date = new Date()): Lobs
facing,
personality,
blinkDelayS,
build,
clawSize,
tailFan,
shiny,
@@ -322,7 +304,7 @@ export function renderLobsterSvg(
<svg
class="lobster-pet__svg"
viewBox="0 0 120 105"
preserveAspectRatio="none"
preserveAspectRatio="xMidYMax meet"
aria-hidden="true"
>
<g class=${PALETTE_FRAME_CLASSES[look.palette.id] ?? ""}>
@@ -446,8 +428,6 @@ function lobsterLookStyleVars(look: LobsterPetLook): string[] {
`--lob-claw:${bodyDonorClaw ?? look.palette.claw}`,
`--lob-blink-delay:${look.blinkDelayS}s`,
`--lob-breathe-delay:-${breatheDelayS}s`,
`--lob-w:${LOBSTER_PET_BUILD_MULS[look.build].w}`,
`--lob-h:${LOBSTER_PET_BUILD_MULS[look.build].h}`,
`--lob-claw-l:${clawMul("left")}`,
`--lob-claw-r:${clawMul("right")}`,
...(look.chimeraParts
@@ -65,7 +65,6 @@ describe("lobster pet variants", () => {
it("stays within the variant catalog for many seeds", () => {
const palettes = new Set<string>();
const personalities = new Set<string>();
const builds = new Set<string>();
const clawSizes = new Set<string>();
const tailFans = new Set<boolean>();
const crusherSides = new Set<string | null>();
@@ -76,7 +75,6 @@ describe("lobster pet variants", () => {
const look = createLobsterPetLook(seed, neutralDate);
palettes.add(look.palette.id);
personalities.add(look.personality);
builds.add(look.build);
clawSizes.add(look.clawSize);
tailFans.add(look.tailFan);
crusherSides.add(look.crusherSide);
@@ -86,7 +84,6 @@ describe("lobster pet variants", () => {
expect([1.7, 2, 2.5]).toContain(look.scale);
expect(["none", "crown", "sprout", "patch"]).toContain(look.accessory);
expect(["perky", "droopy"]).toContain(look.antennae);
expect(["round", "squat", "slender"]).toContain(look.build);
expect(["dainty", "regular", "mighty"]).toContain(look.clawSize);
expect([null, "left", "right"]).toContain(look.crusherSide);
expect([null, "#ffd166", "#ff8ac2", "#b79bff"]).toContain(look.glint);
@@ -96,7 +93,6 @@ describe("lobster pet variants", () => {
}
expect(palettes.size).toBeGreaterThan(2);
expect(personalities.size).toBeGreaterThan(2);
expect(builds.size).toBe(3);
expect(clawSizes.size).toBe(3);
expect(tailFans.size).toBe(2);
expect(crusherSides).toContain(null);
+52
View File
@@ -115,6 +115,31 @@ suite.define(() => {
await expect.poll(() => page.locator(".lobster-pet--act-pet").count()).toBe(0);
});
it("keeps resting visitors proportionate and aligned with their perch", async () => {
for (const seed of [0, 42, 77, 1234]) {
await configureComposerPet({ mode: "offline", outcome: "ok", seed });
await page.clock.runFor(1600);
const shape = await page.locator("openclaw-lobster-pet").evaluate((pet) => {
for (const animation of pet.getAnimations({ subtree: true })) {
animation.pause();
animation.currentTime = Number(animation.effect?.getTiming().delay) || 0;
}
const sprite = pet.querySelector(
".lobster-pet__motion > .lobster-pet:not(.lobster-pet--twin)",
)!;
const svg = sprite.querySelector<SVGSVGElement>(".lobster-pet__svg")!;
const matrix = svg.getScreenCTM()!;
const foot = new DOMPoint(60, 105).matrixTransform(matrix);
return {
aspect: Math.hypot(matrix.a, matrix.b) / Math.hypot(matrix.c, matrix.d),
footOffset: foot.y - sprite.getBoundingClientRect().bottom,
};
});
expect(shape.aspect).toBeCloseTo(1, 2);
expect(Math.abs(shape.footOffset)).toBeLessThan(0.2);
}
});
it("uses the composer ledge and floor, then clears the floor as soon as typing starts", async () => {
await configureComposerPet({ mode: "offline", outcome: "ok", seed: 42 });
expect(await page.locator("openclaw-app-sidebar openclaw-lobster-pet").count()).toBe(0);
@@ -153,6 +178,33 @@ suite.define(() => {
};
});
expect(hop).toEqual({ spot: "floor", hops: 1 });
const landings = await pet.evaluate((element) => {
const sprite = element.querySelector<HTMLElement>(
".lobster-pet__motion > .lobster-pet:not(.lobster-pet--twin)",
)!;
const body = sprite.querySelector<HTMLElement>(".lobster-pet__body")!;
const animation = body
.getAnimations()
.find((entry) => (entry as CSSAnimation).animationName === "lobster-pet-landing")!;
animation.pause();
const frames = [0.14, 0.86].map((fraction) => {
animation.currentTime = Number(animation.effect!.getTiming().duration) * fraction;
const box = body.getBoundingClientRect();
const parent = sprite.getBoundingClientRect();
return {
width: box.width / parent.width,
height: box.height / parent.height,
footOffset: box.bottom - parent.bottom,
};
});
animation.play();
return frames;
});
for (const landing of landings) {
expect(landing.width).toBeLessThanOrEqual(1.06);
expect(landing.height).toBeGreaterThanOrEqual(0.939);
expect(Math.abs(landing.footOffset)).toBeLessThan(0.2);
}
await page.clock.runFor(1300);
await page.screenshot({ path: suite.artifactDir + "/floor-visit.png", animations: "disabled" });
const textarea = page.locator(".new-session-page__message");
+8 -7
View File
@@ -31,6 +31,7 @@ openclaw-lobster-pet .lobster-pet {
}
.lobster-pet__motion--hop .lobster-pet .lobster-pet__body {
transform-origin: 50% 100%;
animation: lobster-pet-landing var(--lob-travel-ms) ease-in-out both;
animation-delay: var(--lob-act-delay, 0s);
}
@@ -69,19 +70,19 @@ openclaw-lobster-pet .lobster-pet {
transform: scale(1);
}
14% {
transform: scale(1.12, 0.82);
transform: scale(1.03, 0.96);
}
38% {
transform: scale(0.92, 1.1) rotate(-4deg);
transform: scale(0.98, 1.03) rotate(-4deg);
}
70% {
transform: scale(0.96, 1.05) rotate(3deg);
transform: scale(0.99, 1.02) rotate(3deg);
}
86% {
transform: scale(1.16, 0.78);
transform: scale(1.04, 0.94);
}
94% {
transform: scale(0.98, 1.04);
transform: scale(0.99, 1.02);
}
}
@@ -100,8 +101,8 @@ openclaw-lobster-pet .lobster-pet--away {
position: absolute;
bottom: 0;
left: var(--lob-x, 80%);
/* Build multipliers stretch the sprite box; the svg renders with
preserveAspectRatio="none", so the silhouette follows the box. */
/* Lobsters keep the artwork's proportions; other species supply their
own fixed sprite dimensions through --lob-w and --lob-h. */
width: calc(16px * var(--lob-scale, 2) * var(--lob-w, 1));
height: calc(14px * var(--lob-scale, 2) * var(--lob-h, 1));
transform: translateX(-50%);