Skip to main content

The app pushes the theme; the library never asks

Context​

#433 is a real gap. A wrapping app usually carries its own dark-mode signal, and it is rarely the two signals theme: 'auto' reads. Tailwind and next-themes write a dark class on <html>. Bootstrap 5.3 writes data-bs-theme. A Gantt inside such an app sits on 'auto', reads prefers-color-scheme, and goes light while everything around it is dark.

The first answer on the branch was a ThemeResolver: theme also accepted a function, the library called it whenever the theme question might have moved, and a widened MutationObserver watched the app's own attributes so the call happened at the right moment.

It cost two infinite loops before it worked. The observer woke on data-fg-theme, which #applyTheme then wrote; a same-value setAttribute still queues a mutation record in Chromium, so the write re-entered the observer that caused it. The failure path had a second loop of its own — a removeAttribute followed by a re-add is two real mutations. Both were fixed. Both were symptoms.

Evidence​

No mainstream library in this class invokes a consumer callback to learn the colour mode. Control runs app -> library, always.

LibraryHow the app tells itDoes it ask back?
AG Grid (theming-colors)Swap the theme grid option, or leave the default colorSchemeVariable part in place and set data-ag-theme-mode="dark" on <html>, <body>, or any ancestor carrying ag-theme-modeNo. The docs give no hook that asks the application which mode to use, and prefers-color-scheme appears nowhere. browserColorScheme is a paint parameter, not a read of the user's preference.
Bootstrap 5.3 (color-modes)The app writes data-bs-theme="dark" on <html> or on any subtreeNo. The mechanism is CSS selectors from the color-mode() mixin. "Nothing in Bootstrap's JS bundle participates."

Bootstrap settles the harder half of the question too. Its own reference toggle resolves 'auto' in the app's code into a literal 'light' or 'dark' before writing the attribute, and Bootstrap ships no [data-bs-theme=auto] rules at all. The app already owns a resolution step. It has a matchMedia listener, it has storage, it has a toggle. Asking it to pass us the answer it already computed costs it one line. Asking us to recompute that answer costs an observer, a re-entrancy guard, a failure mode, and a public error code.

Decision​

theme is 'auto' | 'light' | 'dark'. Nothing else.

An app with its own dark-mode signal writes the answer where the Gantt already looks:

// The app's toggle already knows. One more line and the Gantt knows too.
gantt.theme = isDark ? 'dark' : 'light';

or pins an ancestor once and never touches the Gantt again:

<div data-fg-theme="dark"><div id="gantt"></div></div>

The pin is the recipe for an app that flips a class on <html>: mirror the class onto one wrapper with data-fg-theme, in the same toggle that sets the class. harness/e2e/theme-push.html demonstrates both.

'auto' keeps reading an ancestor's data-fg-theme pin and then prefers-color-scheme. Both are signals we define or the platform defines. Neither is a guess at a convention some framework holds.

Consequences​

A function never becomes a config value. AGENTS.md already holds the reason, for Aggregators: a name serializes into a document and a function does not. A ThemeResolver broke that for a key whose whole job is to name a colour. The three literals serialize; a closure over an app's store does not.

theme is runtime config, not a document field, so this reason needs its own evidence, not a borrowed one. harness/gantt-toolbar.ts has storeTheme/readStoredTheme: the toolbar writes the app's theme choice to localStorage and reads it back on load. Three literals round-trip through that store with no help. A ThemeResolver closure cannot: a function is not a value JSON.stringify can carry, so the toolbar could persist only the closure's last computed answer, never the rule that produced it.

The re-entrancy class is gone, not guarded. The observer still watches data-fg-theme, and the library's own write in #applyTheme still re-enters it (src/view/gantt-shell.ts:1104-1107). What changed is what the callback does: it now only reads the resolved theme and, if the answer moved, fires an event. It writes nothing back, so a self-write cannot trigger a second write, and the loop has nowhere to start. The theme-resolver-failed error code, the reported-once WeakSet, and the side-effecting getter all go with it — surface that existed only to survive the design.

We do less than 'auto' promises, on purpose. An app that changes its dark class without telling us shows a stale Gantt until it does. That is the same contract AG Grid and Bootstrap ship, and the cost lands in one line of app code rather than in a permanent observer over a document we do not control.

checkResolvedTheme() stays. An app that moves the Gantt under a differently-pinned wrapper changes the answer without changing any attribute, so there is one explicit re-ask. It is a method, called once, not a subscription.