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:
parent
5449cd3d8d
commit
0a78c98cc6
19 changed files with 456 additions and 35 deletions
|
|
@ -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 \
|
||||
|
|
|
|||
15
README.md
15
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;' &
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
4
frontend/public/runtime-config.js
Normal file
4
frontend/public/runtime-config.js
Normal 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: '' }
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -2,8 +2,14 @@ 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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
31
frontend/src/theme/ThemeControls.tsx
Normal file
31
frontend/src/theme/ThemeControls.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
frontend/src/theme/bananas-elements.d.ts
vendored
Normal file
27
frontend/src/theme/bananas-elements.d.ts
vendored
Normal 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 {}
|
||||
62
frontend/src/theme/contract.test.ts
Normal file
62
frontend/src/theme/contract.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
2
frontend/src/theme/index.ts
Normal file
2
frontend/src/theme/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { ThemeControls } from './ThemeControls'
|
||||
export { installThemeRuntime, themeBaseUrl } from './installThemeRuntime'
|
||||
66
frontend/src/theme/installThemeRuntime.test.ts
Normal file
66
frontend/src/theme/installThemeRuntime.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
63
frontend/src/theme/installThemeRuntime.ts
Normal file
63
frontend/src/theme/installThemeRuntime.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue