/** Reusable, presentation-only Labs flags. No flag may gate data or requests. */ export interface LabsFlag { id: string; issue: number; since: string; expires: string; summary: string; } export const LABS_STORAGE_KEY = 'houseplan_card_labs_v1'; export const LABS_FLAGS: readonly LabsFlag[] = Object.freeze([ Object.freeze({ id: 'iso', issue: 89, since: '1.62.0', expires: '1.65.0', summary: 'Volumetric plan renderer', }), ]); export interface LabsLocation { search: string; hash: string; } export interface LabsResolution { active: readonly string[]; space: string; persist: string | undefined; knownUrlOperation: boolean; } export interface LabsSnapshot { active: readonly string[]; space: string; } type VersionCore = readonly [number, number, number]; export function parseVersionCore(value: string): VersionCore | null { const match = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(String(value || '').trim()); if (!match) return null; const core = match.slice(1).map(Number) as unknown as VersionCore; return core.every((part) => Number.isSafeInteger(part)) ? core : null; } function compareVersion(a: VersionCore, b: VersionCore): number { for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; return 0; } export function validLabsFlag(flag: LabsFlag): boolean { if (!/^[a-z][a-z0-9-]*$/.test(flag.id) || !Number.isInteger(flag.issue) || flag.issue <= 0 || !flag.summary.trim()) return false; const since = parseVersionCore(flag.since); const expires = parseVersionCore(flag.expires); return !!since && !!expires && compareVersion(since, expires) < 0; } /** A malformed or ambiguous registry disables the whole mechanism. */ export function validLabsRegistry(registry: readonly LabsFlag[]): boolean { const ids = new Set(); for (const flag of registry) { if (!validLabsFlag(flag) || ids.has(flag.id)) return false; ids.add(flag.id); } return true; } export function liveLabsFlags( version: string, registry: readonly LabsFlag[] = LABS_FLAGS, ): ReadonlyMap { const current = parseVersionCore(version); const live = new Map(); if (!current || !validLabsRegistry(registry)) return live; for (const flag of registry) { const since = parseVersionCore(flag.since)!; const expires = parseVersionCore(flag.expires)!; if (compareVersion(current, since) >= 0 && compareVersion(current, expires) < 0) { live.set(flag.id, flag); } } return live; } export function hashParams(hash: string): URLSearchParams { const raw = String(hash || '').replace(/^#/, ''); return new URLSearchParams(raw); } export function hashSpace(hash: string): string { return hashParams(hash).get('space') || ''; } function storedFlags(raw: string | null | undefined): string[] { if (!raw) return []; try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []; } catch { return []; } } function operations(params: URLSearchParams): string[] { return params.getAll('hp-labs').flatMap((value) => value.split(',')) .map((value) => value.trim()).filter(Boolean); } /** Pure URL/storage resolver. Query operations run before stronger hash operations. */ export function resolveLabs( location: LabsLocation, storageValue: string | null | undefined, version: string, registry: readonly LabsFlag[] = LABS_FLAGS, ): LabsResolution { const live = liveLabsFlags(version, registry); const known = new Set(validLabsRegistry(registry) ? registry.map((flag) => flag.id) : []); const active = new Set(storedFlags(storageValue).filter((id) => live.has(id))); let knownUrlOperation = false; const apply = (token: string) => { if (token === 'off') { active.clear(); knownUrlOperation = true; return; } const remove = token.startsWith('-'); const id = remove ? token.slice(1) : token; if (!known.has(id)) return; knownUrlOperation = true; if (remove || !live.has(id)) active.delete(id); else active.add(id); }; for (const token of operations(new URLSearchParams(String(location.search || '').replace(/^\?/, '')))) apply(token); for (const token of operations(hashParams(location.hash))) apply(token); const sorted = Object.freeze([...active].sort()); return { active: sorted, space: hashSpace(location.hash), persist: knownUrlOperation ? JSON.stringify(sorted) : undefined, knownUrlOperation, }; } declare global { interface Window { __hpLabs?: readonly string[]; __hpLabsListenerCleanup?: () => void; } } let snapshot: LabsSnapshot = { active: Object.freeze([]), space: '' }; let configuredVersion = ''; let listening = false; let loggedSignature = ''; const subscribers = new Set<(value: LabsSnapshot) => void>(); function browserStorageRead(): string | null { try { return window.localStorage.getItem(LABS_STORAGE_KEY); } catch { return null; } } function browserStorageWrite(value: string): void { try { window.localStorage.setItem(LABS_STORAGE_KEY, value); } catch { /* private mode/quota */ } } function publishBrowserLabs(): LabsSnapshot { if (typeof window === 'undefined') return snapshot; const resolved = resolveLabs(window.location, browserStorageRead(), configuredVersion); if (resolved.persist !== undefined) browserStorageWrite(resolved.persist); snapshot = { active: resolved.active, space: resolved.space }; window.__hpLabs = resolved.active; for (const subscriber of subscribers) subscriber(snapshot); return snapshot; } function onLocationChange(): void { publishBrowserLabs(); } function ensureBrowserLabs(version: string): LabsSnapshot { configuredVersion = version; if (typeof window === 'undefined') return snapshot; if (!listening) { listening = true; window.__hpLabsListenerCleanup?.(); window.addEventListener('hashchange', onLocationChange); window.addEventListener('popstate', onLocationChange); const cleanup = (): void => { window.removeEventListener('hashchange', onLocationChange); window.removeEventListener('popstate', onLocationChange); if (window.__hpLabsListenerCleanup === cleanup) { delete window.__hpLabsListenerCleanup; } }; window.__hpLabsListenerCleanup = cleanup; } return publishBrowserLabs(); } /** One module-level browser listener; card instances only subscribe to its snapshot. */ export function subscribeLabs( version: string, subscriber: (value: LabsSnapshot) => void, ): () => void { subscribers.add(subscriber); const current = ensureBrowserLabs(version); if (typeof window === 'undefined') subscriber(current); return () => { subscribers.delete(subscriber); }; } export function currentLabs(version: string): LabsSnapshot { return ensureBrowserLabs(version); } /** Diagnostics are emitted only when an active Labs frame is actually rendered. */ export function noteLabsRender(version: string): void { if (!configuredVersion) ensureBrowserLabs(version); const current = snapshot; const signature = current.active.join(','); if (!signature || signature === loggedSignature) return; loggedSignature = signature; const registry = new Map(LABS_FLAGS.map((flag) => [flag.id, flag])); const summary = current.active.map((id) => { const flag = registry.get(id)!; return `${id} (#${flag.issue}, expires ${flag.expires})`; }).join(', '); console.info(`HOUSEPLAN LABS: ${summary}`); }