Files
holy-flavor-rater/frontend/src/hooks/useGlassCapability.ts
T
2026-09-04 23:38:55 +02:00

91 lines
3.2 KiB
TypeScript

import { useEffect, useState } from 'react';
export type GlassTier = 'refract' | 'blur' | 'flat';
export interface GlassCapability {
/** Which visual tier the document is currently rendering at. */
tier: GlassTier;
/** True when SVG rim refraction is live. */
refract: boolean;
/** True when the user asked for reduced motion — aurora + canvas must halt. */
reducedMotion: boolean;
}
const mq = (query: string): MediaQueryList | null => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return null;
try {
return window.matchMedia(query);
} catch {
return null;
}
};
/**
* Can this engine actually render `backdrop-filter: url(#svg-filter)`?
*
* `CSS.supports` alone is not enough: `url()` is syntactically valid in a
* filter-function-list, so Firefox and Safari both parse it happily and then
* paint nothing — a silent failure that would leave their rims invisible
* rather than gracefully flat. Chromium is currently the only engine that
* renders it, so we gate on a Chromium signal too. `userAgentData` is a real
* feature-detectable API rather than UA-string sniffing, and when it is absent
* we simply fall back a tier — which is a designed look, not a broken one.
*/
const supportsRefraction = (): boolean => {
if (typeof window === 'undefined' || typeof CSS === 'undefined' || !CSS.supports) return false;
const parses =
CSS.supports('backdrop-filter', 'url(#x)') ||
CSS.supports('-webkit-backdrop-filter', 'url(#x)');
if (!parses) return false;
// mask-composite carries the rim ring; without it there is no ring to refract.
const canMask =
CSS.supports('mask-composite', 'exclude') || CSS.supports('-webkit-mask-composite', 'xor');
if (!canMask) return false;
const brands = (navigator as Navigator & { userAgentData?: { brands?: { brand: string }[] } })
.userAgentData?.brands;
return Array.isArray(brands) && brands.some((b) => /Chromium/i.test(b.brand));
};
const resolveTier = (): GlassTier => {
if (mq('(prefers-reduced-transparency: reduce)')?.matches) return 'flat';
return supportsRefraction() ? 'refract' : 'blur';
};
/**
* Resolves the glass tier and stamps it on `<html data-glass>`, which is what
* every refraction rule in `Glass.tsx` keys off. Call once, high in the tree.
*/
export const useGlassCapability = (): GlassCapability => {
const [state, setState] = useState<GlassCapability>(() => ({
tier: 'blur',
refract: false,
reducedMotion: false,
}));
useEffect(() => {
const transparency = mq('(prefers-reduced-transparency: reduce)');
const motion = mq('(prefers-reduced-motion: reduce)');
const apply = () => {
const tier = resolveTier();
const reducedMotion = !!motion?.matches;
document.documentElement.dataset.glass = tier;
setState((prev) =>
prev.tier === tier && prev.reducedMotion === reducedMotion
? prev
: { tier, refract: tier === 'refract', reducedMotion },
);
};
apply();
transparency?.addEventListener?.('change', apply);
motion?.addEventListener?.('change', apply);
return () => {
transparency?.removeEventListener?.('change', apply);
motion?.removeEventListener?.('change', apply);
};
}, []);
return state;
};