-
Notifications
You must be signed in to change notification settings - Fork 42
feat: add app.branding config for portal rebranding #708
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
kaviththiranga
wants to merge
3
commits into
openchoreo:main
Choose a base branch
from
kaviththiranga:feat/app-branding-config
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| --- | ||
| '@openchoreo/backstage-design-system': minor | ||
| --- | ||
|
|
||
| Add app-config-driven branding groundwork: `resolveBrandTokens(base, brand?)` | ||
| pure helper (brand primary → derived token slots; identity when no overrides) | ||
| and `ChoreoTokensProvider` with a context-aware `useChoreoTokens`. The portal | ||
| app gains an `app.branding.*` frontend-visible config schema (name, iconLogo, | ||
| fullLogo, theme.light/dark.primaryColor) wired into the theme providers, | ||
| sidebar logos, and sign-in card. Default behavior with no branding config is | ||
| unchanged. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import { ReactNode, createContext } from 'react'; | ||
| import { ThemeTokens } from './tokens'; | ||
|
|
||
| /** | ||
| * Carries the ACTIVE (possibly brand-overridden) token set down to components | ||
| * that read tokens via `useChoreoTokens`. Without a provider the hook falls | ||
| * back to the stock `lightTokens`/`darkTokens` singletons, so wrapping is only | ||
| * required when tokens diverge from the defaults (e.g. `app.branding.*`). | ||
| */ | ||
| export const ChoreoTokensContext = createContext<ThemeTokens | undefined>( | ||
| undefined, | ||
| ); | ||
|
|
||
| export function ChoreoTokensProvider({ | ||
| tokens, | ||
| children, | ||
| }: { | ||
| tokens: ThemeTokens; | ||
| children?: ReactNode; | ||
| }) { | ||
| return ( | ||
| <ChoreoTokensContext.Provider value={tokens}> | ||
| {children} | ||
| </ChoreoTokensContext.Provider> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,178 @@ | ||
| import { resolveBrandTokens } from './brand'; | ||
| import { darkTokens, lightTokens } from './tokens'; | ||
|
|
||
| describe('resolveBrandTokens', () => { | ||
| // Several branded paths legitimately warn (validation, contrast) — spy | ||
| // globally so tests stay quiet and can assert on calls where relevant. | ||
| let warn: jest.SpyInstance; | ||
|
|
||
| beforeEach(() => { | ||
| warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| warn.mockRestore(); | ||
| }); | ||
|
|
||
| it('returns the base object by reference when no overrides are given', () => { | ||
| // This identity IS the pixel-identical default: callers reuse the | ||
| // prebuilt theme singletons when `===` holds. | ||
| expect(resolveBrandTokens(lightTokens)).toBe(lightTokens); | ||
| expect(resolveBrandTokens(lightTokens, {})).toBe(lightTokens); | ||
| expect(resolveBrandTokens(darkTokens, { primary: undefined })).toBe( | ||
| darkTokens, | ||
| ); | ||
| }); | ||
|
|
||
| it('ignores unparseable colors instead of throwing (render safety)', () => { | ||
| // resolveBrandTokens runs inside the theme provider's render with no | ||
| // error boundary above it — a config typo must never crash the app. | ||
| // Named color, #-less hex: decomposeColor rejects these. | ||
| expect(resolveBrandTokens(lightTokens, { primary: { main: 'teal' } })).toBe( | ||
| lightTokens, | ||
| ); | ||
| expect( | ||
| resolveBrandTokens(lightTokens, { primary: { main: '0d9488' } }), | ||
| ).toBe(lightTokens); | ||
| // CSS4 space-separated syntax: mis-parses to NaN channels. | ||
| expect( | ||
| resolveBrandTokens(darkTokens, { | ||
| primary: { main: 'rgb(13 148 136)' }, | ||
| }), | ||
| ).toBe(darkTokens); | ||
| // Valid main but invalid explicit light: whole override ignored. | ||
| expect( | ||
| resolveBrandTokens(lightTokens, { | ||
| primary: { main: '#0d9488', light: 'bogus' }, | ||
| }), | ||
| ).toBe(lightTokens); | ||
| expect(warn).toHaveBeenCalledTimes(4); | ||
| }); | ||
|
|
||
| it('rejects translucent colors (MUI contrast math ignores alpha)', () => { | ||
| // A translucent accent would composite against arbitrary surfaces at | ||
| // render time while getContrastRatio treats it as opaque — reject it | ||
| // like an unparseable value rather than warn on a fictional ratio. | ||
| expect( | ||
| resolveBrandTokens(lightTokens, { | ||
| primary: { main: 'rgba(13, 148, 136, 0.4)' }, | ||
| }), | ||
| ).toBe(lightTokens); | ||
| expect( | ||
| resolveBrandTokens(darkTokens, { | ||
| primary: { main: 'hsla(174, 84%, 32%, 0.5)' }, | ||
| }), | ||
| ).toBe(darkTokens); | ||
| expect(warn).toHaveBeenCalledTimes(2); | ||
| expect(warn.mock.calls[0][0]).toContain('opaque'); | ||
| }); | ||
|
|
||
| it('warns when the accent misses WCAG AA contrast but still applies it', () => { | ||
| // #0d9488 is ~3.7:1 on white — a popular teal that fails AA for text — | ||
| // and its lightened header stop also misses 3:1 under white text. The | ||
| // override is honored (the operator owns the palette); the warnings are | ||
| // the accessibility signal. | ||
| const resolved = resolveBrandTokens(lightTokens, { | ||
| primary: { main: '#0d9488' }, | ||
| }); | ||
| expect(resolved.primary.main).toBe('#0d9488'); | ||
| expect(warn).toHaveBeenCalledTimes(2); | ||
| expect(warn.mock.calls[0][0]).toContain('4.5:1'); | ||
| expect(warn.mock.calls[1][0]).toContain('header gradient'); | ||
| }); | ||
|
|
||
| it('warns when only the header gradient stop misses AA large-text contrast', () => { | ||
| // #767676 passes 4.5:1 as solid text on white, but lighten(main, 0.25) — | ||
| // the light-mode header's fade end under white header text — is ~2.9:1. | ||
| const resolved = resolveBrandTokens(lightTokens, { | ||
| primary: { main: '#767676' }, | ||
| }); | ||
| expect(resolved.primary.main).toBe('#767676'); | ||
| expect(warn).toHaveBeenCalledTimes(1); | ||
| expect(warn.mock.calls[0][0]).toContain('header gradient'); | ||
| }); | ||
|
|
||
| it('does not warn for AA-compliant accents in either mode', () => { | ||
| resolveBrandTokens(lightTokens, { primary: { main: '#0f766e' } }); | ||
| // Opaque rgb()/rgba() forms are accepted, including explicit alpha 1. | ||
| resolveBrandTokens(lightTokens, { primary: { main: 'rgb(15, 118, 110)' } }); | ||
| resolveBrandTokens(lightTokens, { | ||
| primary: { main: 'rgba(15, 118, 110, 1)' }, | ||
| }); | ||
| resolveBrandTokens(darkTokens, { primary: { main: '#2dd4bf' } }); | ||
| expect(warn).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('derives the primary scale from main when light/dark are omitted', () => { | ||
| const resolved = resolveBrandTokens(lightTokens, { | ||
| primary: { main: '#0d9488' }, | ||
| }); | ||
| expect(resolved.primary.main).toBe('#0d9488'); | ||
| expect(resolved.primary.light).not.toBe(lightTokens.primary.light); | ||
| expect(resolved.primary.dark).not.toBe(lightTokens.primary.dark); | ||
| // MUI lighten/darken emit rgb() strings. | ||
| expect(resolved.primary.light).toMatch(/^rgb/); | ||
| expect(resolved.primary.dark).toMatch(/^rgb/); | ||
| }); | ||
|
|
||
| it('respects explicit light/dark overrides', () => { | ||
| const resolved = resolveBrandTokens(lightTokens, { | ||
| primary: { main: '#0d9488', light: '#ccfbf1', dark: '#0f766e' }, | ||
| }); | ||
| expect(resolved.primary).toEqual({ | ||
| main: '#0d9488', | ||
| light: '#ccfbf1', | ||
| dark: '#0f766e', | ||
| }); | ||
| }); | ||
|
|
||
| it('recomputes the brand-accent slots in light mode', () => { | ||
| const resolved = resolveBrandTokens(lightTokens, { | ||
| primary: { main: '#0d9488', dark: '#0f766e' }, | ||
| }); | ||
| expect(resolved.text.link).toBe('#0d9488'); | ||
| expect(resolved.text.linkHover).toBe('#0f766e'); | ||
| expect(resolved.navigation.indicator).toBe('#0d9488'); | ||
| expect(resolved.navigation.selectedColor).toBe('#0d9488'); | ||
| expect(resolved.status.info).toBe('#0d9488'); | ||
| expect(resolved.status.running).toBe('#0d9488'); | ||
| expect(resolved.banner.info).toBe('#0d9488'); | ||
| expect(resolved.banner.link).toBe('#0d9488'); | ||
| expect(resolved.graph.edge).toBe('#0d9488'); | ||
| expect(resolved.graph.minimapViewportTint).toContain('0.1'); | ||
| expect(resolved.gradient.header).toContain('#0d9488'); | ||
| expect(resolved.gradient.cardHeader).toContain('#0d9488'); | ||
| expect(resolved.gradient.burst).toContain('#0d9488'); | ||
| expect(resolved.bursts.gradient).toContain('#0d9488'); | ||
| }); | ||
|
|
||
| it('maps light-role slots to the light scale in dark mode', () => { | ||
| const resolved = resolveBrandTokens(darkTokens, { | ||
| primary: { main: '#2dd4bf', light: '#99f6e4' }, | ||
| }); | ||
| expect(resolved.text.link).toBe('#2dd4bf'); | ||
| expect(resolved.text.linkHover).toBe('#99f6e4'); | ||
| expect(resolved.navigation.selectedColor).toBe('#99f6e4'); | ||
| expect(resolved.banner.link).toBe('#99f6e4'); | ||
| }); | ||
|
|
||
| it('keeps the navy gradients in dark mode', () => { | ||
| const resolved = resolveBrandTokens(darkTokens, { | ||
| primary: { main: '#2dd4bf' }, | ||
| }); | ||
| expect(resolved.gradient).toBe(darkTokens.gradient); | ||
| expect(resolved.bursts).toBe(darkTokens.bursts); | ||
| }); | ||
|
|
||
| it('does not touch non-brand slots or mutate the base', () => { | ||
| const before = JSON.stringify(lightTokens); | ||
| const resolved = resolveBrandTokens(lightTokens, { | ||
| primary: { main: '#0d9488' }, | ||
| }); | ||
| expect(resolved.secondary).toBe(lightTokens.secondary); | ||
| expect(resolved.indigo).toBe(lightTokens.indigo); | ||
| expect(resolved.entityKind).toBe(lightTokens.entityKind); | ||
| expect(resolved.statusBackground).toBe(lightTokens.statusBackground); | ||
| expect(JSON.stringify(lightTokens)).toBe(before); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.