WebProCMS' branding system gives every install three named color families — primary, secondary, and tone (gray) — and turns each one into a full 19-shade palette. Rows on the public site reference brand-aware utilities (bg-primary, text-tone-700, bg-primary-50) that update site-wide the moment the brand color changes.
The problem
A typical site has dozens of color decisions baked into row templates: button fills, hover states, callout backgrounds, badge tints, link colors, headline text, muted body copy, borders, dividers. If those are written as raw Tailwind colors (bg-blue-600, text-zinc-400), changing the brand means hand-editing every row file. If they're written as a single --brand variable, the design loses tonal range — every "subtle" surface ends up the same heavy color as every CTA.
The middle ground — handing editors a Tailwind picker with thousands of color/shade combinations — also fails. Most edits come out off-brand because the editor has to manually colour-match a callout background to the family they picked for the button, and pick a readable foreground each time.
The fix
WebProCMS replaces brand color with a system of three families, each generated into a full 19-shade palette. Row templates reference semantic utilities like bg-primary and text-primary-100 — never raw color names. Changing the family color regenerates the entire palette and propagates through every row instantly.
Editors pick a base color per family (hex, OKLCH, or a matched Tailwind color name) from the Colors settings page, plus a "Smart Picker" that generates coordinated primary/secondary colors from a vibrancy + distribution preset.
The three families
| Family | Default | Use |
|---|---|---|
primary |
oklch(0.441 0.129 272) (purple) |
Brand color. CTAs, highlights, icons, links. |
secondary |
oklch(0.725 0.132 124) (green) |
Supporting accent. Optional — can be toggled off in settings. |
tone |
oklch(0.37 0.012 285.8) (zinc) |
Neutral gray scale. Body text, borders, muted backgrounds. |
There is no brand accent family — it was removed; use secondary, or the per-section-preset text-row-accent / bg-row-accent utilities.
tone is special: instead of being generated from OKLCH math, it aliases one of five built-in Tailwind gray scales (slate, gray, zinc, neutral, stone) for the 11 standard stops, with computed half-steps filled in. Editors pick the gray feel of the site once and every text-tone-* / border-tone-* class on the site follows.
The 19-shade palette
Each color family generates a 19-shade palette. The standard 11 Tailwind stops (50/100/200/.../900/950) are joined by 8 half-steps (150/250/350/450/550/650/750/850) for finer tonal control:
50, 100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800, 850, 900, 950
Generation lives in BrandingStyleService::generateShadeOklch(). The base color's OKLCH lightness is matched to the closest shade in a fixed lightness/chroma curve (e.g. a base color at L=0.441 anchors at shade 700), and every other shade is computed by holding the hue constant and stepping lightness + chroma along that curve. The result is that the editor's chosen color always appears as one of the 19 shades — and the rest of the palette flows from it tonally rather than being arbitrarily generated.
Tone uses toneScaleShades() instead — the 11 standard stops come from Tailwind's built-in scale and the 8 half-steps are computed via OKLCH interpolation between adjacent stops.
The full palette is emitted as CSS custom properties at :root:
--color-primary: oklch(0.441 0.129 272);
--color-primary-50: oklch(0.972 0.012 272);
--color-primary-100: oklch(0.942 0.022 272);
...
--color-primary-950: oklch(0.282 0.072 272);
Tailwind v4 picks these up as utilities automatically (bg-primary-550, text-tone-150, border-primary-400, etc.). The shade-picker UI exposes only the 11 standard stops to keep the dropdown manageable; half-steps are available when hand-written as literal classes in row templates.
No dedicated foreground/surface variables
There is no --color-{family}-foreground or --color-{family}-surface CSS variable for the brand families — that system was removed. Row templates use shade stops directly instead:
| For | Use |
|---|---|
| Text/icon color placed on top of a solid fill (e.g. inside a primary button) | text-primary-100 |
| Subtle tinted background (callouts, badges) | bg-primary-50 (or bg-primary-50 dark:bg-primary-950 for a dark-aware tint) |
A row template that says <button class="bg-primary text-primary-100"> always renders a readable button, regardless of which color the editor picks for primary. A callout that says <div class="bg-primary-50 text-primary"> always renders a tinted highlight that's brand-colored without being loud.
(Flux UI's dashboard-only --color-accent-foreground / --color-accent-surface tokens are a separate, unrelated system that still exists for Flux's internal focus-ring/switch styling — it isn't part of the brand color families described here.)
Picking colors — the Colors settings page
Editors set each family's base color directly (hex, OKLCH, or by matching a Tailwind color name) on Dashboard → Settings → Design → Colors, or use the Smart Picker to generate a coordinated primary/secondary pair from a vibrancy level (Muted/Soft/Balanced/Rich/Vibrant) and a distribution preset (Uniform/Soft Primary/Bold Primary/Bold Secondary/Graduated/Vivid Supporting). Implementation lives in the brandingColors Alpine component in resources/js/manager.js.
Per-section overrides (Section settings page)
The section settings page (the paintbrush button on each row) offers its own context-aware text-color picker, scoped to one row only — it offers a different, larger set of shade options depending on whether the row's background is dark, a brand surface tint, or a light neutral. The section-level override writes an actual shade class name (e.g. text-primary-50) into that one row's formTextClasses. It does not change the global CSS variables — only the class stored for that specific preset.
Tone scale aliasing
For neutral grays, hand-rolled OKLCH interpolation produces visually wrong results compared to Tailwind's tuned defaults. Tone instead aliases the chosen Tailwind gray family for the 11 standard stops:
--color-tone-50: var(--color-zinc-50);
--color-tone-100: var(--color-zinc-100);
--color-tone-500: var(--color-zinc-500);
...
The 8 half-steps (150/250/.../850) are computed via OKLCH interpolation between adjacent standard stops in BrandingStyleService::toneScaleShades(). Editors swap the entire gray feel of the site by picking a different scale (slate / gray / zinc / neutral / stone) — one setting, every tone-* utility on the site updates.
Section spacing tokens
The Spacing settings page also bundles vertical rhythm. Three CSS variables drive every row's vertical spacing on the public site:
| Token | Default (Medium) | Purpose |
|---|---|---|
--section-pad-y |
5rem |
Standard rows |
--section-pad-y-banner |
3rem (1rem less) |
Compact CTAs |
--section-pad-y-hero |
6rem (1rem more) |
Heroes |
Picking Small / Medium / Large / X-Large / 2X-Large / 3X-Large from the Spacing settings page sets all three at once via BrandingStyleService::spacingValues(). Custom values (5.5rem, etc.) are accepted and the banner/hero variants offset from them automatically.
Other tokens controlled the same way: --spacing-section-x (side padding), --spacing-items (grid item gap), --width-container (container max-width), --radius-base (border radius), --shadow-base (box shadow).
Optional families
secondary can be toggled off in settings (branding.color_group_toggles). When off, neither the base color nor any of its 19 shades are emitted as CSS variables — keeping the final stylesheet payload minimal for sites that only need primary + tone. tone and primary are always emitted.
What lives where
| Path | Purpose |
|---|---|
app/Services/BrandingStyleService.php |
generateShadeOklch() (19-shade OKLCH math), toneScaleShades() (gray scale aliasing + half-step interpolation), build() / buildDashboard() (emit :root { --color-* } blocks for public + dashboard). defaultColors() defines the three-family defaults. hexToOklch() converts hex picks to OKLCH for storage. |
resources/js/manager.js |
brandingColors Alpine component (per-family color pickers, Smart Picker). sectionStylePicker Alpine component (per-row context-aware text-color options). generateShades() (JS port of the OKLCH curve for live previews). |
resources/views/pages/dashboard/settings/⚡colors.blade.php |
The Colors page — color pickers, Smart Picker, tone scale selector. |
resources/views/pages/dashboard/settings/⚡spacing.blade.php |
The Spacing page — section spacing, side padding, item gap, container width, radius, shadow. |
resources/css/public.css |
Declares the static --color-* defaults in @theme so Tailwind v4 generates utility classes (bg-primary-550, text-tone-150, etc.) at build time. Runtime BrandingStyleService <style> block overrides the values. |
Settings reference
| Key | Default | Notes |
|---|---|---|
branding.colors.primary |
oklch(0.441 0.129 272) |
Stored as OKLCH string. |
branding.colors.secondary |
oklch(0.725 0.132 124) |
Optional family. |
branding.colors.tone |
oklch(0.37 0.012 285.8) |
Display anchor for tone; actual scale comes from tone_scale. |
branding.tone_scale |
'zinc' |
One of slate, gray, zinc, neutral, stone. |
branding.color_group_toggles.secondary |
true |
Set false to omit secondary family from CSS. |
branding.section_spacing |
'medium' |
Drives all three section spacing tokens. |
branding.section_spacing_banner |
'' |
Custom override for --section-pad-y-banner. |
branding.section_spacing_hero |
'' |
Custom override for --section-pad-y-hero. |
branding.section_side_padding |
'medium' |
--spacing-section-x. |
branding.section_item_gap |
'medium' |
--spacing-items. |
branding.container_width |
'medium' |
--width-container. |
branding.radius |
'medium' |
--radius-base. |
branding.shadow |
'medium' |
--shadow-base. |