Skip to content
Zac Elletson
Trainual2020–2022Lead Designer

Palette System

Replacing Trainual's broken SCSS color foundation with a perceptually-uniform system that fixed accessibility, dark mode, and the white-label feature in one architecture.

// Saguaro — palette + theme map (representative excerpt)
// Full production source is ~38KB; this excerpt shows the architecture shape:
// a perceptually-tuned spectrum, a semantic token layer composed from it,
// and themes that swap by reference rather than by duplication.

// 1. The spectrum --------------------------------------------------------
// Each hue is sampled at 10 perceptual steps. Step 500 is the brand anchor;
// step 50/950 stay within WCAG AA against their inverse on every hue, so
// dark mode is a derivation rather than a parallel re-skin.

$hue-purple: (
  50:  hsl(265, 100%, 97%),
  100: hsl(265,  95%, 92%),
  200: hsl(265,  88%, 84%),
  300: hsl(265,  82%, 73%),
  400: hsl(265,  76%, 62%),
  500: hsl(265,  72%, 52%), // brand anchor
  600: hsl(265,  70%, 44%),
  700: hsl(265,  72%, 36%),
  800: hsl(265,  76%, 28%),
  900: hsl(265,  82%, 20%),
  950: hsl(265,  88%, 12%),
);

// $hue-blue, $hue-green, $hue-amber, $hue-red, $hue-neutral, ...
// (declared the same way; omitted here for brevity)

// 2. Semantic token map --------------------------------------------------
// Components never reach into the spectrum directly. They consume semantic
// tokens, which compose from the spectrum at a single layer of indirection.

@function token($name, $theme: $theme-light) {
  @return map-get($theme, $name);
}

$theme-light: (
  surface-base:     map-get($hue-neutral, 50),
  surface-raised:   #fff,
  surface-sunken:   map-get($hue-neutral, 100),

  text-primary:     map-get($hue-neutral, 900),
  text-secondary:   map-get($hue-neutral, 600),
  text-on-brand:    #fff,

  border-subtle:    map-get($hue-neutral, 200),
  border-strong:    map-get($hue-neutral, 400),

  brand-default:    map-get($hue-purple, 500),
  brand-hover:      map-get($hue-purple, 600),
  brand-pressed:    map-get($hue-purple, 700),

  feedback-success: map-get($hue-green,  500),
  feedback-warning: map-get($hue-amber,  500),
  feedback-danger:  map-get($hue-red,    500),
);

// 3. Dark theme as a derivation -----------------------------------------
// Dark mode inverts the value axis on the same hue scale. No new colors
// are introduced — the spectrum itself does the work.

$theme-dark: map-merge($theme-light, (
  surface-base:     map-get($hue-neutral, 950),
  surface-raised:   map-get($hue-neutral, 900),
  surface-sunken:   map-get($hue-neutral, 925),

  text-primary:     map-get($hue-neutral, 50),
  text-secondary:   map-get($hue-neutral, 300),

  border-subtle:    map-get($hue-neutral, 800),
  border-strong:    map-get($hue-neutral, 600),

  brand-default:    map-get($hue-purple,  400),
  brand-hover:      map-get($hue-purple,  300),
  brand-pressed:    map-get($hue-purple,  200),
));

// 4. White-label override ------------------------------------------------
// The brand-style feature replaces only the brand-* tokens at runtime,
// not the underlying spectrum — so customer overrides cannot break
// accessibility contracts elsewhere in the system.

@mixin apply-brand($brand-hex) {
  $scale: generate-perceptual-scale($brand-hex);
  --brand-default: #{map-get($scale, 500)};
  --brand-hover:   #{map-get($scale, 600)};
  --brand-pressed: #{map-get($scale, 700)};
  --text-on-brand: #{contrast-of(map-get($scale, 500))};
}

The palette + theme map in SCSS — the actual code that became the foundation. Saguaro is the system this code powers.

Act 1 — The Bet

Trainual's color foundation was a wall of SCSS lighten() and darken() calls with hard-coded percentages — a system that couldn't carry a custom brand, couldn't carry dark mode, and couldn't carry accessibility without rebuilding from scratch each time. The work was to replace it with a perceptually uniform color system that could carry all three at once.

Side-by-side before/after of Trainual's color foundation. Before: 'Static color selection' — a small grid of pre-defined swatches limited to a handful of usable hues. After: 'Dynamic color system' — eleven hue ranges arrayed across a 12-step value scale with WCAG contrast badges, plus an accent group and a dark-mode palette directly below
The bet rendered as before/after. The 'static color selection' on the left was the entire usable palette; the 'dynamic color system' on the right is the perceptually-uniform foundation that replaced it — both modes derived from the same scale.

Act 2 — Constraints & Cost

The problem was structural. Trainual's brand-style feature let customers pick any color as their primary, and the existing SCSS used lighten($base, X%) and darken($base, Y%) to derive hover, pressed, and secondary states. With a fixed percentage, the derivations broke at the extremes — a light brand color washed out completely when "lightened" by 45% for a secondary button background; a dark brand color produced unreadable contrast in dark mode. Most of the brand colors customers picked produced WCAG-failing UI somewhere in the stack.

Screenshot of the legacy Trainual brand-style picker — a swatch grid that fans out into a freeform HEX input field, letting customers type in any color regardless of contrast
The customer-facing brand picker. Any hex value was a valid input — so the application's accessibility floor was whatever color the customer happened to pick.
The Trainual brand-styles configuration page in production — sample headings rendered in red, yellow, and green from a customer-chosen palette, sitting alongside the color-picker grid
The same picker used in the live product. Heading 1, 2, and 3 here are rendered in red, yellow, and green because a customer chose them — and the system had no defense against that.

The same lighten/darken pattern propagated everywhere. Variables were duplicated across half a dozen stylesheets, !important overrides defended themselves against other !important overrides, and naming conventions changed file by file ($bunker was a real variable name). Dark mode had been added quickly during a separate squad's sprint and was almost completely undocumented — design files had to back into hex values from Figma frames because no styles existed.

The team's appetite for a full color-system rewrite was low. We had no allocated resources for design-system work. The plan was to fit the architecture into an active product initiative — My Desk 2.0 — so the work would ship as part of feature delivery, not as standalone "infrastructure." That was the only framing that would get the work prioritized.

Act 3 — Decisions & Tradeoffs

Stop deriving colors; declare them perceptually

The first decision was to abandon programmatic lighten / darken entirely. Each hue would be sampled at twelve perceptual steps — tuned for consistent perceptual lightness, not consistent mathematical percentage. A button's hover state didn't come from lighten(brand, 10%); it came from a sibling shade in the same palette. Dark mode became a derivation of the same hue scale, not a parallel re-skin.

Screenshot of legacy SCSS — a $foundation-hover variable defined as lighten(\$accent-primary-default, 60%), with comments and surrounding variables in the same lighten/darken pattern
The legacy pattern in code. Every interactive state was derived from a base color via a percentage — clean to read, broken at the edges of the hue range.
Color comparison strip showing the same lighten(color, 45%) operation applied to a saturated mid-tone, a light pastel, and a dark navy — the mid-tone produces a usable button background, the pastel washes out to near-white, and the navy stays unreadably dark
The same lighten operation, three brand colors. The mid-tone works; the pastel washes out; the navy stays unreadable. A fixed percentage couldn't cover the spectrum, and the spectrum was the feature.

The tradeoff was up-front authoring cost — every hue had to be hand-tuned at every step to maintain perceptual uniformity. Worth it. After the migration, designers and engineers picked named tokens out of a small set, and the lighten/darken math vanished from the codebase.

Live authoring of the SCSS palette and theme tokens — the actual file that became the foundation.
The final Palette System spectrum — eleven hue ranges (red, orange, ocre, green, teal, blue, magenta, violet, purple, neutral) arranged horizontally, each with a 12-step value scale running vertically
The system that came out the other side. Eleven hues × twelve perceptual steps, hand-tuned for visual weight across rows. Every interactive state in the application gets composed from these tokens.

Decouple brand from theme

The second decision was structural. Customer brand colors couldn't be allowed to drive the whole UI directly anymore — that was the source of the accessibility failures. The solution: a thin "brand" layer applied only to the affordances that needed to carry the customer's brand (primary buttons, links, highlights), with everything else (text, surfaces, borders) drawn from theme tokens that were always WCAG-compliant. Customers kept their brand expression; the system kept its accessibility contracts.

The Palette System architecture diagram — Palette Layer at the top (raw color spectrum) flowing into a value-scale extraction, then into a Theme Layer (named theme variables), then branching into Themes (Default theme and Neon theme) each rendered as UI mockups in light and dark
The architecture in one diagram. Raw colors at the top, theme variables in the middle, applied themes at the bottom. The brand/theme decoupling is the horizontal cut — palette feeds theme, theme feeds UI.

Dark mode fell out of the same architecture without a parallel system. The dark theme didn't redefine colors — it pulled inverted positions out of the same hue scale. One palette, two themes, both perceptually calibrated.

Dark-mode palette diagram — the same value scale used in light mode, with the index reversed so that the 'foreground' positions in light theme map to the dark-mode positions and vice versa
Dark mode as derivation. The scale is the same; the index reverses. Every component that read from theme tokens got dark mode for free — no parallel implementation.
The default Trainual theme rendered in light and dark — purple primary affordances, neutral surfaces, WCAG-compliant contrast across both modesThe same Trainual UI re-themed as a neon variant — magenta and electric purple accents on a darker surface, demonstrating that the theme layer could carry significantly different visual identities without rebuilding components
Default theme and Neon theme. Same architecture, different palette mapping in the theme layer — proof that the decoupled structure could carry visual brands well beyond the original product's reach.

Build the data case before asking for resources

The third decision was political. Asking leadership to fund a months-long color-system rewrite as standalone infrastructure was a non-starter. Instead I collected the receipts — customer comments about contrast and dark mode from Productboard, internal engineering Slack threads about the spaghetti stylesheets, the time designers spent reverse-engineering hex values from Figma frames. When the case for resources was made, it was a case about hitting business OKRs (squad velocity, accessibility risk, dark-mode adoption), not a case about craft taste. That framing got the work approved.

Productboard customer feature requests — three threads from customers (Craig Ivemy, Wil Pagod, Jay Kotak) requesting that brand styles work in dark mode and complaining about contrast issues in the existing implementation
The Productboard signal that anchored the data case. Customers asking for the exact thing the architecture rewrite was going to enable, in their own words and on the company's own product-feedback surface.

Pair with engineering

The fourth decision was who built it. Matt, the squad's tech lead, became the engineering co-owner. We architected the new SCSS palette and theme map together — most of the early work happened on nights and weekends because neither of us was officially assigned to the work. The system shipped because two people, not one, were defending it through the design and code reviews where it could have quietly died.

SCSS source files showing the partial structure — separate _palette files for each hue (red, orange, blue, etc.) plus theme partials that import and compose them through a central theme map
The shipped file architecture. Each hue is its own partial; theme files compose them through SasS Maps; nothing duplicates. The structure is what made dark mode possible as a derivation rather than a parallel system.

Act 4 — Outcome

The Palette System shipped as part of My Desk 2.0 at the end of 2020, then continued to absorb the rest of the product through 2021 and into 2022. The effects the org could feel even without instrumentation:

  • ~4x reduction in design iteration time on color-affected work
  • Drastic reduction of styling bugs and defects across squads
  • WCAG contrast compliance, even with customer-defined brand colors
  • Significant reduction of stylesheet variable tech debt
  • Dark mode finally documented, named, and consistent
Side-by-side before/after of the Trainual UI — left: 'Dark mode architecture not sound, no brand color support', showing a broken dark-mode rendering. Right: 'Scalable UI framework and full brand color support & theme capable', showing four mockups across light and dark with two different brand themes
The product surface after the migration. The 'before' is what dark mode looked like running on the legacy variables; the 'after' is the same product on the new theme architecture, including a non-default brand styling that the old system structurally couldn't support.
Side-by-side before/after of the SCSS source — left: 'Static non-scalable variables', a wall of percentage-derived variables. Right: 'Dynamic color system', the structured palette/theme files alongside Figma artifacts showing the design library architecture
The code surface after the migration. Same job, fundamentally different shape. Variables structured by purpose rather than by happenstance, with the Figma library mapping one-to-one against them.
Side-by-side comparison of the Trainual brand-styles configuration page. Left ('Current reality'): heading colors set to red, yellow, and green from a freeform HEX input. Right ('Palette system'): the same UI with constrained palette choices and the new accent group, producing WCAG-compliant headings
The customer-facing brand-styles surface, before and after. The configuration that produced unreadable headings on the left becomes structurally impossible on the right — the system removed the failure mode rather than warning against it.

The qualitative read landed where it needed to: from the designers actually doing the work.

"Already making converting designs to dark mode 4x faster and 4x less brain power." — Tyler R., Product Designer

Slack screenshot from the #product-designers-only channel, Tyler at 1:40 PM Apr 28th: 'Will do! Already making converting designs to dark mode 4x faster and 4x less brain power (you can use those data points in a case study)'
The original of the 4x-faster line — caught live in the design-team Slack, with the suggestion to use it in a case study tucked into the same message.
Animated demo of the Trainual UI switching between light and dark modes — the page instantly re-themes across surfaces, text, accents, and components without layout shift or color regressions
Dark mode as a single token-layer switch. No parallel implementation, no per-component overrides — the architecture carries the flip on its own.

The architecture became the foundation of the Saguaro Design System. Every layer above it — components, documentation, contribution model — depended on the color tokens being correct in the first place.

Act 5 — Reflection

The Palette System worked because there was a technical move and a political move, and they had to land at the same time. The technical move was abandoning a flawed abstraction (percentage-based color derivation) for a sounder one (perceptual scales). The political move was abandoning a flawed framing (rewrite as infrastructure) for a sounder one (rewrite as part of an active feature initiative).

If I were redoing it, the mistake to correct would be how long Matt and I worked on weekends before securing real resourcing. The case for funded systems work has to be made early and loudly, even when the architecture isn't yet provable in code — because by the time the architecture is provable, the team has often already paid for it personally.

The lesson, which set the pattern for the rest of the Saguaro Design System the palette became the foundation for, was that color systems are not visual decisions. They're code architecture, and they survive only when engineering owns them as architecture.