diff --git a/README.md b/README.md index d6fce45..e2ea9c9 100644 --- a/README.md +++ b/README.md @@ -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 `` 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. diff --git a/theme/ui/README.md b/theme/ui/README.md index 3643168..aa1b8d7 100644 --- a/theme/ui/README.md +++ b/theme/ui/README.md @@ -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. 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/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/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/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/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", diff --git a/theme/ui/rspack.config.mjs b/theme/ui/rspack.config.mjs index a6b6202..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, @@ -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. // @@ -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 @@ -89,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())));