feat(fusion_clock): premium glass NFC kiosk + scope CSS to kiosk page

Visual rewrite of the NFC kiosk page:
- Animated mesh gradient background (drifts on a 28s loop)
- Glass-panel state cards with backdrop-filter blur
- Animated SVG NFC icon (concentric waves emanate from a chip)
- Company logo pulled from res.company.logo, displayed in header
- Dominant-hue extraction from logo sets --nfc-h CSS var; entire
  palette interpolates from that one HSL hue
- Success burst (green glow + scale), error shake, smooth state fades
- Reduced-motion fallback respects prefers-reduced-motion
- Glass numpad + employee picker in Enroll Mode

CRITICAL FIX: scoped all kiosk styles under :has(#nfc_kiosk_root) so
they no longer leak into other frontend pages. Previous version applied
html/body overflow:hidden + display:none on header/footer globally,
breaking website scrolling and chrome on every frontend page.
This commit is contained in:
gsinghpal
2026-05-14 08:22:47 -04:00
parent 2abd859a29
commit 94249ba67d
4 changed files with 520 additions and 102 deletions

View File

@@ -39,6 +39,80 @@
}
debugLog("page loaded; debugEnabled=" + debugEnabled + " photoRequired=" + photoRequired + " NDEFReader=" + ("NDEFReader" in window));
// ──────────────────────────────────────────────────────────────
// Dominant-hue extraction from company logo
// Sets the CSS variable --nfc-h on <html> so SCSS can interpolate
// the entire palette from the brand color. Falls back to default
// (220 = aurora-blue) if no logo or extraction fails.
// ──────────────────────────────────────────────────────────────
function rgbToHue(r, g, b) {
const rN = r / 255, gN = g / 255, bN = b / 255;
const max = Math.max(rN, gN, bN), min = Math.min(rN, gN, bN);
const d = max - min;
if (d === 0) return null; // grayscale, no hue info
let h;
if (max === rN) h = ((gN - bN) / d) % 6;
else if (max === gN) h = (bN - rN) / d + 2;
else h = (rN - gN) / d + 4;
h = Math.round(h * 60);
if (h < 0) h += 360;
return h;
}
function extractDominantHue(img) {
try {
const c = document.createElement("canvas");
const w = c.width = Math.min(img.naturalWidth, 200);
const h = c.height = Math.min(img.naturalHeight, 200);
const ctx = c.getContext("2d", { willReadFrequently: true });
ctx.drawImage(img, 0, 0, w, h);
const data = ctx.getImageData(0, 0, w, h).data;
let r = 0, g = 0, b = 0, count = 0;
for (let i = 0; i < data.length; i += 4) {
const a = data[i + 3];
if (a < 128) continue; // skip transparent
const red = data[i], green = data[i + 1], blue = data[i + 2];
const lum = (red + green + blue) / 3;
if (lum > 235 || lum < 25) continue; // skip near-white/near-black
const range = Math.max(red, green, blue) - Math.min(red, green, blue);
if (range < 25) continue; // skip near-grays
r += red; g += green; b += blue; count++;
}
if (count < 50) {
debugLog("hue extraction: too few colored pixels (" + count + "), using default");
return null;
}
const avgR = Math.round(r / count), avgG = Math.round(g / count), avgB = Math.round(b / count);
const hue = rgbToHue(avgR, avgG, avgB);
debugLog("hue extracted: rgb(" + avgR + "," + avgG + "," + avgB + ") → h=" + hue);
return hue;
} catch (e) {
debugLog("hue extraction failed: " + e.message);
return null;
}
}
function applyBrandHue(hue) {
if (hue == null) return;
document.documentElement.style.setProperty("--nfc-h", String(hue));
}
const logoImg = document.getElementById("nfc_company_logo");
if (logoImg) {
const tryExtract = () => {
const hue = extractDominantHue(logoImg);
applyBrandHue(hue);
};
if (logoImg.complete && logoImg.naturalWidth) {
tryExtract();
} else {
logoImg.addEventListener("load", tryExtract);
logoImg.addEventListener("error", () => debugLog("logo failed to load"));
}
} else {
debugLog("no company logo on page; using default hue");
}
// ──────────────────────────────────────────────────────────────
// State machine
// ──────────────────────────────────────────────────────────────
@@ -59,7 +133,16 @@
function renderIdle() {
stateContainer.innerHTML = `
<div class="nfc-kiosk__idle">
<div class="nfc-kiosk__icon">⌐■</div>
<svg class="nfc-kiosk__icon-svg" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<circle class="nfc-wave nfc-wave-3" cx="100" cy="100" r="92"
stroke="currentColor" stroke-width="4" fill="none"/>
<circle class="nfc-wave nfc-wave-2" cx="100" cy="100" r="70"
stroke="currentColor" stroke-width="4" fill="none"/>
<circle class="nfc-wave nfc-wave-1" cx="100" cy="100" r="48"
stroke="currentColor" stroke-width="4" fill="none"/>
<rect class="nfc-chip" x="80" y="80" width="40" height="40"
rx="8" fill="currentColor"/>
</svg>
<div class="nfc-kiosk__prompt">Tap your card to clock in or out</div>
</div>
`;
@@ -67,7 +150,10 @@
function renderProcessing() {
stateContainer.innerHTML = `
<div class="nfc-kiosk__processing">Reading card…</div>
<div class="nfc-kiosk__processing">
<span>Reading card</span>
<span class="dots"><span></span><span></span><span></span></span>
</div>
`;
}