From 0a28256960984118dc0c8963889a028ad533840f Mon Sep 17 00:00:00 2001 From: Ekhorn Date: Thu, 1 Oct 2026 20:13:41 +0000 Subject: [PATCH 1/4] Let a custom project's theme add stylesheets A child theme with parent=openremote can override a page's .ftl and ship its own resources, but its CSS never loads: `styles=` in theme.properties becomes a only in a FreeMarker theme, and these pages are a compiled bundle. The pages now read `orStyles` from the theme properties Keycloak already sends and append a each, after the bundle's own so the child wins where specificity ties. Not Keycloak's own `styles=`, which is inherited from the fallback theme and renders the pages this theme does not implement. --- README.md | 16 ++++++++++++++++ theme/ui/src/branding.ts | 33 +++++++++++++++++++++++++++++++++ theme/ui/src/main.ts | 9 ++++++++- 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d6fce45..b9207f7 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,22 @@ 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. + +`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. diff --git a/theme/ui/src/branding.ts b/theme/ui/src/branding.ts index 32cdeb2..09e6cac 100644 --- a/theme/ui/src/branding.ts +++ b/theme/ui/src/branding.ts @@ -159,6 +159,39 @@ export function stylesApplied(): Promise { return documentStyles; } +/** + * Stylesheets a custom project's own theme asked for. + * + * A custom project extends this theme with a directory of its own under the themes volume and + * `parent=openremote`. In a FreeMarker theme, `styles=` in its theme.properties becomes a + * `` per file; these pages are a compiled bundle, so nothing turns that property into + * anything, and a child theme's CSS is silently never loaded. + * + * `orStyles` rather than Keycloak's own `styles`: that one is inherited from the fallback theme + * (see `parent=` in theme/build.gradle) and is what the pages this theme does not implement are + * rendered with. Reading it would put the fallback theme's stylesheet on our pages, and + * declaring our own would take it away from theirs. + * + * Appended to , so after the bundle's own stylesheet and winning where specificity ties. + * Paths are relative to the theme's resources, as Keycloak's `styles=` are. + */ +export function applyThemeStyles(kcContext: KcContext): void { + const declared = kcContext.properties?.orStyles?.trim(); + const resourcesPath = kcContext.url?.resourcesPath; + + if (!declared || !resourcesPath) { + return; + } + + for (const path of declared.split(/\s+/)) { + const link = document.createElement("link"); + + link.rel = "stylesheet"; + link.href = /^(?:https?:)?\/\//.test(path) ? path : `${resourcesPath}/${path}`; + document.head.appendChild(link); + } +} + function resolveAsset(path: string | undefined, base: string): string | null { if (!path) { return null; diff --git a/theme/ui/src/main.ts b/theme/ui/src/main.ts index 1387506..1e3573e 100644 --- a/theme/ui/src/main.ts +++ b/theme/ui/src/main.ts @@ -7,7 +7,13 @@ */ import "./styles/index.css"; import type { KcContext } from "./login/KcContext"; -import { applyBranding, applyFavicon, armReveal, stylesApplied } from "./branding"; +import { + applyBranding, + applyFavicon, + applyThemeStyles, + armReveal, + stylesApplied +} from "./branding"; import { applyTheme, renderPage } from "./render"; declare global { @@ -45,6 +51,7 @@ async function main(): Promise { } applyFavicon(kcContext); + applyThemeStyles(kcContext); // Before rendering, so a page that throws still gets revealed rather than staying hidden. armReveal(); From 5cd09453e49c042027d4ae9bd8c672e42cf1e483 Mon Sep 17 00:00:00 2001 From: Ekhorn Date: Wed, 14 Oct 2026 11:34:50 +0200 Subject: [PATCH 2/4] Load a child theme's stylesheets in the head --- README.md | 7 +++++++ theme/ui/index.html | 35 +++++++++++++++++++++++++++++++++++ theme/ui/rspack.config.mjs | 4 +++- theme/ui/src/branding.ts | 33 --------------------------------- theme/ui/src/main.ts | 9 +-------- 5 files changed, 46 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index b9207f7..cf0b6d5 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,13 @@ 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 `` 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)`. + `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. diff --git a/theme/ui/index.html b/theme/ui/index.html index d4c1b0c..808a5f5 100644 --- a/theme/ui/index.html +++ b/theme/ui/index.html @@ -39,8 +39,43 @@ document.documentElement.classList.add("or-booting"); })(); + <% for tag in htmlRspackPlugin.tags.headTags { %><%= toHtml(tag) %><% } %> +
+ <% for tag in htmlRspackPlugin.tags.bodyTags { %><%= toHtml(tag) %><% } %> diff --git a/theme/ui/rspack.config.mjs b/theme/ui/rspack.config.mjs index a6b6202..508910b 100644 --- a/theme/ui/rspack.config.mjs +++ b/theme/ui/rspack.config.mjs @@ -40,7 +40,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. // diff --git a/theme/ui/src/branding.ts b/theme/ui/src/branding.ts index 09e6cac..32cdeb2 100644 --- a/theme/ui/src/branding.ts +++ b/theme/ui/src/branding.ts @@ -159,39 +159,6 @@ export function stylesApplied(): Promise { return documentStyles; } -/** - * Stylesheets a custom project's own theme asked for. - * - * A custom project extends this theme with a directory of its own under the themes volume and - * `parent=openremote`. In a FreeMarker theme, `styles=` in its theme.properties becomes a - * `` per file; these pages are a compiled bundle, so nothing turns that property into - * anything, and a child theme's CSS is silently never loaded. - * - * `orStyles` rather than Keycloak's own `styles`: that one is inherited from the fallback theme - * (see `parent=` in theme/build.gradle) and is what the pages this theme does not implement are - * rendered with. Reading it would put the fallback theme's stylesheet on our pages, and - * declaring our own would take it away from theirs. - * - * Appended to , so after the bundle's own stylesheet and winning where specificity ties. - * Paths are relative to the theme's resources, as Keycloak's `styles=` are. - */ -export function applyThemeStyles(kcContext: KcContext): void { - const declared = kcContext.properties?.orStyles?.trim(); - const resourcesPath = kcContext.url?.resourcesPath; - - if (!declared || !resourcesPath) { - return; - } - - for (const path of declared.split(/\s+/)) { - const link = document.createElement("link"); - - link.rel = "stylesheet"; - link.href = /^(?:https?:)?\/\//.test(path) ? path : `${resourcesPath}/${path}`; - document.head.appendChild(link); - } -} - function resolveAsset(path: string | undefined, base: string): string | null { if (!path) { return null; diff --git a/theme/ui/src/main.ts b/theme/ui/src/main.ts index 1e3573e..1387506 100644 --- a/theme/ui/src/main.ts +++ b/theme/ui/src/main.ts @@ -7,13 +7,7 @@ */ import "./styles/index.css"; import type { KcContext } from "./login/KcContext"; -import { - applyBranding, - applyFavicon, - applyThemeStyles, - armReveal, - stylesApplied -} from "./branding"; +import { applyBranding, applyFavicon, armReveal, stylesApplied } from "./branding"; import { applyTheme, renderPage } from "./render"; declare global { @@ -51,7 +45,6 @@ async function main(): Promise { } applyFavicon(kcContext); - applyThemeStyles(kcContext); // Before rendering, so a page that throws still gets revealed rather than staying hidden. armReveal(); From 0217b5201d449bd852e62b30e8d1e2ece9d95ab2 Mon Sep 17 00:00:00 2001 From: Ekhorn Date: Wed, 28 Oct 2026 00:05:58 +0100 Subject: [PATCH 3/4] Preview a child theme in the dev server --- README.md | 9 ++++ theme/ui/README.md | 10 +++- theme/ui/dev-server/child-theme.mjs | 82 +++++++++++++++++++++++++++++ theme/ui/dev-server/stock.mjs | 2 +- theme/ui/rspack.config.mjs | 14 ++++- theme/ui/src/dev/child-theme.ts | 35 ++++++++++++ theme/ui/src/dev/dev-mode.ts | 8 ++- 7 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 theme/ui/dev-server/child-theme.mjs create mode 100644 theme/ui/src/dev/child-theme.ts diff --git a/README.md b/README.md index cf0b6d5..bc7d9bd 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,15 @@ 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. + `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. diff --git a/theme/ui/README.md b/theme/ui/README.md index 3643168..56c04fb 100644 --- a/theme/ui/README.md +++ b/theme/ui/README.md @@ -15,7 +15,15 @@ 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. + +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. diff --git a/theme/ui/dev-server/child-theme.mjs b/theme/ui/dev-server/child-theme.mjs new file mode 100644 index 0000000..0cb5eee --- /dev/null +++ b/theme/ui/dev-server/child-theme.mjs @@ -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/ 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(); + } + }); + } + }; +} diff --git a/theme/ui/dev-server/stock.mjs b/theme/ui/dev-server/stock.mjs index fd77b78..70da2db 100644 --- a/theme/ui/dev-server/stock.mjs +++ b/theme/ui/dev-server/stock.mjs @@ -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 {}; } diff --git a/theme/ui/rspack.config.mjs b/theme/ui/rspack.config.mjs index 508910b..a0e0bc4 100644 --- a/theme/ui/rspack.config.mjs +++ b/theme/ui/rspack.config.mjs @@ -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, @@ -82,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 @@ -91,6 +102,7 @@ export default { */ setupMiddlewares: (middlewares, devServer) => { stockKeycloak({ uiDir: dirname }).install(devServer.app); + child.install(devServer.app); return middlewares; } } diff --git a/theme/ui/src/dev/child-theme.ts b/theme/ui/src/dev/child-theme.ts new file mode 100644 index 0000000..697fa07 --- /dev/null +++ b/theme/ui/src/dev/child-theme.ts @@ -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 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 { + 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; + }), + ); +} diff --git a/theme/ui/src/dev/dev-mode.ts b/theme/ui/src/dev/dev-mode.ts index d849806..7b1e040 100644 --- a/theme/ui/src/dev/dev-mode.ts +++ b/theme/ui/src/dev/dev-mode.ts @@ -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"; @@ -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"); @@ -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()))); From 0cbfd88bef5d902c324f1a7b49615e7b5e9d8217 Mon Sep 17 00:00:00 2001 From: Ekhorn Date: Wed, 28 Oct 2026 00:24:14 +0100 Subject: [PATCH 4/4] Add example child theme --- README.md | 2 + theme/ui/README.md | 2 + .../login/resources/css/example.css | 38 +++++++++++++++++++ .../example-theme/login/theme.properties | 4 ++ theme/ui/package.json | 1 + 5 files changed, 47 insertions(+) create mode 100644 theme/ui/dev-server/example-theme/login/resources/css/example.css create mode 100644 theme/ui/dev-server/example-theme/login/theme.properties diff --git a/README.md b/README.md index bc7d9bd..e2ea9c9 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,8 @@ 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. diff --git a/theme/ui/README.md b/theme/ui/README.md index 56c04fb..aa1b8d7 100644 --- a/theme/ui/README.md +++ b/theme/ui/README.md @@ -23,6 +23,8 @@ 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. diff --git a/theme/ui/dev-server/example-theme/login/resources/css/example.css b/theme/ui/dev-server/example-theme/login/resources/css/example.css new file mode 100644 index 0000000..35b1e0a --- /dev/null +++ b/theme/ui/dev-server/example-theme/login/resources/css/example.css @@ -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); + } +} diff --git a/theme/ui/dev-server/example-theme/login/theme.properties b/theme/ui/dev-server/example-theme/login/theme.properties new file mode 100644 index 0000000..5be76af --- /dev/null +++ b/theme/ui/dev-server/example-theme/login/theme.properties @@ -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 diff --git a/theme/ui/package.json b/theme/ui/package.json index 8d63bd7..c77a29f 100644 --- a/theme/ui/package.json +++ b/theme/ui/package.json @@ -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",