diff --git a/apps/desktop/scripts/run-tests.mjs b/apps/desktop/scripts/run-tests.mjs index 4b522c17..d7c23fc4 100644 --- a/apps/desktop/scripts/run-tests.mjs +++ b/apps/desktop/scripts/run-tests.mjs @@ -23,6 +23,7 @@ const behaviorTests = [ ".test-dist/tests/lease-manager.test.js", ".test-dist/tests/custom-familiar-name.test.js", ".test-dist/tests/internal-ui-route-conformance.test.js", + ".test-dist/tests/familiar-window-render.test.js", ".test-dist/tests/default-familiar-external-show.test.js", ".test-dist/tests/onboarding-state.test.js", ".test-dist/tests/update-version.test.js", diff --git a/apps/desktop/src/check-packaging-contract.ts b/apps/desktop/src/check-packaging-contract.ts index a088ca56..d13c3d24 100644 --- a/apps/desktop/src/check-packaging-contract.ts +++ b/apps/desktop/src/check-packaging-contract.ts @@ -69,6 +69,7 @@ for (const icon of ["claude.svg", "cursor.svg", "opencode.svg", "pi.svg", "vscod } assert.match(readFileSync(join(appDir, "src", "assets.ts"), "utf8"), /assets["']?,\s*["']tray-icon\.png|join\("assets", "tray-icon\.png"\)/, "tray icon code must keep using assets/tray-icon.png."); const petWindowSource = readFileSync(join(appDir, "src", "familiar-window.ts"), "utf8"); +const petWindowRenderSource = readFileSync(join(appDir, "src", "familiar-window-render.ts"), "utf8"); const controlCenterPreloadSource = readFileSync(join(appDir, "control-center-preload.cjs"), "utf8"); const controlCenterRendererSource = readFileSync(join(appDir, "src", "renderer", "src", "main.tsx"), "utf8"); const controlCenterRouteLoadersSource = readFileSync(join(appDir, "src", "renderer", "src", "control-center", "route-loaders.tsx"), "utf8"); @@ -97,6 +98,7 @@ const defaultPetControllerSource = readFileSync(join(appDir, "src", "default-fam const agentPetControllerSourceForLogging = readFileSync(join(appDir, "src", "agent-familiar-controller.ts"), "utf8"); const mappingDoc = readFileSync(join(repoRoot, "docs", "mapping.md"), "utf8"); const controlCenterInternalUiSource = `${windowsSource}\n${controlCenterIpcSource}`; +const petWindowRenderCombinedSource = `${petWindowSource}\n${petWindowRenderSource}`; assert.match(loggerSource, /familiaros\.log/, "desktop logger must write a user-sendable familiaros.log file."); assert.match(loggerSource, /familiaros\.previous\.log/, "desktop logger must retain a previous log file for bug reports."); assert.match(loggerSource, /FAMILIAROS_LOG_LEVEL/, "desktop logger must support verbose dev logging via environment."); @@ -134,10 +136,10 @@ assert.match(petWindowSource, /const webContents = window\.webContents[\s\S]*?co assert.match(petWindowSource, /window\.on\("close", removeListeners\);\s*window\.once\("closed", removeListeners\);/, "familiar window cleanup must run before and after close so agent lease release and Cmd/Ctrl+W are idempotent."); assert.match(displaySource, /width:\s*220/, "familiar windows must stay tightly bounded around familiar and bubble."); assert.match(displaySource, /height:\s*320/, "familiar windows must be tall enough for adaptive long message bubbles at large familiar scale without becoming a huge click shield."); -assert.match(petWindowSource, /function getBubbleClassName/, "familiar bubbles must classify explicit messages by length."); -assert.match(petWindowSource, /is-long-message/, "familiar bubbles must have a long-message layout."); -assert.match(petWindowSource, /is-very-long-message/, "familiar bubbles must have a very-long-message layout for 140-character say messages."); -assert.match(petWindowSource, /body \{ -webkit-app-region: no-drag; pointer-events: none; \}/, "transparent familiar window background must not capture clicks or drags."); +assert.match(petWindowRenderCombinedSource, /function getBubbleClassName|export function getBubbleClassName/, "familiar bubbles must classify explicit messages by length."); +assert.match(petWindowRenderCombinedSource, /is-long-message/, "familiar bubbles must have a long-message layout."); +assert.match(petWindowRenderCombinedSource, /is-very-long-message/, "familiar bubbles must have a very-long-message layout for 140-character say messages."); +assert.match(petWindowRenderCombinedSource, /body \{ -webkit-app-region: no-drag; pointer-events: none; \}/, "transparent familiar window background must not capture clicks or drags."); assert.match(petWindowSource, /function installMousePassthroughAndDrag/, "familiar windows must install real mouse passthrough and controlled drag behavior."); assert.match(petWindowSource, /setIgnoreMouseEvents\(true, \{ forward: true \}\)/, "transparent familiar window background must use OS-level mouse passthrough."); assert.match(petWindowSource, /setIgnoreMouseEvents\(false\)/, "visible familiar and bubble hit targets must re-enable mouse handling."); @@ -158,10 +160,10 @@ assert.match(petPreloadSource, /familiaros:familiar-content-state[\s\S]*?documen assert.match(petWindowSource, /windowLoadChains\.set\(window, next\)/, "familiar content reloads must serialize loadFile calls per BrowserWindow."); assert.match(petWindowSource, /next\.catch\(\(\) => \{\}\)\.finally/, "familiar content reload chain cleanup must not create unhandled rejections."); assert.match(petWindowSource, /destroyed-after-write/, "familiar content reloads must re-check destroyed windows after writing HTML."); -assert.match(petWindowSource, /process\.platform === "win32" \? "none" : "drop-shadow/, "Windows familiar windows must avoid CSS drop-shadow on transparent layered windows."); -assert.match(petWindowSource, /process\.platform === "win32" \? "none" : "blur\(10px\)"/, "Windows familiar windows must avoid backdrop-filter on transparent layered windows."); -assert.match(petWindowSource, /\.bubble\.is-pinned \{[\s\S]*?backdrop-filter: \$\{process\.platform === "win32" \? "none" : "blur\(8px\)"\}/, "Windows pinned bubbles must avoid backdrop-filter on transparent layered windows."); -assert.match(petWindowSource, /\.familiar-shell[\s\S]*?-webkit-app-region: no-drag; cursor: grab;/, "familiar dragging must avoid Electron draggable regions so right-click context menus work."); +assert.match(petWindowRenderCombinedSource, /process\.platform === "win32" \? "none" : "drop-shadow/, "Windows familiar windows must avoid CSS drop-shadow on transparent layered windows."); +assert.match(petWindowRenderCombinedSource, /process\.platform === "win32" \? "none" : "blur\(10px\)"/, "Windows familiar windows must avoid backdrop-filter on transparent layered windows."); +assert.match(petWindowRenderCombinedSource, /\.bubble\.is-pinned \{[\s\S]*?backdrop-filter: \$\{process\.platform === "win32" \? "none" : "blur\(8px\)"\}/, "Windows pinned bubbles must avoid backdrop-filter on transparent layered windows."); +assert.match(petWindowRenderCombinedSource, /\.familiar-shell[\s\S]*?-webkit-app-region: no-drag; cursor: grab;/, "familiar dragging must avoid Electron draggable regions so right-click context menus work."); assert.match(petPreloadSource, /familiaros:familiar-hit-test/, "familiar preload must report visible familiar and bubble hit testing for passthrough."); assert.match(petPreloadSource, /familiaros:familiar-ready/, "familiar preload must report readiness after installing mouse handlers."); assert.match(petPreloadSource, /familiaros:familiar-drag-start/, "familiar preload must start controlled familiar dragging from the sprite."); @@ -206,13 +208,13 @@ assert.match(controlCenterFamiliarsSource, /loadFamiliarsRouteData/, "Control Ce assert.match(controlCenterRouteDataLoadersSource, /getPetsState/, "Control Center shared route data loaders must bridge familiars state APIs."); assert.match(controlCenterIntegrationsSource, /export function IntegrationsView\(\)/, "Control Center must include the integrations page."); assert.match(controlCenterRouteDataLoadersSource, /getIntegrationsState/, "Control Center shared route data loaders must bridge integrations state APIs."); -assert.match(petWindowSource, /--bubble-max-width:/, "familiar window must define a scalable bubble max-width variable."); -assert.match(petWindowSource, /\.bubble\.is-very-long-message \{[\s\S]*?max-width:\s*min\([^)]*var\(--bubble-max-width\)/, "very long message bubbles must stay capped within the familiar window while allowing larger familiars."); -assert.match(petWindowSource, /\.bubble-body \{[\s\S]*?overflow-y:\s*auto/, "bubble bodies must be scrollable for long messages."); +assert.match(petWindowRenderCombinedSource, /--bubble-max-width:/, "familiar window must define a scalable bubble max-width variable."); +assert.match(petWindowRenderCombinedSource, /\.bubble\.is-very-long-message \{[\s\S]*?max-width:\s*min\([^)]*var\(--bubble-max-width\)/, "very long message bubbles must stay capped within the familiar window while allowing larger familiars."); +assert.match(petWindowRenderCombinedSource, /\.bubble-body \{[\s\S]*?overflow-y:\s*auto/, "bubble bodies must be scrollable for long messages."); 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."); -assert.match(petWindowSource, /\.sprite, \.installed-sprite, \.bubble/, "reduced-motion CSS must include built-in and installed sprites."); +assert.match(petWindowRenderCombinedSource, /html\[data-motion-state=\"\$\{motion\}\"\] \$\{selector\}/, "sprite CSS must let drag motion override reaction state."); +assert.match(petWindowRenderCombinedSource, /\.sprite, \.installed-sprite, \.bubble/, "reduced-motion CSS must include built-in and installed sprites."); assert.match(petWindowSource, /function createAgentPetWindow[\s\S]*?installMotionStatePublisher\(window\)/, "agent familiar windows must publish motion state so dragged non-default familiars run."); assert.match(petWindowSource, /loadExplicitPetContent[\s\S]*?state\.preferences\.petScale/, "explicit agent familiar windows must use the saved familiar scale preference."); assert.match(petWindowSource, /interface AgentPetWindowOptions[\s\S]*?readonly scale: PetScaleValue/, "new agent familiar windows must receive the current familiar scale explicitly for their first render."); diff --git a/apps/desktop/src/codemap.md b/apps/desktop/src/codemap.md index 8e4c923a..443409c6 100644 --- a/apps/desktop/src/codemap.md +++ b/apps/desktop/src/codemap.md @@ -48,6 +48,7 @@ familiar-window.ts ├── createDefaultPetWindow() / createAgentPetWindow() ├── loadDefaultPetContent() / loadExplicitPetContent() │ ├── HTML generation with CSS sprite animation +│ ├── familiar-window-render.ts (shared bubble/CSS/stage markup helpers) │ ├── reaction-animation-mapping.ts (resolveReactionSpriteState) │ ├── reaction-messages.ts (pickReactionMessage for bubbles) │ └── Speech bubbles with status badges @@ -150,7 +151,8 @@ plugin-local-loader.ts validates selected folder manifest and snapshots only fam - `renderer/`: Vite React/Tailwind Control Center shell for Dashboard, Familiars, Integrations, Plugins, and Settings. **Familiars**: -- `familiar-window.ts`: Window creation (transparent, frameless, always-on-top), HTML/CSS generation, sprite animation states, speech bubbles, status badges, transient displays +- `familiar-window.ts`: Window creation (transparent, frameless, always-on-top), familiar content reload orchestration, sprite animation states, speech bubble lifecycle, status badges, transient displays +- `familiar-window-render.ts`: Shared familiar renderer helpers for stage markup, bubble markup, CSS shell generation, sprite state CSS, and safe bubble escaping - `default-familiar-controller.ts`: Default familiar visibility, position persistence, transient reactions, status badges, logging - `agent-familiar-controller.ts`: Lease-triggered familiar windows, dismissal tracking, transient displays, status badges, logging - `built-in-familiar.ts`: Built-in familiar constant diff --git a/apps/desktop/src/familiar-window-render.ts b/apps/desktop/src/familiar-window-render.ts new file mode 100644 index 00000000..99b77d85 --- /dev/null +++ b/apps/desktop/src/familiar-window-render.ts @@ -0,0 +1,457 @@ +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +import type { PetScaleValue } from "./app-state.js"; +import { getActiveLocale, t } from "./i18n/index.js"; +import type { FamiliarOSReaction } from "./local-ipc-protocol.js"; +import type { ActiveBubble } from "./plugin-bubble-arbiter.js"; +import type { PluginBubbleHud, PluginBubbleHudItem, PluginBubbleIndicator } from "./plugin-sdk-types.js"; +import { pickReactionMessage } from "./reaction-messages.js"; +import { defaultPetSprite, motionToSpriteState, type PetMotionState, type UniversalSpriteState } from "./reaction-animation-mapping.js"; + +/** Plugin-arbiter bubble content for one familiar surface (both slots). */ +export interface PetPluginBubbles { + readonly transient: ActiveBubble | null; + readonly pinned: ActiveBubble | null; +} + +export interface PetTransientDisplay { + readonly reaction?: FamiliarOSReaction; + readonly message?: string; + readonly reactionMessage?: string; + readonly suppressReactionMessage?: boolean; + readonly dismissToken?: string; + readonly fullMessage?: boolean; + readonly sticky?: boolean; +} + +export type PetStatusBadgeReaction = Exclude; + +export function createPetBodyMarkup(stageLabel: string, bubble: string, spriteMarkup: string, pinnedBubble = "", hasPinned = false): string { + return `
+ ${pinnedBubble} + ${bubble} + +
`; +} + +export function createPetWindowCss( + paused: boolean, + scale: PetScaleValue, + layout?: { + readonly bubbleMaxHeight?: number; + readonly bubbleMaxHeightLong?: number; + readonly bubbleMaxHeightVeryLong?: number; + }, +): string { + const opacity = paused ? "0.62" : "1"; + const playState = paused ? "paused" : "running"; + const scaledWidth = Math.ceil(defaultPetSprite.frameWidth * scale); + const scaledHeight = Math.ceil(defaultPetSprite.frameHeight * scale); + const petBottom = 22; + const hitPadding = 18; + const bubbleBottom = Math.ceil(petBottom + scaledHeight + 8); + const bubbleTop = Math.ceil(petBottom + scaledHeight + 8); + const bubbleMaxHeight = layout?.bubbleMaxHeight ?? 200; + const bubbleMaxHeightLong = layout?.bubbleMaxHeightLong ?? 240; + const bubbleMaxHeightVeryLong = layout?.bubbleMaxHeightVeryLong ?? 300; + const petShellFilter = process.platform === "win32" ? "none" : "drop-shadow(0 10px 12px rgba(15, 23, 42, 0.24)) drop-shadow(0 2px 3px rgba(15, 23, 42, 0.18))"; + const bubbleBackdropFilter = process.platform === "win32" ? "none" : "blur(10px)"; + return ` + :root { + --bubble-max-width: ${Math.max(220, Math.min(720, scaledWidth + 80))}px; + --bubble-max-height: ${bubbleMaxHeight}px; + --bubble-max-height-long: ${bubbleMaxHeightLong}px; + --bubble-max-height-very-long: ${bubbleMaxHeightVeryLong}px; + } + :root { color-scheme: dark; --familiar-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; } + .familiar-hitbox { position: absolute; left: 50%; bottom: ${Math.max(0, petBottom - hitPadding)}px; z-index: 1; width: ${scaledWidth + hitPadding * 2}px; height: ${scaledHeight + hitPadding * 2}px; display: grid; place-items: center; transform: translateX(-50%); pointer-events: auto; -webkit-app-region: no-drag; cursor: grab; } + .familiar-shell { position: relative; width: ${scaledWidth}px; height: ${scaledHeight}px; display: block; opacity: var(--familiar-opacity); filter: ${petShellFilter}; 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(var(--bubble-max-width), calc(100vw - 24px)); max-height: min(var(--bubble-max-height), calc(100vh - ${bubbleBottom + 16}px)); 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: ${bubbleBackdropFilter}; transform: translateX(-50%); transform-origin: 64% 100%; animation: bubble-in 180ms cubic-bezier(0.2, 0, 0, 1); } + .bubble.is-below { bottom: auto; top: ${bubbleTop}px; transform-origin: 64% 0; } + .bubble.is-below::after { top: -7px; bottom: auto; border: none; border-top: 1px solid rgba(255, 255, 255, 0.56); border-left: 1px solid rgba(255, 255, 255, 0.56); border-bottom-right-radius: 0; border-top-left-radius: 3px; transform: translateX(-50%) rotate(45deg); } + .bubble[data-dismiss-token] { cursor: pointer; } + .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 { position: relative; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 18px; width: 18px; min-width: 18px; height: 18px; border-radius: 999px; background: #3b82f6; color: #fff; font: 900 12px/18px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; text-align: center; 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); display: block; width: 18px; height: 18px; line-height: 18px; text-align: center; transform: none; } + .bubble-status-icon.has-svg::before { content: none; } + .bubble-status-icon svg { display: block; width: 14px; height: 14px; color: currentColor; } + .bubble-status-icon img { display: block; width: 14px; height: 14px; object-fit: contain; } + .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%; flex: 1 1 auto; min-height: 0; color: #172033; font: 720 10.5px/13.5px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic", "Meiryo", "Malgun Gothic", "Apple SD Gothic Neo", "PingFang SC", "PingFang TC", "Microsoft YaHei", "Microsoft JhengHei", "Noto Sans CJK JP", "Noto Sans CJK KR", "Noto Sans CJK SC", "Noto Sans CJK TC", sans-serif; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; padding-right: 4px; } + .bubble-text { display: block; min-width: 0; overflow: visible; text-wrap: normal; overflow-wrap: break-word; word-break: break-word; white-space: normal; } + .bubble.is-status-only { max-width: min(156px, calc(100vw - 18px)); padding: 8px 11px; border-radius: 999px; } + .bubble.is-status-only .bubble-header { display: grid; grid-template-columns: 18px minmax(0, auto); align-items: center; justify-content: center; } + .bubble.is-message-only { border-radius: 14px 14px 3px 14px; } + .bubble.has-actions { min-width: min(176px, calc(100vw - 18px)); } + .bubble.is-long-message { max-width: min(calc(var(--bubble-max-width) + 40px), calc(100vw - 24px)); max-height: min(var(--bubble-max-height-long), calc(100vh - ${bubbleBottom + 16}px)); } + .bubble.is-long-message .bubble-text { font-size: 10px; line-height: 13px; } + .bubble.is-very-long-message { max-width: min(calc(var(--bubble-max-width) + 80px), calc(100vw - 24px)); max-height: min(var(--bubble-max-height-very-long), calc(100vh - ${bubbleBottom + 16}px)); } + .bubble.is-very-long-message .bubble-text { 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-status-icon.is-success { background: #10b981; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(16, 185, 129, 0.34); } + .bubble-status-icon.is-error { background: #ef4444; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(239, 68, 68, 0.34); } + .bubble-status-icon.is-warning { background: #f59e0b; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(245, 158, 11, 0.34); } + .bubble-status-icon.is-info { 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: ""; position: absolute; inset: 0; width: 18px; height: 18px; background: radial-gradient(circle at 50% 50%, #fff 0 4px, transparent 4.5px); animation: status-pulse 820ms ease-in-out infinite; } + .bubble.is-thinking .bubble-status-icon::before, .bubble.is-waiting .bubble-status-icon::before { content: ""; position: absolute; left: 3px; top: 3px; box-sizing: border-box; width: 12px; height: 12px; border: 2px solid rgba(255, 255, 255, 0.96); border-top-color: rgba(255, 255, 255, 0.28); border-radius: 999px; animation: status-spin 1s linear infinite; } + .bubble.is-full-message { min-width: min(300px, calc(100vw - 24px)); max-width: min(calc(100vw - 24px), 720px); max-height: max(200px, calc(100vh - ${bubbleBottom + 20}px)); padding: 13px 15px; border-radius: 18px 18px 8px 18px; } + .bubble.is-full-message .bubble-header { font-size: 12px; line-height: 15px; } + .bubble.is-full-message .bubble-body { overflow-y: auto; padding-right: 2px; } + .bubble.is-full-message .bubble-text { display: block; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; -webkit-line-clamp: unset; -webkit-box-orient: initial; font-size: 12px; line-height: 16px; } + .bubble.is-plugin { gap: 6px; } + .bubble.is-plugin .bubble-markdown strong { font-weight: 860; } + .bubble.is-plugin .bubble-markdown em { font-style: italic; } + .bubble.is-plugin .bubble-markdown code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 9.5px; background: rgba(30, 58, 138, 0.08); border-radius: 4px; padding: 0 3px; } + .bubble-media { display: block; max-width: 96px; max-height: 64px; margin: 0 auto 2px; pointer-events: none; } + .bubble-plugin-icon { display: inline-block; font-size: 13px; line-height: 14px; margin-bottom: 2px; } + .bubble-actions { display: flex; flex-wrap: nowrap; gap: 5px; width: 100%; margin-top: 6px; min-width: 0; } + .bubble-action { flex: 1 1 0; min-width: 0; border: 0; border-radius: 8px; padding: 4px 7px; font: 760 10px/12px Inter, ui-sans-serif, system-ui, sans-serif; background: rgba(30, 58, 138, 0.10); color: #172033; cursor: pointer; pointer-events: auto; -webkit-app-region: no-drag; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .bubble-action:hover { background: rgba(30, 58, 138, 0.18); } + .bubble-action.is-primary { background: #2563eb; color: #fff; } + .bubble-action.is-primary:hover { background: #1d4ed8; } + .bubble-action.is-danger { background: #ef4444; color: #fff; } + .bubble-action.is-danger:hover { background: #dc2626; } + .bubble-input { display: flex; gap: 5px; margin-top: 6px; align-items: center; } + .bubble-input-control { box-sizing: border-box; flex: 1 1 auto; min-width: 0; border: 1px solid rgba(30, 58, 138, 0.25); border-radius: 8px; padding: 4px 7px; font: 700 10px/12px Inter, ui-sans-serif, system-ui, sans-serif; background: rgba(255, 255, 255, 0.9); color: #172033; pointer-events: auto; -webkit-app-region: no-drag; } + .bubble.is-pinned { + position: absolute; + left: 50%; + bottom: 6px; + z-index: 4; + transform: translateX(-50%); + width: 188px; + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; + padding: 6px 8px; + background: linear-gradient(135deg, rgba(241, 245, 249, 0.94), rgba(226, 232, 240, 0.92)); + color: #334155; + border: 1px solid rgba(255, 255, 255, 0.7); + border-radius: 12px; + box-shadow: 0 4px 10px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(15, 23, 42, 0.04), inset 0 1px 0 rgba(255, 255, 255, 0.5); + backdrop-filter: ${process.platform === "win32" ? "none" : "blur(8px)"}; + text-align: center; + max-height: none; + max-width: none; + animation: bubble-in 200ms cubic-bezier(0.2, 0, 0, 1); + } + .bubble.is-pinned::after { content: none !important; } + .bubble.is-pinned .bubble-body { width: 100%; text-align: center; overflow: visible; flex: 0 0 auto; } + .bubble.is-pinned .bubble-text { display: inline-block; -webkit-line-clamp: unset; -webkit-box-orient: initial; white-space: pre; overflow-wrap: normal; word-break: keep-all; font: 800 10px/13px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; letter-spacing: -0.03em; color: #334155; text-align: left; } + .bubble.is-pinned .bubble-actions { display: flex; flex-direction: row; flex-wrap: nowrap; gap: 4px; width: 100%; margin-top: 5px; justify-content: center; } + .bubble.is-pinned .bubble-action { flex: 1 1 auto; min-width: 0; padding: 3px 6px; font-size: 9px; font-weight: 700; line-height: 11px; border-radius: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: center; background: rgba(30, 58, 138, 0.08); color: #1e293b; transition: background 150ms ease; } + .bubble.is-pinned .bubble-action:hover { background: rgba(30, 58, 138, 0.14); } + .bubble.is-pinned .bubble-action.is-primary { background: #2563eb; color: #ffffff; } + .bubble.is-pinned .bubble-action.is-primary:hover { background: #1d4ed8; } + .bubble.is-pinned .bubble-action.is-danger { background: #ef4444; color: #ffffff; } + .bubble.is-pinned .bubble-action.is-danger:hover { background: #dc2626; } + .bubble.is-pinned.accent-blue { background: linear-gradient(135deg, rgba(219, 234, 254, 0.94), rgba(191, 219, 254, 0.92)); } + .bubble.is-pinned.accent-purple { background: linear-gradient(135deg, rgba(237, 233, 254, 0.94), rgba(221, 214, 254, 0.92)); } + .bubble.is-pinned.accent-green { background: linear-gradient(135deg, rgba(220, 252, 231, 0.94), rgba(187, 247, 208, 0.92)); } + .bubble.is-pinned.accent-amber { background: linear-gradient(135deg, rgba(254, 243, 199, 0.94), rgba(253, 230, 138, 0.92)); } + .bubble.is-pinned.accent-red { background: linear-gradient(135deg, rgba(254, 226, 226, 0.94), rgba(254, 202, 202, 0.92)); } + .bubble.is-pinned.accent-pink { background: linear-gradient(135deg, rgba(252, 231, 243, 0.94), rgba(251, 207, 232, 0.92)); } + .bubble.is-pinned.accent-slate { background: linear-gradient(135deg, rgba(241, 245, 249, 0.94), rgba(226, 232, 240, 0.92)); } + .stage.has-pinned .familiar-hitbox { bottom: ${Math.max(0, petBottom - hitPadding) + 28}px; } + .stage.has-pinned .bubble:not(.is-pinned) { bottom: ${bubbleBottom + 28}px; } + .bubble.is-plugin.accent-blue { background: linear-gradient(135deg, rgba(219, 234, 254, 0.97), rgba(191, 219, 254, 0.94)); } + .bubble.is-plugin.accent-purple { background: linear-gradient(135deg, rgba(237, 233, 254, 0.97), rgba(221, 214, 254, 0.94)); } + .bubble.is-plugin.accent-green { background: linear-gradient(135deg, rgba(220, 252, 231, 0.97), rgba(187, 247, 208, 0.94)); } + .bubble.is-plugin.accent-amber { background: linear-gradient(135deg, rgba(254, 243, 199, 0.97), rgba(253, 230, 138, 0.94)); } + .bubble.is-plugin.accent-red { background: linear-gradient(135deg, rgba(254, 226, 226, 0.97), rgba(254, 202, 202, 0.94)); } + .bubble.is-plugin.accent-pink { background: linear-gradient(135deg, rgba(252, 231, 243, 0.97), rgba(251, 207, 232, 0.94)); } + .bubble.is-plugin.accent-slate { background: linear-gradient(135deg, rgba(241, 245, 249, 0.97), rgba(226, 232, 240, 0.94)); } + .bubble-hud { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px 8px; + width: 100%; + margin: 2px 0; + box-sizing: border-box; + } + .bubble-hud.items-1 { + grid-template-columns: 1fr; + } + .bubble-hud.items-3 .bubble-hud-item:last-child { + grid-column: span 2; + } + .bubble-hud-item { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + width: 100%; + } + .bubble-hud-item-icon { + flex: 0 0 12px; + width: 12px; + height: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 11px; + line-height: 1; + font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", system-ui, sans-serif; + } + .bubble-hud-item-icon img, .bubble-hud-item-icon svg { + width: 12px; + height: 12px; + object-fit: contain; + display: block; + } + .bubble-hud-item-content { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + } + .bubble-hud-item-meta { + display: flex; + justify-content: flex-start; + align-items: baseline; + gap: 2px; + font-size: 8px; + font-weight: 700; + line-height: 1; + color: #475569; + } + .bubble-hud-item-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .bubble-hud-item-bar { + height: 4px; + background: rgba(71, 85, 105, 0.15); + border-radius: 99px; + overflow: hidden; + position: relative; + width: 100%; + } + .bubble-hud-item-fill { + height: 100%; + border-radius: 99px; + width: 0%; + transition: width 200ms ease; + } + .bubble-hud-item-fill.tone-amber { background: #d97706; } + .bubble-hud-item-fill.tone-blue { background: #2563eb; } + .bubble-hud-item-fill.tone-green { background: #16a34a; } + .bubble-hud-item-fill.tone-pink { background: #db2777; } + .bubble-hud-item-fill.tone-slate { background: #475569; } + .bubble-hud-item-fill.tone-red { background: #dc2626; } + @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% { opacity: 0.52; } 50% { opacity: 1; } } + @keyframes status-spin { to { transform: rotate(360deg); } } + @media (prefers-reduced-motion: reduce) { .sprite, .installed-sprite, .bubble, .bubble-status-icon::before { animation: none !important; } } + `; +} + +export function createSpriteStateCss(selector: ".sprite" | ".installed-sprite"): string { + const reactionRules = Object.keys(defaultPetSprite.states).map((state) => createSpriteRule(`html[data-reaction-state="${state}"] ${selector}`, state as UniversalSpriteState)); + const motionRules = (Object.entries(motionToSpriteState) as Array<[PetMotionState, UniversalSpriteState]>) + .filter(([motion]) => motion !== "idle") + .map(([motion, state]) => createSpriteRule(`html[data-motion-state="${motion}"] ${selector}`, state)); + return [...reactionRules, ...motionRules].join("\n"); +} + +function createSpriteRule(selector: string, state: UniversalSpriteState): string { + const row = defaultPetSprite.states[state]; + const iterations = "iterations" in row ? row.iterations : "infinite"; + return `${selector} { --sprite-row-y: -${row.row * defaultPetSprite.frameHeight}px; --sprite-frames: ${row.frames}; --sprite-duration: ${row.durationMs}ms; --sprite-iterations: ${iterations}; }`; +} + +const namedHostIconGlyphs: Record = { + info: "ℹ", check: "✓", alert: "⚠", heart: "💛", star: "★", bell: "🔔", coffee: "☕", timer: "⏱", + droplet: "💧", sparkles: "✨", zap: "⚡", moon: "☾", sun: "☀", food: "🍖", play: "🎾", pause: "⏸", +}; + +function createHudIconMarkup(item: PluginBubbleHudItem): string { + if (item.svgPath) { + const svg = readSafePluginSvg(item.svgPath); + if (svg) return svg; + } + if (item.iconName) { + return escapeHtml(namedHostIconGlyphs[item.iconName] ?? "•"); + } + return "•"; +} + +function createPluginHudMarkup(hud: PluginBubbleHud): string { + const itemsHtml = hud.items.map((item) => { + const value = Math.round(Math.max(0, Math.min(100, item.value))); + const iconMarkup = createHudIconMarkup(item); + const labelHtml = item.label ? `${escapeHtml(item.label)}` : ""; + const tone = item.tone ?? "slate"; + const ariaLabel = item.label ? ` aria-label="${escapeHtml(`${item.label} ${value}%`)}"` : ""; + return `
${labelHtml}
`; + }).join(""); + return `
${itemsHtml}
`; +} + +function createPluginBubbleMarkup(active: ActiveBubble, pinned: boolean): string { + const bubble = active.bubble; + const token = escapeHtml(active.token); + const toneClass = bubble.tone ? ` is-${bubble.tone}` : ""; + const accentClass = bubble.accent ? ` accent-${escapeHtml(bubble.accent)}` : ""; + const interactive = Boolean(bubble.actions?.length || bubble.input); + const clickDismiss = bubble.dismissOn ? bubble.dismissOn.includes("click") : !interactive; + const dismissAttr = clickDismiss ? ` data-dismiss-token="${token}"` : ""; + const parts: string[] = []; + if (bubble.indicator) parts.push(createPluginIndicatorMarkup(bubble.indicator)); + if (bubble.iconName || bubble.svgPath || bubble.imagePath) { + const media = bubble.svgPath || bubble.imagePath; + if (media) parts.push(``); + else if (bubble.iconName) parts.push(``); + } + const body = bubble.markdownHtml !== undefined + ? `
${bubble.markdownHtml}
` + : bubble.text !== undefined + ? `
${escapeHtml(bubble.text)}
` + : ""; + if (bubble.indicator && body) parts.push(``); + if (body) parts.push(body); + if (bubble.hud) { + parts.push(createPluginHudMarkup(bubble.hud)); + } + if (bubble.input) { + const input = bubble.input; + const inputId = escapeHtml(input.id); + const placeholder = input.placeholder ? ` placeholder="${escapeHtml(input.placeholder)}"` : ""; + const defaultValue = input.default !== undefined ? ` value="${escapeHtml(String(input.default))}"` : ""; + const control = input.type === "select" + ? `` + : ``; + parts.push(`
${control}
`); + } + if (bubble.actions?.length) { + const buttons = bubble.actions.map((action) => ``).join(""); + parts.push(`
${buttons}
`); + } + const actionsClass = bubble.actions?.length ? " has-actions" : ""; + const hudClass = bubble.hud ? " has-hud" : ""; + return `
${parts.join("")}
`; +} + +function createPluginIndicatorMarkup(indicator: PluginBubbleIndicator): string { + const toneClass = indicator.tone ? ` is-${indicator.tone}` : " is-info"; + const style = createIndicatorStyle(indicator); + const styleAttr = style ? ` style="${escapeHtml(style)}"` : ""; + const label = indicator.label ?? ""; + const icon = createIndicatorIconMarkup(indicator); + return `
${label ? `${escapeHtml(label)}` : ""}
`; +} + +function createIndicatorIconMarkup(indicator: PluginBubbleIndicator): { glyph: string; markup: string; hasSvg: boolean } { + if (indicator.iconSvgPath) { + const svg = readSafePluginSvg(indicator.iconSvgPath); + if (svg) return { glyph: "", markup: svg, hasSvg: true }; + } + if (indicator.imagePath) return { glyph: "", markup: ``, hasSvg: true }; + if (indicator.iconName) return { glyph: namedHostIconGlyphs[indicator.iconName] ?? "•", markup: "", hasSvg: false }; + return { glyph: "", markup: "", hasSvg: false }; +} + +function readSafePluginSvg(path: string): string { + try { return readFileSync(path, "utf8"); } + catch { return ""; } +} + +function createIndicatorStyle(indicator: PluginBubbleIndicator): string { + const declarations: string[] = []; + if (indicator.color) declarations.push(`color:${indicator.color}`); + if (indicator.background) declarations.push(`background:${indicator.background}`); + if (indicator.borderColor) declarations.push(`border:1px solid ${indicator.borderColor}`); + return declarations.join(";"); +} + +export function createPinnedBubbleMarkup(pluginBubbles: PetPluginBubbles | null): string { + if (!pluginBubbles?.pinned) return ""; + return createPluginBubbleMarkup(pluginBubbles.pinned, true); +} + +export function pluginBubblesCacheKey(pluginBubbles: PetPluginBubbles | null): string { + if (!pluginBubbles) return "none"; + return `${pluginBubbles.transient?.token ?? "-"}:${pluginBubbles.pinned?.token ?? "-"}`; +} + +export function createBubbleMarkup(display: PetTransientDisplay | null, paused: boolean, badgeReaction: PetStatusBadgeReaction | null, dismissToken?: string, pluginBubbles: PetPluginBubbles | null = null): string { + if (pluginBubbles?.transient) return createPluginBubbleMarkup(pluginBubbles.transient, false); + const suppressReactionMessage = display?.suppressReactionMessage === true; + const text = display?.message ?? display?.reactionMessage ?? (!suppressReactionMessage && display?.reaction ? pickReactionMessage(display.reaction, Math.random, getActiveLocale()) : undefined) ?? (paused ? t("familiar.paused") : ""); + const status = !paused && !suppressReactionMessage && badgeReaction ? getStatusBadge(badgeReaction) : null; + if (!text && !status) return ""; + const isExplicitMessage = Boolean(display?.message && !display?.reactionMessage); + const className = getBubbleClassName(text, isExplicitMessage, status?.className, display?.fullMessage === true); + const header = status ? `
${escapeHtml(status.label)}
` : ""; + const divider = status && text ? `` : ""; + const body = text ? `
${escapeHtml(text)}
` : ""; + const token = dismissToken ?? display?.dismissToken; + const dismissAttr = token ? ` data-dismiss-token="${escapeHtml(token)}"` : ""; + return `
${header}${divider}${body}
`; +} + +const statusBadgeIcons = { + check: '', + alert: '', + wavingHand: '', +} as const; + +function getStatusBadge(reaction: PetStatusBadgeReaction): { readonly className: string; readonly icon?: string; readonly iconSvg?: string; readonly label: string } | null { + if (reaction === "thinking") return { className: "is-thinking", icon: "", label: t("familiar.status.thinking") }; + if (reaction === "working" || reaction === "running") return { className: "is-busy", icon: "", label: t("familiar.status.working") }; + if (reaction === "editing") return { className: "is-busy", icon: "", label: t("familiar.status.editing") }; + if (reaction === "testing") return { className: "is-busy", icon: "", label: t("familiar.status.testing") }; + if (reaction === "waiting") return { className: "is-waiting", icon: "", label: t("familiar.status.waiting") }; + if (reaction === "success" || reaction === "celebrating") return { className: "is-success", iconSvg: statusBadgeIcons.check, label: t("familiar.status.done") }; + if (reaction === "error") return { className: "is-error", iconSvg: statusBadgeIcons.alert, label: t("familiar.status.oops") }; + if (reaction === "waving") return { className: "is-info", iconSvg: statusBadgeIcons.wavingHand, label: t("familiar.status.hi") }; + return null; +} + +export function getBubbleClassName(text: string, isExplicitMessage: boolean, statusClassName: string | undefined, fullMessage = false): string { + const statusClass = statusClassName ? ` ${statusClassName}` : ""; + if (!text) return `bubble is-status-only${statusClass}`; + if (fullMessage) { + return statusClassName + ? `bubble is-message${statusClass} is-full-message` + : `bubble is-message-only is-full-message${isExplicitMessage ? getBubbleLengthClass(text) : ""}`; + } + 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${statusClass}${lengthClass}`; +} + +function getBubbleLengthClass(text: string): string { + return text.length > 95 ? " is-very-long-message" : text.length > 56 ? " is-long-message" : ""; +} + +export function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export function escapeCssUrl(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", ""); +} diff --git a/apps/desktop/src/familiar-window.ts b/apps/desktop/src/familiar-window.ts index 215c0423..9df9588a 100644 --- a/apps/desktop/src/familiar-window.ts +++ b/apps/desktop/src/familiar-window.ts @@ -1,5 +1,4 @@ import { app, BrowserWindow, ipcMain, Menu, screen, type IpcMainEvent } from "electron"; -import { readFileSync } from "node:fs"; import { mkdir, stat, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -9,13 +8,15 @@ import { clampToVisibleWorkArea, defaultPetWindowSize, getDefaultPetInitialPosit import { builtInPet } from "./built-in-familiar.js"; import { getInstalledPetDir } from "./familiar-paths.js"; import { getActiveLocale, getActiveLocaleLang, t } from "./i18n/index.js"; -import type { FamiliarOSReaction } from "./local-ipc-protocol.js"; +import { createBubbleMarkup, createPetBodyMarkup, createPetWindowCss, createPinnedBubbleMarkup, createSpriteStateCss, escapeCssUrl, escapeHtml, type PetPluginBubbles, type PetStatusBadgeReaction, type PetTransientDisplay } from "./familiar-window-render.js"; import { pickReactionMessage } from "./reaction-messages.js"; import { debug, error as logError, info, warn } from "./logger.js"; import { executeDefaultPetPluginCommand, executeDefaultPetPluginMenuSelect, getDefaultPetPluginCommands, getDefaultPetPluginMenuItems } from "./plugin-service.js"; -import type { ActiveBubble } from "./plugin-bubble-arbiter.js"; -import type { PluginBubbleHud, PluginBubbleHudItem, PluginBubbleIndicator, PluginCommandForm } from "./plugin-sdk-types.js"; -import { defaultPetSprite, motionToSpriteState, resolveReactionSpriteState, type PetMotionState, type UniversalSpriteState } from "./reaction-animation-mapping.js"; +import type { PluginCommandForm } from "./plugin-sdk-types.js"; +import { defaultPetSprite, resolveReactionSpriteState, type PetMotionState, type UniversalSpriteState } from "./reaction-animation-mapping.js"; + +export type { PetPluginBubbles, PetStatusBadgeReaction, PetTransientDisplay } from "./familiar-window-render.js"; +export { pluginBubblesCacheKey } from "./familiar-window-render.js"; function resolvePreloadPath(name: string): string { if (app.isPackaged) { @@ -57,24 +58,6 @@ export interface AgentPetWindowOptions extends PetWindowInteractionHooks { readonly plainContextMenu?: boolean; } -/** Plugin-arbiter bubble content for one familiar surface (both slots). */ -export interface PetPluginBubbles { - readonly transient: ActiveBubble | null; - readonly pinned: ActiveBubble | null; -} - -export interface PetTransientDisplay { - readonly reaction?: FamiliarOSReaction; - readonly message?: string; - readonly reactionMessage?: string; - readonly suppressReactionMessage?: boolean; - readonly dismissToken?: string; - readonly fullMessage?: boolean; - readonly sticky?: boolean; -} - -export type PetStatusBadgeReaction = Exclude; - interface PetContentRender { readonly html: string; readonly bodyHtml: string; @@ -766,6 +749,10 @@ export function clearTransientReaction(display: PetTransientDisplay): PetTransie return { ...display, reaction: undefined }; } +function getReactionSpriteState(reaction: PetTransientDisplay["reaction"]): UniversalSpriteState { + return resolveReactionSpriteState(reaction, getAppStateSnapshot().preferences.reactionAnimationOverrides); +} + export function setPetReactionState(window: BrowserWindow, state: UniversalSpriteState): void { if (window.isDestroyed()) return; window.webContents.send("familiaros:familiar-reaction-state", state); @@ -1011,433 +998,6 @@ async function createInstalledPetRender(petId: string, displayName: string, paus }; } -function createPetBodyMarkup(stageLabel: string, bubble: string, spriteMarkup: string, pinnedBubble = "", hasPinned = false): string { - return `
- ${pinnedBubble} - ${bubble} - -
`; -} - -function createPetWindowCss(paused: boolean, scale: PetScaleValue, layout?: Partial): string { - const opacity = paused ? "0.62" : "1"; - const playState = paused ? "paused" : "running"; - const scaledWidth = Math.ceil(defaultPetSprite.frameWidth * scale); - const scaledHeight = Math.ceil(defaultPetSprite.frameHeight * scale); - const petBottom = 22; - const hitPadding = 18; - const bubbleBottom = Math.ceil(petBottom + scaledHeight + 8); - const bubbleTop = Math.ceil(petBottom + scaledHeight + 8); - const bubbleMaxHeight = layout?.bubbleMaxHeight ?? 200; - const bubbleMaxHeightLong = layout?.bubbleMaxHeightLong ?? 240; - const bubbleMaxHeightVeryLong = layout?.bubbleMaxHeightVeryLong ?? 300; - const petShellFilter = process.platform === "win32" ? "none" : "drop-shadow(0 10px 12px rgba(15, 23, 42, 0.24)) drop-shadow(0 2px 3px rgba(15, 23, 42, 0.18))"; - const bubbleBackdropFilter = process.platform === "win32" ? "none" : "blur(10px)"; - return ` - :root { - --bubble-max-width: ${Math.max(220, Math.min(720, scaledWidth + 80))}px; - --bubble-max-height: ${bubbleMaxHeight}px; - --bubble-max-height-long: ${bubbleMaxHeightLong}px; - --bubble-max-height-very-long: ${bubbleMaxHeightVeryLong}px; - } - :root { color-scheme: dark; --familiar-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; } - .familiar-hitbox { position: absolute; left: 50%; bottom: ${Math.max(0, petBottom - hitPadding)}px; z-index: 1; width: ${scaledWidth + hitPadding * 2}px; height: ${scaledHeight + hitPadding * 2}px; display: grid; place-items: center; transform: translateX(-50%); pointer-events: auto; -webkit-app-region: no-drag; cursor: grab; } - .familiar-shell { position: relative; width: ${scaledWidth}px; height: ${scaledHeight}px; display: block; opacity: var(--familiar-opacity); filter: ${petShellFilter}; 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(var(--bubble-max-width), calc(100vw - 24px)); max-height: min(var(--bubble-max-height), calc(100vh - ${bubbleBottom + 16}px)); 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: ${bubbleBackdropFilter}; transform: translateX(-50%); transform-origin: 64% 100%; animation: bubble-in 180ms cubic-bezier(0.2, 0, 0, 1); } - .bubble.is-below { bottom: auto; top: ${bubbleTop}px; transform-origin: 64% 0; } - .bubble.is-below::after { top: -7px; bottom: auto; border: none; border-top: 1px solid rgba(255, 255, 255, 0.56); border-left: 1px solid rgba(255, 255, 255, 0.56); border-bottom-right-radius: 0; border-top-left-radius: 3px; transform: translateX(-50%) rotate(45deg); } - .bubble[data-dismiss-token] { cursor: pointer; } - .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 { position: relative; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 18px; width: 18px; min-width: 18px; height: 18px; border-radius: 999px; background: #3b82f6; color: #fff; font: 900 12px/18px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; text-align: center; 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); display: block; width: 18px; height: 18px; line-height: 18px; text-align: center; transform: none; } - .bubble-status-icon.has-svg::before { content: none; } - .bubble-status-icon svg { display: block; width: 14px; height: 14px; color: currentColor; } - .bubble-status-icon img { display: block; width: 14px; height: 14px; object-fit: contain; } - .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%; flex: 1 1 auto; min-height: 0; color: #172033; font: 720 10.5px/13.5px Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic", "Meiryo", "Malgun Gothic", "Apple SD Gothic Neo", "PingFang SC", "PingFang TC", "Microsoft YaHei", "Microsoft JhengHei", "Noto Sans CJK JP", "Noto Sans CJK KR", "Noto Sans CJK SC", "Noto Sans CJK TC", sans-serif; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; padding-right: 4px; } - .bubble-text { display: block; min-width: 0; overflow: visible; text-wrap: normal; overflow-wrap: break-word; word-break: break-word; white-space: normal; } - .bubble.is-status-only { max-width: min(156px, calc(100vw - 18px)); padding: 8px 11px; border-radius: 999px; } - .bubble.is-status-only .bubble-header { display: grid; grid-template-columns: 18px minmax(0, auto); align-items: center; justify-content: center; } - .bubble.is-message-only { border-radius: 14px 14px 3px 14px; } - .bubble.has-actions { min-width: min(176px, calc(100vw - 18px)); } - .bubble.is-long-message { max-width: min(calc(var(--bubble-max-width) + 40px), calc(100vw - 24px)); max-height: min(var(--bubble-max-height-long), calc(100vh - ${bubbleBottom + 16}px)); } - .bubble.is-long-message .bubble-text { font-size: 10px; line-height: 13px; } - .bubble.is-very-long-message { max-width: min(calc(var(--bubble-max-width) + 80px), calc(100vw - 24px)); max-height: min(var(--bubble-max-height-very-long), calc(100vh - ${bubbleBottom + 16}px)); } - .bubble.is-very-long-message .bubble-text { 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-status-icon.is-success { background: #10b981; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(16, 185, 129, 0.34); } - .bubble-status-icon.is-error { background: #ef4444; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(239, 68, 68, 0.34); } - .bubble-status-icon.is-warning { background: #f59e0b; box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.28), 0 2px 7px rgba(245, 158, 11, 0.34); } - .bubble-status-icon.is-info { 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: ""; position: absolute; inset: 0; width: 18px; height: 18px; background: radial-gradient(circle at 50% 50%, #fff 0 4px, transparent 4.5px); animation: status-pulse 820ms ease-in-out infinite; } - .bubble.is-thinking .bubble-status-icon::before, .bubble.is-waiting .bubble-status-icon::before { content: ""; position: absolute; left: 3px; top: 3px; box-sizing: border-box; width: 12px; height: 12px; border: 2px solid rgba(255, 255, 255, 0.96); border-top-color: rgba(255, 255, 255, 0.28); border-radius: 999px; animation: status-spin 1s linear infinite; } - .bubble.is-full-message { min-width: min(300px, calc(100vw - 24px)); max-width: min(calc(100vw - 24px), 720px); max-height: max(200px, calc(100vh - ${bubbleBottom + 20}px)); padding: 13px 15px; border-radius: 18px 18px 8px 18px; } - .bubble.is-full-message .bubble-header { font-size: 12px; line-height: 15px; } - .bubble.is-full-message .bubble-body { overflow-y: auto; padding-right: 2px; } - .bubble.is-full-message .bubble-text { display: block; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; -webkit-line-clamp: unset; -webkit-box-orient: initial; font-size: 12px; line-height: 16px; } - .bubble.is-plugin { gap: 6px; } - .bubble.is-plugin .bubble-markdown strong { font-weight: 860; } - .bubble.is-plugin .bubble-markdown em { font-style: italic; } - .bubble.is-plugin .bubble-markdown code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 9.5px; background: rgba(30, 58, 138, 0.08); border-radius: 4px; padding: 0 3px; } - .bubble-media { display: block; max-width: 96px; max-height: 64px; margin: 0 auto 2px; pointer-events: none; } - .bubble-plugin-icon { display: inline-block; font-size: 13px; line-height: 14px; margin-bottom: 2px; } - .bubble-actions { display: flex; flex-wrap: nowrap; gap: 5px; width: 100%; margin-top: 6px; min-width: 0; } - .bubble-action { flex: 1 1 0; min-width: 0; border: 0; border-radius: 8px; padding: 4px 7px; font: 760 10px/12px Inter, ui-sans-serif, system-ui, sans-serif; background: rgba(30, 58, 138, 0.10); color: #172033; cursor: pointer; pointer-events: auto; -webkit-app-region: no-drag; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - .bubble-action:hover { background: rgba(30, 58, 138, 0.18); } - .bubble-action.is-primary { background: #2563eb; color: #fff; } - .bubble-action.is-primary:hover { background: #1d4ed8; } - .bubble-action.is-danger { background: #ef4444; color: #fff; } - .bubble-action.is-danger:hover { background: #dc2626; } - .bubble-input { display: flex; gap: 5px; margin-top: 6px; align-items: center; } - .bubble-input-control { box-sizing: border-box; flex: 1 1 auto; min-width: 0; border: 1px solid rgba(30, 58, 138, 0.25); border-radius: 8px; padding: 4px 7px; font: 700 10px/12px Inter, ui-sans-serif, system-ui, sans-serif; background: rgba(255, 255, 255, 0.9); color: #172033; pointer-events: auto; -webkit-app-region: no-drag; } - .bubble.is-pinned { - position: absolute; - left: 50%; - bottom: 6px; - z-index: 4; - transform: translateX(-50%); - width: 188px; - box-sizing: border-box; - display: flex; - flex-direction: column; - align-items: center; - padding: 6px 8px; - background: linear-gradient(135deg, rgba(241, 245, 249, 0.94), rgba(226, 232, 240, 0.92)); - color: #334155; - border: 1px solid rgba(255, 255, 255, 0.7); - border-radius: 12px; - box-shadow: 0 4px 10px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(15, 23, 42, 0.04), inset 0 1px 0 rgba(255, 255, 255, 0.5); - backdrop-filter: ${process.platform === "win32" ? "none" : "blur(8px)"}; - text-align: center; - max-height: none; - max-width: none; - animation: bubble-in 200ms cubic-bezier(0.2, 0, 0, 1); - } - .bubble.is-pinned::after { content: none !important; } - .bubble.is-pinned .bubble-body { width: 100%; text-align: center; overflow: visible; flex: 0 0 auto; } - .bubble.is-pinned .bubble-text { display: inline-block; -webkit-line-clamp: unset; -webkit-box-orient: initial; white-space: pre; overflow-wrap: normal; word-break: keep-all; font: 800 10px/13px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; letter-spacing: -0.03em; color: #334155; text-align: left; } - .bubble.is-pinned .bubble-actions { display: flex; flex-direction: row; flex-wrap: nowrap; gap: 4px; width: 100%; margin-top: 5px; justify-content: center; } - .bubble.is-pinned .bubble-action { flex: 1 1 auto; min-width: 0; padding: 3px 6px; font-size: 9px; font-weight: 700; line-height: 11px; border-radius: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: center; background: rgba(30, 58, 138, 0.08); color: #1e293b; transition: background 150ms ease; } - .bubble.is-pinned .bubble-action:hover { background: rgba(30, 58, 138, 0.14); } - .bubble.is-pinned .bubble-action.is-primary { background: #2563eb; color: #ffffff; } - .bubble.is-pinned .bubble-action.is-primary:hover { background: #1d4ed8; } - .bubble.is-pinned .bubble-action.is-danger { background: #ef4444; color: #ffffff; } - .bubble.is-pinned .bubble-action.is-danger:hover { background: #dc2626; } - .bubble.is-pinned.accent-blue { background: linear-gradient(135deg, rgba(219, 234, 254, 0.94), rgba(191, 219, 254, 0.92)); } - .bubble.is-pinned.accent-purple { background: linear-gradient(135deg, rgba(237, 233, 254, 0.94), rgba(221, 214, 254, 0.92)); } - .bubble.is-pinned.accent-green { background: linear-gradient(135deg, rgba(220, 252, 231, 0.94), rgba(187, 247, 208, 0.92)); } - .bubble.is-pinned.accent-amber { background: linear-gradient(135deg, rgba(254, 243, 199, 0.94), rgba(253, 230, 138, 0.92)); } - .bubble.is-pinned.accent-red { background: linear-gradient(135deg, rgba(254, 226, 226, 0.94), rgba(254, 202, 202, 0.92)); } - .bubble.is-pinned.accent-pink { background: linear-gradient(135deg, rgba(252, 231, 243, 0.94), rgba(251, 207, 232, 0.92)); } - .bubble.is-pinned.accent-slate { background: linear-gradient(135deg, rgba(241, 245, 249, 0.94), rgba(226, 232, 240, 0.92)); } - .stage.has-pinned .familiar-hitbox { bottom: ${Math.max(0, petBottom - hitPadding) + 28}px; } - .stage.has-pinned .bubble:not(.is-pinned) { bottom: ${bubbleBottom + 28}px; } - .bubble.is-plugin.accent-blue { background: linear-gradient(135deg, rgba(219, 234, 254, 0.97), rgba(191, 219, 254, 0.94)); } - .bubble.is-plugin.accent-purple { background: linear-gradient(135deg, rgba(237, 233, 254, 0.97), rgba(221, 214, 254, 0.94)); } - .bubble.is-plugin.accent-green { background: linear-gradient(135deg, rgba(220, 252, 231, 0.97), rgba(187, 247, 208, 0.94)); } - .bubble.is-plugin.accent-amber { background: linear-gradient(135deg, rgba(254, 243, 199, 0.97), rgba(253, 230, 138, 0.94)); } - .bubble.is-plugin.accent-red { background: linear-gradient(135deg, rgba(254, 226, 226, 0.97), rgba(254, 202, 202, 0.94)); } - .bubble.is-plugin.accent-pink { background: linear-gradient(135deg, rgba(252, 231, 243, 0.97), rgba(251, 207, 232, 0.94)); } - .bubble.is-plugin.accent-slate { background: linear-gradient(135deg, rgba(241, 245, 249, 0.97), rgba(226, 232, 240, 0.94)); } - .bubble-hud { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 6px 8px; - width: 100%; - margin: 2px 0; - box-sizing: border-box; - } - .bubble-hud.items-1 { - grid-template-columns: 1fr; - } - .bubble-hud.items-3 .bubble-hud-item:last-child { - grid-column: span 2; - } - .bubble-hud-item { - display: flex; - align-items: center; - gap: 6px; - min-width: 0; - width: 100%; - } - .bubble-hud-item-icon { - flex: 0 0 12px; - width: 12px; - height: 12px; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 11px; - line-height: 1; - font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", system-ui, sans-serif; - } - .bubble-hud-item-icon img, .bubble-hud-item-icon svg { - width: 12px; - height: 12px; - object-fit: contain; - display: block; - } - .bubble-hud-item-content { - flex: 1 1 auto; - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; - } - .bubble-hud-item-meta { - display: flex; - justify-content: flex-start; - align-items: baseline; - gap: 2px; - font-size: 8px; - font-weight: 700; - line-height: 1; - color: #475569; - } - .bubble-hud-item-label { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - .bubble-hud-item-bar { - height: 4px; - background: rgba(71, 85, 105, 0.15); - border-radius: 99px; - overflow: hidden; - position: relative; - width: 100%; - } - .bubble-hud-item-fill { - height: 100%; - border-radius: 99px; - width: 0%; - transition: width 200ms ease; - } - .bubble-hud-item-fill.tone-amber { background: #d97706; } - .bubble-hud-item-fill.tone-blue { background: #2563eb; } - .bubble-hud-item-fill.tone-green { background: #16a34a; } - .bubble-hud-item-fill.tone-pink { background: #db2777; } - .bubble-hud-item-fill.tone-slate { background: #475569; } - .bubble-hud-item-fill.tone-red { background: #dc2626; } - @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% { opacity: 0.52; } 50% { opacity: 1; } } - @keyframes status-spin { to { transform: rotate(360deg); } } - @media (prefers-reduced-motion: reduce) { .sprite, .installed-sprite, .bubble, .bubble-status-icon::before { animation: none !important; } } - `; -} - -function createSpriteStateCss(selector: ".sprite" | ".installed-sprite"): string { - const reactionRules = Object.keys(defaultPetSprite.states).map((state) => createSpriteRule(`html[data-reaction-state="${state}"] ${selector}`, state as UniversalSpriteState)); - const motionRules = (Object.entries(motionToSpriteState) as Array<[PetMotionState, UniversalSpriteState]>) - .filter(([motion]) => motion !== "idle") - .map(([motion, state]) => createSpriteRule(`html[data-motion-state="${motion}"] ${selector}`, state)); - return [...reactionRules, ...motionRules].join("\n"); -} - -function createSpriteRule(selector: string, state: UniversalSpriteState): string { - const row = defaultPetSprite.states[state]; - const iterations = "iterations" in row ? row.iterations : "infinite"; - return `${selector} { --sprite-row-y: -${row.row * defaultPetSprite.frameHeight}px; --sprite-frames: ${row.frames}; --sprite-duration: ${row.durationMs}ms; --sprite-iterations: ${iterations}; }`; -} - -function getReactionSpriteState(reaction: FamiliarOSReaction | undefined): UniversalSpriteState { - return resolveReactionSpriteState(reaction, getAppStateSnapshot().preferences.reactionAnimationOverrides); -} - -const namedHostIconGlyphs: Record = { - info: "ℹ", check: "✓", alert: "⚠", heart: "💛", star: "★", bell: "🔔", coffee: "☕", timer: "⏱", - droplet: "💧", sparkles: "✨", zap: "⚡", moon: "☾", sun: "☀", food: "🍖", play: "🎾", pause: "⏸", -}; - -function createHudIconMarkup(item: PluginBubbleHudItem): string { - if (item.svgPath) { - const svg = readSafePluginSvg(item.svgPath); - if (svg) return svg; - } - if (item.iconName) { - return escapeHtml(namedHostIconGlyphs[item.iconName] ?? "•"); - } - return "•"; -} - -function createPluginHudMarkup(hud: PluginBubbleHud): string { - const itemsHtml = hud.items.map((item) => { - const value = Math.round(Math.max(0, Math.min(100, item.value))); - const iconMarkup = createHudIconMarkup(item); - const labelHtml = item.label ? `${escapeHtml(item.label)}` : ""; - const tone = item.tone ?? "slate"; - const ariaLabel = item.label ? ` aria-label="${escapeHtml(`${item.label} ${value}%`)}"` : ""; - return `
${labelHtml}
`; - }).join(""); - return `
${itemsHtml}
`; -} - -/** Render a plugin-arbiter bubble descriptor into host markup (descriptor-only — no plugin markup). */ -function createPluginBubbleMarkup(active: ActiveBubble, pinned: boolean): string { - const bubble = active.bubble; - const token = escapeHtml(active.token); - const toneClass = bubble.tone ? ` is-${bubble.tone}` : ""; - const accentClass = bubble.accent ? ` accent-${escapeHtml(bubble.accent)}` : ""; - const interactive = Boolean(bubble.actions?.length || bubble.input); - const clickDismiss = bubble.dismissOn ? bubble.dismissOn.includes("click") : !interactive; - const dismissAttr = clickDismiss ? ` data-dismiss-token="${token}"` : ""; - const parts: string[] = []; - if (bubble.indicator) parts.push(createPluginIndicatorMarkup(bubble.indicator)); - if (bubble.iconName || bubble.svgPath || bubble.imagePath) { - const media = bubble.svgPath || bubble.imagePath; - if (media) parts.push(``); - else if (bubble.iconName) parts.push(``); - } - const body = bubble.markdownHtml !== undefined - ? `
${bubble.markdownHtml}
` - : bubble.text !== undefined - ? `
${escapeHtml(bubble.text)}
` - : ""; - if (bubble.indicator && body) parts.push(``); - if (body) parts.push(body); - if (bubble.hud) { - parts.push(createPluginHudMarkup(bubble.hud)); - } - if (bubble.input) { - const input = bubble.input; - const inputId = escapeHtml(input.id); - const placeholder = input.placeholder ? ` placeholder="${escapeHtml(input.placeholder)}"` : ""; - const defaultValue = input.default !== undefined ? ` value="${escapeHtml(String(input.default))}"` : ""; - const control = input.type === "select" - ? `` - : ``; - parts.push(`
${control}
`); - } - if (bubble.actions?.length) { - const buttons = bubble.actions.map((action) => ``).join(""); - parts.push(`
${buttons}
`); - } - const actionsClass = bubble.actions?.length ? " has-actions" : ""; - const hudClass = bubble.hud ? " has-hud" : ""; - return `
${parts.join("")}
`; -} - -function createPluginIndicatorMarkup(indicator: PluginBubbleIndicator): string { - const toneClass = indicator.tone ? ` is-${indicator.tone}` : " is-info"; - const style = createIndicatorStyle(indicator); - const styleAttr = style ? ` style="${escapeHtml(style)}"` : ""; - const label = indicator.label ?? ""; - const icon = createIndicatorIconMarkup(indicator); - return `
${label ? `${escapeHtml(label)}` : ""}
`; -} - -function createIndicatorIconMarkup(indicator: PluginBubbleIndicator): { glyph: string; markup: string; hasSvg: boolean } { - if (indicator.iconSvgPath) { - const svg = readSafePluginSvg(indicator.iconSvgPath); - if (svg) return { glyph: "", markup: svg, hasSvg: true }; - } - if (indicator.imagePath) return { glyph: "", markup: ``, hasSvg: true }; - if (indicator.iconName) return { glyph: namedHostIconGlyphs[indicator.iconName] ?? "•", markup: "", hasSvg: false }; - return { glyph: "", markup: "", hasSvg: false }; -} - -function readSafePluginSvg(path: string): string { - try { return readFileSync(path, "utf8"); } - catch { return ""; } -} - -function createIndicatorStyle(indicator: PluginBubbleIndicator): string { - const declarations: string[] = []; - if (indicator.color) declarations.push(`color:${indicator.color}`); - if (indicator.background) declarations.push(`background:${indicator.background}`); - if (indicator.borderColor) declarations.push(`border:1px solid ${indicator.borderColor}`); - return declarations.join(";"); -} - -function createPinnedBubbleMarkup(pluginBubbles: PetPluginBubbles | null): string { - if (!pluginBubbles?.pinned) return ""; - return createPluginBubbleMarkup(pluginBubbles.pinned, true); -} - -export function pluginBubblesCacheKey(pluginBubbles: PetPluginBubbles | null): string { - if (!pluginBubbles) return "none"; - return `${pluginBubbles.transient?.token ?? "-"}:${pluginBubbles.pinned?.token ?? "-"}`; -} - -function createBubbleMarkup(display: PetTransientDisplay | null, paused: boolean, badgeReaction: PetStatusBadgeReaction | null, dismissToken?: string, pluginBubbles: PetPluginBubbles | null = null): string { - if (pluginBubbles?.transient) return createPluginBubbleMarkup(pluginBubbles.transient, false); - const suppressReactionMessage = display?.suppressReactionMessage === true; - const text = display?.message ?? display?.reactionMessage ?? (!suppressReactionMessage && display?.reaction ? pickReactionMessage(display.reaction, Math.random, getActiveLocale()) : undefined) ?? (paused ? t("familiar.paused") : ""); - const status = !paused && !suppressReactionMessage && badgeReaction ? getStatusBadge(badgeReaction) : null; - if (!text && !status) return ""; - const isExplicitMessage = Boolean(display?.message && !display?.reactionMessage); - const className = getBubbleClassName(text, isExplicitMessage, status?.className, display?.fullMessage === true); - const header = status ? `
${escapeHtml(status.label)}
` : ""; - const divider = status && text ? `` : ""; - const body = text ? `
${escapeHtml(text)}
` : ""; - // Use provided dismissToken, fallback to display's dismissToken for transient messages - const token = dismissToken ?? display?.dismissToken; - const dismissAttr = token ? ` data-dismiss-token="${escapeHtml(token)}"` : ""; - return `
${header}${divider}${body}
`; -} - -const statusBadgeIcons = { - check: '', - alert: '', - wavingHand: '', -} as const; - -function getStatusBadge(reaction: PetStatusBadgeReaction): { readonly className: string; readonly icon?: string; readonly iconSvg?: string; readonly label: string } | null { - if (reaction === "thinking") return { className: "is-thinking", icon: "", label: t("familiar.status.thinking") }; - if (reaction === "working" || reaction === "running") return { className: "is-busy", icon: "", label: t("familiar.status.working") }; - if (reaction === "editing") return { className: "is-busy", icon: "", label: t("familiar.status.editing") }; - if (reaction === "testing") return { className: "is-busy", icon: "", label: t("familiar.status.testing") }; - if (reaction === "waiting") return { className: "is-waiting", icon: "", label: t("familiar.status.waiting") }; - if (reaction === "success" || reaction === "celebrating") return { className: "is-success", iconSvg: statusBadgeIcons.check, label: t("familiar.status.done") }; - if (reaction === "error") return { className: "is-error", iconSvg: statusBadgeIcons.alert, label: t("familiar.status.oops") }; - if (reaction === "waving") return { className: "is-info", iconSvg: statusBadgeIcons.wavingHand, label: t("familiar.status.hi") }; - return null; -} - -function getBubbleClassName(text: string, isExplicitMessage: boolean, statusClassName: string | undefined, fullMessage = false): string { - const statusClass = statusClassName ? ` ${statusClassName}` : ""; - if (!text) return `bubble is-status-only${statusClass}`; - if (fullMessage) { - return statusClassName - ? `bubble is-message${statusClass} is-full-message` - : `bubble is-message-only is-full-message${isExplicitMessage ? getBubbleLengthClass(text) : ""}`; - } - 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${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 { - return value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - -function escapeCssUrl(value: string): string { - return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", ""); -} - function installMotionStatePublisher(window: BrowserWindow): void { let lastX = window.getPosition()[0]; let lastSent: PetMotionState = "idle"; diff --git a/apps/desktop/tests/familiar-window-render.test.ts b/apps/desktop/tests/familiar-window-render.test.ts new file mode 100644 index 00000000..986b8828 --- /dev/null +++ b/apps/desktop/tests/familiar-window-render.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { ActiveBubble } from "../src/plugin-bubble-arbiter.js"; +import { createBubbleMarkup, createPetWindowCss, escapeHtml, getBubbleClassName, pluginBubblesCacheKey } from "../src/familiar-window-render.js"; + +const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const familiarWindowSource = readFileSync(resolve(desktopRoot, "src/familiar-window.ts"), "utf8"); +const familiarWindowRenderSource = readFileSync(resolve(desktopRoot, "src/familiar-window-render.ts"), "utf8"); + +assert.match(familiarWindowSource, /from "\.\/familiar-window-render(?:\.js)?"/, "familiar-window must import the extracted render helper module."); +assert.match(familiarWindowRenderSource, /export function createPetWindowCss/, "familiar-window-render must export the shared CSS builder."); +assert.match(familiarWindowRenderSource, /export function createBubbleMarkup/, "familiar-window-render must export the shared bubble markup builder."); +assert.match(familiarWindowRenderSource, /export function getBubbleClassName/, "familiar-window-render must export the bubble classification helper."); + +const css = createPetWindowCss(false, 1.5, { + bubbleMaxHeight: 180, + bubbleMaxHeightLong: 220, + bubbleMaxHeightVeryLong: 260, +}); +assert.match(css, /--bubble-max-width: 368px/, "scaled familiar CSS must widen the bubble cap."); +assert.match(css, /--bubble-max-height: 180px/, "custom bubble max-height must flow into the CSS shell."); +assert.match(css, /body \{ -webkit-app-region: no-drag; pointer-events: none; \}/, "familiar window CSS must keep transparent backgrounds click-through."); +assert.match(css, /@media \(prefers-reduced-motion: reduce\)/, "familiar window CSS must preserve reduced-motion handling."); + +assert.equal(getBubbleClassName("", false, "is-success"), "bubble is-status-only is-success", "status-only bubbles must keep their status tone."); +assert.equal(getBubbleClassName("x".repeat(60), true, undefined), "bubble is-message-only is-long-message", "explicit medium messages must get the long-message class."); +assert.equal(getBubbleClassName("x".repeat(100), true, undefined), "bubble is-message-only is-very-long-message", "explicit long messages must get the very-long-message class."); +assert.equal(escapeHtml(`5 < 7 & "quoted"`), "5 < 7 & "quoted"", "bubble helper HTML escaping must stay fail-closed."); + +const transientBubble: ActiveBubble = { + token: "plugin-bubble:<1>", + pluginId: "plugin.example", + bubble: { + text: "Ready ", + tone: "info", + accent: "blue", + priority: "normal", + actions: [ + { id: "open", label: "Open ", style: "primary", dismissesBubble: true }, + ], + }, +}; + +const markup = createBubbleMarkup(null, false, null, undefined, { + transient: transientBubble, + pinned: null, +}); +assert.match(markup, /bubble is-plugin/, "plugin transient bubbles must render through the shared plugin bubble template."); +assert.match(markup, /accent-blue/, "plugin bubble accent classes must survive the extracted render seam."); +assert.match(markup, /data-bubble-token="plugin-bubble:<1>"/, "plugin bubble tokens must be HTML-escaped in markup."); +assert.match(markup, /Open <settings>/, "plugin bubble actions must be HTML-escaped in markup."); +assert.equal(pluginBubblesCacheKey({ transient: transientBubble, pinned: null }), "plugin-bubble:<1>:-", "plugin bubble cache keys must remain stable across the render seam."); + +console.error("Familiar window render helper validation passed.");