feat: palette and light/dark from go-bananas, and fix the image user

The theme selector and the appearance toggle are the custom elements
go-bananas ships, loaded at run time. They are custom elements rather than
components written here because the applications this service feeds are a mix of
React, vanilla JavaScript and server-rendered HTML, and a custom element is the
one shape all three can use with no build step, no import map, and no second copy
of React to break the hooks.

Every token in the stylesheet is now written as var(--bn-*, fallback). That
ordering is the safety property: with no theme service configured the page renders
exactly as it did before, and with one configured the same rules follow the
palette. The navigation rail takes the contract's sunken surface and its paired
foreground rather than a fixed dark navy, because a light appearance wants dark
text there.

THEME_BASE_URL is read from runtime-config.js, which the entrypoint rewrites from
the environment on start, so one image serves every environment rather than
baking a hostname into the bundle at build time.

contract.test.ts fetches the live contract and asserts it defines every --bn-*
variable this application consumes. A token renamed on the service would
otherwise leave every var() resolving to its fallback, and the page would look
correct while never actually following the palette. Verified it fails on a
deliberately wrong token name.

The image also had two USER lines, the second silently replacing the first with a
uid that owns neither /app nor /tmp/nginx. nginx could not create its pid file or
its temp paths, so the container failed to start. Confirmed by building both
variants: the old one cannot write nginx's temp directory, the fixed one serves
the front end, its hashed assets, and the /api proxy.
This commit is contained in:
Daniel Noulet 2026-10-05 22:47:20 +02:00
parent 5449cd3d8d
commit 0a78c98cc6
19 changed files with 456 additions and 35 deletions

View file

@ -36,8 +36,12 @@ COPY --chown=app:app backend/migrations/ ./migrations/
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/entrypoint.sh /usr/local/bin/go-guess-entrypoint
# One user, and it owns everything the runtime writes to. A second USER line here
# silently replaced this one with a uid that owns neither /app nor /tmp/nginx, so
# nginx could not create its pid file or its temp paths and the container failed to
# start. nginx also ignores its own user directive when the master is not root, so
# its workers run as whoever this names.
USER app
USER 1000
EXPOSE 8080
ENV ADDRESS=:8081 \
APP_ENV=production \

View file

@ -212,6 +212,21 @@ The `Dockerfile` at the repository root still builds one combined image that
serves both halves on port `8080`. It is convenient for local runs and browser
BDD tests, while Kubernetes deploys the two images separately.
That combined image runs as the `app` user, which owns everything the runtime
writes to. There is deliberately only one `USER` line: a second one silently
replaced it with a uid owning neither `/app` nor `/tmp/nginx`, and nginx could
then not create its pid file or its temp paths, so the container failed to start.
nginx ignores its own `user` directive when the master is not root, so its
workers run as whoever this names.
`THEME_BASE_URL` points the page at the [go-bananas](https://github.com/urpi/go-bananas)
design-token service, which supplies the palette and the light/dark switch at run
time. It is optional and empty by default, so an image without a theme service
renders exactly as before. The entrypoint writes it into `runtime-config.js` on
every start, so one image serves every environment. See
[the front-end README](frontend/README.md#theming) for the deployment
requirements.
Pushes to `master` publish `go-guess:latest` and `go-guess:<commit-sha>` to the
private registry configured in `.github/workflows/publish-image.yml`. The
repository must provide `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` secrets.

View file

@ -46,6 +46,13 @@ spec:
value: {{ .Values.api.environment | quote }}
- name: FRONTEND_URL
value: {{ include "go-guess.frontendURL" . | quote }}
{{- with .Values.api.themeBaseUrl }}
# The theme service is read by the browser, not the API, so it is passed
# through to the entrypoint, which writes the runtime config the page
# reads. Empty means this deployment keeps its own styling.
- name: THEME_BASE_URL
value: {{ . | quote }}
{{- end }}
- name: TOKEN_TTL
value: {{ .Values.api.tokenTTL | quote }}
- name: MAX_UPLOAD_MB

View file

@ -32,6 +32,12 @@ api:
# Public base URL used when building invitation and meeting links. Defaults to
# https://<first tenant>.<domain> when empty.
frontendURL: ""
# Origin of the go-bananas runtime supplying the shared styling contract, for
# example https://themes.urpi.be. Empty keeps this application's own styles, so
# a deployment without a theme service is unaffected. The service must also be
# configured to allow this application's origin, or the browser will refuse to
# load the controls from it.
themeBaseUrl: ""
tokenTTL: 8h
maxUploadMB: 10
service:

View file

@ -37,6 +37,8 @@ services:
DATABASE_URL: postgres://go_guess:go_guess@db:5432/go_guess?sslmode=disable
JWT_SECRET: local-development-secret-change-me-12345
FRONTEND_URL: ${FRONTEND_URL:-https://nmbs.guess-dev.urpi.be}
# Where the go-bananas styling contract is served from. Empty disables it.
THEME_BASE_URL: ${THEME_BASE_URL:-}
APP_ENV: ${APP_ENV:-development}
GO_LOOSE_AUTH_DOMAIN: ${GO_LOOSE_AUTH_DOMAIN:-auth-dev.urpi.be}
GO_LOOSE_APP_DOMAIN: ${GO_LOOSE_APP_DOMAIN:-guess-dev.urpi.be}

View file

@ -13,6 +13,13 @@ seed
mkdir -p /tmp/nginx
# The theme service location is environment, not build time, so one image serves
# every environment. The file is regenerated on every start and is read by the
# page before the bundle runs.
cat > /app/frontend/runtime-config.js <<CONFIG
window.__GO_GUESS_CONFIG__ = { themeBaseUrl: "${THEME_BASE_URL:-}" }
CONFIG
api &
api_pid=$!
nginx -e /dev/stderr -g 'daemon off;' &

View file

@ -46,9 +46,54 @@ hostnames use same-origin `/api`; Vite retains its `/api` proxy for that case.
`src/components/actions/Account.tsx` treats any host whose first DNS label is a
tenant as a possible Go Loose host and asks `/api/auth/session` whether browser
login is available there. A `404` means the API has no Go Loose login for the
host, so the password form is shown; any other unauthenticated answer means Go
Loose is configured and the app redirects to `/api/auth/login`. The callback
exchanges that session for the bearer token used by the typed API client.
host, so the password form is shown. Otherwise the sign-in screen offers a single
button that hands the browser to Go Loose, which returns to the welcome page.
Nothing redirects to the identity provider on its own: that fired on every
anonymous visit and read as a redirect loop rather than as a choice to sign in.
A person who already signed in at another application passes straight through,
because Go Loose recognises the session it holds and issues a code without
asking anything. The callback exchanges that session for the bearer token used by
the typed API client. Participant routes are exempt: a participant opens the
application through a token link and is never signing in.
## Theming
The palette and the light/dark switch come from
[go-bananas](https://github.com/urpi/go-bananas), the design-token service, as
runtime CSS custom properties. Three pieces, and the application installs all
three itself:
- `src/theme/installThemeRuntime.ts` injects
`<link rel="stylesheet" href="$THEME_BASE_URL/rt/v1/contract.css" data-theme-contract>`
and imports `$THEME_BASE_URL/rt/v1/components.js`. Both are best effort: a
theme service that is absent, slow, or unreachable must not stop the
application working.
- `src/theme/ThemeControls.tsx` renders the service's own custom elements,
`<bananas-theme-selector>` and `<bananas-appearance-toggle>`. They are custom
elements rather than components written here because the applications this
service feeds are a mix of React, vanilla JavaScript, and server-rendered HTML,
and a custom element is the one shape all three can use with no build step, no
import map, and no second copy of React. React treats an unknown element as an
inert host node, so rendering them unconditionally is safe.
- Every token in `src/styles/global.css` is written as
`var(--bn-*, fallback)`. That ordering is the whole safety property: with no
theme service configured the page renders exactly as it did before, and with
one configured the same rules follow the palette.
`THEME_BASE_URL` is read from `runtime-config.js`, which the container entrypoint
rewrites from the environment on start, so one image serves every environment.
`VITE_THEME_BASE_URL` is the equivalent for `npm run dev`.
Two deployment requirements are easy to miss. The service must be configured to
allow this application's origin (`CORS_ALLOWED_ORIGINS` on go-bananas), or the
browser refuses to load the controls from it, because a module script is always
fetched with CORS. And `api.themeBaseUrl` in the Helm values must be set; it is
empty by default, which disables theming.
`src/theme/contract.test.ts` fetches the live contract and asserts it defines
every `--bn-*` variable this application consumes. A token renamed on the service
would otherwise leave every `var()` resolving to its fallback, and the page would
look correct while never actually following the palette.
Invitation and participant-meeting URLs use the validated request origin so
links stay on the tenant hostname. Copy actions fall back to a temporary selected
textarea when the Clipboard API is unavailable on local HTTP domains.

View file

@ -6,6 +6,9 @@
<meta name="theme-color" content="#081c2c" />
<meta name="description" content="Go Guess recruitment assessment workspace" />
<title>Go Guess · Recruitment workspace</title>
<!-- Read before the bundle runs: it says where the theme service is, if there
is one. Overwritten by the entrypoint in a container. -->
<script src="/runtime-config.js"></script>
</head>
<body>
<div id="root"></div>

View file

@ -0,0 +1,4 @@
/* Rewritten by the container entrypoint from THEME_BASE_URL on start, so one
* image serves every environment. The committed value disables theming, which is
* what a checkout with no theme service should do. */
window.__GO_GUESS_CONFIG__ = { themeBaseUrl: '' }

View file

@ -1,6 +1,7 @@
import { CalendarDays, Mail } from 'lucide-react'
import { NavLink } from 'react-router-dom'
import type { User } from '../../types'
import { ThemeControls } from '../../theme'
import { UserMenu } from './UserMenu'
export function WorkspaceUtilities({
@ -20,6 +21,7 @@ export function WorkspaceUtilities({
<CalendarDays size={18} aria-hidden="true" />
<span>Schedule</span>
</NavLink>
<ThemeControls />
<UserMenu user={user} onSignOut={onSignOut} />
</nav>
)

View file

@ -3,6 +3,7 @@ import { SyntheticEvent, useState } from 'react'
import Logo from '../../components/layout/Logo'
import { FormField } from '../../components/form'
import { ErrorAlert, errorMessage } from '../../components/actions'
import { ThemeControls } from '../../theme'
import { api, authStorage } from '../../api/client'
const LOGIN_PATH = '/api/auth/login'
@ -73,7 +74,10 @@ export function LoginPage({
</span>
</div>
</div>
<p className="login-foot">Designed for fair, collaborative recruitment.</p>
<div className="login-foot">
<ThemeControls />
<p>Designed for fair, collaborative recruitment.</p>
</div>
</section>
<section className="login-panel" aria-labelledby="login-title">
<div className="login-card">

View file

@ -1,13 +1,19 @@
import {StrictMode} from 'react'
import {createRoot} from 'react-dom/client'
import {BrowserRouter} from 'react-router-dom'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './app/App'
import { installThemeRuntime } from './theme/installThemeRuntime'
import './styles/global.css'
// Started before the first render so the contract stylesheet is in the document
// while the tree mounts. Nothing awaits it: a slow or absent theme service must
// not delay the application, and the page is correct either way.
void installThemeRuntime()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App/>
<App />
</BrowserRouter>
</StrictMode>,
)

View file

@ -1,24 +1,71 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@600;700;800&display=swap');
/* Design tokens.
*
* Every value here is `var(--bn-*, fallback)`. The contract stylesheet published
* by go-bananas defines the --bn-* half at run time from whichever palette and
* appearance are selected; the second argument is this application's own value,
* used whenever that stylesheet is absent.
*
* That ordering is what makes the feature safe to adopt: with no theme service
* configured the page renders exactly as it did before, and with one configured
* the same rules follow the palette. Nothing below this block needs to know which
* of the two it is getting. */
:root {
font-family: 'DM Sans', system-ui, sans-serif;
color: #142b3a;
background: #f3f7f8;
color: var(--ink);
background: var(--page);
font-synthesis: none;
text-rendering: optimizeLegibility;
--navy: #081c2c;
--navy-soft: #123449;
--ink: #142b3a;
--muted: #667986;
--line: #dce5e8;
--surface: #ffffff;
--cyan: #00a9c8;
--cyan-dark: #007f98;
--lime: #d9ef4d;
--orange: #ff7c52;
--radius: 16px;
--shadow: 0 12px 35px rgba(14, 42, 56, 0.08);
/* Surfaces, from the page up to the most raised. */
--page: var(--bn-bg, #f3f7f8);
--surface: var(--bn-surface, #ffffff);
--raised: var(--bn-surface-raised, #ffffff);
--sunken: var(--bn-surface-sunken, #eef3f4);
/* The navigation rail is a distinct plane rather than another surface, so it
* takes the sunken step: readable against the page in either appearance. */
--navy: var(--bn-surface-sunken, #081c2c);
--navy-soft: var(--bn-surface, #123449);
/* Text on the rail. It comes from the contract's own foreground pairing rather
* than being fixed to white, because a light appearance wants dark text there. */
--on-rail: var(--bn-on-secondary, #ffffff);
/* Text. */
--ink: var(--bn-text, #142b3a);
--muted: var(--bn-muted, #667986);
--subtle: var(--bn-subtle, #8a9aa4);
/* Lines. */
--line: var(--bn-border, #dce5e8);
--line-strong: var(--bn-border-strong, #c2d2d6);
/* Accents. */
--cyan: var(--bn-accent, #00a9c8);
--cyan-dark: var(--bn-accent-hover, #007f98);
--lime: var(--bn-secondary, #d9ef4d);
--orange: var(--bn-warning, #ff7c52);
--good: var(--bn-good, #13704d);
--danger: var(--bn-danger, #b3261e);
--focus: var(--bn-focus, #007f98);
/* Shape and depth. */
--radius: var(--bn-radius-lg, 16px);
--radius-sm: var(--bn-radius-md, 10px);
--shadow: var(--bn-shadow-md, 0 12px 35px rgba(14, 42, 56, 0.08));
}
/* Native widgets follow the selected appearance, so a scrollbar, a text
* selection, or a date picker in a form is not light-on-light in dark mode. The
* light default keeps a browser without a theme service exactly as it was. */
:root {
color-scheme: light;
}
:root[data-mode='dark'] {
color-scheme: dark;
}
.eligible-row {
grid-template-columns: auto 1fr;
}
@ -31,7 +78,7 @@
}
html {
min-width: 320px;
background: #f3f7f8;
background: var(--page);
}
body {
margin: 0;
@ -247,7 +294,7 @@ button {
}
.login-story {
background: var(--navy);
color: white;
color: var(--on-rail);
padding: 2.2rem clamp(2rem, 6vw, 6rem);
display: flex;
flex-direction: column;
@ -325,12 +372,12 @@ button {
display: grid;
place-items: center;
padding: 2rem;
background: #f3f7f8;
background: var(--page);
}
.login-card {
width: min(430px, 100%);
padding: clamp(1.8rem, 5vw, 3rem);
background: white;
background: var(--raised);
border-radius: 20px;
box-shadow: var(--shadow);
}
@ -364,7 +411,7 @@ button {
justify-content: center;
}
.form-error {
color: #a92e2e;
color: var(--danger);
}
.error-alert {
display: flex;
@ -411,7 +458,7 @@ button {
inset: 0 auto 0 0;
width: 255px;
background: var(--navy);
color: white;
color: var(--on-rail);
display: flex;
flex-direction: column;
padding: 1.6rem 1.1rem;
@ -674,8 +721,8 @@ button {
color: var(--cyan-dark);
}
.card {
background: white;
border: 1px solid #e1e9eb;
background: var(--raised);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: 0 6px 24px rgba(14, 42, 56, 0.04);
}
@ -2916,3 +2963,21 @@ fieldset {
padding: 0.3rem;
}
}
/* Theme controls.
The controls render into their own shadow root, so nothing here can reach
inside them and nothing inside can leak out. They inherit the custom
properties that cross the boundary, which is why they follow the palette
without a single rule of their own. */
.theme-controls {
display: flex;
align-items: center;
gap: 0.4rem;
}
.login-foot {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}

View file

@ -0,0 +1,31 @@
import { themeBaseUrl } from './installThemeRuntime'
/**
* The palette picker and the appearance toggle.
*
* Both are the custom elements go-bananas ships rather than components written
* here. That is the whole point of the service: the applications it feeds are a
* mix of React, vanilla JavaScript and server-rendered HTML, and a custom element
* is the one shape all three can use without a build step, an import map, or a
* second copy of React. React treats an unknown element as an inert host node, so
* rendering them unconditionally is safe: with no theme service configured they
* simply stay empty.
*
* Neither takes a `base` prop. They read the service origin from the contract
* stylesheet already on the page, which is the one place it is configured, so
* there is no second place to get wrong.
*
* Whether to render at all is a plain read rather than state: the runtime config
* is written before the first render and does not change afterwards, so there is
* nothing to track and no effect to wait on.
*/
export function ThemeControls() {
if (!themeBaseUrl()) return null
return (
<div className="theme-controls">
<bananas-appearance-toggle label="Appearance" />
<bananas-theme-selector label="Palette" />
</div>
)
}

View file

@ -0,0 +1,27 @@
import type { DetailedHTMLProps, HTMLAttributes } from 'react'
/**
* The custom elements go-bananas ships.
*
* React needs to be told about an unknown element before it will render one, and
* declaring them here is the whole integration: the tags, the attributes they
* accept, and nothing else. The behaviour lives in the service, so there is
* nothing to keep in step.
*
* `label` is read by both controls for their accessible name.
*/
type BananasControl = DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & {
/** Accessible name for the control. */
label?: string
}
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'bananas-theme-selector': BananasControl
'bananas-appearance-toggle': BananasControl
}
}
}
export {}

View file

@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
// The stylesheet is imported as text rather than read from disk: this keeps Node
// APIs out of a project whose tsconfig deliberately does not include them, so
// application code cannot reach for one by accident.
import stylesheet from '../styles/global.css?raw'
/**
* The contract is a remote document, so the way this application consumes it can
* rot quietly: a token renamed on the service leaves every `var(--bn-*, fallback)`
* resolving to its fallback, and the page looks fine while never actually
* following the palette. Nothing else would catch that.
*
* This reads the stylesheet, takes every contract variable the application asks
* for, and checks the real contract defines each one. It is skipped when no theme
* service is configured, so an ordinary checkout does not need one.
*/
const baseUrl = (
import.meta.env.THEME_BASE_URL ??
import.meta.env.VITE_THEME_BASE_URL ??
''
).replace(/\/+$/, '')
/** Every `--bn-*` name the stylesheet consumes. */
function contractVariablesUsed(): string[] {
const found = new Set<string>()
const pattern = /var\(\s*(--bn-[a-z0-9-]+)/g
let match: RegExpExecArray | null
while ((match = pattern.exec(stylesheet)) !== null) {
found.add(match[1]!)
}
return [...found].sort()
}
describe('the styling contract', () => {
const used = contractVariablesUsed()
it('consumes the contract rather than hardcoding a palette', () => {
expect(used.length).toBeGreaterThan(0)
})
it('gives every contract variable a fallback, so a missing service changes nothing', () => {
// Each use must carry a second argument. A bare var(--bn-x) with no fallback
// resolves to nothing at all when the service is absent, which is how a
// half-configured deployment ends up with invisible text.
const bare = [...stylesheet.matchAll(/var\(\s*(--bn-[a-z0-9-]+)\s*\)/g)].map((m) => m[1]!)
expect(bare).toEqual([])
})
it.runIf(baseUrl)('defines every variable this application asks for', async () => {
const response = await fetch(`${baseUrl}/rt/v1/contract.css`)
expect(response.status, `fetching the contract from ${baseUrl}`).toBe(200)
const contract = await response.text()
const defined = new Set<string>()
for (const match of contract.matchAll(/(--bn-[a-z0-9-]+)\s*:/g)) {
defined.add(match[1]!)
}
const missing = used.filter((name) => !defined.has(name))
expect(missing, `the contract at ${baseUrl} does not define these`).toEqual([])
})
})

View file

@ -0,0 +1,2 @@
export { ThemeControls } from './ThemeControls'
export { installThemeRuntime, themeBaseUrl } from './installThemeRuntime'

View file

@ -0,0 +1,66 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { installThemeRuntime, themeBaseUrl } from './installThemeRuntime'
const CONTRACT_LINK_MARKER = 'data-theme-contract'
function config(themeBaseUrl: string) {
window.__GO_GUESS_CONFIG__ = { themeBaseUrl }
}
afterEach(() => {
delete window.__GO_GUESS_CONFIG__
document.head.innerHTML = ''
vi.restoreAllMocks()
})
describe('themeBaseUrl', () => {
it('is empty when nothing is configured', () => {
expect(themeBaseUrl()).toBe('')
})
it('reads the runtime config the entrypoint writes', () => {
config('https://themes.example.com')
expect(themeBaseUrl()).toBe('https://themes.example.com')
})
it('tolerates a trailing slash, which would otherwise double up in the path', () => {
config('https://themes.example.com/')
expect(themeBaseUrl()).toBe('https://themes.example.com')
})
})
describe('installThemeRuntime', () => {
it('does nothing when no theme service is configured', async () => {
config('')
await installThemeRuntime()
expect(document.head.querySelector(`link[${CONTRACT_LINK_MARKER}]`)).toBeNull()
})
it('adds the contract stylesheet, marked so the controls can find the origin', async () => {
config('https://themes.example.com')
await installThemeRuntime()
const link = document.head.querySelector<HTMLLinkElement>(`link[${CONTRACT_LINK_MARKER}]`)
expect(link).not.toBeNull()
expect(link!.rel).toBe('stylesheet')
expect(link!.href).toBe('https://themes.example.com/rt/v1/contract.css')
})
it('adds the stylesheet only once, however often it is called', async () => {
config('https://themes.example.com')
await installThemeRuntime()
await installThemeRuntime()
await installThemeRuntime()
expect(document.head.querySelectorAll(`link[${CONTRACT_LINK_MARKER}]`)).toHaveLength(1)
})
it('resolves rather than throwing when the module cannot be fetched', async () => {
// A theme service that is down must not stop the application. The controls are
// custom elements, so failing to load leaves inert elements and the page keeps
// the values baked into its own stylesheet.
config('https://themes.example.com')
await expect(installThemeRuntime()).resolves.toBeUndefined()
// The stylesheet is still requested, so the palette follows whatever
// selection is already stored even with no picker to change it.
expect(document.head.querySelector(`link[${CONTRACT_LINK_MARKER}]`)).not.toBeNull()
})
})

View file

@ -0,0 +1,63 @@
/**
* Where the theme service lives, and whether it answered.
*
* The URL is read from the runtime config rather than baked into the bundle so
* one image serves every environment: the entrypoint rewrites runtime-config.js
* from the environment on start. A build-time variable is only the convenience
* for `npm run dev`, where there is no entrypoint to do it.
*/
export interface RuntimeConfig {
/** Origin of the go-bananas runtime, or empty when theming is not configured. */
themeBaseUrl?: string
}
declare global {
interface Window {
__GO_GUESS_CONFIG__?: RuntimeConfig
}
}
/** The marker the shipped controls read the base URL from. */
const CONTRACT_LINK_MARKER = 'data-theme-contract'
export function themeBaseUrl(): string {
const configured = window.__GO_GUESS_CONFIG__?.themeBaseUrl
if (configured) return configured.replace(/\/+$/, '')
const built = import.meta.env.VITE_THEME_BASE_URL
return built ? built.replace(/\/+$/, '') : ''
}
/** Whether the contract stylesheet is already on the page. */
function contractLinked(): boolean {
return document.querySelector(`link[${CONTRACT_LINK_MARKER}]`) !== null
}
/**
* Loads the shared styling contract and the two controls, once.
*
* The stylesheet goes in first and synchronously-ish, before the controls, so the
* palette is applied before anything paints rather than flashing the application's
* own colours and then swapping them.
*
* Everything here is best effort. A theme service that is unreachable, slow, or
* not configured must not stop the application from working, so every failure
* resolves quietly and the page keeps the values baked into the stylesheet.
*/
export async function installThemeRuntime(): Promise<void> {
const base = themeBaseUrl()
if (!base || contractLinked()) return
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = `${base}/rt/v1/contract.css`
link.setAttribute(CONTRACT_LINK_MARKER, '')
document.head.append(link)
try {
await import(/* @vite-ignore */ `${base}/rt/v1/components.js`)
} catch {
// The controls are custom elements, so a failure here leaves inert elements
// in the page rather than an error. The contract stylesheet still applies, so
// the palette follows the stored selection even without the picker.
}
}