Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bestax-mcp/data/components/Theme.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion bestax-mcp/data/skills.json
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@
{
"id": "css-variables",
"file": "references/css-variables.md",
"bytes": 28095
"bytes": 28577
},
{
"id": "themeable-components",
Expand Down
76 changes: 57 additions & 19 deletions bulma-ui/src/helpers/Theme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import classNames from './classNames';
import { useBulmaClasses, BulmaClassesProps } from './useBulmaClasses';
import { validGaps, validRadii, type BulmaGapStep } from './bulmaClassHelpers';
import { warnOnce } from './devWarnings';
import { useClassPrefix } from './Config';

// --- FULL Bulma v1 CSS variable keys (auto-generated from CSSVAR_KEYS) ---
const bulmaCssVars = [
Expand Down Expand Up @@ -782,10 +783,6 @@ const setRootThemeRules = (order: number, rules: string): void => {
*
* @property {React.ReactNode} children - Content to render inside the theme scope.
* @property {string} [className] - Additional CSS classes (only when isRoot is false).
* @property {'light' | 'dark' | 'system'} [colorMode] - Set Bulma's light/dark scheme by writing
* the `data-theme` attribute on the document root (`<html>`). This is always global, even on a
* scoped Theme. `'system'` removes the attribute so Bulma follows the OS `prefers-color-scheme`.
* Omit to leave the current setting untouched.
* @property {BulmaVars} [bulmaVars] - Object mapping Bulma CSS variable names to values.
* @property {string} [schemeH] - Scheme hue value.
* @property {string} [schemeS] - Scheme saturation value.
Expand Down Expand Up @@ -850,6 +847,17 @@ export interface ThemeProps extends Omit<
* server render does not include them.
*/
isRoot?: boolean;
/**
* Set Bulma's light/dark scheme by writing its theme attribute on the
* document root (`<html>`). This is always global, even on a scoped Theme.
* `'system'` removes the attribute so Bulma follows the OS
* `prefers-color-scheme`. Omit to leave the current setting untouched.
*
* The attribute is `data-theme`. A stylesheet built with a class prefix
* reads Bulma's prefixed form instead (`data-bestax-theme` for the
* `bestax-prefixed` builds), so under a `ConfigProvider` `classPrefix`
* Theme writes both, and `'system'` or unmounting clears or restores both.
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
*/
colorMode?: 'light' | 'dark' | 'system';
bulmaVars?: BulmaVars;
/**
Expand Down Expand Up @@ -1090,32 +1098,62 @@ export const Theme: React.FC<ThemeProps> = ({
// it between the cleanup and the next run.
useEffect(() => () => setRootThemeRules(rootOrder, ''), [rootOrder]);

// Toggle Bulma's light/dark scheme by writing the `data-theme` attribute on
// the document root (<html>). This is always global, even on a scoped Theme.
// `'system'` removes the attribute so Bulma follows the OS preference.
// Bulma names its scheme attribute after the class prefix a stylesheet was
// built with (`data-<prefix>theme`), so the prefixed builds read
// `data-bestax-theme` and never `data-theme`.
const classPrefix = useClassPrefix();

// Toggle Bulma's light/dark scheme by writing the theme attribute on the
// document root (<html>). This is always global, even on a scoped Theme.
// Under a class prefix the prefixed attribute is written as well as
// `data-theme`, so whichever build is loaded sees it; with no prefix only
// `data-theme` is. `'system'` removes them so Bulma follows the OS
// preference.
useEffect(() => {
if (colorMode === undefined) {
return;
}

const root = document.documentElement;
const previous = root.getAttribute('data-theme');
const names = classPrefix
? ['data-theme', `data-${classPrefix}theme`]
: ['data-theme'];
const previous = names.map(name => root.getAttribute(name));

if (colorMode === 'system') {
root.removeAttribute('data-theme');
} else {
root.setAttribute('data-theme', colorMode);
for (const name of names) {
if (colorMode === 'system') {
root.removeAttribute(name);
continue;
}
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
try {
root.setAttribute(name, colorMode);
} catch {
// A class prefix can hold characters an attribute name cannot.
// `data-theme` is still written, as it was before the prefixed
// attribute existed, but a sheet built with that prefix keeps its
// scheme, so say so.
warnOnce(
'Theme:color-mode-prefix',
`[bestax-bulma] <Theme colorMode="${colorMode}">: the class ` +
`prefix "${classPrefix}" makes ${name} an invalid attribute ` +
'name, so only data-theme is written, and a stylesheet built ' +
'with that prefix keeps its scheme.'
);
}
}

// Restore the previous value when colorMode changes or the component unmounts.
// Restore the previous values when colorMode changes or the component unmounts.
return () => {
if (previous === null) {
root.removeAttribute('data-theme');
} else {
root.setAttribute('data-theme', previous);
}
names.forEach((name, i) => {
const value = previous[i];
if (value === null) {
root.removeAttribute(name);
} else {
root.setAttribute(name, value);
}
});
};
}, [colorMode]);
}, [colorMode, classPrefix]);

// For local injection (when isRoot is false), prepare style object for CSS vars
const style: CSSProperties = useMemo(() => {
Expand Down
180 changes: 180 additions & 0 deletions bulma-ui/src/helpers/__tests__/Theme.styles.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// `colorMode` flips Bulma's scheme by writing a theme attribute on <html>.
// Bulma names that attribute after the class prefix a sheet was built with
// (`data-<prefix>theme`), so a prefixed build never reads `data-theme`. An
// attribute reaching the DOM proves nothing about which one the sheet reads,
// so these compile every published stylesheet that carries a dark scheme and
// resolve the scheme variable on the root, the way DateInput.styles.test.tsx
// checks the calendar against its real SCSS.
//
// jsdom applies an `@media` block only when its list names `screen`, so the
// visitor's OS preference is simulated by renaming the sheet's own
// `prefers-color-scheme` block for that scheme, which keeps the block where
// Bulma put it in the cascade.
import fs from 'fs';
import path from 'path';
import * as sass from 'sass';
import { render } from '@testing-library/react';
import { ConfigProvider } from '../Config';
import { Theme } from '../Theme';

const PKG = path.resolve(__dirname, '../../..');
const SCSS = path.join(PKG, 'src', 'scss');

const compile = (file: string) =>
sass.compile(path.join(SCSS, file), {
loadPaths: [path.resolve(PKG, '../node_modules')],
quietDeps: true,
logger: sass.Logger.silent,
}).css;

/**
* Every published stylesheet built with Bulma's full theme set, read off
* `exports` the way Loader.styles.test.tsx does, with the class prefix it
* sets. The light-only flavor and `extras.css` carry no dark scheme for
* `colorMode` to reach.
*/
function themedStylesheets(): Array<{ file: string; prefix: string }> {
const manifest = JSON.parse(
fs.readFileSync(path.join(PKG, 'package.json'), 'utf8')
) as { exports: Record<string, { default?: string }> };
const files = new Set<string>();
for (const entry of Object.values(manifest.exports)) {
const target = entry?.default;
if (target?.endsWith('.css')) {
files.add(target.replace(/^\.\/dist\//, '').replace(/\.css$/, '.scss'));
}
}
return [...files]
.sort()
.map(file => ({
file,
source: fs.readFileSync(path.join(SCSS, file), 'utf8'),
}))
.filter(({ source }) => /bulma\/sass(?:\/themes)?['"]/.test(source))
.map(({ file, source }) => ({
file,
prefix: /\$class-prefix:\s*["']([^"']*)["']/.exec(source)?.[1] ?? '',
}));
}

const SHEETS = themedStylesheets();

it('checks a prefixed and an unprefixed stylesheet', () => {
// Floor against the manifest read or the filter going quietly empty.
expect(SHEETS.map(sheet => sheet.prefix)).toEqual(
expect.arrayContaining(['', 'bestax-'])
);
});
Comment thread
claude[bot] marked this conversation as resolved.
Resolved

type Os = 'light' | 'dark';

/** The sheet as a visitor whose OS prefers `os` sees it. */
const forOs = (css: string, os: Os) => {
const block = `@media (prefers-color-scheme: ${os})`;
expect(css).toContain(block);
return css.split(block).join('@media screen');
};

/** What the root resolves `--bulma-scheme-main-l` to. */
const schemeMainL = () =>
getComputedStyle(document.documentElement)
.getPropertyValue('--bulma-scheme-main-l')
.trim();

describe.each(SHEETS)('$file', ({ file, prefix }) => {
let css: string;
let light: string;
let dark: string;
let sheet: HTMLStyleElement | undefined;

const loadAs = (os: Os) => {
sheet?.remove();
sheet = document.createElement('style');
sheet.textContent = forOs(css, os);
document.head.appendChild(sheet);
};

beforeAll(() => {
css = compile(file);
// The scheme each of Bulma's own theme blocks sets, read off the sheet,
// so the expectations follow Bulma rather than restating its values.
const value = (scheme: Os) => {
const rule = new RegExp(
`\\[data-${prefix}theme=${scheme}\\][^{]*\\{[^}]*--bulma-scheme-main-l:\\s*([^;]+);`
).exec(css);
if (!rule) throw new Error(`${file} has no ${scheme} theme block`);
return rule[1].trim();
};
light = value('light');
dark = value('dark');
expect(light).not.toBe(dark);
}, 60000);

afterEach(() => {
sheet?.remove();
sheet = undefined;
for (const name of Array.from(document.documentElement.attributes)) {
if (name.name.endsWith('theme')) {
document.documentElement.removeAttribute(name.name);
}
}
});

const renderTheme = (
colorMode: 'light' | 'dark' | 'system',
isRoot: boolean
) =>
render(
<ConfigProvider classPrefix={prefix}>
<Theme colorMode={colorMode} isRoot={isRoot}>
<span>Content</span>
</Theme>
</ConfigProvider>
);

it('follows the OS when nothing sets the scheme', () => {
loadAs('light');
expect(schemeMainL()).toBe(light);
loadAs('dark');
expect(schemeMainL()).toBe(dark);
});

describe.each([
['a scoped Theme', false],
['a root Theme', true],
])('%s', (_label, isRoot) => {
it('turns the page dark with colorMode="dark" on a light OS', () => {
loadAs('light');
renderTheme('dark', isRoot);
expect(schemeMainL()).toBe(dark);
});

it('keeps the page light with colorMode="light" on a dark OS', () => {
loadAs('dark');
renderTheme('light', isRoot);
expect(schemeMainL()).toBe(light);
});

it('hands the scheme back to the OS with colorMode="system"', () => {
loadAs('dark');
const { rerender } = renderTheme('light', isRoot);
expect(schemeMainL()).toBe(light);
rerender(
<ConfigProvider classPrefix={prefix}>
<Theme colorMode="system" isRoot={isRoot}>
<span>Content</span>
</Theme>
</ConfigProvider>
);
expect(schemeMainL()).toBe(dark);
});

it('hands the scheme back when it unmounts', () => {
loadAs('light');
const { unmount } = renderTheme('dark', isRoot);
expect(schemeMainL()).toBe(dark);
unmount();
expect(schemeMainL()).toBe(light);
});
});
});
Loading
Loading