Files
houseplan-card/src/visual-continuity.ts
T

487 lines
17 KiB
TypeScript

/**
* Shared visual-continuity primitives for both House Plan cards (#73).
*
* The controller deliberately owns no House Plan model and never calls HA.
* It only guards the transition from the last complete frame to a candidate,
* keeps recovery-overlay timing deterministic, and exposes a small trace for
* lifecycle diagnostics.
*/
export const CONTINUITY_LONG_HIDDEN_MS = 15_000;
export const RECOVERY_OVERLAY_DELAY_MS = 150;
export const RECOVERY_OVERLAY_FADE_IN_MS = 150;
export const RECOVERY_OVERLAY_MIN_OPAQUE_MS = 250;
export const RECOVERY_OVERLAY_FADE_OUT_MS = 180;
export const PAINT_BARRIER_MAX_MS = 2_000;
export type ContinuityState =
| 'steady'
| 'holding'
| 'candidate-ready'
| 'overlay-pending'
| 'overlay-visible'
| 'offline-stale'
| 'recovery-error';
export type RecoveryReason = 'plan' | 'connection' | 'stage-size' | 'asset' | null;
export type OverlayPhase = 'none' | 'entering' | 'fading-in' | 'opaque' | 'leaving';
export interface ContinuityTraceEvent {
at: number;
token: number;
event: string;
state: ContinuityState;
reason?: string;
stage?: [number, number];
configRev?: number;
layoutRev?: number;
assetPending?: number;
}
export interface PageVisibilitySignal {
kind: 'hidden' | 'visible' | 'pageshow';
token: number;
at: number;
hiddenFor: number;
long: boolean;
persisted?: boolean;
}
interface PageVisibilityRuntime {
hiddenAt: number;
token: number;
subscribers: Set<(signal: PageVisibilitySignal) => void>;
onVisibility: () => void;
onPageShow: (event: PageTransitionEvent) => void;
}
const visibilityRuntimes = new WeakMap<Document, PageVisibilityRuntime>();
/** One visibility/pageshow listener per Document, shared by every card. */
export function subscribePageVisibility(
doc: Document,
subscriber: (signal: PageVisibilitySignal) => void,
): () => void {
let runtime = visibilityRuntimes.get(doc);
if (!runtime) {
const subscribers = new Set<(signal: PageVisibilitySignal) => void>();
runtime = {
hiddenAt: doc.visibilityState === 'hidden' ? Date.now() : 0,
token: 0,
subscribers,
onVisibility: () => {
const current = visibilityRuntimes.get(doc);
if (!current) return;
const at = Date.now();
if (doc.visibilityState === 'hidden') {
if (!current.hiddenAt) current.hiddenAt = at;
const signal: PageVisibilitySignal = {
kind: 'hidden', token: current.token, at, hiddenFor: 0, long: false,
};
for (const listener of [...current.subscribers]) listener(signal);
return;
}
const hiddenFor = current.hiddenAt ? Math.max(0, at - current.hiddenAt) : 0;
current.hiddenAt = 0;
current.token++;
const signal: PageVisibilitySignal = {
kind: 'visible', token: current.token, at, hiddenFor,
long: hiddenFor >= CONTINUITY_LONG_HIDDEN_MS,
};
for (const listener of [...current.subscribers]) listener(signal);
},
onPageShow: (event: PageTransitionEvent) => {
const current = visibilityRuntimes.get(doc);
if (!current) return;
const at = Date.now();
current.token++;
const signal: PageVisibilitySignal = {
kind: 'pageshow', token: current.token, at, hiddenFor: 0,
long: !!event.persisted, persisted: !!event.persisted,
};
for (const listener of [...current.subscribers]) listener(signal);
},
};
visibilityRuntimes.set(doc, runtime);
doc.addEventListener('visibilitychange', runtime.onVisibility);
doc.defaultView?.addEventListener('pageshow', runtime.onPageShow);
}
runtime.subscribers.add(subscriber);
return () => {
const current = visibilityRuntimes.get(doc);
if (!current) return;
current.subscribers.delete(subscriber);
if (current.subscribers.size) return;
doc.removeEventListener('visibilitychange', current.onVisibility);
doc.defaultView?.removeEventListener('pageshow', current.onPageShow);
visibilityRuntimes.delete(doc);
};
}
export interface ContinuityClock {
now(): number;
setTimeout(callback: () => void, delay: number): number;
clearTimeout(id: number): void;
requestAnimationFrame(callback: FrameRequestCallback): number;
cancelAnimationFrame(id: number): void;
}
const browserClock: ContinuityClock = {
now: () => Date.now(),
setTimeout: (callback, delay) => window.setTimeout(callback, delay),
clearTimeout: (id) => window.clearTimeout(id),
requestAnimationFrame: (callback) => window.requestAnimationFrame(callback),
cancelAnimationFrame: (id) => window.cancelAnimationFrame(id),
};
export interface PaintBarrierHooks {
updateComplete(): Promise<unknown>;
stageValid(): boolean;
assetsReady(): boolean;
frameFingerprint(): string;
}
/** A bounded, placement-owned state machine; business data stays in the card. */
export class VisualContinuityController {
private _state: ContinuityState = 'steady';
private _token = 0;
private _frameFingerprint = '';
private _hasCompleteFrame = false;
private _overlayPhase: OverlayPhase = 'none';
private _recoveryReason: RecoveryReason = null;
private _overlayTimer = 0;
private _overlayRaf = 0;
private _overlayOpaqueAt = 0;
private _barrierRafs = new Set<number>();
private _trace: ContinuityTraceEvent[] = [];
private _disposed = false;
constructor(
private readonly onChange: () => void,
private readonly clock: ContinuityClock = browserClock,
) {}
get state(): ContinuityState { return this._state; }
get token(): number { return this._token; }
get frameFingerprint(): string { return this._frameFingerprint; }
get hasCompleteFrame(): boolean { return this._hasCompleteFrame; }
get overlayPhase(): OverlayPhase { return this._overlayPhase; }
get overlayVisible(): boolean { return this._overlayPhase !== 'none'; }
get overlayBlocksInteraction(): boolean { return this.overlayVisible; }
get recoveryReason(): RecoveryReason { return this._recoveryReason; }
get trace(): readonly ContinuityTraceEvent[] {
return this._trace.map((event) => ({
...event,
...(event.stage ? { stage: [...event.stage] as [number, number] } : {}),
}));
}
/** Redacted card-owned lifecycle markers; never pass ids or urls here. */
note(event: string, detail: Partial<ContinuityTraceEvent> = {}): void {
this.record(event, undefined, detail);
}
private record(event: string, reason?: string, detail: Partial<ContinuityTraceEvent> = {}): void {
this._trace.push({
at: this.clock.now(), token: this._token, event, state: this._state,
...(reason ? { reason } : {}), ...detail,
});
if (this._trace.length > 80) this._trace.splice(0, this._trace.length - 80);
}
private changed(): void {
if (!this._disposed) this.onChange();
}
/** Warm memo/static cache adoption happens before the first visible render. */
adoptCompleteFrame(fingerprint: string): void {
if (!fingerprint) return;
this._hasCompleteFrame = true;
this._frameFingerprint = fingerprint;
this._state = 'steady';
this._recoveryReason = null;
this.clearOverlay();
this.record('frame-adopted');
this.changed();
}
markCompleteFrame(fingerprint: string): void {
if (!fingerprint) return;
this._hasCompleteFrame = true;
this._frameFingerprint = fingerprint;
this._state = 'steady';
this._recoveryReason = null;
this.clearOverlay();
this.record('frame-complete');
this.changed();
}
/**
* State-only renders are already one synchronous Lit commit and do not need
* a structural candidate. Keep diagnostics/warm placement identity current
* without scheduling the extra render that `markCompleteFrame()` uses for a
* cold/adopted frame.
*/
refreshCompleteFrame(fingerprint: string): void {
if (this._disposed || !this._hasCompleteFrame || this._state !== 'steady'
|| !fingerprint || fingerprint === this._frameFingerprint) return;
this._frameFingerprint = fingerprint;
this.record('frame-refreshed');
}
visibility(signal: PageVisibilitySignal): number {
if (signal.kind === 'hidden') {
this.record('visibility-hidden');
return this._token;
}
if (!signal.long && (signal.kind === 'visible' || signal.kind === 'pageshow')) {
// A quick return with no changed inputs is a strict no-op: no Lit update.
this.record(signal.kind === 'pageshow' ? 'pageshow-noop' : 'visibility-visible-quick');
return this._token;
}
return this.beginCandidate(signal.kind === 'pageshow' ? 'pageshow' : 'long-resume');
}
beginCandidate(reason: string, recoveryReason: RecoveryReason = 'plan'): number {
this._token++;
this._recoveryReason = recoveryReason;
if (this._hasCompleteFrame) {
this._state = recoveryReason === 'connection' ? 'offline-stale' : 'holding';
this.clearOverlay();
} else if (this.overlayVisible) {
// Retry/late reconnect keeps an already opaque fallback in place; it may
// only leave after the new token crosses the paint barrier.
this._state = 'overlay-visible';
} else {
this._state = 'overlay-pending';
this.scheduleOverlay(recoveryReason);
}
this.record('candidate-start', reason);
this.changed();
return this._token;
}
connectionLost(): number {
return this.beginCandidate('connection-lost', 'connection');
}
candidateReady(token: number): boolean {
if (this._disposed || token !== this._token) return false;
this._state = 'candidate-ready';
this.record('candidate-ready');
this.changed();
return true;
}
/**
* Lit completion + two animation opportunities form one cancellable paint
* barrier. A stale token can never reveal an older candidate.
*/
async commitAfterPaint(token: number, hooks: PaintBarrierHooks): Promise<boolean> {
if (this._disposed || token !== this._token) return false;
let barrierActive = true;
const barrier = (async () => {
await hooks.updateComplete();
// Candidate data readiness starts the bounded barrier. Stage dimensions
// and protected-image decode may become ready a little later, so keep
// sampling them inside the same two-second budget instead of returning
// early and leaving an opaque recovery overlay without a running timer.
while (barrierActive && !this._disposed && token === this._token) {
if (hooks.stageValid() && hooks.assetsReady()) {
await this.nextFrame();
if (token !== this._token || !hooks.stageValid() || !hooks.assetsReady()) continue;
await this.nextFrame();
if (token === this._token && hooks.stageValid() && hooks.assetsReady()) return true;
} else {
await this.nextFrame();
}
}
return false;
})();
let timeout = 0;
const timed = new Promise<{ ready: false; timedOut: true }>((resolve) => {
timeout = this.clock.setTimeout(
() => resolve({ ready: false, timedOut: true }), PAINT_BARRIER_MAX_MS,
);
});
const outcome = await Promise.race([
barrier.then((ready) => ({ ready, timedOut: false as const })),
timed,
]);
barrierActive = false;
this.clock.clearTimeout(timeout);
if (this._disposed || token !== this._token) return false;
if (!outcome.ready && !outcome.timedOut) return false;
if (!outcome.ready) {
this.record('paint-barrier-timeout');
if (this._hasCompleteFrame) {
this._state = this._recoveryReason === 'connection' ? 'offline-stale' : 'steady';
this.clearOverlay();
} else {
this._state = 'recovery-error';
// A throttled/background tab may reach the terminal timeout while the
// entry rAF is still pending. Recovery-error is a terminal fallback,
// so never leave it in a transparent/intermediate animation phase.
if (this._overlayPhase !== 'opaque') this.showOverlayNow();
}
this.changed();
return false;
}
this._hasCompleteFrame = true;
this._frameFingerprint = hooks.frameFingerprint();
this.record('paint-barrier');
this.finishOverlayAfterCommit();
return true;
}
retry(reason: RecoveryReason = this._recoveryReason || 'plan'): number {
return this.beginCandidate('retry', reason);
}
private nextFrame(): Promise<void> {
return new Promise((resolve) => {
const id = this.clock.requestAnimationFrame(() => {
this._barrierRafs.delete(id);
resolve();
});
this._barrierRafs.add(id);
});
}
private scheduleOverlay(reason: RecoveryReason): void {
this.clearOverlayTimer();
this._recoveryReason = reason;
this._overlayTimer = this.clock.setTimeout(() => {
this._overlayTimer = 0;
if (this._hasCompleteFrame
|| (this._state !== 'overlay-pending' && this._state !== 'candidate-ready')) return;
this._state = 'overlay-visible';
this._overlayPhase = 'entering';
this.record('overlay-enter');
this.changed();
this._overlayRaf = this.clock.requestAnimationFrame(() => {
this._overlayRaf = 0;
if (this._overlayPhase !== 'entering') return;
this._overlayPhase = 'fading-in';
this.changed();
this._overlayTimer = this.clock.setTimeout(() => {
this._overlayTimer = 0;
if (this._overlayPhase !== 'fading-in') return;
this._overlayPhase = 'opaque';
this._overlayOpaqueAt = this.clock.now();
this.record('overlay-opaque');
this.changed();
}, RECOVERY_OVERLAY_FADE_IN_MS);
});
}, RECOVERY_OVERLAY_DELAY_MS);
}
private showOverlayNow(): void {
this.clearOverlayTimer();
this._overlayPhase = 'opaque';
this._overlayOpaqueAt = this.clock.now();
this.record('overlay-error');
}
private finishOverlayAfterCommit(): void {
this.clearOverlayTimer();
if (this._overlayPhase === 'none' || this._overlayPhase === 'entering'
|| this._overlayPhase === 'fading-in') {
// A candidate that wins during fade-in cancels it without a minimum hold.
this.clearOverlay();
this._state = 'steady';
this._recoveryReason = null;
this.record('candidate-committed');
this.changed();
return;
}
const wait = Math.max(0,
this._overlayOpaqueAt + RECOVERY_OVERLAY_MIN_OPAQUE_MS - this.clock.now());
this._overlayTimer = this.clock.setTimeout(() => {
this._overlayTimer = 0;
this._overlayPhase = 'leaving';
this.record('overlay-leave');
this.changed();
this._overlayTimer = this.clock.setTimeout(() => {
this._overlayTimer = 0;
this._overlayPhase = 'none';
this._state = 'steady';
this._recoveryReason = null;
this.record('candidate-committed');
this.changed();
}, RECOVERY_OVERLAY_FADE_OUT_MS);
}, wait);
}
private clearOverlayTimer(): void {
if (this._overlayTimer) this.clock.clearTimeout(this._overlayTimer);
this._overlayTimer = 0;
}
private clearOverlay(): void {
this.clearOverlayTimer();
if (this._overlayRaf) this.clock.cancelAnimationFrame(this._overlayRaf);
this._overlayRaf = 0;
this._overlayPhase = 'none';
this._overlayOpaqueAt = 0;
}
dispose(): void {
this._disposed = true;
this._token++;
this.clearOverlay();
for (const id of this._barrierRafs) this.clock.cancelAnimationFrame(id);
this._barrierRafs.clear();
}
}
/** Stable, inexpensive content identity for structural WS responses. */
export function contentFingerprint(value: unknown): string {
let a = 0x811c9dc5;
let b = 0x9e3779b9;
// This is the active recursion stack, not a global "seen" set. A shared but
// acyclic child must hash by value at every occurrence; only an ancestor
// reference is a cycle.
const stack = new WeakSet<object>();
const feed = (text: string): void => {
for (let index = 0; index < text.length; index++) {
const code = text.charCodeAt(index);
a ^= code;
a = Math.imul(a, 0x01000193);
b ^= code + ((b << 6) >>> 0) + (b >>> 2);
}
};
const visit = (node: unknown): void => {
if (node === null) { feed('n'); return; }
const type = typeof node;
if (type === 'string') { feed(`s${(node as string).length}:${node}`); return; }
if (type === 'number') { feed(`d${Number.isNaN(node) ? 'NaN' : String(node)}`); return; }
if (type === 'boolean') { feed(node ? 't' : 'f'); return; }
if (type === 'undefined') { feed('u'); return; }
if (type !== 'object') { feed(`${type}:${String(node)}`); return; }
const object = node as object;
if (stack.has(object)) { feed('[cycle]'); return; }
stack.add(object);
if (Array.isArray(node)) {
feed('[');
for (const item of node) visit(item);
feed(']');
stack.delete(object);
return;
}
feed('{');
for (const key of Object.keys(node as Record<string, unknown>).sort()) {
feed(key);
visit((node as Record<string, unknown>)[key]);
}
feed('}');
stack.delete(object);
};
visit(value);
return `${(a >>> 0).toString(16).padStart(8, '0')}${(b >>> 0).toString(16).padStart(8, '0')}`;
}
export function visualFrameFingerprint(parts: readonly unknown[]): string {
return contentFingerprint(parts);
}