diff --git a/VOICE.md b/VOICE.md index 31d86ba..af5ad12 100644 --- a/VOICE.md +++ b/VOICE.md @@ -114,19 +114,24 @@ Voice chrome uses `text-ui-*`, `size-icon-*`, and `h-control-*` / `size-control- ## Sound language -Procedural Web Audio cues (`apps/web/src/lib/oo-sound.ts`) — no sample packs: +Procedural Web Audio (`oo-sound.ts`, `oo-vocals.ts`, `oo-audio-bus.ts`) — no sample packs. Full motion/SFX spec: `packages/ui-web/MOTION-EVOO-CHARACTER.md`. | Cue | Trigger | |-----|---------| -| `work-complete` | Run transitions from active → finished (text mode) | -| `listening-start` / `listening-stop` | Voice phase enters/leaves listening | -| `speaking-start` / `speaking-done` | TTS playback lifecycle | -| `attention` | Run error surfaced in composer | -| `message-sent` | Successful composer send | -| `handoff` | Handoff block appears in thread | -| `group-spark` | Peer Oo reply in a group thread — playful staccato | +| `app-start` | Cold open — mint→violet shimmer | +| `interact` | Primary nav / roster taps | +| `success-resolve` | Run finished — mint→gold | +| `need-you` | `waiting_input` / takeover — with `ouh` vocal | +| `work-complete` | Alias of `success-resolve` | +| `listening-start` / `listening-stop` | Voice phase | +| `speaking-start` / `speaking-done` | TTS lifecycle | +| `attention` | Run error in composer | +| `message-sent` | Composer send | +| `handoff` / `group-spark` | Thread collaboration | -Toggle: **Settings → Voice → Sound feedback**. Respects `prefers-reduced-motion: reduce` and hidden tabs. +Reaction vocals (`mh` / `Mhh` nod, `ouh`, `hm` on success): state-change only, 8–12 s cooldown, **−18…−12 LUFS** via shared bus, **duck under TTS/PTT**. + +Toggle: **Settings → Voice → Sound feedback**. `prefers-reduced-motion: reduce` → no vocals/SFX loops; character visual state only. ## Current slice (this PR) diff --git a/apps/web/src/lib/agent-activity.test.ts b/apps/web/src/lib/agent-activity.test.ts new file mode 100644 index 0000000..3ec66d0 --- /dev/null +++ b/apps/web/src/lib/agent-activity.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { + isDoingStatus, + isNeedsYouStatus, + needsYouChipLabel, + rosterActivitySubtitle, + rosterDoingVerb, + rosterStatusDot, +} from "./agent-activity"; + +describe("agent-activity", () => { + it("classifies needs-you and doing statuses", () => { + expect(isNeedsYouStatus("waiting_input")).toBe(true); + expect(isNeedsYouStatus("waiting_takeover")).toBe(true); + expect(isNeedsYouStatus("running")).toBe(false); + expect(isDoingStatus("running")).toBe(true); + expect(isDoingStatus("idle")).toBe(false); + }); + + it("returns German doing verbs instead of raw status", () => { + expect(rosterDoingVerb("waiting_input")).toBe("Braucht deine Antwort"); + expect(rosterDoingVerb("running")).toBe("Arbeitet…"); + expect(rosterDoingVerb("idle")).toBeNull(); + }); + + it("builds roster subtitle with verb priority", () => { + expect(rosterActivitySubtitle("running", "Some preview")).toBe("Arbeitet…"); + expect(rosterActivitySubtitle("idle", "Hello there")).toBe("Hello there"); + expect(rosterActivitySubtitle("idle", "")).toBe("Bereit"); + }); + + it("maps status dot tones", () => { + expect(rosterStatusDot("waiting_input")).toBe("needs-you"); + expect(rosterStatusDot("running")).toBe("working"); + expect(rosterStatusDot("idle")).toBe("idle"); + }); + + it("returns needs-you chip label only for waiting states", () => { + expect(needsYouChipLabel("waiting_input")).toBe("Braucht dich"); + expect(needsYouChipLabel("running")).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/agent-activity.ts b/apps/web/src/lib/agent-activity.ts new file mode 100644 index 0000000..32828c5 --- /dev/null +++ b/apps/web/src/lib/agent-activity.ts @@ -0,0 +1,86 @@ +import type { RunActivityRow } from "@rakazo/contracts"; + +/** Run statuses where the agent is blocked on the user. */ +export function isNeedsYouStatus(status: string | undefined): boolean { + return status === "waiting_input" || status === "waiting_takeover"; +} + +/** Run statuses where the agent is actively working (not waiting on user). */ +export function isDoingStatus(status: string | undefined): boolean { + return status === "queued" || status === "leased" || status === "running"; +} + +export function isActionableRun(run: RunActivityRow): boolean { + return isNeedsYouStatus(run.status); +} + +export function isFailedRoutine(run: RunActivityRow): boolean { + return run.trigger === "routine" && run.status === "failed"; +} + +/** German doing-verb for roster / activity surfaces — never raw status strings. */ +export function rosterDoingVerb(status: string | undefined): string | null { + switch (status) { + case "queued": + return "Wartet an…"; + case "leased": + return "Startet…"; + case "running": + return "Arbeitet…"; + case "waiting_input": + return "Braucht deine Antwort"; + case "waiting_takeover": + return "Braucht dich"; + default: + return null; + } +} + +/** Short chip label for needs-you states. */ +export function needsYouChipLabel(status: string | undefined): string | null { + if (!isNeedsYouStatus(status)) return null; + return status === "waiting_takeover" ? "Braucht dich" : "Braucht dich"; +} + +export function runActivityLabel(status: RunActivityRow["status"]): string { + switch (status) { + case "queued": + return "Wartet an…"; + case "leased": + return "Startet…"; + case "running": + return "Arbeitet…"; + case "waiting_input": + return "Braucht deine Antwort"; + case "waiting_takeover": + return "Braucht dich"; + case "completed": + return "Erledigt"; + case "failed": + return "Fehlgeschlagen"; + case "cancelled": + return "Abgebrochen"; + default: + return status; + } +} + +/** Roster subtitle: doing-verb when active, otherwise preview fallback. */ +export function rosterActivitySubtitle( + status: string | undefined, + preview: string | undefined, + idleFallback = "Bereit", +): string { + const verb = rosterDoingVerb(status); + if (verb) return verb; + if (preview) return preview; + return idleFallback; +} + +export type RosterStatusDot = "working" | "needs-you" | "idle"; + +export function rosterStatusDot(status: string | undefined): RosterStatusDot { + if (isNeedsYouStatus(status)) return "needs-you"; + if (isDoingStatus(status)) return "working"; + return "idle"; +} diff --git a/apps/web/src/lib/heute-brief.test.ts b/apps/web/src/lib/heute-brief.test.ts index 21b32af..e1b51e5 100644 --- a/apps/web/src/lib/heute-brief.test.ts +++ b/apps/web/src/lib/heute-brief.test.ts @@ -1,6 +1,6 @@ import type { Bot, Group, RunActivityRow } from "@rakazo/contracts"; import { describe, expect, it } from "vitest"; -import { buildHeuteBrief, isToday } from "./heute-brief"; +import { buildHeuteBrief, HEUTE_NEEDS_YOU_CAP, isToday } from "./heute-brief"; const now = new Date("2026-09-22T12:00:00.000Z"); @@ -82,14 +82,17 @@ describe("isToday", () => { }); describe("buildHeuteBrief", () => { - it("collects actionable runs, unread chats, and today follow-ups without duplicates", () => { + it("collects needs-you, doing, and done buckets", () => { const brief = buildHeuteBrief({ bots: [ bot({ id: "bot-1", unread: true }), bot({ id: "bot-2", unread: true, updatedAt: "2026-09-22T08:00:00.000Z", name: "Steve" }), ], groups: [group({ updatedAt: "2026-09-22T09:30:00.000Z" })], - activeRuns: [run({ runId: "run-1", botId: "bot-1" })], + activeRuns: [ + run({ runId: "run-1", botId: "bot-1" }), + run({ runId: "run-3", botId: "bot-4", botName: "Worker", status: "running" }), + ], recentRuns: [ run({ runId: "run-2", @@ -98,23 +101,28 @@ describe("buildHeuteBrief", () => { status: "failed", trigger: "routine", }), + run({ + runId: "run-4", + botId: "bot-5", + botName: "DoneBot", + status: "completed", + updatedAt: "2026-09-22T09:00:00.000Z", + }), ], now, }); expect(brief.actionCount).toBe(3); - expect(brief.jetzt.map((item) => item.kind)).toEqual(["run", "run", "unread"]); - expect(brief.weiter).toHaveLength(1); - expect(brief.weiter[0]?.chat.id).toBe("group-1"); + expect(brief.needsYou.map((item) => item.kind)).toEqual(["run", "run", "unread"]); + expect(brief.doing).toHaveLength(1); + expect(brief.doing[0]?.run.runId).toBe("run-3"); + expect(brief.done).toHaveLength(1); + expect(brief.done[0]?.run.runId).toBe("run-4"); }); - it("caps Weiter at five items", () => { - const bots = Array.from({ length: 7 }, (_, index) => - bot({ - id: `bot-${index}`, - name: `Bot ${index}`, - updatedAt: `2026-09-22T${String(index).padStart(2, "0")}:00:00.000Z`, - }), + it("caps needs-you at three items", () => { + const bots = Array.from({ length: 5 }, (_, index) => + bot({ id: `bot-${index}`, unread: true, name: `Bot ${index}` }), ); const brief = buildHeuteBrief({ bots, @@ -123,6 +131,7 @@ describe("buildHeuteBrief", () => { recentRuns: [], now, }); - expect(brief.weiter).toHaveLength(5); + expect(brief.needsYou).toHaveLength(HEUTE_NEEDS_YOU_CAP); + expect(brief.actionCount).toBe(5); }); }); diff --git a/apps/web/src/lib/heute-brief.ts b/apps/web/src/lib/heute-brief.ts index 41b2d4a..dfebb61 100644 --- a/apps/web/src/lib/heute-brief.ts +++ b/apps/web/src/lib/heute-brief.ts @@ -1,4 +1,5 @@ import type { Bot, Group, RunActivityRow } from "@rakazo/contracts"; +import { isActionableRun, isDoingStatus, isFailedRoutine } from "./agent-activity"; export type JetztRunItem = { kind: "run"; @@ -13,17 +14,39 @@ export type JetztUnreadItem = { export type JetztItem = JetztRunItem | JetztUnreadItem; +export type DoingItem = { + kind: "run"; + run: RunActivityRow; +}; + +export type DoneItem = { + kind: "run"; + run: RunActivityRow; +}; + +/** @deprecated Use needsYou — kept for gradual migration in tests. */ export type WeiterItem = { chatKind: "bot" | "group"; chat: Bot | Group; }; export type HeuteBriefData = { - jetzt: JetztItem[]; - weiter: WeiterItem[]; + /** Needs you — capped at three visible rows. */ + needsYou: JetztItem[]; + /** Active runs where the agent is working, not blocked. */ + doing: DoingItem[]; + /** Recently completed runs from today. */ + done: DoneItem[]; + /** Total actionable count (may exceed visible cap). */ actionCount: number; + /** @deprecated Alias for needsYou — legacy bucket name. */ + jetzt: JetztItem[]; + /** @deprecated Empty — replaced by doing/done buckets. */ + weiter: WeiterItem[]; }; +export const HEUTE_NEEDS_YOU_CAP = 3; + export function isToday(iso: string, now = new Date()): boolean { const date = new Date(iso); if (Number.isNaN(date.getTime())) return false; @@ -52,14 +75,6 @@ function chatTargetKey(chatKind: "bot" | "group", chatId: string): string { return chatKind === "group" ? `group:${chatId}` : `bot:${chatId}`; } -function isActionableRun(run: RunActivityRow): boolean { - return run.status === "waiting_input" || run.status === "waiting_takeover"; -} - -function isFailedRoutine(run: RunActivityRow): boolean { - return run.trigger === "routine" && run.status === "failed"; -} - export function buildHeuteBrief(params: { bots: Bot[]; groups: Group[]; @@ -68,7 +83,7 @@ export function buildHeuteBrief(params: { now?: Date; }): HeuteBriefData { const { bots, groups, activeRuns, recentRuns, now = new Date() } = params; - const jetzt: JetztItem[] = []; + const needsYouAll: JetztItem[] = []; const coveredTargets = new Set(); const actionableRuns = [ @@ -79,43 +94,46 @@ export function buildHeuteBrief(params: { for (const run of actionableRuns) { if (seenRunIds.has(run.runId)) continue; seenRunIds.add(run.runId); - jetzt.push({ kind: "run", run }); + needsYouAll.push({ kind: "run", run }); coveredTargets.add(runTargetKey(run)); } const unreadChats: JetztUnreadItem[] = [ - ...bots.filter((bot) => bot.unread).map((bot) => ({ kind: "unread" as const, chatKind: "bot" as const, chat: bot })), - ...groups.filter((group) => group.unread).map((group) => ({ - kind: "unread" as const, - chatKind: "group" as const, - chat: group, - })), + ...bots + .filter((bot) => bot.unread) + .map((bot) => ({ kind: "unread" as const, chatKind: "bot" as const, chat: bot })), + ...groups + .filter((group) => group.unread) + .map((group) => ({ + kind: "unread" as const, + chatKind: "group" as const, + chat: group, + })), ]; for (const item of unreadChats) { const key = chatTargetKey(item.chatKind, item.chat.id); if (coveredTargets.has(key)) continue; - jetzt.push(item); + needsYouAll.push(item); coveredTargets.add(key); } - const weiterCandidates: WeiterItem[] = [ - ...bots - .filter((bot) => isToday(bot.updatedAt, now)) - .map((bot) => ({ chatKind: "bot" as const, chat: bot })), - ...groups - .filter((group) => isToday(group.updatedAt, now)) - .map((group) => ({ chatKind: "group" as const, chat: group })), - ]; - weiterCandidates.sort( - (a, b) => new Date(b.chat.updatedAt).getTime() - new Date(a.chat.updatedAt).getTime(), - ); - const weiter = weiterCandidates - .filter((item) => !coveredTargets.has(chatTargetKey(item.chatKind, item.chat.id))) - .slice(0, 5); + const doing: DoingItem[] = activeRuns + .filter((run) => isDoingStatus(run.status)) + .map((run) => ({ kind: "run" as const, run })); + + const done: DoneItem[] = recentRuns + .filter((run) => run.status === "completed" && isToday(run.updatedAt, now)) + .slice(0, 5) + .map((run) => ({ kind: "run" as const, run })); + + const needsYou = needsYouAll.slice(0, HEUTE_NEEDS_YOU_CAP); return { - jetzt, - weiter, - actionCount: jetzt.length, + needsYou, + doing, + done, + actionCount: needsYouAll.length, + jetzt: needsYouAll, + weiter: [], }; } diff --git a/apps/web/src/lib/oo-audio-bus.test.ts b/apps/web/src/lib/oo-audio-bus.test.ts new file mode 100644 index 0000000..f45c213 --- /dev/null +++ b/apps/web/src/lib/oo-audio-bus.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ooAudioBus } from "./oo-audio-bus"; + +describe("ooAudioBus", () => { + class FakeContext { + state: AudioContextState = "running"; + currentTime = 0; + destination = {}; + createGain() { + const gain = { + value: 0, + cancelScheduledValues: vi.fn(), + setValueAtTime: vi.fn(function (this: { value: number }, v: number) { + this.value = v; + }), + linearRampToValueAtTime: vi.fn(function (this: { value: number }, v: number) { + this.value = v; + }), + }; + return { gain, connect: vi.fn() }; + } + } + + beforeEach(() => { + vi.stubGlobal("AudioContext", FakeContext); + ooAudioBus.resetVoiceHoldForTests(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("ducks SFX when voice lane is active", () => { + ooAudioBus.ensureContext(); + ooAudioBus.setVoiceLaneActive(true); + ooAudioBus.setVoiceLaneActive(true); + ooAudioBus.setVoiceLaneActive(false); + expect(() => ooAudioBus.setVoiceLaneActive(false)).not.toThrow(); + }); +}); diff --git a/apps/web/src/lib/oo-audio-bus.ts b/apps/web/src/lib/oo-audio-bus.ts new file mode 100644 index 0000000..4a9b1a6 --- /dev/null +++ b/apps/web/src/lib/oo-audio-bus.ts @@ -0,0 +1,78 @@ +/** + * Shared procedural SFX bus — −18…−12 LUFS ballpark, ducks under TTS/PTT. + * See packages/ui-web/MOTION-EVOO-CHARACTER.md + */ + +type AudioContextCtor = new () => AudioContext; + +/** Master output ~−15 LUFS for short procedural peaks. */ +const SFX_MASTER_GAIN = 0.58; +/** Duck depth when voice lane active (TTS or mic wash). */ +const SFX_DUCK_RATIO = 0.28; +const DUCK_RAMP_S = 0.065; + +function audioContextCtor(): AudioContextCtor | undefined { + if (typeof AudioContext === "function") return AudioContext; + if (typeof window === "undefined") return undefined; + const host = window as Window & { webkitAudioContext?: AudioContextCtor }; + return host.webkitAudioContext; +} + +class OoAudioBus { + private ctx: AudioContext | null = null; + private master: GainNode | null = null; + private sfxGain: GainNode | null = null; + private voiceHold = 0; + + ensureContext(): AudioContext | null { + if (this.ctx && this.ctx.state !== "closed") return this.ctx; + const Ctor = audioContextCtor(); + if (!Ctor) return null; + try { + const ctx = new Ctor(); + const master = ctx.createGain(); + master.gain.value = 1; + master.connect(ctx.destination); + const sfxGain = ctx.createGain(); + sfxGain.gain.value = SFX_MASTER_GAIN; + sfxGain.connect(master); + this.ctx = ctx; + this.master = master; + this.sfxGain = sfxGain; + return ctx; + } catch { + return null; + } + } + + /** Route for oo-sound / oo-vocals / oo-voice-mark oscillators. */ + sfxDestination(ctx: AudioContext): AudioNode { + if (ctx !== this.ctx) { + const master = ctx.createGain(); + master.gain.value = SFX_MASTER_GAIN; + master.connect(ctx.destination); + return master; + } + return this.sfxGain ?? ctx.destination; + } + + /** TTS playback or PTT/listening — duck SFX so vocals don't fight the wash. */ + setVoiceLaneActive(active: boolean) { + const ctx = this.ensureContext(); + if (!ctx || !this.sfxGain) return; + this.voiceHold = Math.max(0, this.voiceHold + (active ? 1 : -1)); + const t = ctx.currentTime; + const target = this.voiceHold > 0 ? SFX_MASTER_GAIN * SFX_DUCK_RATIO : SFX_MASTER_GAIN; + this.sfxGain.gain.cancelScheduledValues(t); + this.sfxGain.gain.setValueAtTime(this.sfxGain.gain.value, t); + this.sfxGain.gain.linearRampToValueAtTime(target, t + DUCK_RAMP_S); + } + + /** Test helper — reset duck state. */ + resetVoiceHoldForTests() { + this.voiceHold = 0; + if (this.sfxGain) this.sfxGain.gain.value = SFX_MASTER_GAIN; + } +} + +export const ooAudioBus = new OoAudioBus(); diff --git a/apps/web/src/lib/oo-audio-voice-wire.ts b/apps/web/src/lib/oo-audio-voice-wire.ts new file mode 100644 index 0000000..e750a3f --- /dev/null +++ b/apps/web/src/lib/oo-audio-voice-wire.ts @@ -0,0 +1,21 @@ +import { dictation } from "./dictation"; +import { ooAudioBus } from "./oo-audio-bus"; +import { speaker } from "./tts"; + +/** Duck procedural SFX under TTS playback and PTT/listening wash. */ +export function wireOoAudioVoiceDucking(): () => void { + const unsubSpeaker = speaker.subscribe((snapshot) => { + ooAudioBus.setVoiceLaneActive( + snapshot.status === "preparing" || snapshot.status === "speaking", + ); + }); + const unsubDictation = dictation.subscribe((snapshot) => { + ooAudioBus.setVoiceLaneActive( + snapshot.status === "listening" || snapshot.status === "transcribing", + ); + }); + return () => { + unsubSpeaker(); + unsubDictation(); + }; +} diff --git a/apps/web/src/lib/oo-sound.test.ts b/apps/web/src/lib/oo-sound.test.ts index b93554f..1fb2de2 100644 --- a/apps/web/src/lib/oo-sound.test.ts +++ b/apps/web/src/lib/oo-sound.test.ts @@ -79,6 +79,10 @@ describe("ooSound", () => { it("plays procedural cues when enabled", () => { const cues: OoSoundCue[] = [ + "app-start", + "interact", + "success-resolve", + "need-you", "work-complete", "listening-start", "listening-stop", @@ -87,6 +91,7 @@ describe("ooSound", () => { "attention", "message-sent", "handoff", + "group-spark", ]; for (const cue of cues) { expect(() => ooSound.play(cue)).not.toThrow(); diff --git a/apps/web/src/lib/oo-sound.ts b/apps/web/src/lib/oo-sound.ts index 4f2b691..1de4c2e 100644 --- a/apps/web/src/lib/oo-sound.ts +++ b/apps/web/src/lib/oo-sound.ts @@ -1,6 +1,11 @@ +import { ooAudioBus } from "./oo-audio-bus"; import { readOoSoundEnabled, writeOoSoundEnabled } from "./oo-sound-pref"; export type OoSoundCue = + | "app-start" + | "interact" + | "success-resolve" + | "need-you" | "work-complete" | "listening-start" | "listening-stop" @@ -11,15 +16,6 @@ export type OoSoundCue = | "handoff" | "group-spark"; -type AudioContextCtor = new () => AudioContext; - -function audioContextCtor(): AudioContextCtor | undefined { - if (typeof AudioContext === "function") return AudioContext; - if (typeof window === "undefined") return undefined; - const host = window as Window & { webkitAudioContext?: AudioContextCtor }; - return host.webkitAudioContext; -} - function prefersReducedMotion(): boolean { if (typeof window === "undefined" || !window.matchMedia) return false; return window.matchMedia("(prefers-reduced-motion: reduce)").matches; @@ -54,17 +50,57 @@ function tone( osc.stop(startAt + duration); } +function sweep( + ctx: AudioContext, + destination: AudioNode, + fromHz: number, + toHz: number, + startAt: number, + duration: number, + options: { gain?: number; type?: OscillatorType } = {}, +) { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + const peak = options.gain ?? 0.05; + osc.type = options.type ?? "sine"; + osc.frequency.setValueAtTime(fromHz, startAt); + osc.frequency.exponentialRampToValueAtTime(Math.max(toHz, 1), startAt + duration * 0.85); + gain.gain.setValueAtTime(0.0001, startAt); + gain.gain.exponentialRampToValueAtTime(peak, startAt + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration); + osc.connect(gain); + gain.connect(destination); + osc.start(startAt); + osc.stop(startAt + duration); +} + function playCueOnContext(ctx: AudioContext, cue: OoSoundCue) { - const master = ctx.createGain(); - master.gain.value = 0.85; - master.connect(ctx.destination); + const master = ooAudioBus.sfxDestination(ctx); const t0 = ctx.currentTime + 0.01; switch (cue) { + case "app-start": + sweep(ctx, master, 523.25, 659.25, t0, 0.14, { gain: 0.038, type: "sine" }); + sweep(ctx, master, 659.25, 493.88, t0 + 0.1, 0.18, { gain: 0.032, type: "triangle" }); + break; + case "interact": + tone(ctx, master, 1046.5, t0, 0.06, { + gain: 0.018, + attack: 0.003, + release: 0.04, + type: "triangle", + }); + break; + case "success-resolve": case "work-complete": - tone(ctx, master, 392, t0, 0.42, { gain: 0.07 }); - tone(ctx, master, 523.25, t0 + 0.09, 0.48, { gain: 0.06 }); - tone(ctx, master, 659.25, t0 + 0.18, 0.62, { gain: 0.05, type: "triangle" }); + // mint → gold resolve (~270 ms) + sweep(ctx, master, 523.25, 659.25, t0, 0.12, { gain: 0.042, type: "sine" }); + sweep(ctx, master, 659.25, 783.99, t0 + 0.1, 0.22, { gain: 0.036, type: "triangle" }); + tone(ctx, master, 880, t0 + 0.2, 0.28, { gain: 0.03, type: "sine" }); + break; + case "need-you": + sweep(ctx, master, 220, 330, t0, 0.1, { gain: 0.042, type: "triangle" }); + tone(ctx, master, 277.18, t0 + 0.09, 0.12, { gain: 0.035, release: 0.08 }); break; case "listening-start": tone(ctx, master, 220, t0, 0.22, { gain: 0.045, type: "triangle" }); @@ -102,7 +138,6 @@ function playCueOnContext(ctx: AudioContext, cue: OoSoundCue) { } class OoSoundEngine { - private ctx: AudioContext | null = null; private enabled = readOoSoundEnabled(); isEnabled(): boolean { @@ -114,24 +149,12 @@ class OoSoundEngine { writeOoSoundEnabled(next); } - private ensureContext(): AudioContext | null { - if (this.ctx && this.ctx.state !== "closed") return this.ctx; - const Ctor = audioContextCtor(); - if (!Ctor) return null; - try { - this.ctx = new Ctor(); - return this.ctx; - } catch { - return null; - } - } - play(cue: OoSoundCue) { if (!this.enabled) return; if (prefersReducedMotion()) return; if (typeof document !== "undefined" && document.visibilityState === "hidden") return; - const ctx = this.ensureContext(); + const ctx = ooAudioBus.ensureContext(); if (!ctx) return; const run = () => { diff --git a/apps/web/src/lib/oo-vocals.ts b/apps/web/src/lib/oo-vocals.ts new file mode 100644 index 0000000..0856a8e --- /dev/null +++ b/apps/web/src/lib/oo-vocals.ts @@ -0,0 +1,176 @@ +import { ooAudioBus } from "./oo-audio-bus"; +import { readOoSoundEnabled } from "./oo-sound-pref"; + +export type OoVocalReaction = "ouh" | "mhm" | "aaah" | "mm" | "nod-down" | "nod-up" | "hm"; + +const COOLDOWN_MIN_MS = 8_000; +const COOLDOWN_MAX_MS = 12_000; + +let lastPlayedAt = 0; +let nextCooldownMs = COOLDOWN_MIN_MS; + +function prefersReducedMotion(): boolean { + if (typeof window === "undefined" || !window.matchMedia) return false; + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +function randomCooldown(): number { + return COOLDOWN_MIN_MS + Math.random() * (COOLDOWN_MAX_MS - COOLDOWN_MIN_MS); +} + +function vocalFormant( + ctx: AudioContext, + destination: AudioNode, + baseHz: number, + startAt: number, + duration: number, + gainPeak: number, +) { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + const filter = ctx.createBiquadFilter(); + filter.type = "bandpass"; + filter.frequency.value = baseHz * 2.4; + filter.Q.value = 4.5; + osc.type = "triangle"; + osc.frequency.setValueAtTime(baseHz, startAt); + gain.gain.setValueAtTime(0.0001, startAt); + gain.gain.exponentialRampToValueAtTime(gainPeak, startAt + 0.025); + gain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration); + osc.connect(filter); + filter.connect(gain); + gain.connect(destination); + osc.start(startAt); + osc.stop(startAt + duration + 0.02); +} + +function playReactionOnContext(ctx: AudioContext, reaction: OoVocalReaction) { + const master = ooAudioBus.sfxDestination(ctx); + const t0 = ctx.currentTime + 0.01; + + switch (reaction) { + case "ouh": + vocalFormant(ctx, master, 280, t0, 0.14, 0.055); + vocalFormant(ctx, master, 360, t0 + 0.06, 0.1, 0.04); + break; + case "mhm": + vocalFormant(ctx, master, 180, t0, 0.12, 0.042); + vocalFormant(ctx, master, 200, t0 + 0.1, 0.08, 0.032); + break; + case "aaah": + vocalFormant(ctx, master, 320, t0, 0.18, 0.048); + vocalFormant(ctx, master, 400, t0 + 0.08, 0.14, 0.038); + break; + case "mm": + vocalFormant(ctx, master, 160, t0, 0.16, 0.04); + break; + case "hm": + vocalFormant(ctx, master, 175, t0, 0.09, 0.038); + break; + case "nod-down": + vocalFormant(ctx, master, 165, t0, 0.07, 0.036); + break; + case "nod-up": + vocalFormant(ctx, master, 205, t0, 0.1, 0.038); + vocalFormant(ctx, master, 245, t0 + 0.08, 0.12, 0.032); + break; + default: + break; + } +} + +class OoVocalsEngine { + /** State-change vocal — respects cooldown and reduced-motion. No loops. */ + react(reaction: OoVocalReaction) { + if (!readOoSoundEnabled()) return; + if (prefersReducedMotion()) return; + if (typeof document !== "undefined" && document.visibilityState === "hidden") return; + + const now = Date.now(); + if (now - lastPlayedAt < nextCooldownMs) return; + + const ctx = ooAudioBus.ensureContext(); + if (!ctx) return; + + const run = () => { + try { + playReactionOnContext(ctx, reaction); + lastPlayedAt = Date.now(); + nextCooldownMs = randomCooldown(); + } catch { + // Ignore playback failures. + } + }; + + if (ctx.state === "suspended") { + void ctx + .resume() + .then(run) + .catch(() => undefined); + return; + } + run(); + } + + /** Nod: mh on down, Mhh on up — single cooldown window. */ + reactNod() { + if (!readOoSoundEnabled()) return; + if (prefersReducedMotion()) return; + if (typeof document !== "undefined" && document.visibilityState === "hidden") return; + + const now = Date.now(); + if (now - lastPlayedAt < nextCooldownMs) return; + + const ctx = ooAudioBus.ensureContext(); + if (!ctx) return; + + const run = () => { + try { + const t0 = ctx.currentTime + 0.01; + const master = ooAudioBus.sfxDestination(ctx); + vocalFormant(ctx, master, 165, t0, 0.07, 0.036); + vocalFormant(ctx, master, 205, t0 + 0.09, 0.1, 0.038); + vocalFormant(ctx, master, 245, t0 + 0.17, 0.12, 0.032); + lastPlayedAt = Date.now(); + nextCooldownMs = randomCooldown(); + } catch { + // Ignore playback failures. + } + }; + + if (ctx.state === "suspended") { + void ctx + .resume() + .then(run) + .catch(() => undefined); + return; + } + run(); + } + + reactForEvent(kind: "need-you" | "success" | "nod" | "curious") { + switch (kind) { + case "need-you": + this.react("ouh"); + break; + case "success": + this.react("hm"); + break; + case "nod": + this.reactNod(); + break; + case "curious": + this.react("mm"); + break; + default: + break; + } + } +} + +export const ooVocals = new OoVocalsEngine(); + +export function resetOoVocalsCooldownForTests(): void { + lastPlayedAt = 0; + nextCooldownMs = COOLDOWN_MIN_MS; +} diff --git a/apps/web/src/lib/oo-voice-mark.ts b/apps/web/src/lib/oo-voice-mark.ts new file mode 100644 index 0000000..32fe98c --- /dev/null +++ b/apps/web/src/lib/oo-voice-mark.ts @@ -0,0 +1,96 @@ +import { ooAudioBus } from "./oo-audio-bus"; +import { readOoSoundEnabled } from "./oo-sound-pref"; + +const SESSION_KEY = "evoo_voice_mark_played"; + +function prefersReducedMotion(): boolean { + if (typeof window === "undefined" || !window.matchMedia) return false; + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +function hasPlayedThisSession(): boolean { + if (typeof window === "undefined" || !window.sessionStorage) return true; + return window.sessionStorage.getItem(SESSION_KEY) === "1"; +} + +function markPlayed(): void { + if (typeof window === "undefined" || !window.sessionStorage) return; + window.sessionStorage.setItem(SESSION_KEY, "1"); +} + +function playVoiceMarkOnContext(ctx: AudioContext) { + const master = ooAudioBus.sfxDestination(ctx); + const t0 = ctx.currentTime + 0.01; + + const eOsc = ctx.createOscillator(); + const eGain = ctx.createGain(); + const eFilter = ctx.createBiquadFilter(); + eFilter.type = "bandpass"; + eFilter.frequency.value = 480; + eFilter.Q.value = 5; + eOsc.type = "triangle"; + eOsc.frequency.setValueAtTime(320, t0); + eGain.gain.setValueAtTime(0.0001, t0); + eGain.gain.exponentialRampToValueAtTime(0.05, t0 + 0.02); + eGain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.12); + eOsc.connect(eFilter); + eFilter.connect(eGain); + eGain.connect(master); + eOsc.start(t0); + eOsc.stop(t0 + 0.14); + + const ooOsc = ctx.createOscillator(); + const ooGain = ctx.createGain(); + const ooFilter = ctx.createBiquadFilter(); + ooFilter.type = "bandpass"; + ooFilter.frequency.value = 360; + ooFilter.Q.value = 4; + ooOsc.type = "sine"; + ooOsc.frequency.setValueAtTime(280, t0 + 0.1); + ooOsc.frequency.exponentialRampToValueAtTime(220, t0 + 0.38); + ooGain.gain.setValueAtTime(0.0001, t0 + 0.1); + ooGain.gain.exponentialRampToValueAtTime(0.055, t0 + 0.14); + ooGain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.42); + ooOsc.connect(ooFilter); + ooFilter.connect(ooGain); + ooGain.connect(master); + ooOsc.start(t0 + 0.1); + ooOsc.stop(t0 + 0.45); +} + +class OoVoiceMarkEngine { + maybePlayColdOpen() { + if (!readOoSoundEnabled()) return; + if (prefersReducedMotion()) return; + if (hasPlayedThisSession()) return; + + const ctx = ooAudioBus.ensureContext(); + if (!ctx) return; + + const run = () => { + try { + playVoiceMarkOnContext(ctx); + markPlayed(); + } catch { + // Ignore playback failures. + } + }; + + if (ctx.state === "suspended") { + void ctx + .resume() + .then(run) + .catch(() => undefined); + return; + } + run(); + } +} + +export const ooVoiceMark = new OoVoiceMarkEngine(); + +export function resetOoVoiceMarkForTests(): void { + if (typeof window !== "undefined" && window.sessionStorage) { + window.sessionStorage.removeItem(SESSION_KEY); + } +} diff --git a/apps/web/src/pages/ActivityList.tsx b/apps/web/src/pages/ActivityList.tsx index 15db702..41f0b06 100644 --- a/apps/web/src/pages/ActivityList.tsx +++ b/apps/web/src/pages/ActivityList.tsx @@ -3,13 +3,14 @@ import { t } from "@lingui/core/macro"; import { Trans, useLingui } from "@lingui/react/macro"; import type { RunActivityRow } from "@rakazo/contracts"; import { useEffect, useState } from "react"; +import { isNeedsYouStatus, runActivityLabel } from "../lib/agent-activity"; import { rpc } from "../lib/rpc"; function statusTone(status: RunActivityRow["status"]): string { if (status === "failed") return "text-destructive"; if (status === "cancelled") return "text-muted-foreground"; if (status === "completed") return "text-success"; - if (status === "waiting_input" || status === "waiting_takeover") return "text-warning"; + if (isNeedsYouStatus(status)) return "text-warning"; return "text-foreground"; } @@ -120,7 +121,13 @@ function ActivityRow({ run, onOpen }: { run: RunActivityRow; onOpen: () => void {run.promptSnippet} ) : null} - {label} + {isNeedsYouStatus(run.status) ? ( + + {label} + + ) : ( + {label} + )} @@ -142,24 +149,6 @@ function formatRelativeTime(iso: string, now = new Date()): string { } function statusLabel(status: RunActivityRow["status"]): string { - switch (status) { - case "queued": - return t`Queued`; - case "leased": - return t`Starting`; - case "running": - return t`Running`; - case "waiting_input": - return t`Needs input`; - case "waiting_takeover": - return t`Needs takeover`; - case "completed": - return t`Done`; - case "failed": - return t`Failed`; - case "cancelled": - return t`Cancelled`; - default: - return status; - } + if (isNeedsYouStatus(status)) return t`Needs you`; + return runActivityLabel(status); } diff --git a/apps/web/src/pages/HeuteBrief.tsx b/apps/web/src/pages/HeuteBrief.tsx index babba84..cdf12b7 100644 --- a/apps/web/src/pages/HeuteBrief.tsx +++ b/apps/web/src/pages/HeuteBrief.tsx @@ -1,16 +1,17 @@ import { i18n } from "@lingui/core"; -import { t } from "@lingui/core/macro"; import { Plural, Trans, useLingui } from "@lingui/react/macro"; import type { Bot, Group, RunActivityRow } from "@rakazo/contracts"; import { botColors } from "@rakazo/ui-tokens"; import { BotAvatar, Button, EvooMascot, GroupAvatar } from "@rakazo/ui-web"; import { ExternalLink } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; +import { runActivityLabel } from "../lib/agent-activity"; import { buildHeuteBrief, cleanBriefPreview, + type DoingItem, + type DoneItem, type JetztItem, - type WeiterItem, } from "../lib/heute-brief"; import { rpc } from "../lib/rpc"; @@ -33,7 +34,6 @@ export function HeuteBrief({ onOpenRun, onStartChat, }: HeuteBriefProps) { - const { t } = useLingui(); const [activeRuns, setActiveRuns] = useState([]); const [recentRuns, setRecentRuns] = useState([]); const [loading, setLoading] = useState(true); @@ -79,42 +79,41 @@ export function HeuteBrief({ [activeRuns, bots, groups, recentRuns], ); - const botColorById = useMemo( - () => new Map(bots.map((bot) => [bot.id, bot.color])), - [bots], - ); + const botColorById = useMemo(() => new Map(bots.map((bot) => [bot.id, bot.color])), [bots]); const dateLabel = formatHeuteDate(new Date()); + const hasContent = brief.needsYou.length > 0 || brief.doing.length > 0 || brief.done.length > 0; return ( -
+

{dateLabel}

- +

- {loading && brief.jetzt.length === 0 && brief.weiter.length === 0 ? ( + {loading && !hasContent ? (

Loading…

- ) : brief.jetzt.length === 0 && brief.weiter.length === 0 ? ( + ) : !hasContent ? ( 0} /> ) : (
- {brief.jetzt.length > 0 ? ( -
+ {brief.needsYou.length > 0 ? ( +

- Now + Needs you

- {brief.jetzt.map((item) => ( - ( + ))}
+ {brief.actionCount > brief.needsYou.length ? ( +

+ +

+ ) : null}
) : null} - {brief.weiter.length > 0 ? ( -
+ {brief.doing.length > 0 ? ( +

- Next + Doing

- {brief.weiter.map((item) => ( - ( + + ))} +
+
+ ) : null} + + {brief.done.length > 0 ? ( +
+

+ Done +

+
+ {brief.done.map((item) => ( + ))}
@@ -164,20 +190,25 @@ export function HeuteBrief({ ); } -function StatusLine({ actionCount, loading }: { actionCount: number; loading: boolean }) { - if (loading && actionCount === 0) { +function StatusLine({ + actionCount, + doingCount, + loading, +}: { + actionCount: number; + doingCount: number; + loading: boolean; +}) { + if (loading && actionCount === 0 && doingCount === 0) { return Loading…; } - if (actionCount === 0) { - return All quiet; + if (actionCount > 0) { + return ; } - return ( - - ); + if (doingCount > 0) { + return ; + } + return All quiet; } function EmptyDay({ onStartChat, hasBots }: { onStartChat: () => void; hasBots: boolean }) { @@ -202,7 +233,15 @@ function EmptyDay({ onStartChat, hasBots }: { onStartChat: () => void; hasBots: ); } -function JetztRow({ +function ActionableChip({ label }: { label: string }) { + return ( + + {label} + + ); +} + +function NeedsYouRow({ item, botColorById, onOpenBot, @@ -220,13 +259,13 @@ function JetztRow({ if (item.kind === "run") { const run = item.run; const title = run.groupName ? `${run.botName} · ${run.groupName}` : run.botName; - const subtitle = run.promptSnippet || runStatusLabel(run.status); - const label = t`${title}, ${runStatusLabel(run.status)}`; + const subtitle = run.promptSnippet || runActivityLabel(run.status); + const label = t`${title}, ${runActivityLabel(run.status)}`; return ( ); } @@ -255,11 +293,9 @@ function JetztRow({
-
- ); - })} -
- {/* PROJEKTE SECTION */} -
-
-
{t`Projekte`}
-

- {t`Threads by topic · folders`} -

+ {group.canDeleteSpace ? ( + + ) : null} +
+ ) : null} + {!collapsed && + group.bots + .slice(0, expandedTeamGroups[group.key] ? undefined : 4) + .map((item) => ( + + ))} + {!collapsed && group.bots.length > 4 && ( + + )} +
+ ); + })} +
+ {/* PROJEKTE SECTION */} +
+
+
{t`Projekte`}
+

+ {t`Threads by topic · folders`} +

+
+
+ +
-
- -
-
- {projectSidebarGroups.length === 0 ? ( -
-

{t`No projects yet.`}

- -
- ) : null} - {projectSidebarGroups.map((group) => { + {projectSidebarGroups.length === 0 ? ( +
+

{t`No projects yet.`}

+ +
+ ) : null} + {projectSidebarGroups.map((group) => { const collapsed = Boolean(group.title) && collapsedSidebarSections.has(group.key); return ( @@ -3523,7 +3606,9 @@ export function ShellPage() { className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg px-2.5 py-1 text-ui-label font-semibold tracking-wider uppercase text-muted-foreground/60 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring" onClick={() => { if (group.emptySpaceId) { - const space = spaces.find((item) => item.id === group.emptySpaceId); + const space = spaces.find( + (item) => item.id === group.emptySpaceId, + ); openSpaceChat( group.emptySpaceId, space?.hasContent ? "/app" : "/onboarding", @@ -3679,7 +3764,7 @@ export function ShellPage() {
); })} -
+
)} @@ -4026,11 +4111,7 @@ export function ShellPage() { /> ) : inGroup ? ( @@ -4100,7 +4181,6 @@ export function ShellPage() { onStartSession={openProjectChat} onOpenGroupChat={openProjectGroupChat} onStartVoiceFirst={(goalGroupId) => openProjectVoiceFirst(goalGroupId)} - /> ) : ( <> @@ -4907,9 +4987,7 @@ export function ShellPage() { await refreshBots(); return; } - window.location.assign( - newSpaceDialogVariant === "project" ? "/app" : "/onboarding", - ); + window.location.assign(newSpaceDialogVariant === "project" ? "/app" : "/onboarding"); }} /> ) : null} @@ -5410,7 +5488,6 @@ const Transcript = memo(function Transcript({ [], ); - return (
**Oo trademark = asymmetric Kulleraugen + size difference** diff --git a/packages/ui-web/MOTION-EVOO-CHARACTER.md b/packages/ui-web/MOTION-EVOO-CHARACTER.md new file mode 100644 index 0000000..d717f55 --- /dev/null +++ b/packages/ui-web/MOTION-EVOO-CHARACTER.md @@ -0,0 +1,79 @@ +# evOo Character Motion — Brand Soul & Motion God + +Product priority: **Proaktivität first** (Oos work or clearly wait on you). Motion and SFX serve that — never decorative idle. + +Cross-ref: geometry lock in [`CHARACTER.md`](./CHARACTER.md), voice encounter in [`../../VOICE.md`](../../VOICE.md). + +--- + +## Expressor hierarchy + +| Layer | Role | Share of read | +|-------|------|----------------| +| **Eyes** | Asymmetric Kulleraugen lead every beat | ~lead | +| **Mouth** | Primary expressor | ~80% | +| **Hands** | Punctuation only — then rest | ~20% spikes | + +Never symmetrize eyes. Never let floating hands steal focus from face. + +--- + +## Pose thesis (character only) + +| Rule | Value | +|------|--------| +| Eyes before mouth | **40–80 ms** lead on pose change | +| Pose transition | **220–320 ms** | +| Spring | Cute / soft / snap — `cubic-bezier(0.34, 1.25, 0.64, 1)` | +| Scope | Character SVG + mascot only — not app chrome | + +Tokens: `evoo-character/motion-tokens.ts` (`EVOO_MOUTH_LAG_MS`, `EVOO_POSE_TRANSITION_MS`, `EVOO_CHARACTER_SPRING`). + +--- + +## SFX & vocals (Web Audio) + +Toggle: Settings → Voice → Sound feedback. No vendor branding in UI. + +| Cue | Spec | +|-----|------| +| **eOo cold-open** | ≤450 ms, **max 1×/session** | +| **App start** | 180–320 ms mint→violet shimmer (before optional eOo mark) | +| **Interact** | 40–80 ms soft tick | +| **Success** | Mint→gold resolve **or** soft `hm!` vocal — not both loud | +| **Need-you** | Short `ouh?!` + UI chip together | +| **Reactions** | `mhMhh` / `aaah` / `ouh?!` / `mm` — state-change only | +| **Cooldown** | ≥8–12 s between reaction vocals | +| **Level** | **−18…−12 LUFS** perceived (procedural peaks via shared bus gain) | +| **Duck** | Under TTS playback and PTT/listening wash | + +Implementation: `apps/web/src/lib/oo-sound.ts`, `oo-vocals.ts`, `oo-voice-mark.ts`, `oo-audio-bus.ts`. + +### Nod + +| Phase | Vocal | +|-------|--------| +| Down | `mh` (short, low) | +| Up | `Mhh` (slightly longer, rising) | + +### Dual-Mind bloom + +Reaction vocals may ride the voice-stage cyan/violet bloom — stay **under** the PTT/TTS wash; duck, never compete. + +--- + +## Reduced motion + +When `prefers-reduced-motion: reduce`: + +- **No vocal loops** and no reaction stems +- **Visual state only** — pose snaps without stagger; no mouth-speak pulse +- Procedural UI ticks may still be omitted (same gate as SFX) + +--- + +## Proactivity (Priority S) + +Motion never replaces working/waiting signals. Roster doing-verbs, Heute buckets, and need-you pings are authoritative; face/audio **amplify** them. + +See `apps/web/src/lib/agent-activity.ts` and Heute Brief buckets. diff --git a/packages/ui-web/src/evoo-brand.tsx b/packages/ui-web/src/evoo-brand.tsx index c470fb1..5d60a23 100644 --- a/packages/ui-web/src/evoo-brand.tsx +++ b/packages/ui-web/src/evoo-brand.tsx @@ -1,11 +1,12 @@ import { memo, useEffect, useId, useRef, useState } from "react"; import { EvooCharacterSvg } from "./evoo-character/EvooCharacterSvg.js"; +import { evooMouthLagMs } from "./evoo-character/motion-tokens.js"; import { normalizeEvooPose } from "./evoo-character/pose-map.js"; import type { EvooPose } from "./evoo-character/types.js"; import { cn } from "./lib/utils.js"; -export type { EvooPose } from "./evoo-character/types.js"; export { + EVOO_OO_TRADEMARK, mapBotStatusToPose, mapCollaborationToPose, mapOnboardingToPose, @@ -13,11 +14,11 @@ export { mapStreamingToPose, mapVoicePhaseToPose, mapWelcomeContextToPose, - EVOO_OO_TRADEMARK, normalizeEvooPose, resolveEvooPose, type VoicePresencePhase, } from "./evoo-character/index.js"; +export type { EvooPose } from "./evoo-character/types.js"; /** `none` = calm idle only; `hover` = track while pointer is over mascot; `global` = legacy window tracking */ export type PointerTracking = "none" | "hover" | "global"; @@ -74,18 +75,37 @@ export const EvooMascot = memo(function EvooMascot({ showWordmark = false, }: EvooMascotProps) { const [activePose, setActivePose] = useState(initialPose); + const [mouthPose, setMouthPose] = useState(initialPose); + const [reducedMotion, setReducedMotion] = useState(false); const [pupilOffset, setPupilOffset] = useState({ x: 0, y: 0 }); const [saccadeOffset, setSaccadeOffset] = useState({ x: 0, y: 0 }); const [isBlinking, setIsBlinking] = useState(false); + const [isSmallBlinking, setIsSmallBlinking] = useState(false); const [isWinking, setIsWinking] = useState(false); const [isAsleep, setIsAsleep] = useState(false); const [hapticRipple, setHapticRipple] = useState(false); const containerRef = useRef(null); const filterId = useId(); + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) return; + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + const sync = () => setReducedMotion(mq.matches); + sync(); + mq.addEventListener("change", sync); + return () => mq.removeEventListener("change", sync); + }, []); + useEffect(() => { setActivePose(initialPose); - }, [initialPose]); + if (reducedMotion) { + setMouthPose(initialPose); + return; + } + const lagMs = evooMouthLagMs(); + const timer = window.setTimeout(() => setMouthPose(initialPose), lagMs); + return () => window.clearTimeout(timer); + }, [initialPose, reducedMotion]); useEffect(() => { let blinkTimeout: ReturnType; @@ -93,11 +113,18 @@ export const EvooMascot = memo(function EvooMascot({ const delay = Math.random() * 4000 + 3000; blinkTimeout = setTimeout(() => { if (!isAsleep) { - setIsBlinking(true); - setTimeout(() => { - setIsBlinking(false); - scheduleBlink(); - }, 140); + const roll = Math.random(); + const mode = roll < 0.55 ? "both" : roll < 0.8 ? "big" : "small"; + setIsBlinking(mode === "both" || mode === "big"); + setIsSmallBlinking(mode === "both" || mode === "small"); + setTimeout( + () => { + setIsBlinking(false); + setIsSmallBlinking(false); + scheduleBlink(); + }, + mode === "small" ? 110 : 140, + ); } else { scheduleBlink(); } @@ -215,7 +242,16 @@ export const EvooMascot = memo(function EvooMascot({ const nextPose: EvooPose = canonical === "idle" ? "greeting" : canonical === "greeting" ? "success" : "idle"; setActivePose(nextPose); - setTimeout(() => setActivePose(initialPose), 2800); + if (reducedMotion) { + setMouthPose(nextPose); + } else { + window.setTimeout(() => setMouthPose(nextPose), evooMouthLagMs()); + } + setTimeout(() => { + setActivePose(initialPose); + if (reducedMotion) setMouthPose(initialPose); + else window.setTimeout(() => setMouthPose(initialPose), evooMouthLagMs()); + }, 2800); }; const finalPupilX = pupilOffset.x + saccadeOffset.x; @@ -274,7 +310,13 @@ export const EvooMascot = memo(function EvooMascot({ )} style={{ width: size, height: size * 0.88 }} > -
diff --git a/packages/ui-web/src/evoo-character/EvooCharacterSvg.tsx b/packages/ui-web/src/evoo-character/EvooCharacterSvg.tsx index d5be4f8..bf6d142 100644 --- a/packages/ui-web/src/evoo-character/EvooCharacterSvg.tsx +++ b/packages/ui-web/src/evoo-character/EvooCharacterSvg.tsx @@ -5,21 +5,32 @@ import { EVOO_PUPIL_BIG_R, EVOO_PUPIL_SMALL_R, } from "./brand-lock.js"; -import { POSE_DEFINITIONS } from "./poses.js"; +import { EVOO_CHARACTER_SPRING, EVOO_POSE_TRANSITION_S } from "./motion-tokens.js"; import { normalizeEvooPose } from "./pose-map.js"; +import { POSE_DEFINITIONS } from "./poses.js"; import type { EvooMouthStyle, EvooPose } from "./types.js"; -export { EVOO_EYE_BIG_R, EVOO_EYE_SMALL_R, EVOO_PUPIL_BIG_R, EVOO_PUPIL_SMALL_R } from "./brand-lock.js"; +export { + EVOO_EYE_BIG_R, + EVOO_EYE_SMALL_R, + EVOO_PUPIL_BIG_R, + EVOO_PUPIL_SMALL_R, +} from "./brand-lock.js"; export interface EvooCharacterSvgProps { filterId: string; pose: EvooPose; + /** Mouth follows pose when omitted; lags eyes by 40–80 ms when set separately. */ + mouthPose?: EvooPose; pupilX: number; pupilY: number; isBlinking: boolean; + isSmallBlinking?: boolean; isWinking: boolean; isAsleep: boolean; hapticRipple?: boolean; + /** Skip motion stagger / speak pulse (reduced-motion). */ + reducedMotion?: boolean; } function mouthPath(style: EvooMouthStyle, asleep: boolean): string { @@ -43,7 +54,13 @@ function mouthPath(style: EvooMouthStyle, asleep: boolean): string { } } -function Brow({ side, raise }: { side: "left" | "right"; raise: "left" | "right" | "both" | "worried" }) { +function Brow({ + side, + raise, +}: { + side: "left" | "right"; + raise: "left" | "right" | "both" | "worried"; +}) { const showLeft = raise === "left" || raise === "both" || raise === "worried"; const showRight = raise === "right" || raise === "both" || raise === "worried"; const worried = raise === "worried"; @@ -99,7 +116,16 @@ function Accessory({ case "clipboard": return ( - + @@ -126,7 +152,13 @@ function Accessory({ return ( - + ); case "thought": @@ -218,38 +250,110 @@ function Hands({ case "typing": return ( <> - - - - + + + + ); case "peek": return ( <> - - + + ); case "wave": return ( <> - + ); case "success": return ( <> - + ); case "thinking": return ( <> - + ); case "listening": @@ -263,7 +367,16 @@ function Hands({ return ( <> - + ); case "reading": @@ -303,9 +416,34 @@ function Hands({ case "collaborating": return ( <> - - - + + + ); case "error": @@ -318,8 +456,28 @@ function Hands({ case "delighted": return ( <> - - + + ); default: @@ -330,16 +488,22 @@ function Hands({ export function EvooCharacterSvg({ filterId, pose: rawPose, + mouthPose: rawMouthPose, pupilX, pupilY, isBlinking, + isSmallBlinking = false, isWinking, isAsleep, hapticRipple = false, + reducedMotion = false, }: EvooCharacterSvgProps) { const pose = normalizeEvooPose(isAsleep ? "asleep" : rawPose); + const mouthNorm = normalizeEvooPose(isAsleep ? "asleep" : (rawMouthPose ?? rawPose)); const def = POSE_DEFINITIONS[pose]; + const mouthDef = POSE_DEFINITIONS[mouthNorm]; const gaze = def.gaze ?? { x: 0, y: 0 }; + const transition = `${EVOO_POSE_TRANSITION_S}s ${EVOO_CHARACTER_SPRING}`; const finalPupilX = pupilX + gaze.x; const finalPupilY = pupilY + gaze.y; const eyeOpen = isAsleep ? 0.38 : (def.eyeOpen ?? 1); @@ -355,26 +519,53 @@ export function EvooCharacterSvg({ @keyframes evoo-quantum-flicker { 0%, 100% { opacity: 0.3; transform: scale(0.85); } 50% { opacity: 0.85; transform: scale(1.15); } } @keyframes evoo-executive-nod { 0%, 100% { transform: translateY(0px); } 50% { transform: translateY(2px); } } @keyframes evoo-haptic-ring { 0% { r: 18; stroke-opacity: 0.7; stroke-width: 2.5; } 100% { r: 56; stroke-opacity: 0; stroke-width: 0.5; } } + @keyframes evoo-mouth-speak { 0% { transform: scaleY(0.92); } 100% { transform: scaleY(1.08); } } `} {hapticRipple ? ( - + ) : null} {def.aura === "success" ? ( - + ) : null} {def.aura === "speak" ? ( - + ) : null} {def.browRaise ? ( @@ -388,7 +579,13 @@ export function EvooCharacterSvg({ - + {(!isBlinking || isAsleep) && ( - + + + + {(def.eyeSparkle || pose === "success") && ( - + )} )} @@ -422,7 +655,13 @@ export function EvooCharacterSvg({ - + {(!isBlinking || isAsleep) && !isWinking && ( - - + + + + + )} @@ -454,7 +731,14 @@ export function EvooCharacterSvg({ ) : null} - + diff --git a/packages/ui-web/src/evoo-character/index.ts b/packages/ui-web/src/evoo-character/index.ts index e2b1673..f6dcbad 100644 --- a/packages/ui-web/src/evoo-character/index.ts +++ b/packages/ui-web/src/evoo-character/index.ts @@ -10,6 +10,13 @@ export { EVOO_PUPIL_SMALL_R, } from "./brand-lock.js"; export { POSE_DEFINITIONS } from "./poses.js"; +export { + EVOO_CHARACTER_SPRING, + EVOO_MOUTH_LAG_MS, + EVOO_POSE_TRANSITION_MS, + EVOO_POSE_TRANSITION_S, + evooMouthLagMs, +} from "./motion-tokens.js"; export { mapBotStatusToPose, mapCollaborationToPose, diff --git a/packages/ui-web/src/evoo-character/motion-tokens.ts b/packages/ui-web/src/evoo-character/motion-tokens.ts new file mode 100644 index 0000000..bb6497e --- /dev/null +++ b/packages/ui-web/src/evoo-character/motion-tokens.ts @@ -0,0 +1,19 @@ +/** Character-only motion — see MOTION-EVOO-CHARACTER.md */ + +/** Eyes lead mouth on pose change (ms). */ +export const EVOO_MOUTH_LAG_MS = { min: 40, max: 80 } as const; + +/** Full pose transition duration (ms). */ +export const EVOO_POSE_TRANSITION_MS = { min: 220, max: 320 } as const; + +/** Midpoint transition used in SVG/CSS. */ +export const EVOO_POSE_TRANSITION_S = (EVOO_POSE_TRANSITION_MS.min + EVOO_POSE_TRANSITION_MS.max) / 2 / 1000; + +/** Cute / soft / snap — character springs only. */ +export const EVOO_CHARACTER_SPRING = "cubic-bezier(0.34, 1.25, 0.64, 1)"; + +export function evooMouthLagMs(seed = Math.random()): number { + return Math.round( + EVOO_MOUTH_LAG_MS.min + seed * (EVOO_MOUTH_LAG_MS.max - EVOO_MOUTH_LAG_MS.min), + ); +} diff --git a/packages/ui-web/src/evoo-character/poses.ts b/packages/ui-web/src/evoo-character/poses.ts index 74fff85..ef9a498 100644 --- a/packages/ui-web/src/evoo-character/poses.ts +++ b/packages/ui-web/src/evoo-character/poses.ts @@ -10,14 +10,14 @@ export const POSE_DEFINITIONS: Record = { mouth: "tiny-o", gaze: { x: 0, y: -0.4 }, accessory: "headset", - handPreset: "listening", + handPreset: "rest", bodyAnimation: "evoo-breathe-awake", aura: "speak", }, speaking: { mouth: "speaking", gaze: { x: 0.3, y: 0 }, - handPreset: "speaking", + handPreset: "rest", bodyAnimation: "evoo-conductor-left", aura: "speak", }, @@ -25,20 +25,20 @@ export const POSE_DEFINITIONS: Record = { mouth: "flat", gaze: { x: 1.2, y: -1.1 }, accessory: "thought", - handPreset: "thinking", + handPreset: "rest", aura: "think", }, working: { mouth: "flat", gaze: { x: 0.6, y: 0.8 }, - handPreset: "typing", + handPreset: "rest", bodyAnimation: "evoo-quantum-flicker", }, reading: { mouth: "tiny-o", gaze: { x: 1.4, y: 1.2 }, accessory: "clipboard", - handPreset: "reading", + handPreset: "rest", eyeOpen: 0.92, }, analyzing: { @@ -46,14 +46,14 @@ export const POSE_DEFINITIONS: Record = { gaze: { x: 0.8, y: 0.4 }, accessory: "magnifier", browRaise: "left", - handPreset: "analyzing", + handPreset: "rest", eyeOpen: 0.88, }, skeptical: { mouth: "flat", browRaise: "right", gaze: { x: -0.6, y: -0.2 }, - handPreset: "skeptical", + handPreset: "rest", eyeOpen: 0.9, }, curious: { @@ -74,13 +74,13 @@ export const POSE_DEFINITIONS: Record = { mouth: "concerned", browRaise: "worried", gaze: { x: 0, y: 0.6 }, - handPreset: "concerned", + handPreset: "rest", eyeOpen: 0.94, }, waiting: { - mouth: "flat", + mouth: "tiny-o", gaze: { x: -0.5, y: 0 }, - handPreset: "waiting", + handPreset: "rest", eyeOpen: 0.72, }, success: { @@ -112,7 +112,7 @@ export const POSE_DEFINITIONS: Record = { collaborating: { mouth: "soft-smile", accessory: "tool", - handPreset: "collaborating", + handPreset: "rest", gaze: { x: 0.9, y: -0.3 }, }, onboarding: { @@ -125,7 +125,7 @@ export const POSE_DEFINITIONS: Record = { typing: { mouth: "flat", gaze: { x: 0.6, y: 0.8 }, - handPreset: "typing", + handPreset: "rest", bodyAnimation: "evoo-quantum-flicker", }, peek: {