+
+
+
+
+
diff --git a/k8s-ng/core/keycloak/preview/preview.css b/k8s-ng/core/keycloak/preview/preview.css
new file mode 100644
index 0000000..36231b7
--- /dev/null
+++ b/k8s-ng/core/keycloak/preview/preview.css
@@ -0,0 +1,11 @@
+.preview { border-top: 1px solid #ffffff0c; padding: 18px 24px 21px; color: var(--muted); font-size: 12px; background: var(--bg); }
+.preview-heading, .preview-controls { max-width: 760px; margin: 0 auto; display: flex; gap: 15px; flex-wrap: wrap; justify-content: space-between; align-items: center; }
+.preview-heading { margin-bottom: 12px; }
+.preview-label { color: var(--text); letter-spacing: 1px; font-size: 11px; }
+.preview-group { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
+.preview button, .preview select { border: 1px solid var(--border); border-radius: 0; background: transparent; padding: 5px 9px; color: var(--muted); font-size: 13px; }
+.preview select { background: var(--bg); }
+.preview button[aria-pressed="true"] { color: var(--accent); border-color: var(--accent); }
+.preview button:hover { color: var(--text); }
+.preview .checkbox { margin-left: 8px; }
+@media (max-width: 580px) { .preview { padding: 16px; } .preview-controls { gap: 12px; } }
diff --git a/k8s-ng/core/keycloak/preview/realm.json b/k8s-ng/core/keycloak/preview/realm.json
new file mode 100644
index 0000000..e6636cd
--- /dev/null
+++ b/k8s-ng/core/keycloak/preview/realm.json
@@ -0,0 +1,49 @@
+{
+ "realm": "hexor-preview",
+ "enabled": true,
+ "displayName": "Hexor Lab",
+ "loginTheme": "hexor-lab",
+ "sslRequired": "none",
+ "registrationAllowed": false,
+ "resetPasswordAllowed": true,
+ "rememberMe": true,
+ "loginWithEmailAllowed": true,
+ "clients": [
+ {
+ "clientId": "theme-preview",
+ "enabled": true,
+ "publicClient": true,
+ "standardFlowEnabled": true,
+ "redirectUris": ["http://127.0.0.1:8765/*"],
+ "webOrigins": ["http://127.0.0.1:8765"]
+ }
+ ],
+ "users": [
+ {
+ "username": "demo",
+ "enabled": true,
+ "emailVerified": true,
+ "email": "demo@example.test",
+ "firstName": "Demo",
+ "lastName": "User",
+ "credentials": [{"type": "password", "value": "demo-password", "temporary": false}]
+ },
+ {
+ "username": "demo-otp",
+ "enabled": true,
+ "emailVerified": true,
+ "email": "demo-otp@example.test",
+ "firstName": "Demo",
+ "lastName": "OTP",
+ "credentials": [
+ {"type": "password", "value": "demo-password", "temporary": false},
+ {
+ "type": "otp",
+ "userLabel": "Local preview",
+ "secretData": "{\"value\":\"local-preview-secret\"}",
+ "credentialData": "{\"subType\":\"totp\",\"digits\":6,\"counter\":0,\"period\":30,\"algorithm\":\"HmacSHA1\"}"
+ }
+ ]
+ }
+ ]
+}
diff --git a/k8s-ng/core/keycloak/preview/run-keycloak.sh b/k8s-ng/core/keycloak/preview/run-keycloak.sh
new file mode 100755
index 0000000..909d0cb
--- /dev/null
+++ b/k8s-ng/core/keycloak/preview/run-keycloak.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Run the real login theme in an isolated, disposable Keycloak 26.5.6 instance.
+# Test users: demo / demo-password and demo-otp / demo-password.
+# The existing deployment, Terraform and production realm are not involved.
+preview_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+theme_dir="$(cd -- "$preview_dir/../themes/hexor-lab" && pwd)"
+
+printf '%s\n' \
+ 'After Keycloak starts, open:' \
+ 'http://127.0.0.1:8780/realms/hexor-preview/protocol/openid-connect/auth?client_id=theme-preview&redirect_uri=http%3A%2F%2F127.0.0.1%3A8765%2F&response_type=code&scope=openid' \
+ 'Test account: demo / demo-password'
+
+docker run --rm --name hexor-lab-preview \
+ --publish 127.0.0.1:8780:8080 \
+ --mount "type=bind,source=$theme_dir,target=/opt/keycloak/themes/hexor-lab,readonly" \
+ --mount "type=bind,source=$preview_dir/realm.json,target=/opt/keycloak/data/import/hexor-preview.json,readonly" \
+ quay.io/keycloak/keycloak:26.5.6 \
+ start-dev --import-realm \
+ --spi-theme--static-max-age=-1 \
+ --spi-theme--cache-themes=false \
+ --spi-theme--cache-templates=false
diff --git a/k8s-ng/core/keycloak/preview/serve.py b/k8s-ng/core/keycloak/preview/serve.py
new file mode 100644
index 0000000..92a5b97
--- /dev/null
+++ b/k8s-ng/core/keycloak/preview/serve.py
@@ -0,0 +1,49 @@
+#!/usr/bin/env python3
+"""Local theme preview: python3 serve.py [--port 8765]. No dependencies."""
+
+import argparse
+from functools import partial
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+import json
+from pathlib import Path
+from urllib.parse import urlsplit
+
+ROOT = Path(__file__).resolve().parent
+ASSETS = ("index.html", "styles.css", "preview.css", "app.js", "background.js")
+
+
+class PreviewHandler(SimpleHTTPRequestHandler):
+ def end_headers(self):
+ self.send_header("Cache-Control", "no-store")
+ super().end_headers()
+
+ def do_GET(self):
+ if urlsplit(self.path).path == "/__version":
+ version = ":".join(str((ROOT / name).stat().st_mtime_ns) for name in ASSETS)
+ body = json.dumps({"version": version}).encode()
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+ else:
+ super().do_GET()
+
+ def log_message(self, format, *args):
+ if not self.path.startswith("/__version"):
+ super().log_message(format, *args)
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--port", type=int, default=8765)
+ args = parser.parse_args()
+ server = ThreadingHTTPServer(("127.0.0.1", args.port), partial(PreviewHandler, directory=str(ROOT)))
+ print(f"Local: http://127.0.0.1:{server.server_port}", flush=True)
+ print("Edit index.html, styles.css or app.js; the browser reloads on save. Ctrl+C to stop.", flush=True)
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ server.server_close()
diff --git a/k8s-ng/core/keycloak/preview/styles.css b/k8s-ng/core/keycloak/preview/styles.css
new file mode 120000
index 0000000..8c28443
--- /dev/null
+++ b/k8s-ng/core/keycloak/preview/styles.css
@@ -0,0 +1 @@
+../themes/hexor-lab/login/resources/css/hexor.css
\ No newline at end of file
diff --git a/k8s-ng/core/keycloak/themes/hexor-lab/LICENSE.txt b/k8s-ng/core/keycloak/themes/hexor-lab/LICENSE.txt
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/k8s-ng/core/keycloak/themes/hexor-lab/LICENSE.txt
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/k8s-ng/core/keycloak/themes/hexor-lab/login/resources/css/hexor.css b/k8s-ng/core/keycloak/themes/hexor-lab/login/resources/css/hexor.css
new file mode 100644
index 0000000..31645c4
--- /dev/null
+++ b/k8s-ng/core/keycloak/themes/hexor-lab/login/resources/css/hexor.css
@@ -0,0 +1,83 @@
+/* Start here: all palette and layout settings are ordinary CSS variables. */
+:root {
+ --bg: #0c0f0d;
+ --surface: #111612;
+ --input-bg: #0c110d;
+ --accent: #b9ef70;
+ --accent-ink: #121c09;
+ --text: #e1e7dd;
+ --muted: #8b9989;
+ --border: #354031;
+ --error: #ffa595;
+ --font: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
+ --terminal-width: 560px;
+ --code-opacity: .16;
+}
+* { box-sizing: border-box; }
+[hidden] { display: none !important; }
+body { margin: 0; min-height: 100svh; color: var(--text); background: var(--bg); font: 16px/1.6 var(--font); display: flex; flex-direction: column; isolation: isolate; }
+#code-background { position: fixed; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: -1; opacity: var(--code-opacity); mask-image: linear-gradient(to bottom, transparent, #000 15%, #000 85%, transparent); }
+button, input, select { font: inherit; }
+button, select { cursor: pointer; }
+button, a, input, select { -webkit-tap-highlight-color: transparent; }
+button, a, select { touch-action: manipulation; }
+a { color: var(--muted); text-underline-offset: 4px; text-decoration-thickness: 1px; }
+a:hover { color: var(--accent); }
+button:focus-visible, a:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
+::selection { background: var(--accent); color: var(--accent-ink); }
+.site-header { padding: 27px 38px; display: flex; justify-content: space-between; gap: 20px; align-items: center; }
+.wordmark { font: 500 clamp(26px, 4vw, 34px)/1.2 var(--font); letter-spacing: -1px; text-decoration: none; color: var(--text); }
+.page-main { flex: 1; display: grid; place-items: center; padding: 35px 24px 64px; }
+.terminal-wrap { width: min(100%, var(--terminal-width)); }
+.terminal { background: var(--surface); border: 1px solid var(--border); box-shadow: 0 24px 90px #0004; }
+.titlebar { display: flex; padding: 12px 20px; border-bottom: 1px solid var(--border); font-size: 14px; color: var(--muted); }
+.titlebar span span { color: var(--accent); margin-right: 9px; }
+.terminal-body { padding: 30px 36px 34px; }
+.heading { margin: 0 0 28px; }
+h1 { margin: 0 0 8px; font-size: clamp(28px, 6vw, 34px); line-height: 1.3; font-weight: 500; letter-spacing: -1px; }
+.cursor { color: var(--accent); animation: blink 1.2s step-end infinite; }
+@keyframes blink { 50% { opacity: 0; } }
+.description { color: var(--muted); margin: 0; font-size: 15px; }
+.field { margin-bottom: 21px; }
+.field > label { display: block; margin-bottom: 8px; font-size: 16px; }
+.prompt { color: var(--accent); margin-right: 7px; }
+.label-hint { color: var(--muted); font-size: 14px; }
+input:not([type="checkbox"]) { width: 100%; min-height: 50px; padding: 12px 14px; background: var(--input-bg); border: 1px solid var(--border); border-radius: 0; color: var(--text); caret-color: var(--accent); outline: none; box-shadow: none; font-size: 16px; }
+input:not([type="checkbox"]):hover { border-color: var(--muted); }
+input:not([type="checkbox"]):focus { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
+input::placeholder { color: var(--muted); opacity: .7; }
+input[aria-invalid="true"]:not([type="checkbox"]) { border-color: var(--error); }
+input:-webkit-autofill { -webkit-text-fill-color: var(--text); box-shadow: 0 0 0 1000px var(--input-bg) inset; }
+.password-wrap { position: relative; }
+.password-wrap input { padding-right: 72px; }
+.reveal { position: absolute; right: 5px; top: 5px; bottom: 5px; padding: 0 10px; background: transparent; color: var(--muted); border: 0; font-size: 12px; letter-spacing: .5px; }
+.reveal:hover { color: var(--accent); }
+.form-options { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px; margin: 1px 0 26px; font-size: 14px; }
+.checkbox { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; color: var(--muted); }
+input[type="checkbox"] { width: 16px; height: 16px; margin: 0; accent-color: var(--accent); cursor: pointer; }
+.primary { width: 100%; min-height: 50px; display: flex; align-items: center; justify-content: space-between; padding: 11px 16px; background: var(--accent); color: var(--accent-ink); border: 1px solid var(--accent); border-radius: 0; font-weight: bold; transition: filter .15s; }
+.primary:hover { filter: brightness(1.1); }
+.primary:active { transform: translateY(1px); }
+.primary span:last-child { font-size: 20px; font-weight: normal; line-height: 1; }
+.divider { display: flex; align-items: center; gap: 15px; margin: 24px 0 18px; color: var(--muted); font-size: 11px; letter-spacing: 1px; }
+.divider::before, .divider::after { content: ""; height: 1px; background: var(--border); flex: 1; }
+.alternatives { display: flex; gap: 12px; }
+.secondary { display: inline-flex; justify-content: center; align-items: center; gap: 10px; min-height: 48px; padding: 10px 15px; background: transparent; color: var(--text); border: 1px solid var(--border); border-radius: 0; font-size: 16px; text-decoration: none; }
+.alternatives .secondary { flex: 1; }
+.secondary:hover { border-color: var(--accent); color: var(--accent); background: #ffffff03; }
+.secondary > span { color: var(--muted); font-size: 16px; }
+.google-icon { font-family: sans-serif; font-weight: bold; }
+.notice { border-left: 2px solid var(--accent); padding: 10px 13px; background: #ffffff04; color: var(--text); font-size: 15px; margin: 0 0 22px; overflow-wrap: anywhere; }
+.notice.error { border-color: var(--error); color: var(--error); }
+.field-error { color: var(--error); font-size: 14px; margin: -8px 0 18px; }
+.field-help { margin: 10px 0 0; color: var(--muted); font-size: 14px; }
+input.otp-input { letter-spacing: 10px; font-size: 24px; padding: 15px; }
+.back-link { display: block; text-align: center; margin-top: 24px; font-size: 15px; }
+.success-panel { text-align: center; padding: 15px 0; }
+.success-mark { color: var(--accent); font-size: 32px; }
+@media (max-width: 580px) {
+ .site-header { padding: 20px; }
+ .page-main { padding: 25px 16px 40px; }
+ .terminal-body { padding: 24px; }
+}
+@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
diff --git a/k8s-ng/core/keycloak/themes/hexor-lab/login/resources/css/keycloak.css b/k8s-ng/core/keycloak/themes/hexor-lab/login/resources/css/keycloak.css
new file mode 100644
index 0000000..dec850e
--- /dev/null
+++ b/k8s-ng/core/keycloak/themes/hexor-lab/login/resources/css/keycloak.css
@@ -0,0 +1,130 @@
+/* Adapt the native Keycloak 26.5.6 form components to the shared design. */
+html.login-pf {
+ color-scheme: dark;
+ --pf-v5-global--FontFamily--text: var(--font);
+ --pf-v5-global--FontFamily--heading: var(--font);
+ --pf-v5-global--FontFamily--monospace: var(--font);
+ --pf-v5-global--FontSize--md: 16px;
+ --pf-v5-global--Color--100: var(--text);
+ --pf-v5-global--Color--200: var(--muted);
+ --pf-v5-global--primary-color--100: var(--accent);
+ --pf-v5-global--primary-color--200: var(--accent);
+ --pf-v5-global--link--Color: var(--accent);
+ --pf-v5-global--link--Color--hover: var(--text);
+ --pf-v5-global--BackgroundColor--100: var(--surface);
+ --pf-v5-global--BackgroundColor--200: var(--input-bg);
+ --pf-v5-global--BorderColor--100: var(--border);
+ --pf-v5-global--danger-color--100: var(--error);
+ --pf-v5-global--success-color--100: var(--accent);
+}
+html.login-pf body#keycloak-bg {
+ background: var(--bg);
+ color: var(--text);
+ font: 16px/1.6 var(--font);
+ height: auto;
+}
+.hexor-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 30px 36px 0; }
+.hexor-title { margin: 0; font: 500 clamp(28px, 6vw, 34px)/1.3 var(--font); letter-spacing: -1px; }
+.hexor-title::after { content: "_"; color: var(--accent); animation: blink 1.2s step-end infinite; }
+.hexor-body { padding: 28px 36px 34px; }
+.hexor-body > :last-child { margin-bottom: 0; }
+.hexor-locale { max-width: 100%; }
+.hexor-locale select { max-width: 180px; }
+.hexor-body .pf-v5-c-form { gap: 21px; }
+.hexor-body .pf-v5-c-form__group { gap: 8px; }
+.hexor-body .pf-v5-c-form__group-label { padding-bottom: 0 !important; }
+.hexor-body .pf-v5-c-form__label { color: var(--text); font-size: 16px; line-height: 1.6; }
+.hexor-body .pf-v5-c-form__label-text::before { content: ">"; color: var(--accent); margin-inline-end: 12px; }
+.hexor-body .pf-v5-c-form__helper-text { margin-top: 10px; }
+.hexor-body .pf-v5-c-helper-text { gap: 10px; flex-wrap: wrap; }
+.hexor-body .pf-v5-c-helper-text__item-text { color: var(--muted); font-size: 14px; }
+.hexor-body .pf-v5-c-check { color: var(--muted); font-size: 14px; align-items: center; }
+.hexor-body .pf-v5-c-check__label { color: var(--muted); font-size: 14px; }
+.hexor-body .pf-v5-c-form-control, .hexor-locale .pf-v5-c-form-control {
+ background: var(--input-bg);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 0;
+ --pf-v5-c-form-control--after--BorderBottomWidth: 0;
+ --pf-v5-c-form-control--before--BorderWidth: 0;
+ --pf-v5-c-form-control--Height: auto;
+}
+.hexor-body .pf-v5-c-form-control::before,
+.hexor-body .pf-v5-c-form-control::after,
+.hexor-locale .pf-v5-c-form-control::before,
+.hexor-locale .pf-v5-c-form-control::after { display: none; }
+.hexor-body .pf-v5-c-form-control:focus-within { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
+.hexor-body .pf-v5-c-form-control.pf-m-error { border-color: var(--error); }
+.hexor-body .pf-v5-c-form-control > :is(input, textarea, select),
+.hexor-locale .pf-v5-c-form-control > select {
+ border: 0;
+ border-radius: 0;
+ padding: 12px 14px;
+ min-height: 50px;
+ background: transparent;
+ color: var(--text);
+ font: 16px/1.6 var(--font);
+ box-shadow: none;
+}
+.hexor-body .pf-v5-c-form-control > input:focus { box-shadow: none; }
+.hexor-body .pf-v5-c-form-control input[readonly] { color: var(--muted); }
+.hexor-body .pf-v5-c-input-group { gap: 0; }
+.hexor-body .pf-v5-c-input-group__item { min-width: 0; }
+.hexor-body .pf-v5-c-form-control__utilities { background: transparent; color: var(--muted); }
+.hexor-body .pf-v5-c-button {
+ min-height: 48px;
+ border: 1px solid var(--border);
+ border-radius: 0;
+ padding: 10px 15px;
+ background: transparent;
+ color: var(--text);
+ font: 16px/1.6 var(--font);
+ box-shadow: none;
+ text-decoration: none;
+ white-space: normal;
+}
+.hexor-body .pf-v5-c-button::after { display: none; }
+.hexor-body .pf-v5-c-button:hover { border-color: var(--accent); color: var(--accent); }
+.hexor-body .pf-v5-c-button:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
+.hexor-body .pf-v5-c-button.pf-m-primary {
+ width: 100%;
+ min-height: 50px;
+ background: var(--accent);
+ border-color: var(--accent);
+ color: var(--accent-ink);
+ font-weight: bold;
+}
+.hexor-body .pf-v5-c-button.pf-m-primary:hover { filter: brightness(1.1); }
+.hexor-body .pf-v5-c-button:disabled, .hexor-body .pf-m-aria-disabled { opacity: .5; pointer-events: none; }
+.hexor-body .pf-v5-c-button.pf-m-control { min-width: 46px; height: 100%; color: var(--muted); background: var(--input-bg); }
+.hexor-body .pf-v5-c-button.pf-m-link { min-height: auto; border-color: transparent; padding-inline: 0; color: var(--accent); }
+.hexor-body .pf-v5-c-form__actions { padding-top: 0 !important; }
+.hexor-body a:not(.pf-v5-c-button) { color: var(--muted); }
+.hexor-body a:not(.pf-v5-c-button):hover { color: var(--accent); }
+.hexor-body .pf-v5-c-alert { border: 0; border-inline-start: 2px solid var(--accent); padding: 10px 13px; background: #ffffff04; color: var(--text); box-shadow: none; }
+.hexor-body .pf-v5-c-alert.pf-m-danger { border-color: var(--error); }
+.hexor-body .pf-v5-c-alert__icon { display: none; }
+.hexor-body .pf-v5-c-alert__title { color: var(--text); font: 15px/1.6 var(--font); }
+.hexor-body .pf-v5-c-alert.pf-m-danger .pf-v5-c-alert__title,
+.hexor-body .pf-v5-c-helper-text__item.pf-m-error .pf-v5-c-helper-text__item-text { color: var(--error); }
+.hexor-footer:empty { display: none; }
+.hexor-footer-band { padding: 0; margin-top: 22px; color: var(--muted); font-size: 14px; }
+.hexor-divider-label { display: flex; align-items: center; gap: 15px; margin: 24px 0 18px; font-size: 11px; letter-spacing: 1px; }
+.hexor-divider-label::before, .hexor-divider-label::after { content: ""; flex: 1; height: 1px; background: var(--border); }
+#kc-social-providers ul { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0; list-style: none; }
+#kc-social-providers li { flex: 1 1 160px; padding: 0; }
+#kc-social-providers a { display: flex; justify-content: center !important; align-items: center; gap: 10px; }
+#kc-social-providers svg { width: 20px; height: 20px; flex-shrink: 0; fill: currentColor; }
+#kc-social-providers svg:not(.google) { filter: none; }
+#kc-social-providers .pf-v5-u-m-auto { margin: 0 !important; }
+#kc-select-try-another-way-form { margin-top: 20px; }
+#kc-info-wrapper, #kc-registration { color: var(--muted); font-size: 14px; }
+#kc-otp-login-form #otp { font-size: 24px; letter-spacing: 8px; }
+.hexor-body :is(.pf-v5-c-tile, .pf-v5-c-data-list__item, .pf-v5-c-panel) { background: var(--input-bg); color: var(--text); border: 1px solid var(--border); box-shadow: none; }
+.hexor-body .pf-v5-c-tile.pf-m-selected { border-color: var(--accent); }
+.hexor-body img, .hexor-body canvas, .hexor-body svg { max-width: 100%; }
+.hexor-body pre { overflow-wrap: anywhere; white-space: pre-wrap; }
+@media (max-width: 580px) {
+ .hexor-heading { padding: 24px 24px 0; }
+ .hexor-body { padding: 24px; }
+}
diff --git a/k8s-ng/core/keycloak/themes/hexor-lab/login/resources/img/favicon.svg b/k8s-ng/core/keycloak/themes/hexor-lab/login/resources/img/favicon.svg
new file mode 100644
index 0000000..4789d12
--- /dev/null
+++ b/k8s-ng/core/keycloak/themes/hexor-lab/login/resources/img/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/k8s-ng/core/keycloak/themes/hexor-lab/login/resources/js/background.js b/k8s-ng/core/keycloak/themes/hexor-lab/login/resources/js/background.js
new file mode 100644
index 0000000..8e0e630
--- /dev/null
+++ b/k8s-ng/core/keycloak/themes/hexor-lab/login/resources/js/background.js
@@ -0,0 +1,126 @@
+/* Decorative code stream. Brightness: --code-opacity in css/hexor.css. */
+(() => {
+ "use strict";
+ const canvas = document.querySelector("#code-background");
+ const context = canvas?.getContext("2d");
+ if (!context) return;
+
+ const source = [
+ "use std::sync::Arc;",
+ "use tokio::net::TcpListener;",
+ "",
+ "async fn main() -> Result<()> {",
+ " let config = Config::load()?;",
+ " let state = Arc::new(State::new());",
+ " let router = routes(state.clone());",
+ "",
+ " server::bind(config.address)",
+ " .serve(router)",
+ " .await?;",
+ " Ok(())",
+ "}",
+ "",
+ "impl Session {",
+ " pub fn verify(&self) -> bool {",
+ " self.expires_at > clock::now()",
+ " && self.signature.is_valid()",
+ " }",
+ "}",
+ "",
+ "fn routes(state: Arc) -> Router {",
+ " Router::new()",
+ ' .route("/", get(index))',
+ ' .route("/session", post(create))',
+ " .with_state(state)",
+ "}",
+ "",
+ "match identity.authenticate().await {",
+ " Ok(session) => session.commit(),",
+ " Err(error) => trace!(?error),",
+ "}",
+ "",
+ "let transport = Transport::builder()",
+ " .with_timeout(Duration::from_secs(30))",
+ " .with_keepalive(true)",
+ " .connect(endpoint).await?;",
+ "",
+ ];
+ const motion = window.matchMedia("(prefers-reduced-motion: reduce)");
+ const lineHeight = 24;
+ let streams = [];
+ let width = 0;
+ let height = 0;
+ let color;
+
+ function updateColor() {
+ color = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim();
+ }
+
+ function resize() {
+ width = window.innerWidth;
+ height = window.innerHeight;
+ const ratio = Math.min(window.devicePixelRatio || 1, 1.5);
+ canvas.width = Math.round(width * ratio);
+ canvas.height = Math.round(height * ratio);
+ context.setTransform(ratio, 0, 0, ratio, 0, 0);
+ context.font = '13px "SFMono-Regular", Consolas, "Liberation Mono", monospace';
+ const columns = Math.max(1, Math.ceil(width / 470));
+ const rows = Math.ceil(height / lineHeight);
+ streams = Array.from({ length: columns }, (_, column) => {
+ const start = Math.floor(Math.random() * source.length);
+ const count = Math.max(4, rows - 7 + Math.floor(Math.random() * 5));
+ return {
+ x: column * (width / columns) + 24,
+ y: 44 - Math.random() * 80,
+ lines: Array.from({ length: count }, (_, i) => source[(start + i) % source.length]),
+ next: (start + count) % source.length,
+ typed: Math.random() * 12,
+ pause: 0,
+ number: 20 + start,
+ speed: .55 + Math.random() * .6,
+ };
+ });
+ draw(false);
+ }
+
+ function draw(advance) {
+ context.clearRect(0, 0, width, height);
+ context.fillStyle = color;
+ for (const stream of streams) {
+ const current = source[stream.next];
+ if (advance) {
+ if (stream.typed < current.length) stream.typed += stream.speed;
+ else if (++stream.pause > 8) {
+ stream.lines.push(current);
+ stream.next = (stream.next + 1) % source.length;
+ stream.typed = 0;
+ stream.pause = 0;
+ }
+ // Scroll gently as new lines reach the bottom of the viewport.
+ const overflow = stream.y + (stream.lines.length + 2) * lineHeight - height;
+ if (overflow > 0) stream.y -= Math.min(1.5, overflow);
+ if (stream.y < -lineHeight) {
+ stream.lines.shift();
+ stream.y += lineHeight;
+ stream.number++;
+ }
+ }
+ stream.lines.concat(current.slice(0, Math.floor(stream.typed))).forEach((line, i) => {
+ const y = stream.y + i * lineHeight;
+ context.globalAlpha = .3;
+ context.fillText(String(stream.number + i).padStart(3, "0"), stream.x, y);
+ context.globalAlpha = i === stream.lines.length ? .95 : .6;
+ context.fillText(line, stream.x + 42, y);
+ });
+ }
+ context.globalAlpha = 1;
+ }
+
+ updateColor();
+ resize();
+ window.addEventListener("resize", resize);
+ // Ten frames per second is enough for slow typing; no work in hidden tabs.
+ setInterval(() => {
+ if (!document.hidden && !motion.matches) draw(true);
+ }, 100);
+})();
diff --git a/k8s-ng/core/keycloak/themes/hexor-lab/login/template.ftl b/k8s-ng/core/keycloak/themes/hexor-lab/login/template.ftl
new file mode 100644
index 0000000..d2fade8
--- /dev/null
+++ b/k8s-ng/core/keycloak/themes/hexor-lab/login/template.ftl
@@ -0,0 +1,247 @@
+<#-- Based on Keycloak 26.5.6 keycloak.v2/login/template.ftl (Apache-2.0). -->
+<#import "field.ftl" as field>
+<#import "footer.ftl" as loginFooter>
+<#macro username>
+ <#assign label>
+ <#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")}#if>
+ #assign>
+ <@field.group name="username" label=label>
+
+ #if>
+ #if>
+
+ <#-- App-initiated actions should not see warning messages about the need to complete the action -->
+ <#-- during login. -->
+ <#if displayMessage && message?has_content && (message.type != 'warning' || !isAppInitiatedAction??)>
+