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:
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user