mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
186 lines
5.8 KiB
TypeScript
186 lines
5.8 KiB
TypeScript
/** Reusable, presentation-only alpha capabilities. No capability may gate data or requests. */
|
|
|
|
export interface LabsFlag {
|
|
id: string;
|
|
issue: number;
|
|
summary: string;
|
|
}
|
|
|
|
export const ALPHA_STORAGE_KEY = 'houseplan_card_alpha_v1';
|
|
|
|
export const LABS_FLAGS: readonly LabsFlag[] = Object.freeze([
|
|
Object.freeze({
|
|
id: 'iso',
|
|
issue: 89,
|
|
summary: 'Volumetric plan renderer',
|
|
}),
|
|
]);
|
|
|
|
export interface LabsLocation {
|
|
search: string;
|
|
hash: string;
|
|
}
|
|
|
|
export interface LabsResolution {
|
|
alpha: boolean;
|
|
active: readonly string[];
|
|
space: string;
|
|
persist: '1' | '0' | undefined;
|
|
knownUrlOperation: boolean;
|
|
}
|
|
|
|
export interface LabsSnapshot {
|
|
alpha: boolean;
|
|
active: readonly string[];
|
|
space: string;
|
|
}
|
|
|
|
export function validLabsFlag(flag: LabsFlag): boolean {
|
|
return /^[a-z][a-z0-9-]*$/.test(flag.id)
|
|
&& Number.isInteger(flag.issue) && flag.issue > 0
|
|
&& !!flag.summary.trim();
|
|
}
|
|
|
|
/** A malformed or ambiguous registry disables the whole mechanism. */
|
|
export function validLabsRegistry(registry: readonly LabsFlag[]): boolean {
|
|
const ids = new Set<string>();
|
|
for (const flag of registry) {
|
|
if (!validLabsFlag(flag) || ids.has(flag.id)) return false;
|
|
ids.add(flag.id);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
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') || '';
|
|
}
|
|
|
|
interface AlphaUrlResolution {
|
|
present: boolean;
|
|
value: boolean | undefined;
|
|
}
|
|
|
|
/** Query is weaker than hash; unrecognised values never become truthy. */
|
|
function alphaFromUrl(location: LabsLocation): AlphaUrlResolution {
|
|
let present = false;
|
|
let value: boolean | undefined;
|
|
const apply = (params: URLSearchParams): void => {
|
|
for (const operation of params.getAll('hp_alpha')) {
|
|
present = true;
|
|
if (operation === '1') value = true;
|
|
else if (operation === '0') value = false;
|
|
}
|
|
};
|
|
apply(new URLSearchParams(String(location.search || '').replace(/^\?/, '')));
|
|
apply(hashParams(location.hash));
|
|
return { present, value };
|
|
}
|
|
|
|
/** Pure URL/storage resolver. Alpha enables every capability known to this build. */
|
|
export function resolveLabs(
|
|
location: LabsLocation,
|
|
storageValue: string | null | undefined,
|
|
registry: readonly LabsFlag[] = LABS_FLAGS,
|
|
): LabsResolution {
|
|
const registryValid = validLabsRegistry(registry);
|
|
const url = alphaFromUrl(location);
|
|
const knownUrlOperation = url.value !== undefined;
|
|
const storedAlpha = storageValue === '1';
|
|
const requestedAlpha = knownUrlOperation
|
|
? url.value === true
|
|
: url.present ? false : storedAlpha;
|
|
const alpha = registryValid && requestedAlpha;
|
|
const active = Object.freeze(alpha ? registry.map((flag) => flag.id).sort() : []);
|
|
return {
|
|
alpha,
|
|
active,
|
|
space: hashSpace(location.hash),
|
|
persist: registryValid && knownUrlOperation ? (alpha ? '1' : '0') : undefined,
|
|
knownUrlOperation,
|
|
};
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
__hpAlpha?: boolean;
|
|
__hpLabs?: readonly string[];
|
|
__hpLabsListenerCleanup?: () => void;
|
|
}
|
|
}
|
|
|
|
let snapshot: LabsSnapshot = { alpha: false, active: Object.freeze([]), space: '' };
|
|
let listening = false;
|
|
let loggedSignature = '';
|
|
const subscribers = new Set<(value: LabsSnapshot) => void>();
|
|
|
|
function browserStorageRead(): string | null {
|
|
try { return window.localStorage.getItem(ALPHA_STORAGE_KEY); } catch { return null; }
|
|
}
|
|
|
|
function browserStorageWrite(value: '1' | '0'): void {
|
|
try { window.localStorage.setItem(ALPHA_STORAGE_KEY, value); } catch { /* private mode/quota */ }
|
|
}
|
|
|
|
function publishBrowserLabs(): LabsSnapshot {
|
|
if (typeof window === 'undefined') return snapshot;
|
|
const resolved = resolveLabs(window.location, browserStorageRead());
|
|
if (resolved.persist !== undefined) browserStorageWrite(resolved.persist);
|
|
snapshot = { alpha: resolved.alpha, active: resolved.active, space: resolved.space };
|
|
window.__hpAlpha = resolved.alpha;
|
|
window.__hpLabs = resolved.active;
|
|
for (const subscriber of subscribers) subscriber(snapshot);
|
|
return snapshot;
|
|
}
|
|
|
|
function onLocationChange(): void { publishBrowserLabs(); }
|
|
|
|
function ensureBrowserLabs(): LabsSnapshot {
|
|
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(subscriber: (value: LabsSnapshot) => void): () => void {
|
|
subscribers.add(subscriber);
|
|
const current = ensureBrowserLabs();
|
|
if (typeof window === 'undefined') subscriber(current);
|
|
return () => { subscribers.delete(subscriber); };
|
|
}
|
|
|
|
export function currentLabs(): LabsSnapshot {
|
|
return ensureBrowserLabs();
|
|
}
|
|
|
|
/** Diagnostics are emitted only when an active alpha frame is actually rendered. */
|
|
export function noteLabsRender(): void {
|
|
const current = snapshot;
|
|
const signature = current.alpha ? 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})`;
|
|
}).join(', ');
|
|
console.info(`HOUSEPLAN ALPHA: hp_alpha=1; ${summary}`);
|
|
}
|