Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,40 @@ docker run ... -e OR_MANAGER_URL=http://127.0.0.1:8080 openremote/keycloak:devel

It is read at render time from the theme's `theme.properties`, so it takes effect on restart with no rebuild. Such a setup also needs the Keycloak origin added to `OR_WEBSERVER_ALLOWED_ORIGINS` on the manager, since production CORS is not `*` (in dev mode the manager already allows all origins).

### Custom stylesheets

Branding that a realm sets in `manager_config.json` needs no theme at all. A custom project that wants to go further extends this one with a theme of its own, mounted under `/deployment/keycloak/themes` and selected per realm:

```properties
# deployment/keycloak/themes/acme/login/theme.properties
parent=openremote
orStyles=css/custom.css
```

The stylesheets in `orStyles` are loaded after the theme's own, so they win where specificity ties, and paths resolve against that theme's `resources` directory exactly as Keycloak's `styles=` does. With `--spi-theme-cache-themes=false --spi-theme-static-max-age=-1` on the container, editing the file is a browser refresh.

They are written into the page's `<head>` by an inline script in the generated template, so they block the first paint rather than arriving after it, and the page is never painted in this theme's styling first. A child theme's stylesheet is therefore on the critical path, exactly as Keycloak's `styles=` is: a slow one is a slow login page.

Two things the page cannot hold back for you:

- **Fonts.** A font never blocks the page. A `@font-face` with `font-display: swap`, which Google Fonts' URLs ask for, paints in a fallback first and swaps when the font arrives. Self-host the font and declare `font-display: block` or `optional` if that swap is visible.
- **Entrance animations and transitions.** The page is hidden while the realm's branding loads: an animation that runs during that time is over before anyone sees it, and a transition the branding sets off is still running when the page appears. Scope them under `html:not(.or-booting)`.

To work on one without a container, point the dev server at the theme directory:

```shell
cd theme/ui
OR_CHILD_THEME=/path/to/deployment/keycloak/themes/acme/login yarn start
```

It serves that theme's `resources/` and applies what `orStyles` names, reloading the page when a stylesheet changes. The pages are rendered from mock data, so this is for styling: packaging, message merging and anything a realm configures still need the container.

`theme/ui/dev-server/example-theme` is a small one to start from, and `yarn start:example` runs the dev server with it.

`orStyles` rather than Keycloak's own `styles=`, because that property is inherited from the fallback theme and is what the pages this theme does not implement are rendered with: reading it would put the fallback theme's stylesheet on these pages, and declaring our own would take it away from theirs.

Write against the design system's tokens (`--or-color-*`) and the block classes the pages render (`.or-login`, `.or-card`, `.or-field`, `.or-actions`); anything deeper is internal to Vaadin's components and will move. A child theme can also override a whole page's `.ftl`, which Keycloak serves instead of ours. It cannot override messages: every string in these pages comes from the compiled bundle.

### Translations

Pages address Keycloak's own message keys, and Keycloakify ships Keycloak's bundle for ~30 languages, so enabling internationalization on a realm translates the theme.
Expand Down
12 changes: 11 additions & 1 deletion theme/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,17 @@ yarn start # http://localhost:5173

No Keycloak, no container. Page data comes from Keycloakify's `getKcContextMock`, so every page has realistic values, and rspack live-reloads on save.

Live reload covers `src/` only. `rspack.config.mjs` and the modules it imports — everything under `dev-server/` — are read once at startup, so a change there needs the dev server restarted. Nothing there is part of the built theme, so it never needs a Gradle build.
Point it at a custom project's own theme to work on that one instead:

```shell
OR_CHILD_THEME=~/acme/deployment/keycloak/themes/acme/login yarn start
```

It serves that theme's `resources/` under `/__child-theme/` and applies the stylesheets its `theme.properties` names in `orStyles`, reloading the page when one of them changes. The real page has Keycloak write those links into the head; the harness appends them, which puts them in the same place in the cascade.

Without a project at hand, `yarn start:example` does the same with `dev-server/example-theme`, a small child theme that tints the page, marks the card and animates it in.

Live reload covers `src/` only, plus a child theme's `resources/`. `rspack.config.mjs` and the modules it imports — everything under `dev-server/` — are read once at startup, so a change there needs the dev server restarted. Nothing there is part of the built theme, so it never needs a Gradle build.

A menu down the left side lists **every login page** in two sections: the ones with an implementation, then the ones a built theme leaves to Keycloak's own (clicking those renders Keycloak's page from the same mock data). It overlays rather than displacing the page, so the layout you are judging is the real one; below 940px it collapses to a tab at the left edge. It also carries brand color and logo overrides, for checking a custom project's branding without a manager running.

Expand Down
82 changes: 82 additions & 0 deletions theme/ui/dev-server/child-theme.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* A custom project's own theme, served to the dev server so its stylesheets can be worked on
* without a Keycloak container.
*
* OR_CHILD_THEME points at the theme's login directory, the one holding theme.properties:
*
* OR_CHILD_THEME=~/acme/deployment/keycloak/themes/acme/login yarn start
*
* GET /__child-theme the stylesheets to load, or none when nothing is configured
* GET /__child-theme/<path> a file from that theme's resources
*
* `orStyles` is read from theme.properties and resolved against resources/, which is what
* Keycloak does with it, so the preview goes through the same two values production does rather
* than a parallel arrangement of its own.
*/
import { readFile } from "node:fs/promises";
import path from "node:path";
import { themeProperties } from "./stock.mjs";

const TYPES = {
".css": "text/css",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".gif": "image/gif",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2"
};

export function childTheme({ themeDir }) {
const resourcesDir = themeDir === null ? null : path.join(themeDir, "resources");

/* Read per request, so editing theme.properties needs a reload rather than a restart. */
const styles = () => {
if (themeDir === null) {
return [];
}

const declared = themeProperties(path.join(themeDir, "theme.properties")).orStyles ?? "";

return declared
.trim()
.split(/\s+/)
.filter(Boolean)
.map(style => (/^(?:https?:)?\/\//.test(style) ? style : `/__child-theme/${style}`));
};

return {
resourcesDir,

install(app) {
app.get("/__child-theme", (_request, response) => {
response.setHeader("content-type", "application/json");
response.end(JSON.stringify({ themeDir, styles: styles() }));
});

app.get(/^\/__child-theme\/(.+)$/, async (request, response) => {
const relative = decodeURIComponent(request.params[0]);
const file =
resourcesDir === null || relative.includes("..") ? null : path.join(resourcesDir, relative);

if (file === null) {
response.statusCode = 404;
response.end();
return;
}

try {
const body = await readFile(file);
response.setHeader("content-type", TYPES[path.extname(file)] ?? "application/octet-stream");
// Edited between reloads, so never from the browser's cache.
response.setHeader("cache-control", "no-store");
response.end(body);
} catch {
response.statusCode = 404;
response.end();
}
});
}
};
}
38 changes: 38 additions & 0 deletions theme/ui/dev-server/example-theme/login/resources/css/example.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* What a custom project's stylesheet can do, written against the design system's tokens and the
* block classes the pages render, so it follows the realm's brand color and dark mode without
* restating either.
*/

/* A wash of the brand color over the page's own tint, which stays underneath. */
html {
--example-wash: color-mix(in srgb, var(--or-color-primary) 30%, transparent);
background-image:
linear-gradient(160deg, var(--example-wash), transparent 60%),
linear-gradient(var(--or-color-contrast-5pct), var(--or-color-contrast-5pct));
}

/* The same tint reads far stronger against a dark page. */
html[theme~="dark"] {
--example-wash: var(--or-color-primary-10pct);
}

.or-card {
border-top: 0.25rem solid var(--or-color-primary);
box-shadow: var(--lumo-box-shadow-s);
}

/* Only once the page is shown: it is hidden while the realm's branding loads, and an animation
that ran then would be over before anyone saw it. */
@media (prefers-reduced-motion: no-preference) {
html:not(.or-booting) .or-card {
animation: example-rise 0.3s ease-out;
}
}

@keyframes example-rise {
from {
opacity: 0;
transform: translateY(0.5rem);
}
}
4 changes: 4 additions & 0 deletions theme/ui/dev-server/example-theme/login/theme.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Minimal example theme to show what can be done to style the pages this theme renders.
# Run `yarn start:example` to serve it with the dev server. It is not part of the built theme.
parent=openremote
orStyles=css/example.css
2 changes: 1 addition & 1 deletion theme/ui/dev-server/stock.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const TYPES = {
};

/** `parent=` and `import=` from a theme.properties, or nothing if the file is not there. */
function themeProperties(file) {
export function themeProperties(file) {
if (!existsSync(file)) {
return {};
}
Expand Down
35 changes: 35 additions & 0 deletions theme/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,43 @@
document.documentElement.classList.add("or-booting");
})();
</script>
<% for tag in htmlRspackPlugin.tags.headTags { %><%= toHtml(tag) %><% } %>
<script>
/*
* A child theme's stylesheets, written out while the parser is still in the head, so they
* block the first paint exactly as Keycloak's own `styles=` does. Appended from the bundle
* instead, they arrived after the page had been built and painted with the theme's own
* styling, and it visibly flipped.
*
* After the bundle's tags above, so a child theme wins where specificity ties.
*
* Nothing here may use a template literal or `$` followed by a brace: the build turns this
* file into a FreeMarker template, where that is an interpolation.
*/
(function () {
const kcContext = window.kcContext;
const declared = kcContext && kcContext.properties && kcContext.properties.orStyles;
const resourcesPath = kcContext && kcContext.url && kcContext.url.resourcesPath;

if (!declared || !resourcesPath) {
return;
}

for (const path of declared.trim().split(/\s+/)) {
const link = document.createElement("link");

link.rel = "stylesheet";
link.href = /^(?:https?:)?\/\//.test(path) ? path : resourcesPath + "/" + path;

// Serialized by the DOM rather than built by hand, so an & or a " in the path arrives
// as it was written.
document.write(link.outerHTML);
}
})();
</script>
</head>
<body>
<div id="app"></div>
<% for tag in htmlRspackPlugin.tags.bodyTags { %><%= toHtml(tag) %><% } %>
</body>
</html>
1 change: 1 addition & 0 deletions theme/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"description": "OpenRemote Keycloak login theme: Lit + @openremote/or-vaadin-components, bundled with rspack and wrapped into a Keycloak theme by Keycloakify.",
"scripts": {
"start": "cross-env NODE_ENV=development rspack serve",
"start:example": "cross-env NODE_ENV=development OR_CHILD_THEME=dev-server/example-theme/login rspack serve",
"build": "rspack build",
"prebuild-keycloak-theme": "keycloakify update-kc-gen",
"typecheck": "tsc",
Expand Down
18 changes: 16 additions & 2 deletions theme/ui/rspack.config.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { CopyRspackPlugin, HtmlRspackPlugin } from "@rspack/core";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { childTheme } from "./dev-server/child-theme.mjs";
import { stockKeycloak } from "./dev-server/stock.mjs";

const dirname = path.dirname(fileURLToPath(import.meta.url));
const isDev = process.env.NODE_ENV === "development";

/* A custom project's own theme, worked on without a container. See dev-server/child-theme.mjs. */
const child = childTheme({
themeDir: process.env.OR_CHILD_THEME ? path.resolve(process.env.OR_CHILD_THEME) : null
});

export default {
mode: isDev ? "development" : "production",
devtool: isDev ? "source-map" : false,
Expand Down Expand Up @@ -40,7 +46,9 @@ export default {
]
},
plugins: [
new HtmlRspackPlugin({ template: "./index.html" }),
// inject: false because index.html places the tags itself, before the script that writes
// a child theme's stylesheets. Injected, they would land after those and win the ties.
new HtmlRspackPlugin({ template: "./index.html", inject: false }),
// public/ is served directly by the dev server but must be copied for the real build,
// otherwise the default logo 404s inside Keycloak.
//
Expand Down Expand Up @@ -80,7 +88,12 @@ export default {
},
// Templates and styles are reached through the import graph, but list them explicitly so
// a file that is only referenced (not imported) still triggers a rebuild.
watchFiles: ["src/**/*", "index.html"],
watchFiles: [
"src/**/*",
"index.html",
// So editing a child theme's stylesheet reloads the page as editing ours does.
...(child.resourcesDir === null ? [] : [path.join(child.resourcesDir, "**/*")])
],
/*
* Stock Keycloak pages rendered right here, from Keycloak's own templates and this page's mock
* data: the default for the compare pane, and what an unimplemented page shows. No Keycloak
Expand All @@ -89,6 +102,7 @@ export default {
*/
setupMiddlewares: (middlewares, devServer) => {
stockKeycloak({ uiDir: dirname }).install(devServer.app);
child.install(devServer.app);
return middlewares;
}
}
Expand Down
35 changes: 35 additions & 0 deletions theme/ui/src/dev/child-theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Applies the child theme the dev server was pointed at, if any, and settles once its stylesheets
* have loaded or failed to.
*
* Appends a <link> per stylesheet after the bundle's styles, so the child theme wins where
* specificity ties, as in production. Production writes them into the head from index.html, which
* needs a kcContext the dev server does not have. The harness keeps the page hidden until they have
* loaded, so it is never shown without them.
*/
export async function applyChildTheme(): Promise<void> {
let styles: string[];

try {
const response = await fetch("/__child-theme");
styles = ((await response.json()) as { styles?: string[] }).styles ?? [];
} catch {
// No dev server route: nothing to apply.
return;
}

await Promise.all(
styles.map((href) => {
const link = document.createElement("link");
// A stylesheet that fails to load is left out, as the browser leaves it out of a real page.
const settled = new Promise((resolve) => {
link.onload = link.onerror = resolve;
});

link.rel = "stylesheet";
link.href = href;
document.head.appendChild(link);
return settled;
}),
);
}
8 changes: 6 additions & 2 deletions theme/ui/src/dev/dev-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { KcContext } from "../login/KcContext";
import { reveal } from "../branding";
import { implementedPageIds } from "../page-registry";
import { applyTheme, readThemeOverride, renderPage } from "../render";
import { applyChildTheme } from "./child-theme";
import { compareMode, mountCompare } from "./compare";
import { installFlows, rewriteNavigation } from "./flows";
import { mountPageNav } from "./page-nav";
Expand Down Expand Up @@ -40,6 +41,8 @@ const REALM_DISPLAY_NAME = "Manufacturer";
* existing DOM, and the design-system stylesheet and the menu are never torn down.
*/
export function runDevMode(root: HTMLElement): void {
const childTheme = applyChildTheme();

const currentPageId = () => new URLSearchParams(location.search).get("page") ?? "login.ftl";
const currentSettings = () => readSettings(location.search);
const currentLanguage = () => new URLSearchParams(location.search).get("lang");
Expand Down Expand Up @@ -321,9 +324,10 @@ export function runDevMode(root: HTMLElement): void {
rendered = kcContext;
/*
* No manager here, and nothing in the harness can point one at the dev server, so there is
* nothing to fetch. The page still has to be shown, which applyBranding did on the way out.
* nothing to fetch. The page still has to be shown, which applyBranding did on the way out,
* once a child theme's stylesheets are in.
*/
reveal();
void childTheme.then(reveal);
publish();

stock.push(compare.setPage(pageId, () => renderStock("compare", kcContext, stockVariants())));
Expand Down
Loading