Skip to content

Branding your login screens

Rakomi’s hosted sign-in screens are painted from a palette each tenant sets in their dashboard. If you build your own screens against our API, GET /v1/auth/config returns that palette so your screens can match — including a separate dark palette when the tenant has one.

This guide is about reading that response correctly. Most of it is one field.

theme_mode is a capability, not a rendering instruction

Section titled “theme_mode is a capability, not a rendering instruction”
{
"branding": {
"theme_mode": "both",
"background_color": "#ecfdf5",
"text_color": "#111827",
"primary_color": "#047857",
"dark": {
"background_color": "#022c22",
"text_color": "#ecfdf5",
"primary_color": "#34d399"
}
}
}

theme_mode declares which palettes this tenant supports. It is not a request to render a particular one.

These are CSS color-scheme semantics, not prefers-color-scheme — and the difference is not cosmetic, because prefers-color-scheme has no both:

valuemeaning
bothThe tenant supports both palettes. Follow the end user’s OS preference.
lightLight only. Force it — ignore the user’s preference.
darkDark only. Force it — ignore the user’s preference.

light and dark mean force, not prefer. A tenant who selects one is telling you their brand only works in that scheme; rendering the other because the user’s OS asked for it produces a screen they have never seen and cannot fix.

An unrecognised value must fall back to light

Section titled “An unrecognised value must fall back to light”
const mode = ["light", "dark", "both"].includes(branding.theme_mode)
? branding.theme_mode
: "light";

The set is published, so it may gain members. A client that meets a value it does not know must fall back to light and keep working — it must not reject the response, throw, or render nothing. An installed client that hard-fails on an unknown enum member turns an additive change on our side into an outage on yours.

The flat fields — background_color, text_color, primary_color, button_color, button_text_color, heading_coloralways carry the LIGHT palette, whatever theme_mode says. There is no light object; the flat fields are it.

So for a dark-only tenant who has set light values you still receive flat fields, and you still use the dark object to paint. The flat fields are not a fallback for dark — they are the light palette, which that tenant simply does not display. ⚠ The flat colour fields are emitted only when a light value resolves: a tenant who populated only the dark columns receives a branding object with no flat colours at all, so check each field for presence rather than assuming the flat set is always complete.

The dark object appears only when there is something to paint. Its presence is the signal:

const palette = scheme === "dark" && branding.dark ? branding.dark : branding;

Do not test for an empty object, and do not assume theme_mode: "dark" guarantees a dark object — check the object itself.

Two other things follow from this:

  • branding absent entirely means the tenant has no branding at all. That is different from theme_mode: "light", which means they deliberately chose light-only. theme_mode is emitted even when it is light, precisely so you can tell those two apart.
  • A field may be missing inside dark. Treat an absent field as “not provided for this scheme” and derive your own value from the dark fields you did receive. Do not reach back into the flat fields for it — those are the light palette, and a light value dropped onto a dark ground is the one substitution most likely to be unreadable.

border_radius and tenant_name are theme-independent. They appear once, at the top level, and apply to both schemes.

primary_color and button_color are deliberately distinct, in both palettes:

  • primary_color — the brand colour on the background: link text, accents, the focus ring.
  • button_color — the brand colour as a fill: the primary button’s plate.

The same colour can be perfectly readable as a large filled surface and unreadable as small link text on a light background. Collapsing the two is the most common mistake when reading this response, and it produces screens that pass a glance and fail a contrast check.

button_text_color is the text on that fill. It is present whenever a fill is, whether or not the tenant chose it explicitly — so use it directly rather than picking your own ink for the button.

logo_url appears at the top level and, when the tenant has a second logo, inside dark. When only one logo is uploaded, both URLs are identical — the same image under the same cache key, which is what you want. Render whichever URL belongs to the scheme you are painting and let caching do the rest.

async function loadBranding(apiKey) {
const res = await fetch("https://api.rakomi.com/v1/auth/config", {
headers: { "X-API-Key": apiKey }, // akm_live_xxx — tenant-level, like every other call
});
if (!res.ok) {
// Do NOT fall through to "no branding": a 401/403/429 is an error you must surface, not a tenant
// with a plain login screen. Rendering unbranded screens on an auth failure hides the failure.
throw new Error(`/v1/auth/config failed: ${res.status}`);
}
const { branding } = await res.json();
if (!branding) return null; // genuinely no branding — use your own defaults
const declared = ["light", "dark", "both"].includes(branding.theme_mode)
? branding.theme_mode
: "light";
// `both` follows the user; a single declared mode forces itself.
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const scheme = declared === "both" ? (prefersDark ? "dark" : "light") : declared;
// Paint dark ONLY when there is a dark palette to paint. `dark` is absent when the tenant has nothing
// dark to show — and the flat fields are the LIGHT palette, so painting them under a forced dark
// color-scheme is the one substitution this guide tells you not to make.
const paintDark = scheme === "dark" && Boolean(branding.dark);
const palette = paintDark ? branding.dark : branding;
const colorScheme = paintDark ? "dark" : "light";
return { colorScheme, palette, radius: branding.border_radius, name: branding.tenant_name };
}

Apply colorScheme to your document as well as to your colours — setting document.documentElement.style.colorScheme = colorScheme makes native controls, scrollbars and form widgets match the palette you actually painted.

Use each colour in the role its name describes

Section titled “Use each colour in the role its name describes”

The response gives you a colour per role, and the roles are not interchangeable: text_color is body text on background_color, button_text_color is the label on button_color, primary_color is link text and accents on background_color, heading_color is headings on background_color.

Pairing them any other way — link colour on a button, heading colour on a fill — produces combinations that were never intended to sit together. If your layout needs a pair the response does not name, check that pair yourself against WCAG 2.2 SC 1.4.3 before shipping it.

  • GET /v1/auth/config — the full response schema, including every field’s description.
  • Localization — the sibling response fields that decide language.