Add unified pet status bubbles
This commit is contained in:
parent
aa21ed41ce
commit
13d525fb87
4 changed files with 150 additions and 42 deletions
|
|
@ -3,13 +3,16 @@ import { BrowserWindow } from "electron";
|
|||
import { getAppStateSnapshot } from "./app-state.js";
|
||||
import { defaultPetWindowSize, getDefaultPetInitialPosition } from "./display.js";
|
||||
import { transientDisplayMs, type OpenPetsReaction } from "./local-ipc-protocol.js";
|
||||
import { clearTransientReaction, createAgentPetWindow, getTransientReactionAnimationMs, loadExplicitPetContent, mergePetTransientDisplay, setPetReactionState, type PetTransientDisplay } from "./pet-window.js";
|
||||
import { clearTransientReaction, createAgentPetWindow, getTransientReactionAnimationMs, loadExplicitPetContent, mergePetTransientDisplay, setPetReactionState, type PetStatusBadgeReaction, type PetTransientDisplay } from "./pet-window.js";
|
||||
|
||||
const agentPetWindows = new Map<string, BrowserWindow>();
|
||||
const transientDisplays = new Map<string, PetTransientDisplay>();
|
||||
const statusBadges = new Map<string, PetStatusBadgeReaction>();
|
||||
const transientTimers = new Map<string, NodeJS.Timeout>();
|
||||
const transientAnimationTimers = new Map<string, NodeJS.Timeout>();
|
||||
const statusBadgeTimers = new Map<string, NodeJS.Timeout>();
|
||||
const dismissedAgentPets = new Set<string>();
|
||||
const busyStatusBadgeMs = 120_000;
|
||||
|
||||
export function showAgentPet(petId: string): boolean {
|
||||
if (dismissedAgentPets.has(petId)) return false;
|
||||
|
|
@ -49,6 +52,7 @@ export function applyAgentPetReaction(petId: string, reaction: OpenPetsReaction)
|
|||
}
|
||||
|
||||
export function applyAgentPetSay(petId: string, message: string, reaction?: OpenPetsReaction): { readonly shown: boolean; readonly reason?: string } {
|
||||
if (!reaction) clearStatusBadge(petId);
|
||||
setAgentDisplay(petId, { message, reaction });
|
||||
const shown = showAgentPet(petId);
|
||||
return shown ? { shown } : { shown, reason: "dismissed" };
|
||||
|
|
@ -63,7 +67,7 @@ export function closeAllAgentPets(): void {
|
|||
export function refreshAgentPetContent(): void {
|
||||
for (const [petId, window] of agentPetWindows.entries()) {
|
||||
if (!window.isDestroyed()) {
|
||||
void loadExplicitPetContent(window, petId, transientDisplays.get(petId) ?? null);
|
||||
void loadExplicitPetContent(window, petId, transientDisplays.get(petId) ?? null, statusBadges.get(petId) ?? null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -81,6 +85,7 @@ function getOrCreateAgentPetWindow(petId: string): BrowserWindow {
|
|||
displayName: pet.displayName,
|
||||
position: { x: initial.x - offset * 36, y: initial.y - offset * 24 },
|
||||
display: transientDisplays.get(petId) ?? null,
|
||||
badge: statusBadges.get(petId) ?? null,
|
||||
onCloseRequested: () => dismissAgentPetForActiveLease(petId),
|
||||
});
|
||||
|
||||
|
|
@ -95,6 +100,7 @@ function getOrCreateAgentPetWindow(petId: string): BrowserWindow {
|
|||
function setAgentDisplay(petId: string, display: PetTransientDisplay): void {
|
||||
const preparedDisplay = mergePetTransientDisplay(transientDisplays.get(petId) ?? null, display);
|
||||
transientDisplays.set(petId, preparedDisplay);
|
||||
if (display.reaction) setStatusBadge(petId, display.reaction);
|
||||
const existingTimer = transientTimers.get(petId);
|
||||
if (existingTimer) clearTimeout(existingTimer);
|
||||
const existingAnimationTimer = transientAnimationTimers.get(petId);
|
||||
|
|
@ -121,11 +127,11 @@ function setAgentDisplay(petId: string, display: PetTransientDisplay): void {
|
|||
if (animationTimer) clearTimeout(animationTimer);
|
||||
transientAnimationTimers.delete(petId);
|
||||
const window = agentPetWindows.get(petId);
|
||||
if (window && !window.isDestroyed()) void loadExplicitPetContent(window, petId, null);
|
||||
if (window && !window.isDestroyed()) void loadExplicitPetContent(window, petId, null, statusBadges.get(petId) ?? null);
|
||||
}, transientDisplayMs);
|
||||
transientTimers.set(petId, timer);
|
||||
const window = agentPetWindows.get(petId);
|
||||
if (window && !window.isDestroyed()) void loadExplicitPetContent(window, petId, preparedDisplay);
|
||||
if (window && !window.isDestroyed()) void loadExplicitPetContent(window, petId, preparedDisplay, statusBadges.get(petId) ?? null);
|
||||
}
|
||||
|
||||
function clearAgentDisplay(petId: string): void {
|
||||
|
|
@ -135,5 +141,37 @@ function clearAgentDisplay(petId: string): void {
|
|||
if (animationTimer) clearTimeout(animationTimer);
|
||||
transientTimers.delete(petId);
|
||||
transientAnimationTimers.delete(petId);
|
||||
const badgeTimer = statusBadgeTimers.get(petId);
|
||||
if (badgeTimer) clearTimeout(badgeTimer);
|
||||
statusBadgeTimers.delete(petId);
|
||||
transientDisplays.delete(petId);
|
||||
statusBadges.delete(petId);
|
||||
}
|
||||
|
||||
function setStatusBadge(petId: string, reaction: OpenPetsReaction): void {
|
||||
if (reaction === "idle") {
|
||||
clearStatusBadge(petId);
|
||||
return;
|
||||
}
|
||||
|
||||
statusBadges.set(petId, reaction);
|
||||
const existingTimer = statusBadgeTimers.get(petId);
|
||||
if (existingTimer) clearTimeout(existingTimer);
|
||||
const timer = setTimeout(() => {
|
||||
clearStatusBadge(petId);
|
||||
const window = agentPetWindows.get(petId);
|
||||
if (window && !window.isDestroyed()) void loadExplicitPetContent(window, petId, transientDisplays.get(petId) ?? null, null);
|
||||
}, isBusyStatusBadgeReaction(reaction) ? busyStatusBadgeMs : transientDisplayMs);
|
||||
statusBadgeTimers.set(petId, timer);
|
||||
}
|
||||
|
||||
function clearStatusBadge(petId: string): void {
|
||||
statusBadges.delete(petId);
|
||||
const timer = statusBadgeTimers.get(petId);
|
||||
if (timer) clearTimeout(timer);
|
||||
statusBadgeTimers.delete(petId);
|
||||
}
|
||||
|
||||
function isBusyStatusBadgeReaction(reaction: OpenPetsReaction): boolean {
|
||||
return reaction === "thinking" || reaction === "working" || reaction === "editing" || reaction === "running" || reaction === "testing" || reaction === "waiting";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,8 +132,8 @@ assert.match(windowsSource, /pm-sprite-state-happy\.pm-animate-sprite \{ animati
|
|||
assert.match(windowsSource, /@keyframes pm-sprite-thinking/, "Pet Manager must define thinking row animation keyframes.");
|
||||
assert.match(windowsSource, /@keyframes pm-sprite-wave/, "Pet Manager must define wave row animation keyframes.");
|
||||
assert.match(windowsSource, /@keyframes pm-sprite-happy/, "Pet Manager must define happy row animation keyframes.");
|
||||
assert.match(petWindowSource, /max-width:\s*min\(204px/, "very long message bubbles must stay capped within the tight pet window.");
|
||||
assert.match(petWindowSource, /-webkit-line-clamp:\s*9/, "very long message bubbles must allow enough visible lines.");
|
||||
assert.match(petWindowSource, /max-width:\s*min\(220px/, "very long message bubbles must stay capped within the tight pet window.");
|
||||
assert.match(petWindowSource, /-webkit-line-clamp:\s*8/, "very long message bubbles must allow enough visible lines.");
|
||||
assert.match(petWindowSource, /createSpriteStateCss\("\.sprite"\)/, "built-in sprite CSS must react to reaction state.");
|
||||
assert.match(petWindowSource, /createSpriteStateCss\("\.installed-sprite"\)/, "installed sprite CSS must react to reaction state.");
|
||||
assert.match(petWindowSource, /html\[data-motion-state=\"\$\{motion\}\"\] \$\{selector\}/, "sprite CSS must let drag motion override reaction state.");
|
||||
|
|
|
|||
|
|
@ -3,13 +3,16 @@ import { BrowserWindow, screen } from "electron";
|
|||
import { getAppStateSnapshot, getDefaultPetPosition, resetDefaultPetPosition, setDefaultPetPosition, updatePreferences } from "./app-state.js";
|
||||
import { defaultPetWindowSize, getDefaultPetInitialPosition } from "./display.js";
|
||||
import { transientDisplayMs, type OpenPetsReaction } from "./local-ipc-protocol.js";
|
||||
import { clearTransientReaction, createDefaultPetWindow, getSafeDefaultPetPosition, getTransientReactionAnimationMs, loadDefaultPetContent, mergePetTransientDisplay, readWindowPosition, setPetReactionState, type PetTransientDisplay } from "./pet-window.js";
|
||||
import { clearTransientReaction, createDefaultPetWindow, getSafeDefaultPetPosition, getTransientReactionAnimationMs, loadDefaultPetContent, mergePetTransientDisplay, readWindowPosition, setPetReactionState, type PetStatusBadgeReaction, type PetTransientDisplay } from "./pet-window.js";
|
||||
|
||||
let defaultPetWindow: BrowserWindow | null = null;
|
||||
let paused = false;
|
||||
let transientDisplay: PetTransientDisplay | null = null;
|
||||
let statusBadge: PetStatusBadgeReaction | null = null;
|
||||
let transientDisplayTimeout: NodeJS.Timeout | null = null;
|
||||
let transientAnimationTimeout: NodeJS.Timeout | null = null;
|
||||
let statusBadgeTimeout: NodeJS.Timeout | null = null;
|
||||
const busyStatusBadgeMs = 120_000;
|
||||
|
||||
export function showDefaultPet(): void {
|
||||
updatePreferences({ openDefaultPetOnLaunch: true });
|
||||
|
|
@ -44,7 +47,7 @@ export function setDefaultPetPaused(nextPaused: boolean): void {
|
|||
return;
|
||||
}
|
||||
|
||||
void loadDefaultPetContent(defaultPetWindow, paused, transientDisplay);
|
||||
void loadDefaultPetContent(defaultPetWindow, paused, transientDisplay, statusBadge);
|
||||
}
|
||||
|
||||
export function getDefaultPetPaused(): boolean {
|
||||
|
|
@ -56,7 +59,7 @@ export function refreshDefaultPetContent(): void {
|
|||
return;
|
||||
}
|
||||
|
||||
void loadDefaultPetContent(defaultPetWindow, paused, transientDisplay);
|
||||
void loadDefaultPetContent(defaultPetWindow, paused, transientDisplay, statusBadge);
|
||||
}
|
||||
|
||||
export function applyExternalPetReaction(reaction: OpenPetsReaction): { readonly shown: boolean; readonly reason?: string } {
|
||||
|
|
@ -74,6 +77,7 @@ export function applyExternalPetSay(message: string, reaction?: OpenPetsReaction
|
|||
return { shown: false, reason: "paused" };
|
||||
}
|
||||
|
||||
if (!reaction) clearStatusBadge();
|
||||
setTransientDisplay({ message, reaction });
|
||||
showDefaultPetForExternalEvent();
|
||||
return { shown: isDefaultPetVisible() };
|
||||
|
|
@ -108,6 +112,7 @@ function getOrCreateDefaultPetWindow(): BrowserWindow {
|
|||
position,
|
||||
paused,
|
||||
display: transientDisplay,
|
||||
badge: statusBadge,
|
||||
onPositionChanged: setDefaultPetPosition,
|
||||
onHideRequested: hideDefaultPet,
|
||||
});
|
||||
|
|
@ -121,6 +126,7 @@ function getOrCreateDefaultPetWindow(): BrowserWindow {
|
|||
|
||||
function setTransientDisplay(display: PetTransientDisplay): void {
|
||||
transientDisplay = mergePetTransientDisplay(transientDisplay, display);
|
||||
if (display.reaction) setStatusBadge(display.reaction);
|
||||
|
||||
if (transientDisplayTimeout) {
|
||||
clearTimeout(transientDisplayTimeout);
|
||||
|
|
@ -160,6 +166,30 @@ function showDefaultPetForExternalEvent(): void {
|
|||
}
|
||||
}
|
||||
|
||||
function setStatusBadge(reaction: OpenPetsReaction): void {
|
||||
if (reaction === "idle") {
|
||||
clearStatusBadge();
|
||||
return;
|
||||
}
|
||||
|
||||
statusBadge = reaction;
|
||||
if (statusBadgeTimeout) clearTimeout(statusBadgeTimeout);
|
||||
statusBadgeTimeout = setTimeout(() => {
|
||||
clearStatusBadge();
|
||||
refreshDefaultPetContent();
|
||||
}, isBusyStatusBadgeReaction(reaction) ? busyStatusBadgeMs : transientDisplayMs);
|
||||
}
|
||||
|
||||
function clearStatusBadge(): void {
|
||||
statusBadge = null;
|
||||
if (statusBadgeTimeout) clearTimeout(statusBadgeTimeout);
|
||||
statusBadgeTimeout = null;
|
||||
}
|
||||
|
||||
function isBusyStatusBadgeReaction(reaction: OpenPetsReaction): boolean {
|
||||
return reaction === "thinking" || reaction === "working" || reaction === "editing" || reaction === "running" || reaction === "testing" || reaction === "waiting";
|
||||
}
|
||||
|
||||
function reclampDefaultPetWindow(): void {
|
||||
if (!defaultPetWindow || defaultPetWindow.isDestroyed()) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export interface DefaultPetWindowOptions {
|
|||
readonly position: Point;
|
||||
readonly paused: boolean;
|
||||
readonly display: PetTransientDisplay | null;
|
||||
readonly badge: PetStatusBadgeReaction | null;
|
||||
readonly onPositionChanged: (position: Point) => void;
|
||||
readonly onHideRequested: () => void;
|
||||
}
|
||||
|
|
@ -23,6 +24,7 @@ export interface AgentPetWindowOptions {
|
|||
readonly displayName: string;
|
||||
readonly position: Point;
|
||||
readonly display: PetTransientDisplay | null;
|
||||
readonly badge: PetStatusBadgeReaction | null;
|
||||
readonly onCloseRequested: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -32,6 +34,8 @@ export interface PetTransientDisplay {
|
|||
readonly reactionMessage?: string;
|
||||
}
|
||||
|
||||
export type PetStatusBadgeReaction = Exclude<OpenPetsReaction, "idle">;
|
||||
|
||||
type PetMotionState = "idle" | "run-left" | "run-right";
|
||||
type UniversalSpriteState = "idle" | "running-right" | "running-left" | "waving" | "jumping" | "failed" | "waiting" | "running" | "review";
|
||||
|
||||
|
|
@ -94,7 +98,7 @@ export function createDefaultPetWindow(options: DefaultPetWindowOptions): Browse
|
|||
options.onPositionChanged(readWindowPosition(window));
|
||||
});
|
||||
|
||||
void loadDefaultPetContent(window, options.paused, options.display);
|
||||
void loadDefaultPetContent(window, options.paused, options.display, options.badge);
|
||||
|
||||
return window;
|
||||
}
|
||||
|
|
@ -104,7 +108,7 @@ export function createAgentPetWindow(options: AgentPetWindowOptions): BrowserWin
|
|||
installMousePassthroughAndDrag(window);
|
||||
installMotionStatePublisher(window);
|
||||
installPetContextMenu(window, { label: "Close pet", click: options.onCloseRequested });
|
||||
void loadExplicitPetContent(window, options.petId, options.display);
|
||||
void loadExplicitPetContent(window, options.petId, options.display, options.badge);
|
||||
return window;
|
||||
}
|
||||
|
||||
|
|
@ -241,21 +245,21 @@ function applyPetAlwaysOnTop(window: BrowserWindow): void {
|
|||
}
|
||||
}
|
||||
|
||||
export async function loadDefaultPetContent(window: BrowserWindow, paused: boolean, display: PetTransientDisplay | null = null): Promise<void> {
|
||||
const html = await createDefaultPetHtml(paused, display);
|
||||
export async function loadDefaultPetContent(window: BrowserWindow, paused: boolean, display: PetTransientDisplay | null = null, badge: PetStatusBadgeReaction | null = null): Promise<void> {
|
||||
const html = await createDefaultPetHtml(paused, display, badge);
|
||||
await loadPetHtmlFile(window, html, "default").catch((error: unknown) => {
|
||||
console.error("Failed to load default pet URL.", error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadExplicitPetContent(window: BrowserWindow, petId: string, display: PetTransientDisplay | null = null): Promise<void> {
|
||||
export async function loadExplicitPetContent(window: BrowserWindow, petId: string, display: PetTransientDisplay | null = null, badge: PetStatusBadgeReaction | null = null): Promise<void> {
|
||||
try {
|
||||
const state = getAppStateSnapshot();
|
||||
const pet = state.pets.installed.find((candidate) => candidate.id === petId);
|
||||
if (!pet || pet.broken || pet.id === builtInPet.id) {
|
||||
throw new Error(`Cannot render explicit pet: ${petId}`);
|
||||
}
|
||||
const html = await createInstalledPetHtml(pet.id, pet.displayName, false, display, state.preferences.petScale as PetScaleValue);
|
||||
const html = await createInstalledPetHtml(pet.id, pet.displayName, false, display, state.preferences.petScale as PetScaleValue, badge);
|
||||
await loadPetHtmlFile(window, html, `explicit-${pet.id}`);
|
||||
} catch (error: unknown) {
|
||||
console.error(`Failed to load explicit pet ${petId} URL.`, error);
|
||||
|
|
@ -299,14 +303,14 @@ export function readWindowPosition(window: BrowserWindow): Point {
|
|||
return clampToPrimaryWorkArea({ x, y }, defaultPetWindowSize);
|
||||
}
|
||||
|
||||
async function createDefaultPetHtml(paused: boolean, display: PetTransientDisplay | null): Promise<string> {
|
||||
const installedPetHtml = await tryCreateInstalledPetHtml(paused, display);
|
||||
async function createDefaultPetHtml(paused: boolean, display: PetTransientDisplay | null, badge: PetStatusBadgeReaction | null): Promise<string> {
|
||||
const installedPetHtml = await tryCreateInstalledPetHtml(paused, display, badge);
|
||||
if (installedPetHtml) {
|
||||
return installedPetHtml;
|
||||
}
|
||||
|
||||
const spriteUrl = pathToFileURL(join(app.getAppPath(), "assets", defaultPetSprite.fileName)).toString();
|
||||
const bubble = createBubbleMarkup(display, paused);
|
||||
const bubble = createBubbleMarkup(display, paused, badge);
|
||||
const stateRows = defaultPetSprite.states;
|
||||
const scale = getAppStateSnapshot().preferences.petScale as PetScaleValue;
|
||||
|
||||
|
|
@ -353,7 +357,7 @@ async function createDefaultPetHtml(paused: boolean, display: PetTransientDispla
|
|||
</html>`;
|
||||
}
|
||||
|
||||
async function tryCreateInstalledPetHtml(paused: boolean, display: PetTransientDisplay | null): Promise<string | null> {
|
||||
async function tryCreateInstalledPetHtml(paused: boolean, display: PetTransientDisplay | null, badge: PetStatusBadgeReaction | null): Promise<string | null> {
|
||||
const state = getAppStateSnapshot();
|
||||
const selected = state.pets.installed.find((pet) => pet.id === state.preferences.defaultPetId);
|
||||
|
||||
|
|
@ -362,7 +366,7 @@ async function tryCreateInstalledPetHtml(paused: boolean, display: PetTransientD
|
|||
}
|
||||
|
||||
try {
|
||||
return await createInstalledPetHtml(selected.id, selected.displayName, paused, display, state.preferences.petScale as PetScaleValue);
|
||||
return await createInstalledPetHtml(selected.id, selected.displayName, paused, display, state.preferences.petScale as PetScaleValue, badge);
|
||||
} catch (error) {
|
||||
console.error(`Failed to render installed default pet ${selected.id}; falling back to built-in pet.`, error);
|
||||
try {
|
||||
|
|
@ -374,7 +378,7 @@ async function tryCreateInstalledPetHtml(paused: boolean, display: PetTransientD
|
|||
}
|
||||
}
|
||||
|
||||
async function createInstalledPetHtml(petId: string, displayName: string, paused: boolean, display: PetTransientDisplay | null, scale: PetScaleValue): Promise<string> {
|
||||
async function createInstalledPetHtml(petId: string, displayName: string, paused: boolean, display: PetTransientDisplay | null, scale: PetScaleValue, badge: PetStatusBadgeReaction | null): Promise<string> {
|
||||
const spritesheetPath = join(getInstalledPetDir(petId), "spritesheet.webp");
|
||||
const spritesheet = await stat(spritesheetPath);
|
||||
if (!spritesheet.isFile() || spritesheet.size <= 0 || spritesheet.size > 100 * 1024 * 1024) {
|
||||
|
|
@ -382,7 +386,7 @@ async function createInstalledPetHtml(petId: string, displayName: string, paused
|
|||
}
|
||||
|
||||
const imageUrl = pathToFileURL(spritesheetPath).toString();
|
||||
const bubble = createBubbleMarkup(display, paused);
|
||||
const bubble = createBubbleMarkup(display, paused, badge);
|
||||
const stateRows = defaultPetSprite.states;
|
||||
|
||||
return `<!doctype html>
|
||||
|
|
@ -440,25 +444,38 @@ function createPetWindowCss(paused: boolean, scale: PetScaleValue): string {
|
|||
const scaledWidth = Math.ceil(defaultPetSprite.frameWidth * scale);
|
||||
const scaledHeight = Math.ceil(defaultPetSprite.frameHeight * scale);
|
||||
const petBottom = 22;
|
||||
const bubbleBottom = Math.ceil(petBottom + scaledHeight + 3);
|
||||
const bubbleBottom = Math.ceil(petBottom + scaledHeight + 8);
|
||||
return `
|
||||
:root { color-scheme: dark; --pet-opacity: ${opacity}; --play-state: ${playState}; }
|
||||
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: transparent; user-select: none; -webkit-font-smoothing: antialiased; }
|
||||
html { color: #172033; }
|
||||
body { -webkit-app-region: no-drag; pointer-events: none; }
|
||||
.stage { width: 100%; height: 100%; position: relative; box-sizing: border-box; overflow: visible; }
|
||||
.pet-shell { position: absolute; left: 50%; bottom: ${petBottom}px; width: ${scaledWidth}px; height: ${scaledHeight}px; display: block; opacity: var(--pet-opacity); filter: drop-shadow(0 10px 12px rgba(15, 23, 42, 0.24)) drop-shadow(0 2px 3px rgba(15, 23, 42, 0.18)); transform: translateX(-50%); transition-property: opacity, filter; transition-duration: 180ms; transition-timing-function: cubic-bezier(0.2, 0, 0, 1); pointer-events: auto; -webkit-app-region: no-drag; cursor: grab; }
|
||||
.bubble { position: absolute; left: 50%; bottom: ${bubbleBottom}px; z-index: 2; box-sizing: border-box; max-width: min(200px, calc(100vw - 14px)); max-height: 92px; padding: 8px 10px; background: #fff; color: #000; font: 700 11px/14px "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace; text-align: left; border: 2px solid #000; border-radius: 4px; box-shadow: 2px 2px 0 #000; white-space: normal; overflow-wrap: anywhere; word-break: normal; overflow: hidden; pointer-events: auto; -webkit-app-region: no-drag; transform: translateX(-50%); opacity: 1; animation: bubble-in 160ms steps(2, end); }
|
||||
.bubble-text { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 5; -webkit-box-orient: vertical; }
|
||||
.bubble::after, .bubble::before { content: none; }
|
||||
.bubble.is-reaction { max-width: min(142px, calc(100vw - 10px)); max-height: 64px; padding: 6px 8px; font: 750 11px/13px "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace; text-align: center; overflow-wrap: break-word; }
|
||||
.bubble.is-reaction .bubble-text { -webkit-line-clamp: 3; }
|
||||
.bubble.is-long-message { max-width: min(204px, calc(100vw - 14px)); max-height: 106px; font-size: 10.5px; line-height: 13px; }
|
||||
.bubble.is-long-message .bubble-text { -webkit-line-clamp: 7; }
|
||||
.bubble.is-very-long-message { max-width: min(204px, calc(100vw - 14px)); max-height: 126px; font-size: 10px; line-height: 13px; }
|
||||
.bubble.is-very-long-message .bubble-text { -webkit-line-clamp: 9; }
|
||||
@keyframes bubble-in { from { opacity: 0; transform: translateX(-50%) translateY(3px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
|
||||
@media (prefers-reduced-motion: reduce) { .sprite, .installed-sprite, .bubble { animation: none !important; } }
|
||||
.pet-shell { position: absolute; left: 50%; bottom: ${petBottom}px; z-index: 1; width: ${scaledWidth}px; height: ${scaledHeight}px; display: block; opacity: var(--pet-opacity); filter: drop-shadow(0 10px 12px rgba(15, 23, 42, 0.24)) drop-shadow(0 2px 3px rgba(15, 23, 42, 0.18)); transform: translateX(-50%); transition-property: opacity, filter; transition-duration: 180ms; transition-timing-function: cubic-bezier(0.2, 0, 0, 1); pointer-events: auto; -webkit-app-region: no-drag; cursor: grab; }
|
||||
.bubble { position: absolute; left: 50%; bottom: ${bubbleBottom}px; z-index: 4; box-sizing: border-box; display: inline-flex; flex-direction: column; width: fit-content; min-width: 92px; max-width: min(220px, calc(100vw - 18px)); max-height: 128px; padding: 10px 12px; background: linear-gradient(135deg, rgba(239, 246, 255, 0.97), rgba(237, 233, 254, 0.96)); color: #172033; font: 760 11px/14px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; text-align: left; border: 1px solid rgba(255, 255, 255, 0.78); border-radius: 14px; box-shadow: 0 12px 24px rgba(15, 23, 42, 0.16), 0 2px 5px rgba(15, 23, 42, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.82); white-space: normal; overflow-wrap: break-word; word-break: normal; overflow: visible; pointer-events: auto; -webkit-app-region: no-drag; opacity: 1; backdrop-filter: blur(10px); transform: translateX(-50%); transform-origin: 64% 100%; animation: bubble-in 180ms cubic-bezier(0.2, 0, 0, 1); }
|
||||
.bubble::after { content: ""; position: absolute; left: 64%; bottom: -7px; width: 12px; height: 12px; background: inherit; border-right: 1px solid rgba(255, 255, 255, 0.56); border-bottom: 1px solid rgba(255, 255, 255, 0.56); border-bottom-right-radius: 3px; transform: translateX(-50%) rotate(45deg); box-shadow: 3px 3px 7px rgba(15, 23, 42, 0.08); }
|
||||
.bubble-header { display: inline-flex; align-items: center; min-width: 0; gap: 7px; color: currentColor; font: 780 11px/14px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; letter-spacing: 0.01em; }
|
||||
.bubble-status-icon { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 16px; height: 16px; border-radius: 999px; background: #3b82f6; color: #fff; font-size: 10px; line-height: 1; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(59, 130, 246, 0.3); }
|
||||
.bubble-status-icon::before { content: attr(data-icon); }
|
||||
.bubble-status-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bubble-divider { height: 1px; width: 100%; margin: 8px 0; background: rgba(30, 58, 138, 0.12); }
|
||||
.bubble-body { min-width: 0; width: 100%; color: #172033; font: 720 10.5px/13.5px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
.bubble-text { display: -webkit-box; min-width: 0; overflow: hidden; -webkit-line-clamp: 4; -webkit-box-orient: vertical; text-wrap: normal; overflow-wrap: break-word; }
|
||||
.bubble.is-status-only { max-width: min(156px, calc(100vw - 18px)); padding: 8px 11px; border-radius: 999px; }
|
||||
.bubble.is-message-only { border-radius: 14px 14px 3px 14px; }
|
||||
.bubble.is-long-message { max-width: min(220px, calc(100vw - 18px)); max-height: 138px; }
|
||||
.bubble.is-long-message .bubble-text { -webkit-line-clamp: 6; font-size: 10px; line-height: 13px; }
|
||||
.bubble.is-very-long-message { max-width: min(220px, calc(100vw - 18px)); max-height: 156px; }
|
||||
.bubble.is-very-long-message .bubble-text { -webkit-line-clamp: 8; font-size: 9.5px; line-height: 12.5px; }
|
||||
.bubble.is-busy .bubble-status-icon { background: #3b82f6; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(59, 130, 246, 0.34); }
|
||||
.bubble.is-waiting .bubble-status-icon { background: #f59e0b; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(245, 158, 11, 0.34); }
|
||||
.bubble.is-success .bubble-status-icon { background: #10b981; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(16, 185, 129, 0.34); }
|
||||
.bubble.is-error .bubble-status-icon { background: #ef4444; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(239, 68, 68, 0.34); }
|
||||
.bubble.is-info .bubble-status-icon { background: #38bdf8; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(56, 189, 248, 0.34); }
|
||||
.bubble.is-busy .bubble-status-icon::before { content: ""; display: block; width: 7px; height: 7px; border-radius: 999px; background: #fff; animation: status-pulse 820ms ease-in-out infinite; }
|
||||
@keyframes bubble-in { from { opacity: 0; transform: translateX(-50%) translateY(4px) scale(0.96); } to { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); } }
|
||||
@keyframes status-pulse { 0%, 100% { transform: scale(0.72); opacity: 0.58; } 50% { transform: scale(1); opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) { .sprite, .installed-sprite, .bubble, .bubble-status-icon::before { animation: none !important; } }
|
||||
`;
|
||||
}
|
||||
|
||||
|
|
@ -480,17 +497,40 @@ function getReactionSpriteState(reaction: OpenPetsReaction | undefined): Univers
|
|||
return reaction ? reactionToSpriteState[reaction] : "idle";
|
||||
}
|
||||
|
||||
function createBubbleMarkup(display: PetTransientDisplay | null, paused: boolean): string {
|
||||
function createBubbleMarkup(display: PetTransientDisplay | null, paused: boolean, badgeReaction: PetStatusBadgeReaction | null): string {
|
||||
const text = display?.message ?? display?.reactionMessage ?? (display?.reaction ? pickReactionMessage(display.reaction) : undefined) ?? (paused ? "Paused" : "");
|
||||
if (!text) return "";
|
||||
const className = getBubbleClassName(text, Boolean(display?.message && !display?.reactionMessage));
|
||||
return `<div class="${className}" role="status" aria-live="polite"><span class="bubble-text">${escapeHtml(text)}</span></div>`;
|
||||
const status = !paused && badgeReaction ? getStatusBadge(badgeReaction) : null;
|
||||
if (!text && !status) return "";
|
||||
const isExplicitMessage = Boolean(display?.message && !display?.reactionMessage);
|
||||
const className = getBubbleClassName(text, isExplicitMessage, status?.className);
|
||||
const header = status ? `<div class="bubble-header"><span class="bubble-status-icon" data-icon="${escapeHtml(status.icon)}" aria-hidden="true"></span><span class="bubble-status-label">${escapeHtml(status.label)}</span></div>` : "";
|
||||
const divider = status && text ? `<div class="bubble-divider" aria-hidden="true"></div>` : "";
|
||||
const body = text ? `<div class="bubble-body"><span class="bubble-text">${escapeHtml(text)}</span></div>` : "";
|
||||
return `<div class="${className}" role="status" aria-live="polite">${header}${divider}${body}</div>`;
|
||||
}
|
||||
|
||||
function getBubbleClassName(text: string, isExplicitMessage: boolean): string {
|
||||
if (!isExplicitMessage) return "bubble is-reaction";
|
||||
function getStatusBadge(reaction: PetStatusBadgeReaction): { readonly className: string; readonly icon: string; readonly label: string } | null {
|
||||
if (reaction === "thinking") return { className: "is-busy", icon: "", label: "Thinking" };
|
||||
if (reaction === "working" || reaction === "running") return { className: "is-busy", icon: "", label: "Working" };
|
||||
if (reaction === "editing") return { className: "is-busy", icon: "", label: "Editing" };
|
||||
if (reaction === "testing") return { className: "is-busy", icon: "", label: "Testing" };
|
||||
if (reaction === "waiting") return { className: "is-waiting", icon: "◷", label: "Waiting" };
|
||||
if (reaction === "success" || reaction === "celebrating") return { className: "is-success", icon: "✓", label: "Done" };
|
||||
if (reaction === "error") return { className: "is-error", icon: "!", label: "Oops" };
|
||||
if (reaction === "waving") return { className: "is-info", icon: "♪", label: "Hi" };
|
||||
return null;
|
||||
}
|
||||
|
||||
function getBubbleClassName(text: string, isExplicitMessage: boolean, statusClassName: string | undefined): string {
|
||||
const statusClass = statusClassName ? ` ${statusClassName}` : "";
|
||||
if (!text) return `bubble is-status-only${statusClass}`;
|
||||
if (!statusClassName) return `bubble is-message-only${isExplicitMessage ? getBubbleLengthClass(text) : ""}`;
|
||||
const lengthClass = text.length > 95 ? " is-very-long-message" : text.length > 56 ? " is-long-message" : "";
|
||||
return `bubble is-message${lengthClass}`;
|
||||
return `bubble is-message${statusClass}${lengthClass}`;
|
||||
}
|
||||
|
||||
function getBubbleLengthClass(text: string): string {
|
||||
return text.length > 95 ? " is-very-long-message" : text.length > 56 ? " is-long-message" : "";
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue