Skip to content

fix(frontend): derive primary/ring shades so branding overrides propagate - #169

Merged
jbouder merged 2 commits into
mainfrom
fix/branding-derive-primary-shades
Aug 17, 2026
Merged

fix(frontend): derive primary/ring shades so branding overrides propagate#169
jbouder merged 2 commits into
mainfrom
fix/branding-derive-primary-shades

Conversation

@jbouder

@jbouder jbouder commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

The defect

frontend.branding.theme.{light,dark} (chart → /config.jsonapplyAppConfig() in frontend/src/app/config.ts) lets a deployer override a documented set of theme tokens at runtime, including primary, primaryForeground and ring.

frontend/src/index.css also hardcoded four tokens to the Nebari magenta (oklch hue ~311), none of which were reachable from primary/ring:

token light dark used by
--primary-hover L37 L129 hover:bg-primary-hover / active:bg-primary-hover in components/ui/button.tsx and components/ui/badge.tsx
--sidebar-primary L75 L166 --color-sidebar-primary
--sidebar-primary-foreground L76 L167 --color-sidebar-primary-foreground
--sidebar-ring L79 L170 --color-sidebar-ring

User-visible symptom

A deployment rebranded to a non-purple primary renders correctly at rest and then flashes the old Nebari magenta on every button and badge hover/press. Confirmed on a live cluster. The sidebar tokens are the same defect but currently latent — nothing in this app renders a sidebar yet.

The fix

1. Derive instead of hardcode (frontend/src/index.css), mirroring what the sibling nebari-landing already does for --sidebar-primary:

--sidebar-primary: var(--primary);
--sidebar-primary-foreground: var(--primary-foreground);
--sidebar-ring: var(--ring);

--primary-hover can't be a plain alias — it is a darker shade in light mode and a lighter one in dark — so it becomes a mix against black/white, with percentages chosen to reproduce the previous literals:

:root  { --primary-hover: color-mix(in oklab, var(--primary), black 15%); }
.dark  { --primary-hover: color-mix(in oklab, var(--primary), white 18%); }

in oklab rather than in oklch: black and white are achromatic, so their hue is powerless, and mixing in a rectangular space means the result never depends on how that is handled. For these anchors the two spaces are mathematically identical anyway when powerless hue is carried forward.

2. Make the contract honest. primaryHover, sidebarPrimary, sidebarPrimaryForeground and sidebarRing are added to the ThemeTokens type, the values.yaml comment and the Configuration docs, so an explicit override of a derived shade stays supported and documented.

The docs and values.yaml also now state that the token list is the supported contract, not a runtime filter: ThemeTokens is a compile-time type, toCssVars() iterates Object.entries(tokens) with no allow-list, and the chart passes the map through toJson with no values.schema.json. Any camelCase key therefore lands as its --kebab-case custom property today. Behaviour is unchanged — filtering would break deployers who are already relying on that — but it is now written down as unsupported rather than silently depended on.

3. Regression guard. frontend/src/index.css.test.ts asserts the four tokens reference var(--primary) / var(--primary-foreground) / var(--ring) in both :root and .dark. The file header tells maintainers to re-pull the theme with shadcn add @nebari/theme, which would restore the literals; this makes that visible in CI. Verified the test fails when a literal is put back.

No version bump — per the CI/Releasing docs, bumping Chart.yaml version is what cuts a release.

Verification

Default appearance does not regress. Rendered sRGB of the new derivations against the old literals:

old literal new derived Δ
light --primary-hover oklch(47.01% 0.1577 311.26)#77399a oklch(47.02% 0.1603 311.45)#77379a 2/255 on one channel, ΔEoklab 0.003
dark --primary-hover oklch(69.98% 0.1926 311.48)#c575f4 oklch(68.82% 0.1770 311.67)#bf76e9 ΔEoklab 0.019
--sidebar-primary, --sidebar-primary-foreground, --sidebar-ring exact, the literals were already byte-identical to --primary / --primary-foreground / --ring

Light is visually identical. Dark is a hair darker and less saturated — 18% is the ΔE-optimal point, and the closest a mix toward white can get, since white contributes no chroma. On a transient hover fill this reads the same; happy to pin the dark literal instead if a maintainer wants it byte-exact.

Build output. Lightning CSS (via Tailwind v4) emits a graceful fallback on its own, so no @supports guard is needed here:

:root{--primary-hover:var(--primary)}
@supports (color:color-mix(in lab, red, red)){:root{--primary-hover:color-mix(in oklab, var(--primary), black 15%)}}

i.e. browsers without color-mix() fall back to the un-shaded primary rather than to an invalid value.

Commands run (all from a clean clone, all pass):

command result
frontend/ npm ci ok, 0 vulnerabilities
frontend/ npm run build (tsc -b && vite build) ok — typecheck + build
frontend/ npm test 9 files, 54 tests passed (was 8/46)
frontend/ npm run check (Biome, the lint-frontend CI job) exit 0 — 2 pre-existing infos about the biome.json schema version
helm lint charts/nebari-llm-serving/ 0 charts failed
helm template … --set-json 'frontend.branding.theme=…' /config.json renders primaryHover through correctly
docs/ npm ci && npm test 2 files, 25 tests passed
docs/ npm run build 11 pages built
SKIP_BUILD=1 bash scripts/check-links.sh LINKS_OK

Go tests for operator/ and key-manager/ were not run — no Go files touched.

Related

nebari-dev/provenance-collector-pack has the identical defect — the two index.css files appear to share an origin — and is getting a parallel PR.

…gate

`frontend.branding.theme.{light,dark}` lets a deployer override --primary,
--primary-foreground and --ring at runtime via /config.json, but index.css
also hardcoded four tokens to the Nebari magenta with no path back to
--primary: --primary-hover, --sidebar-primary, --sidebar-primary-foreground
and --sidebar-ring.

--primary-hover backs `hover:bg-primary-hover` / `active:bg-primary-hover` on
Button and Badge, so a deployment rebranded to a non-purple primary renders
correctly at rest and then flashes Nebari magenta on every button hover.
Observed on a live cluster.

Derive the four from the tokens branding can actually reach, mirroring what
nebari-landing already does for --sidebar-primary. --primary-hover is not a
plain alias (darker in light mode, lighter in dark) so it becomes a
color-mix() against black/white; the mix percentages were chosen to reproduce
the previous literals. oklab rather than oklch is used for the mix so the
result never depends on how an achromatic anchor's powerless hue is handled.

Also make the documented contract honest: add primaryHover, sidebarPrimary,
sidebarPrimaryForeground and sidebarRing to ThemeTokens, values.yaml and the
Configuration docs, and note that ThemeTokens is a compile-time type rather
than a runtime allow-list - toCssVars() applies whatever keys it is handed, so
unlisted keys currently pass through as --kebab-case custom properties.
Behaviour is unchanged there; it is now documented as unsupported instead of
silently relied on.

Adds a test over index.css so re-pulling the theme (`shadcn add @nebari/theme`)
cannot quietly restore the literals.
@github-actions

Copy link
Copy Markdown

📄 Docs preview for fix/branding-derive-primary-shades:
https://fix-branding-derive-primary.llm-serving-pack.pages.dev

Aligns with nebari-dev/provenance-collector-pack#81, which lands the same
derivation against a byte-identical index.css. 18% is also the ΔE-optimal
point against the previous literal oklch(69.98% 0.1926 311.48): 0.019 oklab
ΔE at 18% vs 0.020 at 20%. The two packs should not diverge on this constant.
@jbouder

jbouder commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 5bce9d3: the dark --primary-hover mix goes from white 20% to white 18%, to stay in step with nebari-dev/provenance-collector-pack#81, which lands the same derivation against a byte-identical index.css. Light is unchanged at black 15% — both packs already agreed there.

18% is also the better constant on its own merits. Sweeping N against the previous literal oklch(69.98% 0.1926 311.48):

N result ΔEoklab
16% #bd72e8 0.0222
18% #bf76e9 0.0194
20% #c079ea 0.0203
21% #c17bea 0.0220

18% is the minimum. I had rounded to 20% for tidiness, which traded away accuracy for nothing — 21% is the point that matches the old lightness exactly, and 18% is the point that minimises total error, so 20% was neither. The PR body table above is updated to match.

Gates re-run on the new commit: npm run build (tsc -b && vite build) ok, npm test 9 files / 54 tests pass (including the index.css.test.ts guard), npm run check (Biome) exit 0. Built CSS confirms --primary-hover:color-mix(in oklab, var(--primary), white 18%) with the Lightning CSS @supports fallback intact.

@jbouder
jbouder merged commit c054c56 into main Aug 17, 2026
12 checks passed
@jbouder
jbouder deleted the fix/branding-derive-primary-shades branch August 17, 2026 13:16
jbouder added a commit to nebari-dev/apps-pack that referenced this pull request Aug 17, 2026
* fix(ui): derive primary/ring shades so branding overrides propagate

--primary-hover, --sidebar-primary, --sidebar-primary-foreground and
--sidebar-ring were pinned to the magenta primitives, which a runtime
ui.branding.theme override of `primary` / `primaryForeground` / `ring`
cannot reach. A deployment rebranded to a non-magenta primary therefore
renders correctly at rest and flashes Nebari magenta on every button,
badge, switch, slider, checkbox and radio hover or press.

The three sidebar tokens were byte-identical to --primary /
--primary-foreground / --ring, so they become plain aliases.
--primary-hover is a darker shade in light mode and a lighter one in
dark, so it becomes a color-mix against black/white, in oklab (a
rectangular space, so mixing toward an achromatic anchor cannot drift
the hue). 15% / 18% reproduce the previous literals to 0.003 / 0.019
oklab dE.

primaryHover, sidebarPrimary, sidebarPrimaryForeground and sidebarRing
are added to ThemeTokens, values.yaml and the Branding docs, which also
now record that the token list is the supported contract rather than a
runtime filter - toCssVars() applies any key it is given.

Matches nebari-dev/llm-serving-pack#169,
nebari-dev/provenance-collector-pack#81 and nebari-dev/nebari-landing#200.

* docs: tidy the derived-token wording
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants