Fix login theme layering, restyle login card, add photo rotation with dynamic accent

- theme.properties: keep loading the keycloak.v2 parent stylesheet (previously
  shadowed by our same-named css/styles.css, so the base card grid/layout
  never loaded); our overrides now live in resources/css/custom.css.
- custom.css: rewritten against the real rendered markup/PatternFly tokens
  (#keycloak-bg, .pf-v5-c-login__main, #kc-login) instead of guessed
  selectors, with a frosted-glass card, dark-mode variant, and rounded
  inputs/buttons.
- bg-picker.js: picks the background photo deterministically by epoch-day
  from an IMAGES list (add filenames as new photos are uploaded), and
  derives the card's accent color (top border + primary button) from the
  chosen photo via saturation-weighted dominant-hue extraction.
- dev/: local static preview (real login markup snapshot + vendored
  Font Awesome glyph) to iterate on the theme without redeploying.
This commit is contained in:
2026-07-24 19:11:13 +02:00
parent 8beecff42d
commit 0c3db46b89
6 changed files with 360 additions and 40 deletions

View File

@@ -0,0 +1,70 @@
/* Overrides for the keycloak.v2 base theme (loaded first via theme.properties'
`styles` list) - keep using its tokens/selectors instead of re-implementing
layout, since that's what actually sizes/positions the login card. */
/* keycloak.v2 draws a top accent border on the card using this token. */
:root {
--keycloak-card-top-color: #6d28d9;
}
/* Real selector confirmed from the rendered page: <body id="keycloak-bg">.
A bare `body` rule loses to keycloak.v2's `.login-pf body` by specificity,
so this needs the id to actually win. */
#keycloak-bg {
/* bg-picker.js sets --login-bg-image on <html> daily; the var() fallback
below is what shows if JS doesn't run. A local declaration here would
shadow the inherited value regardless of what JS sets, so don't add one. */
background: linear-gradient(rgba(15, 23, 42, 0.55), rgba(15, 23, 42, 0.55)),
var(--login-bg-image, url("../img/background.svg")) center center / cover no-repeat fixed;
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
}
#kc-header-wrapper {
font-weight: 600;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.45);
}
/* PatternFly draws the login card's background/shadow on .pf-v5-c-login__main
itself via these two custom properties - override the tokens rather than
fighting the selector. */
.pf-v5-c-login__main {
--pf-v5-c-login__main--BackgroundColor: rgba(255, 255, 255, 0.92);
--pf-v5-c-login__main--BoxShadow: 0 20px 60px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(10px);
border-radius: 16px;
overflow: hidden;
}
@media (prefers-color-scheme: dark) {
.pf-v5-c-login__main {
--pf-v5-c-login__main--BackgroundColor: rgba(15, 23, 42, 0.82);
}
}
/* #kc-login is the actual submit button's id - safe to target directly
without fighting PatternFly's own hover/focus custom-property chain.
Shares the same dynamic accent as the card's top border, so the two
change together as bg-picker.js recomputes it per background image. */
#kc-login {
background-color: var(--keycloak-card-top-color, #4338ca);
border-radius: 8px;
transition: filter 0.2s ease;
}
#kc-login:hover,
#kc-login:focus {
filter: brightness(0.85);
}
.pf-v5-c-form-control,
.pf-v5-c-form-control input {
border-radius: 8px;
}
.pf-v5-c-input-group .pf-v5-c-form-control {
border-radius: 8px 0 0 8px;
}
.pf-v5-c-input-group .pf-v5-c-button.pf-m-control {
border-radius: 0 8px 8px 0;
}

View File

@@ -1,39 +0,0 @@
body {
background: linear-gradient(rgba(15, 23, 42, 0.45), rgba(15, 23, 42, 0.45)),
url("../img/background.svg") center center / cover no-repeat fixed;
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
}
/* Keycloak's built-in theme markup/classes vary by version, so several
selectors are listed as best-effort hooks for the login card - inspect
the rendered page and adjust whichever one actually matches. */
.login-pf-page .card-pf,
#kc-form-wrapper,
.pf-c-login__main-body,
.pf-v5-c-login__main-body {
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
border-radius: 16px;
border: none;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
}
#kc-header-wrapper,
.pf-c-login__header,
.pf-v5-c-login__header {
color: #fff;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
.btn-primary,
.pf-c-button.pf-m-primary,
.pf-v5-c-button.pf-m-primary {
border-radius: 8px;
transition: background-color 0.2s ease;
}
input.form-control,
.pf-c-form-control,
.pf-v5-c-form-control {
border-radius: 8px;
}

View File

@@ -0,0 +1,112 @@
(function () {
// Add new filenames here as they're uploaded to resources/img/.
var IMAGES = ["1.jpg"];
var epochDay = Math.floor(Date.now() / 86400000);
var chosen = IMAGES[epochDay % IMAGES.length];
var url = new URL("../img/" + chosen, document.currentScript.src).href;
document.documentElement.style.setProperty("--login-bg-image", "url(" + JSON.stringify(url) + ")");
// Derive the login card's accent color (--keycloak-card-top-color, the top
// border strip) from whichever background photo was picked above, instead
// of hard-coding one color that only suits a single image.
var img = new Image();
img.crossOrigin = "anonymous";
img.onload = function () {
var accent = dominantAccentColor(img);
if (accent) {
document.documentElement.style.setProperty("--keycloak-card-top-color", accent);
}
};
img.src = url;
function dominantAccentColor(img) {
var canvas = document.createElement("canvas");
var w = 100;
var h = Math.max(1, Math.round((img.naturalHeight / img.naturalWidth) * w));
canvas.width = w;
canvas.height = h;
var ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, w, h);
var data;
try {
data = ctx.getImageData(0, 0, w, h).data;
} catch (e) {
return null; // canvas tainted (image not same-origin / no CORS headers)
}
// Bucket pixels by hue. A large, pale, uniform area (sky) still adds up
// to a bigger SUM of vividness than a smaller, richly-colored subject
// (lavender rows), so summing alone still lets area win. Score each bin
// by its MEAN vividness instead - that measures "how saturated is this
// hue, typically" rather than "how much area does this hue cover" - with
// a minimum-population floor so a handful of stray outlier pixels can't
// win by having a freakishly high average.
var HUE_BINS = 36; // 10 degrees each
var binCount = new Array(HUE_BINS).fill(0);
var binScore = new Array(HUE_BINS).fill(0);
var binR = new Array(HUE_BINS).fill(0);
var binG = new Array(HUE_BINS).fill(0);
var binB = new Array(HUE_BINS).fill(0);
var totalPixels = 0;
for (var i = 0; i < data.length; i += 4) {
var r = data[i], g = data[i + 1], b = data[i + 2];
totalPixels++;
var hsl = rgbToHsl(r, g, b);
if (hsl.s < 0.12) continue; // skip near-greys/whites/blacks
var lightnessWeight = 1 - Math.abs(hsl.l - 0.5) * 2;
var score = hsl.s * lightnessWeight;
if (score <= 0) continue;
var bin = Math.floor(hsl.h / (360 / HUE_BINS)) % HUE_BINS;
binCount[bin]++;
binScore[bin] += score;
binR[bin] += r * score;
binG[bin] += g * score;
binB[bin] += b * score;
}
var minPopulation = totalPixels * 0.02; // ignore hues covering <2% of the image
var best = -1;
var bestMean = 0;
for (var j = 0; j < HUE_BINS; j++) {
if (binCount[j] < minPopulation) continue;
var mean = binScore[j] / binCount[j];
if (mean > bestMean) {
bestMean = mean;
best = j;
}
}
if (best === -1) return null;
var r2 = Math.round(binR[best] / binScore[best]);
var g2 = Math.round(binG[best] / binScore[best]);
var b2 = Math.round(binB[best] / binScore[best]);
return "rgb(" + r2 + ", " + g2 + ", " + b2 + ")";
}
function rgbToHsl(r, g, b) {
r /= 255;
g /= 255;
b /= 255;
var max = Math.max(r, g, b), min = Math.min(r, g, b);
var h, s, l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
var d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
default: h = (r - g) / d + 4; break;
}
h *= 60;
}
return { h: h, s: s, l: l };
}
})();

View File

@@ -1,2 +1,3 @@
parent=keycloak.v2
styles=css/styles.css
styles=css/styles.css css/custom.css
scripts=js/bg-picker.js

176
dev/preview.html Normal file
View File

@@ -0,0 +1,176 @@
<!DOCTYPE html>
<html class="login-pf" lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="color-scheme" content="light dark">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in to Vilanet (local preview)</title>
<link rel="icon" href="https://id.vilanet.fr/resources/3gbnb/login/vilanet/img/favicon.ico" />
<link href="https://id.vilanet.fr/resources/3gbnb/common/keycloak/vendor/patternfly-v5/patternfly.min.css" rel="stylesheet" />
<link href="https://id.vilanet.fr/resources/3gbnb/common/keycloak/vendor/patternfly-v5/patternfly-addons.css" rel="stylesheet" />
<!-- parent theme (keycloak.v2) base styles - loaded live, this one rarely changes -->
<link href="https://id.vilanet.fr/resources/3gbnb/login/keycloak.v2/css/styles.css" rel="stylesheet" />
<!-- OUR theme - loaded from disk, edit + refresh to iterate -->
<link href="../assets/login/resources/css/custom.css" rel="stylesheet" />
<script src="../assets/login/resources/js/bg-picker.js"></script>
<!-- patternfly.min.css's fa-solid-900.woff2 is fetched cross-origin from
id.vilanet.fr, which has no Access-Control-Allow-Origin header - browsers
enforce CORS on @font-face specifically, so those icon glyphs silently
fail to render here. Redeclare same-origin so icons show in the preview;
not needed in the real deployment, where everything is same-origin. -->
<style>
@font-face {
font-family: "Font Awesome 5 Free";
font-weight: 900;
font-style: normal;
src: url("vendor/fa-solid-900.woff2") format("woff2");
}
</style>
<script type="module" async blocking="render">
const DARK_MODE_CLASS = "pf-v5-theme-dark";
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
updateDarkMode(mediaQuery.matches);
mediaQuery.addEventListener("change", (event) => updateDarkMode(event.matches));
function updateDarkMode(isEnabled) {
const { classList } = document.documentElement;
if (isEnabled) {
classList.add(DARK_MODE_CLASS);
} else {
classList.remove(DARK_MODE_CLASS);
}
}
</script>
</head>
<!--
Static snapshot of the real login-login page for realm "vilanet", fetched
from id.vilanet.fr. Not wired to a real Keycloak session - the form does
not submit anywhere. This is for iterating on assets/login/resources/css/custom.css
without redeploying the pod: edit that file, then just refresh this page.
Serve from the REPO ROOT so the relative paths above resolve correctly:
python3 -m http.server 8000
then open http://localhost:8000/dev/preview.html
Re-fetch this snapshot any time the FreeMarker markup itself changes
(e.g. after a Keycloak upgrade) - see the curl recipe in the project notes.
-->
<body id="keycloak-bg" class="" data-page-id="login-login">
<div class="pf-v5-c-login">
<div class="pf-v5-c-login__container">
<header id="kc-header" class="pf-v5-c-login__header">
<div id="kc-header-wrapper"
class="pf-v5-c-brand">Vilanet</div>
</header>
<main class="pf-v5-c-login__main">
<div class="pf-v5-c-login__main-header">
<h1 class="pf-v5-c-title pf-m-3xl" id="kc-page-title">
Sign in to your account
</h1>
</div>
<div class="pf-v5-c-login__main-body">
<div id="kc-form">
<div id="kc-form-wrapper">
<form id="kc-form-login" class="pf-v5-c-form pf-v5-u-w-100" onsubmit="return false;" action="#" method="post" novalidate="novalidate">
<div class="pf-v5-c-form__group">
<div class="pf-v5-c-form__group-label pf-v5-u-pb-xs">
<label for="username" class="pf-v5-c-form__label">
<span class="pf-v5-c-form__label-text">
Username
</span>
</label>
</div>
<span class="pf-v5-c-form-control ">
<input id="username" name="username" value="" type="text" autocomplete="username" autofocus
aria-invalid=""/>
</span>
<div id="input-error-container-username">
</div>
</div>
<div class="pf-v5-c-form__group">
<div class="pf-v5-c-form__group-label pf-v5-u-pb-xs">
<label for="password" class="pf-v5-c-form__label">
<span class="pf-v5-c-form__label-text">
Password
</span>
</label>
</div>
<div class="pf-v5-c-input-group">
<div class="pf-v5-c-input-group__item pf-m-fill">
<span class="pf-v5-c-form-control ">
<input id="password" name="password" value="" type="password" autocomplete="current-password"
aria-invalid=""/>
</span>
</div>
<div class="pf-v5-c-input-group__item">
<button class="pf-v5-c-button pf-m-control" type="button" aria-label="Show password"
aria-controls="password" data-password-toggle
data-icon-show="fa-eye fas" data-icon-hide="fa-eye-slash fas"
data-label-show="Show password" data-label-hide="Hide password" id="password-show-password"
onclick="const i=this.querySelector('i'); const pw=document.getElementById('password'); const showing=pw.type==='text'; pw.type = showing ? 'password' : 'text'; i.className = showing ? 'fa-eye fas' : 'fa-eye-slash fas';">
<i class="fa-eye fas" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="pf-v5-c-form__helper-text" aria-live="polite">
<div class="pf-v5-c-helper-text pf-v5-u-display-flex pf-v5-u-justify-content-space-between">
<div class="pf-v5-c-check">
<label for="rememberMe" class="pf-v5-c-check">
<input
class="pf-v5-c-check__input"
type="checkbox"
id="rememberMe"
name="rememberMe"
/>
<span class="pf-v5-c-check__label">Remember me</span>
</label>
</div>
</div>
</div>
<div id="input-error-container-password">
</div>
</div>
<input type="hidden" id="id-hidden-input" name="credentialId" />
<div class="pf-v5-c-form__group">
<div class="pf-v5-c-form__actions pf-v5-u-pt-xs pf-v5-u-flex-wrap">
<button class="pf-v5-c-button pf-m-primary pf-m-block" name="login" id="kc-login"
type="submit" >
Sign In
</button>
</div>
</div>
</form>
</div>
</div>
<div class="pf-v5-c-login__main-footer">
</div>
</div>
<div class="pf-v5-c-login__main-footer">
</div>
</main>
</div>
</div>
</body>
</html>

BIN
dev/vendor/fa-solid-900.woff2 vendored Normal file

Binary file not shown.