Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ const wallet = useStoreSelector(walletStore, (s) => s);
to avoid hydration mismatches.
- Review checklist: reject any PR that subscribes to a whole store object.

## Testing realtime hooks

Use the in-memory fake transport (`@/lib/realtime/testing`) instead of hand-rolled WebSocket mocks. See [docs/realtime.md](docs/realtime.md).

## Realtime conventions

- Subscribe only through `realtimeClient` from `@/lib/realtime`, inside a `useEffect`/`useLayoutEffect`; never `new WebSocket`/`new EventSource` or deep imports like `@/lib/realtime/client` (enforced by ESLint). Escape hatch: `// eslint-disable-next-line <rule> -- <justification>`.
Expand Down
20 changes: 20 additions & 0 deletions docs/attention-cues.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Attention cues (audio + haptics)

Attention is governed centrally, like motion (`usePrefersReducedMotion`,
`prefers-reduced-motion` in `globals.css`). **No component plays sounds or
vibrates directly** — request a cue by severity through `AttentionCueService`
(`src/lib/attentionCue.ts`, wired by `src/hooks/useAttentionCue.ts`).

Policy:

- Only `critical` notifications (oracle trigger, claim settled/denied) cue.
- Off by default; persisted via `preferences.attentionCues`, toggled in the
Notification Center.
- Suppressed under `prefers-reduced-motion`, `Save-Data`, and quiet hours
(22:00–07:00 local).
- Audio is synthesized (Web Audio, no asset) and only starts after a user
gesture unlocks the `AudioContext`; earlier events are dropped, not queued.
- Bursts within 1s coalesce into one cue; the first emission (backfill) never
cues. `navigator.vibrate` is a silent no-op where unsupported (iOS).
- A cue never replaces the accessible (ARIA live) announcement.
- The `AudioContext` is closed on unmount.
32 changes: 32 additions & 0 deletions docs/realtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ The docs mirror the actual module layout so every path below is verifiable:
| `src/lib/realtime/capabilities.ts` | Capability negotiation with the backend |
| `src/lib/realtime/merge.ts` | Merging realtime payloads into cached REST data |
| `src/lib/realtime/shadow.ts` | Shadow mode: observe realtime without applying it |
| `src/lib/realtime/contract.ts` | Topics, validators, fixtures, frames |
| `src/lib/realtime/testing.ts` | Fake transport for tests (never import from production) |
| `public/flags.json` | Per-topic feature flag defaults |

## Topic / message contract
Expand All @@ -43,6 +45,19 @@ A message has the shape:
cached REST snapshot rather than replacing it wholesale.
- Unknown `type` values are ignored (forward compatibility).

## Request/response

`realtimeClient.request(topic, payload, { timeoutMs, signal })` multiplexes a
one-shot query over the open WebSocket.

- Client frame: `{ id, topic, request }`; server replies `{ id, response }` or
`{ id, error: { code, message } }`. Frames with an `id` never reach topic
subscribers.
- Rejects with `RealtimeRequestError` (`timeout | aborted | disconnected |
overflow | server`). Pending requests reject on disconnect/reconnect and are
never replayed; at most 100 may be in flight. Payloads are never logged.
- SSE/long-poll fallbacks do not support requests; use REST.

## Connection state machine

`client.ts` drives a small state machine:
Expand Down Expand Up @@ -100,6 +115,23 @@ The realtime contract above must be implemented in `refract-backend`. Until
that lands, treat this document as the spec the backend is built against.
Changes to the contract require a coordinated change in both repos.

## Testing

Use the fake transport from `@/lib/realtime/testing` (tests only — never import
it from production code):

```ts
const fake = installFakeRealtime();
realtimeClient.subscribe("oracle_update", handler);
fake.current.open();
fake.current.emit("oracle_update", { severity: "triggered", value: 9 }); // typed from the contract
fake.current.drop(); // force close; advance fake timers for reconnect
afterEach(() => fake.teardown());
```

`src/lib/realtime/__tests__/topicCoverage.test.ts` fails when a contract topic
lacks a mock producer, a consumer, or valid fixtures (see its allowlist).

## Operational runbook

### "Data stopped updating"
Expand Down
12 changes: 12 additions & 0 deletions src/components/NotificationCenter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useNotifications } from "@/hooks/useNotifications";
import { usePreferencesStore } from "@/lib/store/preferencesStore";
import type { RefractNotification } from "@/lib/notifications";
import { VirtualList } from "@/components/ui/VirtualList";

Expand All @@ -27,6 +28,8 @@ const SEVERITY_COLORS: Record<RefractNotification["severity"], { bg: string; tex
export function NotificationCenter() {
const [isOpen, setIsOpen] = useState(false);
const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotifications();
const attentionCues = usePreferencesStore((s) => s.attentionCues);
const setAttentionCues = usePreferencesStore((s) => s.setAttentionCues);
const containerRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);

Expand Down Expand Up @@ -163,6 +166,15 @@ export function NotificationCenter() {
)}
</div>

<label className="mb-3 flex cursor-pointer items-center gap-2 text-[11px] text-pm-text/70">
<input
type="checkbox"
checked={attentionCues}
onChange={(e) => setAttentionCues(e.target.checked)}
/>
Sound &amp; vibration for critical alerts
</label>

<div className="max-h-[360px] overflow-y-auto space-y-2.5 pr-1">
{notifications.length === 0 ? (
<div className="py-8 text-center text-xs text-pm-text/40">
Expand Down
42 changes: 42 additions & 0 deletions src/hooks/useAttentionCue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"use client";

import { useCallback, useEffect, useRef } from "react";

import { AttentionCueService } from "@/lib/attentionCue";
import { usePreferencesStore } from "@/lib/store/preferencesStore";
import type { RefractNotification } from "@/lib/notifications";

/** Requests audio/haptic cues for critical notifications that appear after first load. */
export function useAttentionCue(notifications: RefractNotification[]) {
const service = useRef<AttentionCueService | null>(null);
const seen = useRef<Set<string> | null>(null);

useEffect(() => {
const svc = new AttentionCueService({
enabled: () => usePreferencesStore.getState().attentionCues,
now: () => new Date(),
});
service.current = svc;
const unlock = () => svc.unlock();
window.addEventListener("pointerdown", unlock, { once: true });
window.addEventListener("keydown", unlock, { once: true });
return () => {
window.removeEventListener("pointerdown", unlock);
window.removeEventListener("keydown", unlock);
svc.dispose();
service.current = null;
};
}, []);

useEffect(() => {
const ids = new Set(notifications.map((n) => n.id));
// First emission is existing/backfilled state: seed, don't cue.
if (seen.current) {
const fresh = notifications.filter((n) => !seen.current!.has(n.id) && !n.read);
for (const n of fresh) service.current?.cue(n.severity);
}
seen.current = ids;
}, [notifications]);

return { unlock: useCallback(() => service.current?.unlock(), []) };
}
3 changes: 3 additions & 0 deletions src/hooks/useNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { useWallet } from "@/lib/wallet/WalletProvider";
import { useHolderPolicies } from "./useHolderPolicies";
import { useClaims } from "./useClaims";
import { useAttentionCue } from "./useAttentionCue";
import { useLockupStatus } from "./useLockupStatus";
import { useOracleStatus } from "./useOracleStatus";
import {
Expand Down Expand Up @@ -54,6 +55,8 @@ export function useNotifications() {
});
}, [policies, claims, lockupExpiresAt, oracleReadings, readIds]);

useAttentionCue(notifications);

const unreadCount = useMemo(() => {
return notifications.filter((n) => !n.read).length;
}, [notifications]);
Expand Down
87 changes: 87 additions & 0 deletions src/lib/__tests__/attentionCue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AttentionCueService } from "../attentionCue";

const osc = () => ({ frequency: {}, connect: vi.fn(), start: vi.fn(), stop: vi.fn() });
let created: number;
let closed: number;
let vibrate: ReturnType<typeof vi.fn>;
let enabled: boolean;
let now: Date;
let reduced: boolean;

beforeEach(() => {
created = 0;
closed = 0;
enabled = true;
reduced = false;
now = new Date(2026, 0, 1, 12, 0, 0);
vibrate = vi.fn();
vi.stubGlobal("AudioContext", function () {
created += 1;
return {
state: "running",
currentTime: 0,
destination: {},
resume: vi.fn(),
close: () => {
closed += 1;
},
createOscillator: osc,
createGain: () => ({ gain: {}, connect: vi.fn() }),
};
});
vi.stubGlobal("navigator", { vibrate });
vi.stubGlobal("window", { matchMedia: () => ({ matches: reduced }) });
});
afterEach(() => vi.unstubAllGlobals());

const make = () => new AttentionCueService({ enabled: () => enabled, now: () => now });

describe("AttentionCueService", () => {
it("does nothing before user unlock", () => {
expect(make().cue("critical")).toBe(false);
expect(created).toBe(0);
});
it("cues for critical after unlock and vibrates", () => {
const s = make();
s.unlock();
expect(s.cue("critical")).toBe(true);
expect(vibrate).toHaveBeenCalled();
});
it("is silent when the preference is off", () => {
const s = make();
s.unlock();
enabled = false;
expect(s.cue("critical")).toBe(false);
});
it("ignores non-critical severities and replayed events", () => {
const s = make();
s.unlock();
expect(s.cue("warning")).toBe(false);
expect(s.cue("critical", { replayed: true })).toBe(false);
});
it("coalesces bursts", () => {
const s = make();
s.unlock();
expect(s.cue("critical")).toBe(true);
expect(s.cue("critical")).toBe(false);
now = new Date(now.getTime() + 1500);
expect(s.cue("critical")).toBe(true);
});
it("is silent during quiet hours and reduced motion", () => {
const s = make();
s.unlock();
now = new Date(2026, 0, 1, 23, 0, 0);
expect(s.cue("critical")).toBe(false);
now = new Date(2026, 0, 1, 12, 0, 0);
reduced = true;
expect(s.cue("critical")).toBe(false);
});
it("releases the AudioContext on dispose", () => {
const s = make();
s.unlock();
s.dispose();
expect(closed).toBe(1);
expect(s.cue("critical")).toBe(false);
});
});
85 changes: 85 additions & 0 deletions src/lib/attentionCue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Attention-cue service: owns the AudioContext, unlock state and gating.
* Consumers request a cue by severity and never touch Web Audio directly.
* See docs/attention-cues.md for the policy.
*/
import type { RefractNotification } from "./notifications";

export const COALESCE_WINDOW_MS = 1_000;
export const QUIET_HOURS = { startHour: 22, endHour: 7 } as const;
const VIBRATION_PATTERN = [120, 60, 120];

export interface CueEnv {
enabled: () => boolean;
now: () => Date;
}

type Severity = RefractNotification["severity"];

function inQuietHours(date: Date): boolean {
const h = date.getHours();
return h >= QUIET_HOURS.startHour || h < QUIET_HOURS.endHour;
}

export class AttentionCueService {
private ctx: AudioContext | null = null;
private unlocked = false;
private lastCueAt = -Infinity;

constructor(private env: CueEnv) {}

/** Call from a user gesture; creates/resumes the AudioContext. */
unlock(): void {
if (this.unlocked || typeof AudioContext === "undefined") return;
this.ctx = new AudioContext();
void this.ctx.resume?.();
this.unlocked = true;
}

/**
* Request a cue. Returns true when a cue fired. Replayed/backfilled events,
* non-critical severities, disabled preference, locked audio, reduced motion,
* save-data, quiet hours and bursts inside the coalescing window are dropped.
*/
cue(severity: Severity, opts: { replayed?: boolean } = {}): boolean {
if (severity !== "critical" || opts.replayed || !this.env.enabled()) return false;
if (!this.unlocked || !this.ctx) return false;
if (this.suppressed()) return false;
const now = this.env.now();
if (now.getTime() - this.lastCueAt < COALESCE_WINDOW_MS) return false;
this.lastCueAt = now.getTime();
this.beep(this.ctx);
try {
navigator.vibrate?.(VIBRATION_PATTERN);
} catch {
// Unsupported (iOS) — silent no-op.
}
return true;
}

private suppressed(): boolean {
if (inQuietHours(this.env.now())) return true;
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return true;
const conn = (navigator as Navigator & { connection?: { saveData?: boolean } }).connection;
return Boolean(conn?.saveData);
}

private beep(ctx: AudioContext): void {
if (ctx.state === "suspended") void ctx.resume();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.frequency.value = 880;
gain.gain.value = 0.08;
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.15);
}

dispose(): void {
void this.ctx?.close();
this.ctx = null;
this.unlocked = false;
this.lastCueAt = -Infinity;
}
}
Loading