sync: update from private repo (0dd1a8e)
Some checks failed
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync 2026-06-11 11:50:39 +00:00
parent 3b1645cc91
commit c5be399fdd
15 changed files with 337 additions and 65 deletions

View File

@ -88,6 +88,16 @@ describe('callReflectionLlm', () => {
expect(result.durationMs).toBeGreaterThanOrEqual(0);
});
it('reconstructs raw with the resolved tool_call + usage (#500)', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okStream()));
const result = await callReflectionLlm(cfg, 'system', 'user');
const raw = result.raw as { usage?: unknown; choices?: Array<{ message?: { tool_calls?: Array<{ function?: { name?: string; arguments?: string } }> } }> };
expect(raw.usage).toEqual({ prompt_tokens: 42, completion_tokens: 17 });
const tc = raw.choices?.[0]?.message?.tool_calls?.[0]?.function;
expect(tc?.name).toBe('submit_reflection');
expect(JSON.parse(tc!.arguments!)).toMatchObject({ reasoning: 'x' });
});
it('retries a 5xx (backend tool-call parse failure) and succeeds on resample', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(httpError(500, '{"error":{"message":"Failed to parse input at pos 41"}}'))

View File

@ -134,6 +134,7 @@ async function callOnce(
let usage: { prompt_tokens: number; completion_tokens: number } | undefined;
let errorMsg: string | null = null;
let errorGatewayType: string | undefined;
let backendId: string | undefined;
for await (const event of client.chat(
messages,
@ -148,6 +149,8 @@ async function callOnce(
}
} else if (event.type === 'done') {
usage = event.usage;
} else if (event.type === 'backend') {
backendId = event.backendId;
} else if (event.type === 'error') {
errorMsg = event.error;
errorGatewayType = event.gatewayErrorType;
@ -173,6 +176,15 @@ async function callOnce(
tokensIn: usage?.prompt_tokens ?? 0,
tokensOut: usage?.completion_tokens ?? 0,
durationMs: Date.now() - start,
raw: { usage },
// Reconstruct the OpenAI response shape so `raw` keeps debugging fidelity
// after the move to the streaming client (issue #500): the resolved
// tool_call, usage, and (proxy) backend id rather than just `{ usage }`.
raw: {
usage,
backendId,
choices: [
{ message: { tool_calls: [{ function: { name: 'submit_reflection', arguments: JSON.stringify(parsed) } }] } },
],
},
};
}

View File

@ -5,6 +5,7 @@ import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import { resolveAndGuard, resolveOutputPathWithin } from './core.js';
import { logger } from '../../logger.js';
import { recordLlmUsage } from '../../llm/usage-recorder.js';
// --- Supported image extensions ---
@ -310,7 +311,9 @@ export async function callVisionModel(
}
const json = (await response.json()) as {
model?: string;
choices?: Array<{ message?: { content?: string } }>;
usage?: { prompt_tokens?: number; completion_tokens?: number };
};
const content = json.choices?.[0]?.message?.content;
@ -318,6 +321,20 @@ export async function callVisionModel(
return { output: 'Vision API returned no content', isError: true };
}
// This vision call is a raw fetch outside OpenAICompatClient, so it would
// otherwise miss the per-user usage ledger. Record it directly (issue
// #499). Vision uses its own endpoint directly (never the gateway).
let route = 'unknown';
try { route = new URL(visionBaseUrl).host || 'unknown'; } catch { /* keep 'unknown' */ }
recordLlmUsage({
userId: ctx.userId ?? 'local',
source: 'direct',
model: json.model || visionModel,
route,
tokensIn: json.usage?.prompt_tokens ?? 0,
tokensOut: json.usage?.completion_tokens ?? 0,
});
return { output: content, isError: false };
} catch (e) {
if ((e as Error).name === 'AbortError') {

View File

@ -0,0 +1,65 @@
/**
* ReadImage vision call records to the per-user usage ledger (issue #499).
* The vision call is a raw fetch outside OpenAICompatClient, so it records
* directly via recordLlmUsage.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { callVisionModel } from './image.js';
import type { ToolContext } from './core.js';
import { setLlmUsageRecorder, type LlmUsageEvent } from '../../llm/usage-recorder.js';
function ctx(): ToolContext {
return {
workspacePath: '/tmp',
userId: 'u1',
toolsConfig: { visionBaseUrl: 'http://vision-host:11434/v1', visionModel: 'qwen2-vl' },
} as unknown as ToolContext;
}
afterEach(() => {
setLlmUsageRecorder(null);
vi.unstubAllGlobals();
});
describe('callVisionModel usage recording', () => {
it('records a direct usage event with the vision host as route', async () => {
const events: LlmUsageEvent[] = [];
setLlmUsageRecorder((e) => events.push(e));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
model: 'qwen2-vl',
choices: [{ message: { content: 'a cat' } }],
usage: { prompt_tokens: 250, completion_tokens: 12 },
}),
}));
const res = await callVisionModel('data:image/png;base64,xxx', 'describe', ctx());
expect(res.isError).toBe(false);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({
userId: 'u1', source: 'direct', model: 'qwen2-vl', route: 'vision-host:11434',
tokensIn: 250, tokensOut: 12,
});
});
it('records zero tokens when the vision response omits usage', async () => {
const events: LlmUsageEvent[] = [];
setLlmUsageRecorder((e) => events.push(e));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ choices: [{ message: { content: 'x' } }] }),
}));
await callVisionModel('data:image/png;base64,xxx', '', ctx());
expect(events[0]).toMatchObject({ tokensIn: 0, tokensOut: 0, model: 'qwen2-vl' });
});
it('does not record on a vision API error', async () => {
const events: LlmUsageEvent[] = [];
setLlmUsageRecorder((e) => events.push(e));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500, text: async () => 'boom' }));
const res = await callVisionModel('data:image/png;base64,xxx', '', ctx());
expect(res.isError).toBe(true);
expect(events).toHaveLength(0);
});
});

View File

@ -452,6 +452,10 @@ export class OpenAICompatClient {
// per attempt; only the attempt that reaches `done` records.
let observedModel = '';
let observedBackendId = '';
// Whether we saw at least one real (non-error) SSE chunk. An EOF
// that closes the stream without any chunk and without [DONE] is an
// abnormal completion we must NOT record as a request (issue #498).
let sawChunk = false;
try {
response = await fetch(`${this.baseUrl}/chat/completions`, {
@ -616,10 +620,15 @@ export class OpenAICompatClient {
};
return;
}
// Unknown error.type: keep parsing (it may be followed by real
// content). It is NOT a real chunk, so it doesn't flip
// `sawChunk` — an error-only stream that then EOFs without
// [DONE] stays unrecorded (issue #498).
}
// usage (stream_options で末尾チャンクに付く)
if (chunk['usage'] != null) {
sawChunk = true; // a real completion payload
const u = chunk['usage'] as Record<string, unknown>;
usage = {
prompt_tokens: (u['prompt_tokens'] as number) ?? 0,
@ -641,6 +650,7 @@ export class OpenAICompatClient {
const choices = chunk['choices'] as Array<Record<string, unknown>> | undefined;
if (!choices || choices.length === 0) continue;
sawChunk = true; // a real content/finish chunk
const choice = choices[0] as Record<string, unknown>;
const delta = choice['delta'] as Record<string, unknown> | undefined;
@ -737,9 +747,11 @@ export class OpenAICompatClient {
reader.releaseLock();
}
// [DONE] なしにストリームが終了した場合
// [DONE] なしにストリームが終了した場合。チャンクを1つも受け取らずに
// EOF した「不明完了」は requests に数えない (issue #498)。明示的な
// [DONE] 経路は従来どおり常に記録する。
yield* drainToolCalls(toolCallAccumulators);
this.finalizeDone(usage, observedModel, observedBackendId, context);
if (sawChunk) this.finalizeDone(usage, observedModel, observedBackendId, context);
yield { type: 'done', usage };
return;
}

View File

@ -9,6 +9,10 @@ import { logger } from '../logger.js';
* construction. This is the single chokepoint the design relies on to
* avoid the propagation leaks this codebase has repeatedly hit.
*
* Exception: the ReadImage vision tool (engine/tools/image.ts) issues a
* raw, non-streaming fetch to its own vision endpoint rather than going
* through OpenAICompatClient, so it calls recordLlmUsage() directly.
*
* Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
*/
export interface LlmUsageEvent {

View File

@ -123,3 +123,58 @@ describe('LLM usage recording', () => {
await expect(drain(new OpenAICompatClient('http://h:1/v1', 'm'), { userId: 'u1' })).resolves.toBeUndefined();
});
});
// --- issue #498: don't record unknown completions ---
/** Raw SSE response with full control over whether [DONE] is sent. */
function rawSse(chunks: unknown[], withDone: boolean): Response {
const lines = chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`);
if (withDone) lines.push('data: [DONE]\n\n');
const encoder = new TextEncoder();
let i = 0;
return {
ok: true, status: 200,
headers: { get: () => null },
body: { getReader: () => ({
read: async () => (i < lines.length ? { done: false, value: encoder.encode(lines[i++]) } : { done: true, value: undefined }),
releaseLock: () => {},
}) },
} as unknown as Response;
}
async function collect(client: OpenAICompatClient, ctx?: { userId?: string }) {
const out: string[] = [];
for await (const e of client.chat([{ role: 'user', content: 'q' }], undefined, undefined, ctx)) out.push(e.type);
return out;
}
describe('LLM usage recording — unknown completions (#498)', () => {
it('does NOT record an EOF with no chunks and no [DONE]', async () => {
const events: LlmUsageEvent[] = [];
setLlmUsageRecorder((e) => events.push(e));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(rawSse([], false)));
const types = await collect(new OpenAICompatClient('http://h:1/v1', 'm'), { userId: 'u1' });
expect(events).toHaveLength(0);
expect(types[types.length - 1]).toBe('done'); // still terminates gracefully
});
it('DOES record an EOF (no [DONE]) once a real chunk arrived', async () => {
const events: LlmUsageEvent[] = [];
setLlmUsageRecorder((e) => events.push(e));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(rawSse([textChunk('m'), usageChunk(3, 2)], false)));
await drain(new OpenAICompatClient('http://h:1/v1', 'm'), { userId: 'u1' });
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ tokensIn: 3, tokensOut: 2 });
});
it('does NOT record an error-only stream that EOFs without [DONE]', async () => {
// An unknown error payload alone is not a real chunk; if the stream then
// closes without [DONE], it must not be counted as a request.
const events: LlmUsageEvent[] = [];
setLlmUsageRecorder((e) => events.push(e));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(rawSse([{ error: { type: 'mystery', message: 'kaboom' } }], false)));
const types = await collect(new OpenAICompatClient('http://h:1/v1', 'm'), { userId: 'u1' });
expect(events).toHaveLength(0);
expect(types[types.length - 1]).toBe('done'); // unknown error falls through, EOF → done
});
});

View File

@ -22,6 +22,7 @@ import { TopBar } from './components/layout/TopBar';
import { NavDrawer } from './components/layout/NavDrawer';
import { useEdgeSwipe } from './hooks/useEdgeSwipe';
import { visibleNavItemsFor } from './components/layout/TopBar';
import { useVisibleDetailTabs } from './components/detail/detailTabs';
import { ResizeHandle } from './components/layout/ResizeHandle';
import { TaskListPanel } from './components/list/TaskListPanel';
import { ChatPane } from './components/chat/ChatPane';
@ -168,6 +169,12 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
// nav drawer / edge-swipe stay in sync with whether the hamburger is shown.
const [compactMode, setCompactMode] = useState(false);
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
// Detail tabs (with live Browser/SSH gating) for the tablet chat header.
const visibleDetailTabs = useVisibleDetailTabs(localTaskId);
const openDetailTab = useCallback((id: string) => {
setUrlState(prev => ({ ...prev, detailTab: id as typeof detailTab }));
setTabletDetailOpen(true);
}, [setUrlState]);
const openNavDrawer = () => {
setTabletDetailOpen(false);
@ -581,7 +588,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
</div>
<div className="bg-canvas border border-hairline rounded-md overflow-hidden">
{chatReady ? (
<ChatPane task={localTask!} comments={localComments} onSubmit={handleComment} onCancel={handleCancel} onOpenDetail={() => setTabletDetailOpen(true)} />
<ChatPane task={localTask!} comments={localComments} onSubmit={handleComment} onCancel={handleCancel} detailTabs={visibleDetailTabs} activeDetailTab={detailTab} onSelectDetailTab={openDetailTab} />
) : panelOpen ? (
<SkeletonChatPane />
) : (

View File

@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { useState, useRef, useEffect, useMemo, useCallback, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { LocalTask, LocalTaskComment } from '../../api';
import { ChatMessage } from './ChatMessage';
@ -27,11 +27,16 @@ interface ChatPaneProps {
comments: LocalTaskComment[];
onSubmit: (body: string, attachments?: Array<{ name: string; contentBase64: string }>) => Promise<void>;
onCancel?: () => Promise<void>;
onOpenDetail?: () => void;
/** Tablet: the detail tabs. Tapping one opens the detail drawer to that tab,
* replacing the single "詳細" button so you jump straight where you want. */
detailTabs?: Array<{ id: string; labelKey: string }>;
activeDetailTab?: string;
onSelectDetailTab?: (id: string) => void;
}
export function ChatPane({ task, comments, onSubmit, onCancel, onOpenDetail }: ChatPaneProps) {
export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activeDetailTab, onSelectDetailTab }: ChatPaneProps) {
const { t } = useTranslation('chat');
const { t: dt } = useTranslation('detail');
const [draft, setDraft] = useState('');
const [attachments, setAttachments] = useState<Array<{ name: string; contentBase64: string }>>([]);
const [submitting, setSubmitting] = useState(false);
@ -259,17 +264,34 @@ export function ChatPane({ task, comments, onSubmit, onCancel, onOpenDetail }: C
</span>
</div>
)}
{onOpenDetail && (
<button
onClick={onOpenDetail}
className="px-2.5 h-7 text-2xs font-medium text-slate-700 border border-hairline bg-canvas hover:bg-surface rounded-md transition-colors"
title={t('pane.viewDetail')}
>
{t('pane.detail')}
</button>
)}
</div>
</div>
{/* Tablet: detail tabs as direct buttons (instead of one "" button)
so a tap opens the drawer straight to the chosen tab. */}
{detailTabs && detailTabs.length > 0 && onSelectDetailTab && (
<div className="mt-2 flex flex-wrap gap-x-1.5 gap-y-1">
{detailTabs.map(tab => {
const active = tab.id === activeDetailTab;
const live = tab.id === 'browser' || tab.id === 'ssh';
return (
<button
key={tab.id}
onClick={() => onSelectDetailTab(tab.id)}
aria-pressed={active}
style={live ? ({ '--flow-color': tab.id === 'ssh' ? '#34d399' : '#38bdf8' } as CSSProperties) : undefined}
className={`whitespace-nowrap px-2 h-7 inline-flex items-center rounded-md text-2xs font-medium transition-colors ${
live ? 'live-flow-border' : 'border border-hairline'
} ${
active ? 'text-slate-900 font-semibold bg-surface' : 'text-slate-600 bg-canvas hover:bg-surface'
}`}
>
{dt(tab.labelKey)}
</button>
);
})}
</div>
)}
</div>
{/* Messages */}

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { DetailTabId } from '../../lib/urlState';
@ -231,6 +231,10 @@ export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPe
{tabs.map(tab => {
const active = activeTab === tab.id;
const pending = active && tabTransitionPending;
// Browser/SSH tabs only appear while their session is LIVE (gated in
// DetailPanel), so any browser/ssh tab here is running — give it the
// flowing-light border to signal "this is live right now".
const live = tab.id === 'browser' || tab.id === 'ssh';
return (
<button
key={tab.id}
@ -243,7 +247,16 @@ export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPe
: 'border-transparent text-slate-500 font-medium hover:text-slate-800'
}`}
>
{t(tab.labelKey)}
{live ? (
<span
className="live-flow-border px-1.5 py-0.5"
style={{ '--flow-color': tab.id === 'ssh' ? '#34d399' : '#38bdf8' } as CSSProperties}
>
{t(tab.labelKey)}
</span>
) : (
t(tab.labelKey)
)}
{pending && (
<span
aria-hidden="true"

View File

@ -6,6 +6,7 @@ import { relativeTime } from '../../lib/utils';
import { ownerDisplayName } from '../../lib/owner';
import { DetailTabId } from '../../lib/urlState';
import { DetailHeader } from './DetailHeader';
import { useVisibleDetailTabs } from './detailTabs';
import { ContinueWithPieceDialog } from './ContinueWithPieceDialog';
import { SkeletonDetailPanel } from '../shared/Skeleton';
import { OverviewTab } from './tabs/OverviewTab';
@ -15,7 +16,6 @@ import { TraceTab } from './tabs/TraceTab';
import { BrowserTab } from './tabs/BrowserTab';
import { ConsoleTab } from './tabs/ConsoleTab';
import { BrowserSessionPanel } from '../browser/BrowserSessionPanel';
import type { ConsoleStatus } from '../../lib/ssh-console-types';
import { useAuthState } from '../../App';
import type { SubtaskFilePreviewHandler } from './tabs/SubtasksPanel';
@ -46,14 +46,6 @@ interface LocalDetailPanelProps {
onShareChange?: () => void;
}
const LOCAL_TABS: Array<{ id: DetailTabId; labelKey: string }> = [
{ id: 'overview', labelKey: 'tabs.overview' },
{ id: 'activity', labelKey: 'tabs.activity' },
{ id: 'files', labelKey: 'tabs.files' },
{ id: 'trace', labelKey: 'tabs.trace' },
{ id: 'browser', labelKey: 'tabs.browser' },
{ id: 'ssh', labelKey: 'tabs.ssh' },
];
export function LocalDetailPanel({
task, taskId, section, currentPath, entries, pathSegments,
@ -91,40 +83,9 @@ export function LocalDetailPanel({
enabled: editingVisibility,
});
// SSH console tab visibility: show whenever an active console session exists for this task.
// (Piece-level pre-show via latestJob.allowedTools is not currently exposed by the API; this
// fallback covers the real case where the AI has actually opened a session.)
const { data: consoleStatus } = useQuery<ConsoleStatus>({
queryKey: ['console-status', task?.id],
queryFn: async () => {
const r = await fetch(`/api/local/tasks/${task!.id}/console/status`);
return r.ok ? r.json() : { active: false };
},
enabled: !!task,
refetchInterval: 5000,
});
const showSshTab = consoleStatus?.active === true;
// Browser tab visibility: mirror SSH — show only once a viewable browser
// session is live for this task. `available: true` means a noVNC session
// exists (the agent has actually used the browser); every other case
// (no_session / headless_mode / display_unavailable / novnc_not_installed)
// is non-viewable, so the tab would show nothing useful and stays hidden.
// Shares the ['task-session', id] query with BrowserTab (deduped by key).
const { data: browserSession } = useQuery<{ available: boolean }>({
queryKey: ['task-session', task?.id],
queryFn: async () => {
const r = await fetch(`/api/local/browser/sessions/task-session/${task!.id}`);
return r.ok ? r.json() : { available: false };
},
enabled: !!task,
refetchInterval: 5000,
});
const showBrowserTab = browserSession?.available === true;
const visibleTabs = LOCAL_TABS.filter(
(t) => (t.id !== 'ssh' || showSshTab) && (t.id !== 'browser' || showBrowserTab),
);
// Visible tabs (incl. Browser/SSH only while their session is live). Shared
// with the tablet chat header via useVisibleDetailTabs so the two agree.
const visibleTabs = useVisibleDetailTabs(task?.id ?? null);
const handleStartEdit = () => {
if (!task) return;

View File

@ -0,0 +1,51 @@
import { useQuery } from '@tanstack/react-query';
import type { DetailTabId } from '../../lib/urlState';
export interface DetailTab {
id: DetailTabId;
labelKey: string; // resolves against the 'detail' i18n namespace
}
/** All detail tabs in display order. Browser/SSH are conditional see
* useVisibleDetailTabs. */
export const LOCAL_TABS: DetailTab[] = [
{ id: 'overview', labelKey: 'tabs.overview' },
{ id: 'activity', labelKey: 'tabs.activity' },
{ id: 'files', labelKey: 'tabs.files' },
{ id: 'trace', labelKey: 'tabs.trace' },
{ id: 'browser', labelKey: 'tabs.browser' },
{ id: 'ssh', labelKey: 'tabs.ssh' },
];
/**
* The detail tabs visible for a task. Browser and SSH only appear while their
* session is live (so the tab carries the live signal). Single source of truth
* shared by the detail panel and the tablet chat header so the two never
* disagree. The console-status / task-session queries are keyed the same as the
* ones BrowserTab/ConsoleTab use, so react-query dedupes no extra fetch.
*/
export function useVisibleDetailTabs(taskId: number | null | undefined): DetailTab[] {
const { data: consoleStatus } = useQuery<{ active: boolean }>({
queryKey: ['console-status', taskId],
queryFn: async () => {
const r = await fetch(`/api/local/tasks/${taskId}/console/status`);
return r.ok ? r.json() : { active: false };
},
enabled: taskId != null,
refetchInterval: 5000,
});
const { data: browserSession } = useQuery<{ available: boolean }>({
queryKey: ['task-session', taskId],
queryFn: async () => {
const r = await fetch(`/api/local/browser/sessions/task-session/${taskId}`);
return r.ok ? r.json() : { available: false };
},
enabled: taskId != null,
refetchInterval: 5000,
});
const showSsh = consoleStatus?.active === true;
const showBrowser = browserSession?.available === true;
return LOCAL_TABS.filter(
(t) => (t.id !== 'ssh' || showSsh) && (t.id !== 'browser' || showBrowser),
);
}

View File

@ -1,7 +1,5 @@
{
"pane": {
"viewDetail": "View detail",
"detail": "Detail",
"empty": "No messages yet",
"newMessages": "{{count}} new",
"toLatest": "Jump to latest",

View File

@ -1,7 +1,5 @@
{
"pane": {
"viewDetail": "詳細を表示",
"detail": "詳細",
"empty": "メッセージはまだありません",
"newMessages": "{{count}} 件の新着",
"toLatest": "最新へ",

View File

@ -251,6 +251,40 @@
.animate-mobile-tab-swap { animation: none; }
}
/* Live "flowing light" border for Browser/SSH tabs while a session is live.
A bright arc travels around a thin rounded ring: a conic gradient rotated
via the animatable --flow-angle custom property, masked to the border only.
If @property is unsupported the angle stays at 0deg a static glow ring
(graceful degradation, no spin). */
.live-flow-border {
position: relative;
border-radius: 7px;
}
.live-flow-border::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1.5px;
background: conic-gradient(
from var(--flow-angle, 0deg),
transparent 0deg,
var(--flow-color, #38bdf8) 50deg,
#e0f2fe 72deg,
transparent 112deg,
transparent 360deg
);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: flowBorderSpin 2.4s linear infinite;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.live-flow-border::before { animation: none; opacity: 0.65; }
}
.tool-spark-burst {
position: absolute;
right: calc(var(--pet-size, 64px) * 0.5);
@ -324,6 +358,19 @@
}
}
/* Animatable angle for the live-flow-border conic gradient. Registering it as
<angle> is what lets the rotation actually animate (a plain custom property
animates discretely). Unsupported browsers ignore this and fall back to the
0deg initial value static ring. */
@property --flow-angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
@keyframes flowBorderSpin {
to { --flow-angle: 360deg; }
}
@keyframes petIdle {
0%, 100% { transform: translateY(0) rotate(0deg) scale(1); }
50% { transform: translateY(-6px) rotate(1deg) scale(1.03); }