From 2cdd1246ed76a7985d5aebcd1acf7c133fafc5d7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Jul 2026 08:59:51 -0400 Subject: [PATCH] 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 --- ui/src/components/lobster-pet-contract.ts | 11 +- ui/src/components/lobster-pet-look.ts | 70 ++++----- ui/src/components/lobster-pet-lore.ts | 59 +++++++ ui/src/components/lobster-pet-plans.ts | 6 +- ui/src/components/lobster-pet-sprites.ts | 98 ++++++++++++ ui/src/components/lobster-pet.test.ts | 50 +++++- ui/src/i18n/locales/en.ts | 2 + ui/src/pages/lobsterdex/lobsterdex-page.ts | 71 ++++++++- ui/src/pages/lobsterdex/view.test.ts | 8 +- ui/src/pages/lobsterdex/view.ts | 47 +++++- ui/src/styles/lobster-pet.css | 173 ++++++++++++++++++++- 11 files changed, 533 insertions(+), 62 deletions(-) diff --git a/ui/src/components/lobster-pet-contract.ts b/ui/src/components/lobster-pet-contract.ts index 97b7a9e4dc37..6368843ece13 100644 --- a/ui/src/components/lobster-pet-contract.ts +++ b/ui/src/components/lobster-pet-contract.ts @@ -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. diff --git a/ui/src/components/lobster-pet-look.ts b/ui/src/components/lobster-pet-look.ts index c961ee98a8c7..31ee9daa5b4f 100644 --- a/ui/src/components/lobster-pet-look.ts +++ b/ui/src/components/lobster-pet-look.ts @@ -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 = 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 = { 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} ${ - look.palette.id === "retro" + hasRetroGeometry ? nothing : svg`` } @@ -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} - + ${look.palette.id === "invisible" ? nothing : svg``} @@ -402,18 +392,18 @@ export function renderLobsterSvg( ` } ${ - look.palette.id === "retro" + hasRetroGeometry ? svg` ${RETRO_FACE} ${RETRO_MEGA_CLAW} ` : 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} diff --git a/ui/src/components/lobster-pet-lore.ts b/ui/src/components/lobster-pet-lore.ts index 2503e5c481ae..896752506d23 100644 --- a/ui/src/components/lobster-pet-lore.ts +++ b/ui/src/components/lobster-pet-lore.ts @@ -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> = { 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 = { @@ -68,7 +112,13 @@ export const LOBSTER_PALETTE_LORE: Record = new Set([ "clawtron", "selene", "pixel", + "banana", + "bee", + "rubberduck", + "sourdough", + "zombie", + "plush", + "disco", + "invisible", + "goldenretro", ]); +const BANANA_MARKS = svg` + + + + + + + + +`; + +const BEE_PARTS = svg` + + + + + + + + + +`; + +const DUCK_BILL = svg` + + + + + +`; + +const SOURDOUGH_SCORING = svg` + + + + + + + + + + +`; + +const ZOMBIE_STITCHES = svg` + + + + + + + +`; + +const PLUSH_SEAMS = svg` + + + + + + + + + +`; + +const DISCO_FACETS = svg` + + + + + + + + + +`; + +export const PALETTE_OVERLAYS: Partial> = { + 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` @@ -285,6 +382,7 @@ export const RETRO_MEGA_CLAW = svg` /> { 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"); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index e742d0f8cab6..0e8f6e28c54e 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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: { diff --git a/ui/src/pages/lobsterdex/lobsterdex-page.ts b/ui/src/pages/lobsterdex/lobsterdex-page.ts index 13bd39cde782..22798f196289 100644 --- a/ui/src/pages/lobsterdex/lobsterdex-page.ts +++ b/ui/src/pages/lobsterdex/lobsterdex-page.ts @@ -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(`#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 => { + 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`
${titleForRoute("lobsterdex")}
- ${renderSettingsWorkspace(renderLobsterdex(getLobsterdexEntries()))} + ${renderSettingsWorkspace( + renderLobsterdex(getLobsterdexEntries(), { + copiedPaletteId: this.copiedPaletteId, + onCopyLink: (paletteId) => void this.copyLink(paletteId), + }), + )} `; } } diff --git a/ui/src/pages/lobsterdex/view.test.ts b/ui/src/pages/lobsterdex/view.test.ts index 723daa0cdae6..c8eb7c7cc50d 100644 --- a/ui/src/pages/lobsterdex/view.test.ts +++ b/ui/src/pages/lobsterdex/view.test.ts @@ -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("?"); diff --git a/ui/src/pages/lobsterdex/view.ts b/ui/src/pages/lobsterdex/view.ts index 5b60e6f8f02c..87c4576ee1b1 100644 --- a/ui/src/pages/lobsterdex/view.ts +++ b/ui/src/pages/lobsterdex/view.ts @@ -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; -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` -
+
+

${name}

${seen ? lore.flavor : lore.hint}

- ${firstSeen - ? html`

` - : nothing} +
+ ${firstSeen + ? html`

` + : nothing} + ${shinySeen + ? html`

` + : nothing} +
`; })} diff --git a/ui/src/styles/lobster-pet.css b/ui/src/styles/lobster-pet.css index 09d134759d05..06ec4b89c280 100644 --- a/ui/src/styles/lobster-pet.css +++ b/ui/src/styles/lobster-pet.css @@ -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;