Files
houseplan-card/src/labs.ts
T
Claude e5ab4659f6 feat: 2.5D stage 6 — public setting, raised tiles, soft sun, theme-free walls (#649)
- settings.volumetric_view (General settings › Display, third switch) replaces
  the alpha entry, the header projection toggle and the phone-menu item; one
  rule for card, sidebar page and kiosk; editors stay Flat; backend accepts
  only a boolean, support package copies it.
- Raised tiles in 2.5D View: no ring, rounded body min(0.275 D, 0.3 h), edge
  0.1 D with the lab filters, ×1.12 size also in layout/collision, lift
  0.075 D, one floor-shadow layer under all markers (theme × floor table),
  frames hugging tile and edge (Alert > Focus > Selected > Hover), forced
  colours without edge and shadow (src/iso-tiles.ts, styles/iso-tiles).
- Soft sun wash instead of projected Flat wedges (src/iso-sun.ts): same gates
  and windows as sun_rays, length from elevation, parallelogram along the sun,
  tone and streaks by floor lightness, sill line.
- Walls take the user's wall colour (top opaque, sides × .77/.68/.60); theme
  rules for walls/openings/labels removed; furniture uses the Flat stroke rule.
- Smokes smoke_iso_tiles/iso_sun/iso_theme_walls/volumetric_setting, 16 new
  mutants, acceptance scenes STAGE6_ACCEPTANCE_SCENARIOS (golden with their
  Linux CI baselines), ACCEPTANCE.md side by side.

Issue: #649
User-Visible: yes
2026-09-25 18:55:14 +03:00

181 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';
/** #649: 2.5D graduated to General settings; the alpha switch has no capabilities now. */
export const LABS_FLAGS: readonly LabsFlag[] = Object.freeze([]);
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}`);
}