feat(ui): grow the Lobsterdex to 37 palettes with a golden mythic, deep links, and shiny dates (#115179)

* feat(ui): grow the Lobsterdex to 37 palettes with a golden mythic, deep links, and shiny dates

* fix(ui): keep wave-2 lobster overlays module-local
This commit is contained in:
Peter Steinberger
2026-07-28 08:59:51 -04:00
committed by GitHub
parent 0b628237e9
commit 2cdd1246ed
11 changed files with 533 additions and 62 deletions
+10 -1
View File
@@ -20,19 +20,28 @@ export type LobsterPetPaletteId =
| "oilslick"
| "aurora"
| "nebula"
| "banana"
| "mood"
| "bee"
| "rubberduck"
| "clawtron"
| "selene"
| "geode"
| "ghost"
| "glass"
| "split"
| "sourdough"
| "zombie"
| "plush"
| "cottoncandy"
| "disco"
| "pixel"
| "blueprint"
| "phosphor"
| "heisenbug"
| "retro";
| "invisible"
| "retro"
| "goldenretro";
// Pass-through ledge visitors. Strangers are other lobsters; everyone else
// is, at best, lobster-adjacent. None of them count for the Lobsterdex.
+30 -40
View File
@@ -14,7 +14,7 @@ import type {
LobsterPetPaletteId,
LobsterPetPersonalityId,
} from "./lobster-pet-contract.ts";
import { lobsterPaletteName } from "./lobster-pet-lore.ts";
import { lobsterPaletteName, lobsterRandomName } from "./lobster-pet-lore.ts";
import { moonPhaseFraction } from "./lobster-pet-moon.ts";
import {
ACCESSORY_SPRITES,
@@ -35,6 +35,7 @@ import {
MECHA_PLATES,
NEBULA_STARS,
OILSLICK_SHEEN,
PALETTE_OVERLAYS,
PASSER_SPRITES,
PASSER_TITLES,
PATTERNED_PALETTES,
@@ -74,26 +75,39 @@ const PALETTES: Array<[LobsterPetPalette, number]> = [
[{ id: "oilslick", shell: "#15171d", claw: "#23262e" }, 2],
[{ id: "aurora", shell: "#dce6f0", claw: "#e9f0f7" }, 2],
[{ id: "nebula", shell: "#34255c", claw: "#4a3a7d" }, 2],
[{ id: "banana", shell: "#f7e27d", claw: "#f3d55b" }, 2],
// CSS custom properties are the palette color contract, so accent vars stay
// intact through every look renderer rather than being parsed as hex values.
[{ id: "mood", shell: "var(--accent, #7f77dd)", claw: "var(--accent-hover, #9a93e8)" }, 1.5],
[{ id: "bee", shell: "#f4c531", claw: "#2b2b23" }, 1.5],
[{ id: "rubberduck", shell: "#ffd93b", claw: "#ffb03b" }, 1.5],
[{ id: "clawtron", shell: "#8d99a6", claw: "#a2aeba" }, 1],
[{ id: "selene", shell: "#c9ced8", claw: "#d8dde5" }, 1],
[{ id: "geode", shell: "#6b6474", claw: "#7d7588" }, 1],
[{ id: "ghost", shell: "#dce8f2", claw: "#ecf3fa" }, 1],
[{ id: "glass", shell: "#cfe4f4", claw: "#e0eef8" }, 1],
[{ id: "split", shell: "#ff4f40", claw: "#ff775f" }, 1],
[{ id: "sourdough", shell: "#d9a662", claw: "#e6bc82" }, 1],
[{ id: "zombie", shell: "#9db08a", claw: "#86a17a" }, 1],
[{ id: "plush", shell: "#e8967a", claw: "#f2b09a" }, 1],
// Pastel pink/blue iridescence, after the famous Maine catches.
[{ id: "cottoncandy", shell: "#f6a8c9", claw: "#a5c6f0" }, 0.8],
[{ id: "disco", shell: "#b8c4d8", claw: "#cbd5e6" }, 0.8],
[{ id: "pixel", shell: "#d84c3e", claw: "#ef8f6a" }, 0.7],
[{ id: "blueprint", shell: "#123a66", claw: "#123a66" }, 0.7],
[{ id: "phosphor", shell: "#0d2415", claw: "#0f2b19" }, 0.7],
[{ id: "heisenbug", shell: "#262a33", claw: "#343945" }, 0.6],
// CSS values, rather than parsed colors, are the palette contract. Like
// mood's var() colors, translucent rgba() values must pass through intact.
[{ id: "invisible", shell: "rgba(127,140,160,0.07)", claw: "rgba(127,140,160,0.07)" }, 0.55],
// The grail: homage to the classic OpenClaw logo (big raised claw, smirk,
// angry brows, white sticker outline). ~0.5% of sessions.
[{ id: "retro", shell: "#e8262c", claw: "#f04a3e" }, 0.5],
[{ id: "goldenretro", shell: "#e8b422", claw: "#f6cf5a" }, 0.1],
];
const RETRO_GEOMETRY_PALETTES: ReadonlySet<LobsterPetPaletteId> = new Set(["retro", "goldenretro"]);
// Catalog order for collection UIs (Lobsterdex): common to grail.
export const LOBSTER_PET_PALETTES: readonly LobsterPetPalette[] = PALETTES.map(
([palette]) => palette,
@@ -195,40 +209,9 @@ const LOBSTER_PET_CLAW_MULS: Record<LobsterPetClawSize, number> = {
mighty: 1.18,
};
// Seeded pet names; rare palettes carry signature names. Shown via the
// sprite's native title tooltip, so no i18n surface.
const PET_NAMES = [
"Pinchy",
"Barnaby",
"Thermidor",
"Clawdette",
"Sheldon",
"Scuttles",
"Bisque",
"Crusty",
"Snips",
"Bubbles",
"Clawdia",
"Ferdinand",
"Maple",
"Pearl",
"Biscuit",
"Captain",
"Ziggy",
"Noodle",
"Waffles",
"Pippin",
"Squirt",
"Chip",
"Clementine",
"Moss",
] as const;
export function lobsterPetName(look: LobsterPetLook, seed: number): string {
const signatureName = lobsterPaletteName(look.palette.id);
return signatureName !== look.palette.id
? signatureName
: expectDefined(PET_NAMES[(seed >>> 3) % PET_NAMES.length], "lobster pet name catalog entry");
return signatureName !== look.palette.id ? signatureName : lobsterRandomName(seed);
}
// A stranger wears a different palette than the resident pet.
@@ -319,7 +302,12 @@ export function createLobsterPetLook(seed: number, now: Date = new Date()): Lobs
};
// The LED rides the perky antenna tip. Keep the original antenna roll above
// so adding Clawtron does not shift any later seeded trait.
const preparedLook = palette.id === "clawtron" ? { ...look, antennae: "perky" as const } : look;
let preparedLook = palette.id === "clawtron" ? { ...look, antennae: "perky" as const } : look;
// The undead do not do perky. Preserve the antenna roll above so later
// seeded traits stay aligned, then enforce the identity at the end.
if (palette.id === "zombie") {
preparedLook = { ...look, antennae: "droopy" };
}
if (isLobsterAnniversary(now)) {
// Birthday dress code: everyone is the classic logo, party hats on.
const retro = PALETTES.find(([entry]) => entry.id === "retro")?.[0];
@@ -342,6 +330,7 @@ export function renderLobsterSvg(
} = {},
) {
const isPixel = look.palette.id === "pixel";
const hasRetroGeometry = RETRO_GEOMETRY_PALETTES.has(look.palette.id);
const openEyeStyle = options.shell || options.sleeping ? "display:none" : "";
const closedEyeStyle =
options.shell || options.sleeping ? "opacity:1" : options.standalone ? "display:none" : "";
@@ -358,13 +347,13 @@ export function renderLobsterSvg(
isPixel
? PIXEL_LOBSTER(openEyeStyle, closedEyeStyle)
: svg`
${look.palette.id === "retro" ? RETRO_ANTENNAE : ANTENNAE_SPRITES[look.antennae]}
${hasRetroGeometry ? RETRO_ANTENNAE : ANTENNAE_SPRITES[look.antennae]}
${look.tailFan ? TAIL_FAN : nothing}
<g class="lob-claw lob-claw--l">
<path d="M20 42 C5 37 0 47 5 57 C10 67 20 62 25 52 C28 45 25 42 20 42 Z" fill="var(--lob-claw)" />
</g>
${
look.palette.id === "retro"
hasRetroGeometry
? nothing
: svg`<g class="lob-claw lob-claw--r"><path d="M100 42 C115 37 120 47 115 57 C110 67 100 62 95 52 C92 45 95 42 100 42 Z" fill="var(--lob-claw)" /></g>`
}
@@ -383,8 +372,9 @@ export function renderLobsterSvg(
${look.palette.id === "blueprint" ? BLUEPRINT_MARKS : nothing}
${look.palette.id === "clawtron" ? MECHA_PLATES : nothing}
${look.palette.id === "selene" ? SELENE_MOON(selenePhase) : nothing}
${PALETTE_OVERLAYS[look.palette.id] ?? nothing}
${look.freckles && !PATTERNED_PALETTES.has(look.palette.id) ? FRECKLE_SPOTS : nothing}
<ellipse cx="48" cy="28" rx="20" ry="11" fill="#ffffff" opacity="0.1" />
${look.palette.id === "invisible" ? nothing : svg`<ellipse cx="48" cy="28" rx="20" ry="11" fill="#ffffff" opacity="0.1" />`}
<g class="lob-eye-open" style=${openEyeStyle}>
<circle cx="45" cy="32" r="5.5" fill="#0a1014" />
<circle cx="75" cy="32" r="5.5" fill="#0a1014" />
@@ -402,18 +392,18 @@ export function renderLobsterSvg(
`
}
${
look.palette.id === "retro"
hasRetroGeometry
? svg`
${RETRO_FACE}
<g class="lob-claw lob-claw--r">${RETRO_MEGA_CLAW}</g>
`
: nothing
}
${options.grumpy && look.palette.id !== "retro" ? GRUMPY_FACE : nothing}
${options.grumpy && !hasRetroGeometry ? GRUMPY_FACE : nothing}
${look.accessory === "none" || options.shell ? nothing : ACCESSORY_SPRITES[look.accessory]}
${
// The retro grail's mega claw owns the same shoulder; it moves light.
options.bindle && look.palette.id !== "retro" ? BINDLE : nothing
options.bindle && !hasRetroGeometry ? BINDLE : nothing
}
${options.sailorCap && !options.shell && !HEADWEAR.has(look.accessory) ? SAILOR_CAP : nothing}
</g>
+59
View File
@@ -1,3 +1,4 @@
import { expectDefined } from "@openclaw/normalization-core";
import type { LobsterPetPaletteId } from "./lobster-pet-contract.ts";
type LobsterPaletteLore = {
@@ -16,25 +17,68 @@ const RARE_NAMES: Partial<Record<LobsterPetPaletteId, string>> = {
oilslick: "Slick",
aurora: "Borealis",
nebula: "Cosmo",
banana: "Peel",
mood: "Ringo",
bee: "Buzz",
rubberduck: "Debuggy",
clawtron: "Clawtron",
selene: "Selene",
geode: "Amethyst",
ghost: "Boo",
glass: "Prism",
split: "Picasso",
sourdough: "Boule",
zombie: "Shambles",
plush: "Buttons",
cottoncandy: "Taffy",
disco: "Boogie",
blueprint: "Prototype",
phosphor: "TTY",
heisenbug: "Segfault",
invisible: "Nobody",
pixel: "Sprite",
retro: "OG",
goldenretro: "24K",
};
const PET_NAMES = [
"Pinchy",
"Barnaby",
"Thermidor",
"Clawdette",
"Sheldon",
"Scuttles",
"Bisque",
"Crusty",
"Snips",
"Bubbles",
"Clawdia",
"Ferdinand",
"Maple",
"Pearl",
"Biscuit",
"Captain",
"Ziggy",
"Noodle",
"Waffles",
"Pippin",
"Squirt",
"Chip",
"Clementine",
"Moss",
] as const;
export function lobsterPaletteName(paletteId: LobsterPetPaletteId): string {
return RARE_NAMES[paletteId] ?? paletteId;
}
export function lobsterRandomName(seed: number): string {
return expectDefined(
PET_NAMES[(seed >>> 3) % PET_NAMES.length],
"lobster pet name catalog entry",
);
}
// Like pet names and bottle fortunes, Lobsterdex lore is an English-only
// easter-egg channel rather than product UI copy.
export const LOBSTER_PALETTE_LORE: Record<LobsterPetPaletteId, LobsterPaletteLore> = {
@@ -68,7 +112,13 @@ export const LOBSTER_PALETTE_LORE: Record<LobsterPetPaletteId, LobsterPaletteLor
flavor: "Not from any ocean on this planet.",
hint: "Came from very far away.",
},
banana: { flavor: "Banana. For scale.", hint: "For scale." },
mood: { flavor: "Wears whatever color you're feeling.", hint: "Matches your vibe." },
bee: { flavor: "Technically not a bee.", hint: "Do not tell the bees." },
rubberduck: {
flavor: "Listens to your bugs. Judges silently.",
hint: "Quack.",
},
clawtron: { flavor: "60% rivets, 40% love.", hint: "Beep boop snip." },
selene: { flavor: "Carries the current moon on its belly.", hint: "Waxes and wanes." },
geode: { flavor: "Rock outside, amethyst inside.", hint: "Crack the surface." },
@@ -78,10 +128,17 @@ export const LOBSTER_PALETTE_LORE: Record<LobsterPetPaletteId, LobsterPaletteLor
hint: "Easy to miss, hard to forget.",
},
split: { flavor: "Two lobsters, one shell.", hint: "Can't pick a side." },
sourdough: {
flavor: "Started from a starter. 24-hour proof.",
hint: "Still proofing.",
},
zombie: { flavor: "Slightly used. Still friendly.", hint: "It's been better." },
plush: { flavor: "Machine washable. Air dry only.", hint: "Soft to the touch." },
cottoncandy: {
flavor: "Pastel, like the famous Maine catch.",
hint: "Sweeter than it looks.",
},
disco: { flavor: "Born under a mirror ball.", hint: "The night is still young." },
blueprint: {
flavor: "Final hardware pending approval.",
hint: "Still on the drawing board.",
@@ -91,9 +148,11 @@ export const LOBSTER_PALETTE_LORE: Record<LobsterPetPaletteId, LobsterPaletteLor
flavor: "Only renders right when nobody's watching.",
hint: "Cannot be reproduced.",
},
invisible: { flavor: "It's right there. Look closer.", hint: "Trust me, it exists." },
pixel: { flavor: "Rendered in 1987. Still runs.", hint: "Insert coin." },
retro: {
flavor: "The original: big claw, no apologies.",
hint: "The one that started it all.",
},
goldenretro: { flavor: "One in a generation.", hint: "Worth the wait." },
};
+3 -3
View File
@@ -260,8 +260,8 @@ export type LobsterLoadIdentity = {
};
// Rare per-load identities, resolved on top of the seeded look: the Elder
// outranks an old-friend return, and retro looks (grail or anniversary dress
// code) are never repainted. Lobsterdex completion is snapshotted here too,
// outranks an old-friend return, and retro-geometry looks (grail or anniversary
// dress code) are never repainted. Lobsterdex completion is snapshotted here too,
// so the golden ledge trim appears between loads, never mid-visit.
export function resolveLobsterLoadIdentity(
seed: number,
@@ -291,7 +291,7 @@ export function resolveLobsterLoadIdentity(
},
};
}
if (look.palette.id === "retro") {
if (look.palette.id === "retro" || look.palette.id === "goldenretro") {
return base;
}
const known = [...seen]
+98
View File
@@ -250,8 +250,105 @@ export const PATTERNED_PALETTES: ReadonlySet<LobsterPetPaletteId> = new Set([
"clawtron",
"selene",
"pixel",
"banana",
"bee",
"rubberduck",
"sourdough",
"zombie",
"plush",
"disco",
"invisible",
"goldenretro",
]);
const BANANA_MARKS = svg`
<g fill="#8a6430">
<rect x="56" y="8" width="8" height="6" rx="2.5" />
<ellipse cx="60" cy="91" rx="6" ry="3.5" />
<circle cx="37" cy="48" r="2.5" />
<circle cx="79" cy="55" r="2" />
<circle cx="47" cy="70" r="1.8" />
<circle cx="72" cy="79" r="2.3" />
</g>
`;
const BEE_PARTS = svg`
<g class="lob-bee-wings" fill="#ffffff" opacity="0.45">
<ellipse cx="38" cy="14" rx="8" ry="4.5" transform="rotate(-24 38 14)" />
<ellipse cx="82" cy="14" rx="8" ry="4.5" transform="rotate(24 82 14)" />
</g>
<g fill="#2b2b23" opacity="0.9">
<path d="M19 42 Q60 51 101 42 L103 50 Q60 60 17 50 Z" />
<path d="M17 58 Q60 67 103 58 L101 67 Q60 76 19 67 Z" />
<path d="M24 76 Q60 84 96 76 L90 85 Q60 92 30 85 Z" />
</g>
`;
const DUCK_BILL = svg`
<g>
<ellipse cx="60" cy="71" rx="21" ry="14" fill="#ffffff" opacity="0.5" />
<rect x="47" y="41" width="26" height="8" rx="4" fill="#ff9a2e" />
<rect x="50" y="47" width="20" height="5" rx="2.5" fill="#e98322" />
</g>
`;
const SOURDOUGH_SCORING = svg`
<g fill="none" stroke="#a8763e" stroke-width="2.5" stroke-linecap="round">
<path d="M38 23 Q45 29 52 30" />
<path d="M52 17 Q59 24 66 25" />
<path d="M67 18 Q74 24 81 25" />
</g>
<g fill="#ffffff" opacity="0.5">
<circle cx="34" cy="39" r="1.2" /><circle cx="86" cy="38" r="1" />
<circle cx="45" cy="57" r="1.4" /><circle cx="74" cy="62" r="1.1" />
<circle cx="56" cy="78" r="1" /><circle cx="83" cy="75" r="1.3" />
</g>
`;
const ZOMBIE_STITCHES = svg`
<g fill="none" stroke="#5a6b52" stroke-width="2" stroke-linecap="round">
<path d="M32 24 Q47 19 61 23" />
<path d="M38 19 L40 26 M46 18 L47 25 M54 19 L53 26" />
<path d="M57 72 Q72 78 87 72" />
<path d="M65 72 L63 79 M73 73 L72 80 M81 71 L83 78" />
</g>
<ellipse cx="35" cy="61" rx="9" ry="6" fill="#86987a" opacity="0.8" transform="rotate(-18 35 61)" />
`;
const PLUSH_SEAMS = svg`
<g fill="none" stroke="#c97a5e" stroke-width="1.5" stroke-dasharray="3 3">
<path d="M30 32 Q60 4 90 32" />
<path d="M60 50 Q58 72 60 96" />
</g>
<g class="lob-plush-button">
<circle cx="78" cy="44" r="3.5" fill="#7a4a3a" />
<circle cx="76.8" cy="44" r="0.7" fill="#e8967a" />
<circle cx="79.2" cy="44" r="0.7" fill="#e8967a" />
</g>
`;
const DISCO_FACETS = svg`
<g class="lob-disco" fill="#ffffff">
<rect x="42" y="18" width="4" height="4" opacity="0.3" /><rect x="52" y="16" width="4" height="4" opacity="0.5" />
<rect x="63" y="17" width="4" height="4" opacity="0.25" /><rect x="74" y="20" width="4" height="4" opacity="0.4" />
<rect x="31" y="40" width="4" height="4" opacity="0.4" /><rect x="53" y="39" width="4" height="4" opacity="0.25" />
<rect x="65" y="42" width="4" height="4" opacity="0.5" /><rect x="86" y="40" width="4" height="4" opacity="0.3" />
<rect x="39" y="59" width="4" height="4" opacity="0.25" /><rect x="51" y="62" width="4" height="4" opacity="0.45" />
<rect x="68" y="60" width="4" height="4" opacity="0.3" /><rect x="80" y="58" width="4" height="4" opacity="0.5" />
<rect x="49" y="79" width="4" height="4" opacity="0.35" /><rect x="70" y="80" width="4" height="4" opacity="0.25" />
</g>
`;
export const PALETTE_OVERLAYS: Partial<Record<LobsterPetPaletteId, TemplateResult>> = {
banana: BANANA_MARKS,
bee: BEE_PARTS,
rubberduck: DUCK_BILL,
sourdough: SOURDOUGH_SCORING,
zombie: ZOMBIE_STITCHES,
plush: PLUSH_SEAMS,
disco: DISCO_FACETS,
};
// Calico mottling: dark blotches scattered clear of the eye line.
export const CALICO_SPOTS = svg`
<g class="lob-spots" fill="#2a1f16" opacity="0.8">
@@ -285,6 +382,7 @@ export const RETRO_MEGA_CLAW = svg`
/>
<path
d="M92 14 C97 22 99 31 95 41"
class="lob-retro-claw-line"
stroke="#b8151b"
stroke-width="3"
stroke-linecap="round"
+45 -5
View File
@@ -41,19 +41,28 @@ const LOBSTER_PET_PALETTE_IDS: LobsterPetPaletteId[] = [
"oilslick",
"aurora",
"nebula",
"banana",
"mood",
"bee",
"rubberduck",
"clawtron",
"selene",
"geode",
"ghost",
"glass",
"split",
"sourdough",
"zombie",
"plush",
"cottoncandy",
"disco",
"pixel",
"blueprint",
"phosphor",
"heisenbug",
"invisible",
"retro",
"goldenretro",
];
const SPOT_ZONES = { left: [12, 38], right: [60, 84] } as const;
@@ -218,7 +227,7 @@ describe("lobster pet look", () => {
for (const id of LOBSTER_PET_PALETTE_IDS) {
expect(counts.get(id) ?? 0).toBeGreaterThan(0);
}
// Exact catalog weights total 123.5. Every one-point-or-lower palette is
// Exact catalog weights total 132.95. Every one-point-or-lower palette is
// below 1%, with enough tolerance here for the deterministic sample.
for (const grail of [
"clawtron",
@@ -227,20 +236,31 @@ describe("lobster pet look", () => {
"ghost",
"glass",
"split",
"sourdough",
"zombie",
"plush",
"cottoncandy",
"disco",
"pixel",
"blueprint",
"phosphor",
"heisenbug",
"invisible",
"retro",
"goldenretro",
]) {
expect(counts.get(grail) ?? 0).toBeLessThan(total * 0.018);
}
const goldenRetroCount = counts.get("goldenretro") ?? 0;
const retroCount = counts.get("retro") ?? 0;
for (const paletteId of LOBSTER_PET_PALETTE_IDS.filter((candidate) => candidate !== "retro")) {
expect(goldenRetroCount).toBeLessThan(retroCount);
for (const paletteId of LOBSTER_PET_PALETTE_IDS.filter(
(candidate) => candidate !== "retro" && candidate !== "goldenretro",
)) {
expect(retroCount).toBeLessThan(counts.get(paletteId) ?? 0);
}
expect((counts.get("crimson") ?? 0) + (counts.get("coral") ?? 0)).toBeGreaterThan(total * 0.4);
// The two common palettes own 52 / 132.95 of the expanded weight pool.
expect((counts.get("crimson") ?? 0) + (counts.get("coral") ?? 0)).toBeGreaterThan(total * 0.38);
// Shinies exist and stay near their 1-in-512 odds.
expect(shinies).toBeGreaterThan(0);
expect(shinies).toBeLessThan(total * 0.006);
@@ -273,6 +293,16 @@ describe("lobster pet look", () => {
expect(clawtron?.antennae).toBe("perky");
});
it("keeps zombies' antennae droopy", () => {
// Pinned date: on the anniversary every palette repaints retro and no
// zombie could ever be found.
const neutralDate = new Date("2026-07-15T12:00:00");
const zombie = Array.from({ length: 20_000 }, (_, seed) =>
createLobsterPetLook(seed, neutralDate),
).find((look) => look.palette.id === "zombie");
expect(zombie?.antennae).toBe("droopy");
});
it("derives distinct salted seeds per session key, stable within a load", () => {
expect(lobsterPetSeed("agent:a:main")).toBe(lobsterPetSeed("agent:a:main"));
expect(lobsterPetSeed("agent:a:main")).not.toBe(lobsterPetSeed("agent:b:other"));
@@ -919,6 +949,16 @@ describe("lobster plans", () => {
expect(friend.oldFriend).toBe(true);
expect(friend.look.palette.id).toBe("gold");
expect(friend.friendName).toBe("Goldenrod");
const goldenRetro = expectDefined(
LOBSTER_PET_PALETTES.find((palette) => palette.id === "goldenretro"),
"golden retro palette",
);
const grail = resolveLobsterLoadIdentity(191, {
...createLobsterPetLook(191, neutralDate),
palette: goldenRetro,
});
expect(grail.oldFriend).toBe(false);
expect(grail.look.palette.id).toBe("goldenretro");
// A seed whose friend roll misses stays a fresh stranger.
expect(identityOf(42).oldFriend).toBe(false);
});
@@ -944,7 +984,7 @@ describe("lobster plans", () => {
describe("rare lobster loads", () => {
// Probe seeds (deterministic per stream): 644 hosts the Elder; 191 rolls
// an old-friend return plus a balloon entrance; 105715 hatches a shiny lumen;
// an old-friend return plus a balloon entrance; 4689 hatches a shiny lumen;
// 104 is a shy load that beaches a bottle at ~194s; 37 is a shy load with
// a snail crossing at ~407s.
it("hosts the Elder: barnacled, renamed, and never molting", async () => {
@@ -996,7 +1036,7 @@ describe("rare lobster loads", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
vi.stubGlobal("localStorage", window.localStorage);
const element = createPet(105_715);
const element = createPet(4_689);
await arrive(element);
expect(spriteClasses(element)).toContain("lobster-pet--shiny");
+2
View File
@@ -1171,6 +1171,8 @@ export const en: TranslationMap = {
lobsterdexSeen: "{seen}/{total} visited",
lobsterdexFirstVisited: "{name} · first visited {date}",
lobsterdexCardFirstVisited: "First visited {date}",
lobsterdexCardShinySeen: "✦ Shiny spotted {date}",
lobsterdexCardCopyLink: "Copy link",
lobsterdexOpen: "Open Lobsterdex",
},
security: {
+70 -1
View File
@@ -1,17 +1,86 @@
import { html } from "lit";
import { state } from "lit/decorators.js";
import { titleForRoute } from "../../app-navigation.ts";
import { getLobsterdexEntries } from "../../components/lobster-dex.ts";
import type { LobsterPetPaletteId } from "../../components/lobster-pet-contract.ts";
import { LOBSTER_PET_PALETTES } from "../../components/lobster-pet.ts";
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { renderLobsterdex } from "./view.ts";
class LobsterdexPage extends OpenClawLightDomElement {
@state() private copiedPaletteId: LobsterPetPaletteId | null = null;
private copyResetTimer: number | null = null;
override disconnectedCallback(): void {
if (this.copyResetTimer !== null) {
window.clearTimeout(this.copyResetTimer);
this.copyResetTimer = null;
}
super.disconnectedCallback();
}
protected override firstUpdated(): void {
const hashPrefix = "#lobsterdex-";
if (!location.hash.startsWith(hashPrefix)) {
return;
}
const palette = LOBSTER_PET_PALETTES.find(
(entry) => entry.id === location.hash.slice(hashPrefix.length),
);
if (!palette) {
return;
}
const card = this.querySelector<HTMLElement>(`#lobsterdex-${palette.id}`);
if (!card) {
return;
}
const clearHighlight = (event: AnimationEvent) => {
// Palette animations bubble through the card too; only its own pulse
// owns this transient deep-link marker.
if (event.target !== card || event.animationName !== "lobsterdex-card-highlight") {
return;
}
card.classList.remove("lobsterdex-page__card--highlight");
card.removeEventListener("animationend", clearHighlight);
};
card.addEventListener("animationend", clearHighlight);
card.classList.add("lobsterdex-page__card--highlight");
// Double rAF: the workspace shell finishes layout after first render, and
// scrolling immediately leaves the target beyond the settled viewport.
requestAnimationFrame(() => {
requestAnimationFrame(() => card.scrollIntoView({ block: "center" }));
});
}
private readonly copyLink = async (paletteId: LobsterPetPaletteId): Promise<void> => {
const url = `${location.origin}${location.pathname}#lobsterdex-${paletteId}`;
try {
await navigator.clipboard.writeText(url);
} catch {
return;
}
this.copiedPaletteId = paletteId;
if (this.copyResetTimer !== null) {
window.clearTimeout(this.copyResetTimer);
}
this.copyResetTimer = window.setTimeout(() => {
this.copiedPaletteId = null;
this.copyResetTimer = null;
}, 1_500);
};
override render() {
return html`
<section class="content-header">
<div class="page-title">${titleForRoute("lobsterdex")}</div>
</section>
${renderSettingsWorkspace(renderLobsterdex(getLobsterdexEntries()))}
${renderSettingsWorkspace(
renderLobsterdex(getLobsterdexEntries(), {
copiedPaletteId: this.copiedPaletteId,
onCopyLink: (paletteId) => void this.copyLink(paletteId),
}),
)}
`;
}
}
+7 -1
View File
@@ -19,9 +19,10 @@ describe("renderLobsterdex", () => {
const container = document.createElement("div");
render(renderLobsterdex(entries), container);
expect(container.querySelector(".lobsterdex-page__count")?.textContent).toBe("1/28 visited");
expect(container.querySelector(".lobsterdex-page__count")?.textContent).toBe("1/37 visited");
const seen = container.querySelector(".lobster-pet--palette-crimson")?.closest("article");
expect(seen?.id).toBe("lobsterdex-crimson");
expect(seen?.querySelector("h3")?.textContent).toBe("Ruby");
expect(seen?.querySelector(".lobsterdex-page__lore")?.textContent).toBe(
"The classic red, first in every tide pool.",
@@ -29,7 +30,12 @@ describe("renderLobsterdex", () => {
expect(seen?.querySelector(".lobsterdex-page__date")?.textContent).toContain(
new Date(firstSeenAt).toLocaleDateString("en"),
);
expect(seen?.querySelectorAll(".lobsterdex-page__date")).toHaveLength(2);
expect(seen?.querySelector(".lobsterdex-page__dates")?.textContent).toContain(
`✦ Shiny spotted ${new Date(firstSeenAt).toLocaleDateString("en")}`,
);
expect(seen?.querySelector(".lobsterdex-page__star")).not.toBeNull();
expect(seen?.querySelector('button[aria-label="Copy link"]')).not.toBeNull();
const unseen = container.querySelector(".lobster-pet--palette-coral")?.closest("article");
expect(unseen?.querySelector("h3")?.textContent).toBe("?");
+41 -6
View File
@@ -1,4 +1,6 @@
import { html, nothing } from "lit";
import { icons } from "../../components/icons.ts";
import type { LobsterPetPaletteId } from "../../components/lobster-pet-contract.ts";
import { LOBSTER_PALETTE_LORE } from "../../components/lobster-pet-lore.ts";
import {
LOBSTER_PET_PALETTES,
@@ -17,7 +19,16 @@ type LobsterdexViewEntry = {
type LobsterdexViewEntries = ReadonlyMap<string, LobsterdexViewEntry>;
export function renderLobsterdex(entries: LobsterdexViewEntries) {
type LobsterdexViewProps = {
copiedPaletteId?: LobsterPetPaletteId | null;
onCopyLink?: (paletteId: LobsterPetPaletteId) => void;
};
function formatLobsterdexDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString(i18n.getLocale());
}
export function renderLobsterdex(entries: LobsterdexViewEntries, props: LobsterdexViewProps = {}) {
const seenCount = LOBSTER_PET_PALETTES.filter((palette) => entries.has(palette.id)).length;
const complete = seenCount === LOBSTER_PET_PALETTES.length;
const countLabel = t("quickSettings.appearance.lobsterdexSeen", {
@@ -44,11 +55,30 @@ export function renderLobsterdex(entries: LobsterdexViewEntries) {
const firstSeen =
seen && entry.firstSeenAt !== null
? t("quickSettings.appearance.lobsterdexCardFirstVisited", {
date: new Date(entry.firstSeenAt).toLocaleDateString(i18n.getLocale()),
date: formatLobsterdexDate(entry.firstSeenAt),
})
: null;
const shinySeen =
entry?.shinySeenAt != null
? t("quickSettings.appearance.lobsterdexCardShinySeen", {
date: formatLobsterdexDate(entry.shinySeenAt),
})
: null;
return html`
<article class="lobsterdex-page__card ${seen ? "" : "lobsterdex-page__card--unseen"}">
<article
id="lobsterdex-${palette.id}"
class="lobsterdex-page__card ${seen ? "" : "lobsterdex-page__card--unseen"}"
>
<button
type="button"
class="lobsterdex-page__copy-link"
aria-label=${t("quickSettings.appearance.lobsterdexCardCopyLink")}
@click=${() => props.onCopyLink?.(palette.id)}
>
<span aria-hidden="true"
>${props.copiedPaletteId === palette.id ? icons.check : icons.link}</span
>
</button>
<div
class="lobsterdex-page__sprite lobster-pet lobster-pet--palette-${palette.id} ${seen
? ""
@@ -66,9 +96,14 @@ export function renderLobsterdex(entries: LobsterdexViewEntries) {
</div>
<h3>${name}</h3>
<p class="lobsterdex-page__lore">${seen ? lore.flavor : lore.hint}</p>
${firstSeen
? html`<p class="lobsterdex-page__date"><time>${firstSeen}</time></p>`
: nothing}
<div class="lobsterdex-page__dates">
${firstSeen
? html`<p class="lobsterdex-page__date"><time>${firstSeen}</time></p>`
: nothing}
${shinySeen
? html`<p class="lobsterdex-page__date"><time>${shinySeen}</time></p>`
: nothing}
</div>
</article>
`;
})}
+168 -5
View File
@@ -154,6 +154,22 @@ openclaw-lobster-pet[data-dex-complete]::after {
--lob-glint: #8fa8ff;
}
.lobster-pet--palette-sourdough {
--lob-glint: #e6bc82;
}
.lobster-pet--palette-zombie {
--lob-glint: #b8d49a;
}
.lobster-pet--palette-plush {
--lob-glint: #ffb59e;
}
.lobster-pet--palette-disco {
--lob-glint: #cbd5e6;
}
.lobster-pet--palette-blueprint .lob-antennae path {
stroke: #cfe3ff;
stroke-dasharray: 4 3;
@@ -304,6 +320,54 @@ openclaw-lobster-pet[data-dex-complete]::after {
animation: lobster-pet-lumen 3.2s ease-in-out infinite;
}
.lob-bee-wings ellipse {
transform-box: fill-box;
transform-origin: center;
animation: lobster-pet-bee-wing-left 0.25s ease-in-out infinite alternate;
}
.lob-bee-wings ellipse:nth-child(2) {
animation-name: lobster-pet-bee-wing-right;
}
@keyframes lobster-pet-bee-wing-left {
from {
transform: rotate(-30deg);
}
to {
transform: rotate(-16deg);
}
}
@keyframes lobster-pet-bee-wing-right {
from {
transform: rotate(16deg);
}
to {
transform: rotate(30deg);
}
}
.lob-disco {
animation: lobster-pet-disco-sweep 6s linear infinite;
}
.lob-disco rect:nth-child(3n + 1) {
transform-box: fill-box;
transform-origin: center;
animation: lobster-pet-twinkle 2.4s ease-in-out infinite;
}
.lob-disco rect:nth-child(3n + 4) {
animation-delay: 0.8s;
}
@keyframes lobster-pet-disco-sweep {
to {
filter: hue-rotate(360deg);
}
}
:root[data-theme-mode="light"] .lob-lumen {
filter: none;
opacity: 0.3;
@@ -342,9 +406,10 @@ openclaw-lobster-pet[data-dex-complete]::after {
}
}
/* Shiny: saturated sheen plus twinkling sparks. The filter skips retro so
the sticker-outline halo survives; sparkle spans carry the show there. */
.lobster-pet--shiny:not(.lobster-pet--palette-retro) .lobster-pet__svg {
/* Shiny: saturated sheen plus twinkling sparks. The filter skips both retro
palettes so their sticker-outline halos survive; sparkle spans carry the show. */
.lobster-pet--shiny:not(.lobster-pet--palette-retro):not(.lobster-pet--palette-goldenretro)
.lobster-pet__svg {
filter: saturate(1.3) brightness(1.07);
}
@@ -376,11 +441,35 @@ openclaw-lobster-pet[data-dex-complete]::after {
/* Retro: homage to the classic OpenClaw logo — white sticker outline around
the whole sprite (the halo is two stacked 1px drop-shadows). */
.lobster-pet--palette-retro .lobster-pet__svg {
.lobster-pet--palette-retro .lobster-pet__svg,
.lobster-pet--palette-goldenretro .lobster-pet__svg {
filter: drop-shadow(0 0 1.2px #f5f7fa) drop-shadow(0 0 1.2px #f5f7fa)
drop-shadow(0 0 1.2px #f5f7fa);
}
.lobster-pet--palette-goldenretro .lobster-pet__svg {
animation:
lobster-pet-breathe 3.4s ease-in-out infinite,
lobster-pet-goldenretro-shimmer 4s ease-in-out infinite;
}
.lobster-pet--palette-goldenretro .lob-retro-claw-line {
stroke: #b8860b;
}
@keyframes lobster-pet-goldenretro-shimmer {
0%,
100% {
filter: saturate(1) brightness(1) drop-shadow(0 0 1.2px #f5f7fa) drop-shadow(0 0 1.2px #f5f7fa)
drop-shadow(0 0 1.2px #f5f7fa) drop-shadow(0 0 2px rgba(244, 184, 64, 0.75));
}
50% {
filter: saturate(1.25) brightness(1.12) drop-shadow(0 0 1.2px #f5f7fa)
drop-shadow(0 0 1.2px #f5f7fa) drop-shadow(0 0 1.2px #f5f7fa)
drop-shadow(0 0 2px rgba(244, 184, 64, 0.75));
}
}
/* Split two-tone: the right body half (drawn in lobster-pet.ts) plus the
right claw and antenna wear the second shell color. */
.lobster-pet--palette-split {
@@ -1138,6 +1227,10 @@ openclaw-lobster-pet[data-dex-complete]::after {
.lob-heisenbug-frame,
.lob-led,
.lob-pixel-frame,
.lob-bee-wings ellipse,
.lob-disco,
.lob-disco rect,
.lobster-pet--palette-goldenretro .lobster-pet__svg,
.lobster-pet--palette-phosphor .lobster-pet__svg {
animation: none !important;
}
@@ -1429,7 +1522,7 @@ openclaw-lobster-pet[data-dex-complete]::after {
.lobsterdex-page__card {
position: relative;
display: grid;
grid-template-rows: 94px auto minmax(42px, auto) 18px;
grid-template-rows: 94px auto minmax(42px, auto) auto;
gap: 7px;
justify-items: center;
min-width: 0;
@@ -1441,6 +1534,24 @@ openclaw-lobster-pet[data-dex-complete]::after {
text-align: center;
}
.lobsterdex-page__card--highlight {
animation: lobsterdex-card-highlight 2s ease-out;
}
@keyframes lobsterdex-card-highlight {
0%,
100% {
border-color: var(--border);
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.08);
}
35% {
border-color: var(--accent);
box-shadow:
0 0 0 3px color-mix(in srgb, var(--accent) 26%, transparent),
0 0 22px color-mix(in srgb, var(--accent) 34%, transparent);
}
}
.lobsterdex-page__card--unseen {
border-style: dashed;
box-shadow: none;
@@ -1474,6 +1585,58 @@ openclaw-lobster-pet[data-dex-complete]::after {
font-size: 10px;
}
.lobsterdex-page__dates {
min-height: 18px;
}
.lobsterdex-page__copy-link {
position: absolute;
top: 8px;
left: 8px;
display: grid;
width: 26px;
height: 26px;
padding: 5px;
border: 0;
border-radius: 7px;
place-items: center;
color: var(--muted);
background: color-mix(in srgb, var(--card) 82%, transparent);
opacity: 0;
pointer-events: none;
cursor: pointer;
transition:
opacity 0.16s ease,
color 0.16s ease,
background 0.16s ease;
}
.lobsterdex-page__copy-link svg {
width: 15px;
height: 15px;
}
.lobsterdex-page__card:hover .lobsterdex-page__copy-link,
.lobsterdex-page__card:focus-within .lobsterdex-page__copy-link {
opacity: 1;
pointer-events: auto;
}
/* Touch screens have no hover to reveal the control; keep it tappable in a
quieter resting state instead of unreachable. */
@media (hover: none) {
.lobsterdex-page__copy-link {
opacity: 0.55;
pointer-events: auto;
}
}
.lobsterdex-page__copy-link:hover,
.lobsterdex-page__copy-link:focus-visible {
color: var(--text-strong);
background: color-mix(in srgb, var(--accent) 13%, var(--card));
}
.lobsterdex-page__star {
top: -1px;
right: 1px;