Skip to content

Apply preset custom uiTheme colors on first load - #3210

Open
mahelaCooray wants to merge 1 commit into
ONLYOFFICE:masterfrom
mahelaCooray:fix/preset-custom-uitheme-colors
Open

Apply preset custom uiTheme colors on first load#3210
mahelaCooray wants to merge 1 commit into
ONLYOFFICE:masterfrom
mahelaCooray:fix/preset-custom-uitheme-colors

Conversation

@mahelaCooray

Copy link
Copy Markdown

What problem does this solve?

A custom theme passed via customization.uiTheme doesn't apply its colors on a fresh load. The editor renders the base dark/light theme instead, and the custom colors only appear once you re-pick the theme from the Interface Theme menu.

Root cause: the theme arrives as an id only, and its colors (loaded async from themes.json) are turned into CSS vars by apply_theme, which only runs on runtime selection. On preset load, check_launched_custom_theme sets the body class but never writes the colors.

How does this solve it?

Two changes in apps/common/main/lib/controller/Themes.js:

  1. check_launched_custom_theme now writes the launched theme's colors once, the first time it resolves, mirroring what apply_theme does. So a preset theme behaves like a selected one.
  2. create_colors_css emits a doubled-class selector (:root .id.id). A single :root .id only ties the base :root .theme-type-dark { ... } rules and loses on cascade order, so a dark/light custom theme's tokens (e.g. --background-toolbar) silently fell back to the base value. This also fixes runtime selection of dark custom themes, not just preset load.

How do I test this?

Tested locally against onlyoffice/documentserver-de 9.4.0 with a custom theme preset via uiTheme. Without the change the toolbar loads in the base theme; with it the custom colors apply on first paint. Reproduced the base-theme fallback as a control, then confirmed the fix renders the custom colors.

A custom theme passed via customization.uiTheme is registered by id, but its
colors (loaded async from themes.json) were only written to CSS by apply_theme,
which runs solely on runtime selection. On a fresh load the editor fell back to
the base dark/light theme.

- check_launched_custom_theme now writes the launched theme's colors once, the
  first time it resolves, mirroring apply_theme.
- create_colors_css emits a doubled-class selector so a custom theme's tokens
  out-specify the base :root .theme-type-dark rules instead of tying and losing.

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

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@jelloir

jelloir commented Aug 8, 2026

Copy link
Copy Markdown

Independent confirmation of this on ONLYOFFICE Desktop Editors (Linux, onlyoffice-desktopeditors
9.4.0-129), which seems worth adding since the PR description only covers Document Server.

I hit the same bug from the other direction: a custom theme dropped into
~/.local/share/onlyoffice/desktopeditors/uithemes/ applies only after re-picking it from the
Interface Theme menu, every session. Both hunks fix it on desktop too. I tested by porting them
into the shipped minified bundles of a real install.

Hunk 1 (:root .id.id) — this is the one that matters most for custom dark themes.
:root .theme-dark, :root .theme-type-dark defines 210 variables, and any theme declaring
"type": "dark" gets theme-type-dark on <body>, so at equal specificity the base block wins
and most of the theme is silently discarded. Measured per key before the patch:
background-normal, background-toolbar, text-normal, border-regular-control and
background-accent-button all kept base values, while background-tabbar and background-button
— the only two of those not in that block — took the theme's. Perfect correlation with block
membership. With the doubled selector the theme's values win throughout.

Hunk 2 (write on launch) — confirmed. Before it, a cold start applies only the ui-theme
localStorage cache, which get_current_theme_colors builds from the hardcoded
themeColorTokens list (142 entries here). Anything outside that list never arrives, including
font-family-base and every *-icon-offset-x. On the antique sprite sheet those offsets select
the light glyph column, so icons come up dark and near-invisible on a dark theme until you toggle.
After the patch, a cold start is correct, verified across a full uninstall/reinstall of the package.

Writing the CSS is not sufficient on its own

Several consumers read theme state once, during init, which on a cold start happens before
check_launched_custom_theme() writes the theme's variables. Two identified:

  • SDK canvas (row/column headers, rulers) — painted from asc_setSkin(), which init() calls
    with get_current_theme_colors(themeColorTokens) before the theme's variables exist.
  • Toolbar tab style — read from --toolbar-preferred-tab-style. The theme-type-dark block
    sets it to fill, so a theme asking for line renders with boxed tabs and no underline.

apply_theme() does both — re-pushes the skin (Themes.js:429) and fires uitheme:changed — which
is why toggling themes appears to fix them. Doing the same at the end of the new block resolves the
canvas case (confirmed):

const colors_obj = get_current_theme_colors();
if ( validate_vars(colors_obj) ) {
    colors_obj.type = themes_map[theme_id].type;
    colors_obj.name = theme_id;
    this.api.asc_setSkin(colors_obj);      // needs the api reference in scope here
}
Common.NotificationCenter.trigger('uitheme:changed', theme_id, 'native');

(check_launched_custom_theme has no this, so the api needs stashing in the closure from
init(), or the refresh factoring out of apply_theme. The 'native' caller tag keeps the
desktop bridge from echoing the change back.)

Two things I could not solve — noting them rather than claiming a fix

1. A ~2 second window on document open where the UI is the base theme. Sampling frames from a
screen recording, the toolbar goes #1e1e2f (themed) → #1f1f1f/#282828 with a #fcfcfc status
bar (base) while a document loads → back to themed. So a newly opened editor builds its UI in the
base theme and flips over afterwards.

2. Scrollbars never take the theme's colours. Unlike the row/column headers, they are fixed
neither by the late asc_setSkin nor by switching themes back and forth, so this looks unrelated
to the load-order issue above. (On a different machine the same theme did colour the scrollbars
after a toggle, so it may be environment-specific — I could not reconcile the two, and am reporting
it rather than explaining it.)

Worth noting: I also tried writing the theme's variables directly into the shipped stylesheets, so
they exist at first paint with no JS involved. That did not close either issue — which suggests
the constraint is not when the CSS becomes available but when the theme's class is applied to
<body>, since every rule is keyed on it. I have not verified that, and someone who knows the
startup sequence will get there much faster than I would.

Both are pre-existing and orthogonal to this PR — it makes things strictly better — but they are
what stands between it and custom themes being seamless.

Happy to open a follow-up PR for the asc_setSkin / uitheme:changed part if that is easier than
folding it in here.

@jelloir

jelloir commented Aug 9, 2026

Copy link
Copy Markdown

Follow-up from further testing of a custom dark uitheme (Catppuccin Mocha) on
onlyoffice-desktopeditors 9.4.0-129, Debian 13.

Three findings below. (1) is a one-line fix in this repo; (2) and (3) are in the SDK, not web-apps,
but they are what stops a custom dark theme from looking finished, so I'm recording them here rather
than splitting them off — happy to move them if you'd prefer separate issues.

Everything below was measured against a running editor rather than read off the source: the desktop
build exposes a CEF debugger, so the UI can be inspected live and the result pixel-sampled from a
screenshot.

LD_LIBRARY_PATH=/opt/onlyoffice/desktopeditors /opt/onlyoffice/desktopeditors/DesktopEditors \
  --no-sandbox --remote-debugging-port=9222
curl -s http://127.0.0.1:9222/json     # the editor itself is an iframe execution context

1. Sheet tab accent is a hardcoded colour with no var() fallback

In the spreadsheet statusbar, the active/selected sheet tab draws its 4px accent from a literal:

.statusbar #status-sheets-bar .nav-tabs>li.active>span   { box-shadow:0 4px 0 #49795d inset }
.statusbar #status-sheets-bar .nav-tabs>li.selected>span { box-shadow:0 4px 0 #49795d inset }

Every neighbouring declaration in those same rules has the two-line stock-then-var() treatment
(background-color:#f1f1f1; background-color:var(--background-pane)), so this looks like an
oversight rather than a decision. The consequence is that the stock spreadsheet green stays put under
every custom theme, directly under a ribbon tab underline that has been recoloured — the two
disagree by design.

--highlight-toolbar-tab-underline-spreadsheet already exists and already holds the right value.
Suggested fix:

box-shadow:0 4px 0 #49795d inset;
box-shadow:0 4px 0 var(--highlight-toolbar-tab-underline-spreadsheet) inset;

Confirmed by overriding exactly that selector in a local stylesheet: the accent then tracks the theme
and matches the ribbon underline above it.


2. Grid scrollbars: the thumb fill collapses to a grey built from the colour's red byte

The grid scrollbar is painted into a <canvas> inside #ws-v-scrollbar. The theme colour reaches
the SDK, but the fill is drawn as #RRRRRR — the red byte repeated — so green and blue are
discarded. Three measurements, sampling the painted scrollbar out of a screenshot:

--canvas-scroll-thumb painted
#313244 #313131
#585b70 (hover) #585858
#0000ff (injected probe) #000000

The probe rules this out as a blend or an opacity artefact, and it is not a luminance conversion
either — a luminance grey of #0000ff would be roughly #1d1d1d, not #000000.

Why this has stayed invisible upstream: every stock theme already uses pure greys here —
#f7f7f7, #c0c0c0, #cbcbcb (light) and #404040, #999, #616161 (dark). For any colour where
R == G == B the bug is a no-op. It only shows on a theme whose surfaces are tinted.

Two further notes that may help locate it:

  • The same variable renders correctly in part of the same widget. With the #0000ff probe in
    place, the scrollbar region contained 3,980 px of greyed #000000 alongside 105 px of true
    #0000FF, so the greying is one fill path, not a colour that arrived wrong.
  • This explains why neither a late asc_setSkin nor a theme toggle ever fixed these, which I'd
    previously assumed pointed at a timing problem: both re-push the same value down the same lossy
    path, so they cannot help.

Because the loss is downstream of the theme, there is no value a uitheme can supply that renders
non-grey. It isn't workaroundable from a theme file.


3. Document dark mode paints a fixed page colour, ignoring the theme

With Advanced Settings → Appearance → Turn on document dark mode, the page renders #393939
regardless of the active UI theme — identical under stock theme-dark and under a custom dark theme.
Checks:

  • No theme variable covers it. The only --canvas-*dark* tokens in the shipped CSS are the six
    --canvas-dark-cell-title*.
  • --canvas-content-background controls the page normally (#ffffff) but has no effect once dark
    mode is on.
  • It isn't a stored constant: no 393939 / 3750201 / (57,57,57) literal in
    sdkjs/word/sdk-all-min.js, and a runtime walk of Asc.editor and AscCommon to depth 3
    (11,018 objects) found no #393939 string and no CColor(57,57,57).

So a custom dark theme can style the whole UI and then hand off to a page background it has no say
over. Is exposing this as a theme token (or deriving it from --canvas-content-background /
--canvas-background) something you'd consider?

Minor, related: the checkbox is only rendered in the document editor's settings panel, but the value
is global — it's stored in localStorage["content-theme"] and both documenteditor/main/app.js and
spreadsheeteditor/main/app.js call asc_setContentDarkMode on their theme-apply path. That is
mildly confusing from the UI, since turning it on in Documents silently changes Spreadsheets too.


Not a bug, but worth documenting

Two behaviours cost a fair amount of time to work out, and neither is written down anywhere I could
find. If they're deliberate, a line in the theming docs would save the next person the trouble:

  • brand-word / brand-cell / brand-slide / brand-pdf / brand-draw are the document tab's
    fill, and the shell always draws that tab's label in text-inverse.
    So brand-* has to stay a
    light colour in a dark theme; set it to a dark one and the filename becomes invisible. The stock
    dark themes set all five to a flat near-black (#222222 in theme-night) and still get a legible
    light label, so they evidently don't take this path — which makes them misleading as a reference.
  • The native shell reads brand-* and the other chrome keys straight from the JSON in
    uithemes/
    , not from anything the web layer loads, so those keys respond to a different file
    than the rest of the theme.

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.

3 participants