mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
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:
committed by
GitHub
parent
0b628237e9
commit
2cdd1246ed
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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." },
|
||||
};
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
)}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("?");
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
})}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user