* feat(web): Ingo UX — proactivity signals, Oo face richness, soft audio Priority S: Heute Brief buckets (Needs you ≤3 / Doing / Done), roster doing-verbs and Braucht-dich chips, sidebar badge on Heute, need-you ping on waiting states. Priority A/B: Web Audio cues (app-start shimmer, interact tick, success-resolve, need-you ouh), reaction vocals with cooldown, optional eOo cold-open voice mark. Oo eyes gain iris/specular detail and asymmetric blink; hands reduced to rest except punctuation poses; speaking mouth animates. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): biome format and unused import in HeuteBrief Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): a11y and import order for Heute badge Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * docs+motion: Motion God craft — pose stagger, audio bus, ducking Add MOTION-EVOO-CHARACTER.md (Brand Soul brief): eyes lead, mouth ~80%, hands punctuation, 40–80 ms eye→mouth lag, 220–320 ms springs, SFX LUFS and duck-under-TTS/PTT, nod mh/Mhh, reduced-motion rules. Implement motion-tokens, staggered mouthPose in EvooMascot, shared oo-audio-bus with voice-lane ducking, mint→gold success, hm nod vocal. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(web): align conversation roster with bot-only sidebar groups after rebase Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
106 lines
2.6 KiB
TypeScript
106 lines
2.6 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { type OoSoundCue, ooSound } from "./oo-sound";
|
|
import { readOoSoundEnabled, writeOoSoundEnabled } from "./oo-sound-pref";
|
|
|
|
describe("oo-sound-pref", () => {
|
|
const storage = new Map<string, string>();
|
|
|
|
beforeEach(() => {
|
|
storage.clear();
|
|
const localStorage = {
|
|
getItem: (key: string) => storage.get(key) ?? null,
|
|
setItem: (key: string, value: string) => {
|
|
storage.set(key, value);
|
|
},
|
|
removeItem: (key: string) => {
|
|
storage.delete(key);
|
|
},
|
|
};
|
|
vi.stubGlobal("localStorage", localStorage);
|
|
vi.stubGlobal("window", { localStorage });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("defaults sound to enabled", () => {
|
|
expect(readOoSoundEnabled()).toBe(true);
|
|
});
|
|
|
|
it("persists disabled preference", () => {
|
|
writeOoSoundEnabled(false);
|
|
expect(readOoSoundEnabled()).toBe(false);
|
|
writeOoSoundEnabled(true);
|
|
expect(readOoSoundEnabled()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("ooSound", () => {
|
|
class FakeContext {
|
|
state: AudioContextState = "running";
|
|
currentTime = 0;
|
|
destination = {};
|
|
createGain() {
|
|
return {
|
|
gain: { value: 0, setValueAtTime: vi.fn(), exponentialRampToValueAtTime: vi.fn() },
|
|
connect: vi.fn(),
|
|
};
|
|
}
|
|
createOscillator() {
|
|
return {
|
|
type: "sine",
|
|
frequency: { setValueAtTime: vi.fn() },
|
|
connect: vi.fn(),
|
|
start: vi.fn(),
|
|
stop: vi.fn(),
|
|
};
|
|
}
|
|
resume = vi.fn(async () => undefined);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal("AudioContext", FakeContext);
|
|
vi.stubGlobal("window", {
|
|
matchMedia: () => ({
|
|
matches: false,
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
}),
|
|
});
|
|
vi.stubGlobal("document", { visibilityState: "visible" });
|
|
writeOoSoundEnabled(true);
|
|
ooSound.setEnabled(true);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("plays procedural cues when enabled", () => {
|
|
const cues: OoSoundCue[] = [
|
|
"app-start",
|
|
"interact",
|
|
"success-resolve",
|
|
"need-you",
|
|
"work-complete",
|
|
"listening-start",
|
|
"listening-stop",
|
|
"speaking-start",
|
|
"speaking-done",
|
|
"attention",
|
|
"message-sent",
|
|
"handoff",
|
|
"group-spark",
|
|
];
|
|
for (const cue of cues) {
|
|
expect(() => ooSound.play(cue)).not.toThrow();
|
|
}
|
|
});
|
|
|
|
it("skips playback when disabled", () => {
|
|
ooSound.setEnabled(false);
|
|
expect(() => ooSound.play("work-complete")).not.toThrow();
|
|
});
|
|
});
|