Skip to content

Update Winter logos & use CSS variables for backend colour - #1541

Merged
LukeTowers merged 11 commits into
developfrom
wip/colour-tokens-experiment
Sep 26, 2026
Merged

LukeTowers merged 11 commits into
developfrom
wip/colour-tokens-experiment

Conversation

@LukeTowers

@LukeTowers LukeTowers commented Sep 14, 2026 •

Copy link
Copy Markdown
Member

Core's backend colours were hard-coded as literals across ~90 LESS files, so dark mode or any theming meant hunting down every rule that painted something. This routes them through a single token block and fixes the accessibility problems that surfaced while reviewing the result.

How the tokenisation works

Every rewrite keeps the original value as the var() fallback:

color: #666666;   ->   color: var(--wn-text, #666666);

So compiled output is unchanged if the token block is ever absent, and the change can be reversed exactly.

653 colour sites across 83 LESS files now resolve through 169 tokens in modules/system/assets/ui/less/tokens.less. That file is imported by storm.less, so the :root block ships in storm.css. The tokens fall into these groups:

group count values
Neutral scale, snapped onto the design system's slate/indigo ramps 28 changed
Identity colours (flash, callout, chart, file-type icons…), named from the LESS variable they already sat on 68 preserved exactly
Inline colours with no variable to borrow a name from, named by source file + role 69 preserved exactly
Focus indicators, chosen by backdrop 2 new
Fancy tab colours derived at runtime via color-mix 2 new, see below

tokens.less is maintained directly, and each entry records the literal it replaced.

What stays literal, and why

87 hex literals remain, each one deliberately:

  • Compile-time locked (~52): LESS evaluates darken()/mix()/saturate() at build time, so a variable feeding one cannot become a var(). These are concentrated in global.variables.less, global.mixins.gradient.less and the other *.variables.less/mixin files. For example, @color-list-selection-text feeds a darken() on hover.
  • Rich editor (27): richeditor.less imports the DRM-gated Froala vendor LESS, so its CSS can't be rebuilt from core. The .fr-view styles in _base_styles.less and editorsetting/default_styles.less are rendered inside Froala's iframe, where :root custom properties from the parent document never reach.
  • Print (7): site.print.less forces backgrounds transparent, so a dark theme redefining the tokens would print near-white text onto white paper.
  • Dead fallback (1): .tt-dropdown-menu in install.less declares its border twice, and the rgba() line always overrides the hex one.

Also left alone:

  • Mail brand stylesheet: it renders into email, where custom properties don't reach, and a client that doesn't understand var() discards the whole declaration.
  • rgba() shadows and overlays: a token pass over these was tried and reverted. Tokenising the channel triplet lets a theme change a shadow's hue but not its strength, and strength is what dark mode actually needs. Role tokens holding a complete rgba() are the better shape, and belong with the dark-mode pass.

What actually changes colour

  • The 28 neutral-scale tokens. The backend shifts from neutral grey toward blue-slate; the largest single change is body text #666666 -> #445a6b.
  • The fancy-layout tab colours. See the accessibility section.

Everything else (identity and inline colours) is byte-identical to before.

Accessibility fixes

Reviewing the result surfaced three pre-existing problems in the fancy form layout:

  • Tabs had no usable focus indicator. The visible tab is not the anchor's box: it is span.title plus two pseudo-elements skewed ±20° hanging off either end, so the browser's outline drew a rectangle through the angled corners. Replaced with an inset bar on the three pieces that form the shape.
  • Toolbar buttons had no focus styling at all. They are transparent and text-only, box-shadow is removed, and only opacity changed on :hover. .btn carries a global outline: none !important, so the ring is drawn with box-shadow.
  • Focusing a tab shifted the strip up 2px, permanently. The strip is overflow: hidden, which still makes it a scroll container. The browser scrolled the tab's intentional overhang into view, and the offset survived blur. Fixed with a negative scroll-margin.

Focus colour is chosen by backdrop: white on the dark master strip, and #103141 on the brand strip, where white reaches only 2.8:1, below the 3:1 that WCAG 1.4.11 asks of a focus indicator.

Separately, the fancy tab strip, inactive tab and active tab sat within ΔE 2.3–4.4 of each other, leaving the inactive tab nearly invisible. They now derive from the brand colour via color-mix at ~11 ΔE steps, which also means they follow custom branding at runtime instead of baking a darken() in at compile time. Those tokens are declared inside @supports, so where color-mix is unsupported the token stays undefined and each use site falls back to its literal. The inactive tab label alpha also goes from .35 to .8.

Other changes

  • Refreshed backend brand assets (logos, favicon, icons).
  • The fancy delete button only renders a visible label when it actually has a title.
  • Table error rows get their own --wn-surface-error / --wn-table-error instead of borrowing a neutral hover token.
  • Rebuilt the compiled CSS for export, import, relation and mediafinder. These aren't registered bundles, so winter:util compile less never touches them, and without a rebuild the LESS changes would have been inert. The rebuild also picks up older drift: import.css still had @brand-danger's old #ab2a1c baked in.

Verification

  • Structural: collapsing the tokens back out of the compiled CSS and diffing against a pristine build shows no selector painting a different colour set, other than the expected neutral-scale and fancy-tab changes. No rule was dropped, added or malformed. The only text-level differences are cssnano repacking shorthands once a rewrite made two values textually equal. (Re-run after merging develop: the only new selectors are develop's own .list-selection rules from Add "select all records matching this query" to list bulk actions #1542.)
  • Contrast: WCAG 2.1, measured across the 27 text/background token pairs that genuinely co-occur on an element. No pair that passed AA now fails. Body text improves (5.74 → 7.19 on inputs, 5.27 → 6.55 on hover surfaces). Nine pairs lose some contrast: six stay comfortably above AA (e.g. heading text 11.0 → 10.0), and three were already failing. In total, 11 pairs were failing before this work, and none are fixed here.
  • Focus behaviour: verified in-browser with real keyboard navigation on both tab variants. The indicator follows the tab shape, there's no UA ring on mouse click, and the strip no longer shifts.
  • Both asset pipelines (winter:util compile less for winter.css, Mix for storm.css) compile clean. Every var(--wn-*) reference resolves to a definition, and no token is unused.

Known follow-ups

  • The ~52 compile-time-locked literals need either color-mix or precomputed pairs before dark mode can be a pure token redefinition.
  • Shadows and overlays need role tokens (--wn-shadow-sm, --wn-overlay-backdrop, …) as part of the dark-mode pass.
  • The 11 pre-existing contrast failures (datepicker selected state, progress bar, flash default, loading indicator, file-upload progress, …) are now each a one-line fix in tokens.less.

Summary by CodeRabbit

  • New Features

    • Added theme-aware color customization across administration screens, forms, tables, lists, media tools, editors, notifications, and dialogs while preserving default colors.
    • Added clearer keyboard focus indicators for tabs and form controls.
    • Added dedicated styling for table error states and updated standard logo assets.
  • Style

    • Refined import colors, media finder visuals, tab focus behavior, and selected-state presentation.
    • Improved print styling to remain independent of screen themes.

LukeTowers and others added 4 commits September 14, 2026 13:10
Replace the Winter wordmark logos with the new light/dark pair and refresh
the supporting icons. The old names described the brand ("winter-logo"); the
new ones describe where they are used, so a re-brand is a file swap rather
than a rename plus a stylesheet edit.

- winter-logo.svg       -> logo-dark.svg   (used on light backgrounds)
- winter-logo-white.svg -> logo-light.svg  (used on dark backgrounds)
- logo.svg dropped: nothing referenced it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fancy form toolbar renders the delete button's title via an :after
pseudo-element. The CMS toolbar renders the same button icon-only with no
title, where the empty :after still contributed its margin-left as dead
space to the right of the icon.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Core had ~717 colour literals spread across 91 LESS files, so dark mode and
theming meant hunting every rule that painted something. This introduces a
single token block and rewrites those literals to read from it.

Every rewrite keeps the original value as the var() fallback:

    color: #666666;  ->  color: var(--wn-text, #666666);

which means compiled output is unchanged if the token block is ever absent,
and the change is exactly reversible.

661 of 717 literals now resolve through 164 tokens in
modules/system/assets/ui/less/tokens.less, imported by storm.less. The
tokens fall into three groups:

- Neutral scale (28) snapped onto the design system's slate/indigo ramps.
  These do change colour; the largest shift is body text #666666 -> #445a6b.
- Identity colours (68) named from the LESS variable they already sat on
  (flash, callout, chart, file-type icons). Values preserved exactly.
- Inline colours (68) with no variable to borrow a name from, named by source
  file and role. Values preserved exactly.

The remainder stay literal: LESS evaluates darken()/mix()/saturate() at
compile time, so a variable feeding one cannot become a var().

Verified by collapsing the tokens back out of the compiled CSS and comparing
to a pristine build: no selector paints a different colour set, and no rule
was dropped, added or malformed. WCAG contrast was measured across every
text/background token pair that co-occurs on an element; nothing that passed
AA now fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three problems in the fancy form layout, all visible to keyboard users.

Tabs had no usable focus indicator. The visible tab is not the anchor's box:
it is span.title plus two pseudo-elements skewed +/-20deg hanging off either
end, so the browser's outline drew a rectangle straight through the angled
corners. Replaced with an inset bar on the three pieces that form the shape;
drawn pre-transform, so it follows the skew. The UA ring is suppressed on
:focus rather than :focus-visible, or it reappears on mouse click.

Toolbar buttons had no focus styling at all. They are transparent, text-only
and have their box-shadow removed, and only opacity changed on :hover, so
keyboard focus was indistinguishable from rest. .btn carries a global
`outline: none !important`, so the ring is drawn with box-shadow instead.

Focus colour is picked by backdrop: white on the dark master strip, and
#103141 on the brand strip, where white reaches only 2.8:1 -- below the 3:1
WCAG 1.4.11 asks of a focus indicator.

Two related fixes:

- Focusing a tab shifted the whole strip up 2px, permanently. A tab is
  2-3px taller than the strip and that overhang is what merges the active
  tab into the panel; the strip is `overflow: hidden`, which still makes it
  a scroll container, so the browser scrolled the overhang into view and the
  offset survived blur. A negative scroll-margin shrinks the box the browser
  tries to reveal. (`overflow: clip` is not available: the strip must stay
  horizontally scrollable for drag.scroll.js, and clip beside a scrolling
  axis computes back to hidden.)

- The fancy tab strip, inactive tab and active tab sat within ΔE 2.3-4.4 of
  each other, so the inactive tab was nearly invisible. They now derive from
  the brand colour via color-mix at ~11 ΔE steps, which also means they track
  custom branding at runtime rather than baking a darken() at compile time.
  The derived tokens are declared inside @supports: where color-mix is
  unsupported the token stays undefined and each use site falls back to its
  literal, which is the only case a var() fallback covers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

This comment was marked as resolved.

@LukeTowers
LukeTowers force-pushed the wip/colour-tokens-experiment branch from 2f391b9 to dbd532a Compare September 14, 2026 19:22
coderabbitai[bot]

This comment was marked as resolved.

This comment was marked as resolved.

The header told contributors to edit tokens-values.json and re-run apply.php.
Neither exists in this repository -- they live in a separate plugin repo -- so
the instruction could not be followed from core.

Reworded so the file reads as maintained directly, which is how anyone working
in core will treat it. Each entry still records the literal it replaced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
coderabbitai[bot]

This comment was marked as resolved.

LukeTowers and others added 2 commits September 14, 2026 14:06
From CodeRabbit and Copilot review on #1541.

Compiled CSS that was never rebuilt. export.less, import.less, relation.less
and mediafinder.less are not registered as asset bundles, so
`winter:util compile less` never touches them and their committed CSS still
carried the pre-token literals -- the LESS changes were inert. Rebuilt through
the same CombineAssets path winter:util uses, unminified to match how these
four were originally built. This also picks up drift that predates this PR:
import.css still had @brand-danger's old #ab2a1c baked in.

Rich editor reverted. richeditor.less imports the DRM-gated froala vendor
LESS, so its CSS cannot be built here at all, and default_styles.less is the
default body of a user-editable setting that Froala renders inside an iframe,
where custom properties from the parent document never reach. Tokens there
could not take effect, so those four files go back to literals.

Print styles detached from the theme. site.print.less is entirely inside
@media print and also forces backgrounds transparent, so a dark theme
redefining the tokens would print near-white ink onto white paper. All six
declarations are literal again, with a note saying why.

Table error row. The pink #fbecec error background was routed to
--wn-surface-hover (a cool neutral) -- close enough in deltaE to pass the fit
guard, wrong semantically. It gets its own --wn-surface-error. Renamed
--wn-table-border to --wn-table-error: it holds the error red and is used for
a background too, so the old name was misleading.

Partially tokenised declarations. The appliers rewrote only the first literal
per line, leaving checkerboard gradients and two-sided inset shadows half
tokenised, so an override applied to some stops and not others. Filled in 28
across 4 files.

Flash close button. `color: white` was hardcoded, so it ignored overrides of
--wn-flash-text; it now follows @color-flash-text.

Dead border fallback. .tt-dropdown-menu declares border twice, the rgba()
unconditionally overriding the tokenised one -- the legacy no-rgba fallback.
Tokenising a declaration that can never take effect is worse than leaving it,
so it is literal again with a note.

Token count 168 -> 164; no undefined references and none unused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The earlier passes matched hex literals only, so every rgba() in core was
invisible to them -- 185 declarations of shadow, overlay and highlight that a
theme still had to override selector by selector.

106 of the 116 rgba() values in core LESS are pure black or pure white at 37
different alphas. Those are not 37 colours, they are two colours used at many
strengths, so the token holds the CHANNEL TRIPLET and the alpha stays at the
call site:

    box-shadow: 0 1px 2px rgba(0, 0, 0, .15);
    ->  box-shadow: 0 1px 2px rgba(var(--wn-scrim-dark, 0, 0, 0), .15);

Redefining --wn-scrim-dark then moves all of them at once while each keeps its
own strength, which is what a dark theme actually wants -- shadows there are
rarely pure black.

Verified in-browser before applying: LESS passes the construct through
untouched, rgba() accepts a custom property expanding to "r, g, b", and the
var() fallback still works when the token is absent (everything after the
first comma is the fallback), so the "absent token degrades to the original
value" guarantee holds. No @supports needed -- rgba() and custom properties
are universally supported.

Genuinely coloured rgba values are left alone: they are identity.

rgba declarations in compiled core CSS: 185 -> 52, and 36 of the remainder are
in richeditor.css, the DRM-gated Froala output that cannot be rebuilt here.
Overall token coverage of the colour surface: 44% -> 52%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
coderabbitai[bot]

This comment was marked as resolved.

LukeTowers and others added 4 commits September 15, 2026 00:11
The scrim pass put the channel triplet in a token and left alpha at the call
site, so a theme could change a shadow's hue but not its strength. Hue is the
axis nobody needs: shifting rgba(0,0,0,.2) to rgba(13,17,23,.2) on a dark
surface is visually almost identical. The real dark-mode problem with a shadow
is that it is invisible against a dark background, which you fix by raising
alpha, swapping to a border, or dropping it -- none of which the token reaches.
The same applies to the white highlights: you would want alpha 0, not a
different white.

So those 106 declarations looked themeable without being themeable. Reverted
to literals, which is at least honest about what they are. If shadows should
be themed later, the useful shape is a small set of role tokens holding the
COMPLETE rgba (--wn-shadow-sm, --wn-overlay-backdrop, ...), which means
deciding that .12/.14/.15/.16 are the same shadow -- a design call worth making
deliberately, alongside the dark-mode pass, not inferred here.

Also from review:

- mailbrandsetting/custom.less is rendered into email, where :root custom
  properties never arrive and a client that does not understand var() discards
  the whole declaration rather than using the fallback inside it. It should
  never have been tokenised; reverted with a note.
- tokens.less emitted standalone `//` separators, which stylelint flags as
  scss/comment-no-empty. The header no longer produces them.

Original rgba spelling restored occurrence-by-occurrence so the revert leaves
no whitespace churn: the only rgba change against develop is the deliberate
fancy inactive-tab label alpha (.35 -> .8) from the contrast fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…experiment

# Conflicts:
#	modules/system/assets/ui/less/list.variables.less
#	modules/system/assets/ui/storm.css
From CodeRabbit review on #1541.

The fancy delete button's :hover still hard-coded #bc4436 / #fff, so a theme overriding --wn-fancylayout-bg or --wn-text-inverse changed the resting state and hovering snapped back to the stock colours. It now uses a new --wn-fancylayout-bg-hover alongside --wn-text-inverse.

The framework and flashmessage warning flashes sat on @brand-warning, a variable reference rather than a literal, so the hex sweep never saw them. They get their own --wn-framework-flash-warning-bg and --wn-flashmessage-flash-warning-bg, matching their success/error/info siblings. The suggested reuse of --wn-flash-warning-bg would have recoloured them: that token holds Snowboard's #b87410, while these compile to #de8754.

Compiled output changes only in those three rules plus the new token declarations. Token count 166 -> 169; no undefined references and none unused.
@LukeTowers LukeTowers changed the title Route core backend colours through CSS custom properties Update Winter logos & use CSS variables for backend colour Sep 26, 2026
@LukeTowers
LukeTowers merged commit 4ab9e5a into develop Sep 26, 2026
16 checks passed
@LukeTowers
LukeTowers deleted the wip/colour-tokens-experiment branch September 26, 2026 04:52
LukeTowers added a commit to wintercms/wn-tailwindui-plugin that referenced this pull request Sep 26, 2026
The first version of these lived only in a session scratchpad and was lost
when it was cleared, taking the ability to revert or re-run the change with
it. They are in git now for that reason.

- apply.php / unapply.php  replay and exactly reverse the token rewrites
- build.sh                 compiles BOTH asset pipelines (winter:util for
                           winter.css, mix for storm.css) with plugins
                           disabled, then restores their state
- verify.php               collapses the tokens back out of the compiled CSS
                           and proves no selector paints a different colour set
- contrast.php             WCAG 2.1 before/after, only for token pairs that
                           actually co-occur on an element
- extract-map.php          re-captures token-map.json from the applied tree
- lab.php                  CIELAB / deltaE / chroma / WCAG helpers

token-map.json was captured from the applied tree rather than by re-running
the original role-inference heuristics: those were tuned interactively and
are not reproducible, whereas reading the tree back cannot drift from what
was built and reviewed.

Changing a colour is: edit tokens-values.json, apply.php, build.sh.
tokens.less in core is generated and must not be hand-edited.

verify.php's baselines are generated (--capture) and go stale as soon as core
moves, so they are gitignored rather than committed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LukeTowers added a commit to wintercms/wn-tailwindui-plugin that referenced this pull request Sep 26, 2026
HANDOFF.md still said the light-mode colour cleanup was unstarted ("Nothing
here is applied to core yet — it's the plan + approval gate"). It is now open
as wintercms/winter#1541.

HANDOFF-token-experiment.md is rewritten from a mid-experiment parking doc
into a record of what shipped, where the tooling is, and what is still open:
the 56 literals LESS colour functions keep locked, 11 pre-existing contrast
failures that are now one-line fixes, and the absence of any test guarding
that every var(--wn-*) resolves.

It also keeps the traps that cost real time, notably: there are two asset
pipelines and tokens only ship in one of them; a var() fallback does NOT
rescue a defined-but-invalid value, so a token needing a feature guard must
be declared inside @supports; verify.php validates the plumbing, not the
palette, so it cannot catch a bad colour; and `overflow: hidden` is still a
scroll container, which is what made the tab strip jump on focus.

color-consolidation.html and color-token-map.html are marked superseded --
their groupings predate the fit guard that shipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LukeTowers added a commit to wintercms/wn-tailwindui-plugin that referenced this pull request Sep 26, 2026
Adds a sticky bar with a progress meter, per-group approve/override chips
with a note field, and a Copy-decisions button that emits both a readable
summary and a JSON block. State persists to localStorage; the clipboard
write falls back to a hidden textarea + execCommand, which is what works
when the report is opened over file://.

Kept for reference: the groupings in this report predate the fit guard that
shipped in wintercms/winter#1541, so they no longer match the applied token
set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LukeTowers added a commit to wintercms/wn-tailwindui-plugin that referenced this pull request Sep 26, 2026
wintercms/winter#1541 shipped with review changes the tooling never saw: the rgba scrim tokens and several misrouted identity tokens were reverted, and five tokens were added in the final round (--wn-list-selection-bg/-border, --wn-framework-flash-warning-bg, --wn-flashmessage-flash-warning-bg, --wn-fancylayout-bg-hover).

tokens-values.json now matches core exactly: apply.php run against develop regenerates tokens.less byte-for-byte and rewrites nothing. token-map.json was recaptured from the merged tree with extract-map.php (661 rewrites, 83 files, 169 tokens).
LukeTowers added a commit to wintercms/wn-tailwindui-plugin that referenced this pull request Sep 26, 2026
The brand asset refresh in wintercms/winter#1541 removed winter-logo-white.svg. The auth layouts and Plugin.php were updated for it, but the side-menu partial still requested the old file, so the backend showed a broken image wherever no custom logo is set. logo-light.svg is the variant with white lettering for dark backgrounds.
LukeTowers added a commit to wintercms/wn-tailwindui-plugin that referenced this pull request Sep 26, 2026
Core now paints through --wn-* tokens (wintercms/winter#1541), so dark mode can redefine those instead of overriding each rule that uses them. The .dark block now maps the neutral tokens onto the existing --drk-* palette by role (text, surfaces, borders, inputs, hover, selection), plus the few identity tokens this file already repainted consistently.

That makes 86 declarations here redundant; they are removed, and 48 rules left empty with them. Each removal was verified rather than assumed: on 25 backend pages in dark mode, every element's and pseudo-element's colours, borders, outlines and shadows were captured, the token block applied, and a declaration removed only if doing so changed nothing on any page where it matched. Declarations that matched nothing on those pages are kept, since their redundancy could not be shown.

The token block also darkens elements this file never covered, which were rendering their light-mode colours in dark mode: dark slate text and icons on dark surfaces (pagination, record navigation, file upload names, component list), light #d1dbe0 borders and placeholders, the recordfinder and datepicker panels, sweet-alert text and scoreboard values.

The switch knob is pinned light: core paints it with --wn-surface-hover, which is now dark.

Light mode is untouched: every change is scoped under .dark, and the same 25 pages capture identically in light mode before and after.
LukeTowers added a commit to wintercms/wn-tailwindui-plugin that referenced this pull request Sep 26, 2026
Dark mode now depends on core's --wn-* tokens (wintercms/winter#1541), which ship in Winter 1.2.15; on an older core the tokens are absent and the surfaces they cover would stay light. The constraint follows the other first-party plugins in also allowing dev-develop.
LukeTowers added a commit to wintercms/wn-tailwindui-plugin that referenced this pull request Sep 26, 2026
The plugin now requires the Winter release with colour tokens, which itself requires PHP 8.1, so the plugin's ">=7.2" was no longer true; it also covers the audit tooling's use of PHP 8 functions.

Handoff steps B and C still told the next maintainer to approve the old consolidation report and replace the literals, work that has shipped in wintercms/winter#1541 in a form that report no longer matches. They now say so and point at the maintained tooling.
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