Skip to content

Commit 8f15640

Browse files
paddymulclaude
andcommitted
feat: add custom theme support via component_config
Add ThemeConfig (colorScheme, accentColor, accentHoverColor, backgroundColor, foregroundColor, oddRowBackgroundColor, borderColor) nested in ComponentConfig. Flows through existing pipeline — Python widget, server /load endpoint, and JS rendering all support theme overrides with no new traitlets or sync wiring. Resolves #582 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9bec65f commit 8f15640

13 files changed

Lines changed: 469 additions & 33 deletions

File tree

buckaroo/dataflow/styling_core.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,13 +160,24 @@
160160
'default_renderer_columns': NotRequired[List[str]] # used to render index column values with string not the specified displayer
161161
})
162162

163+
ThemeConfig = TypedDict('ThemeConfig', {
164+
'colorScheme': NotRequired[Literal["light", "dark", "auto"]],
165+
'accentColor': NotRequired[str],
166+
'accentHoverColor': NotRequired[str],
167+
'backgroundColor': NotRequired[str],
168+
'foregroundColor': NotRequired[str],
169+
'oddRowBackgroundColor': NotRequired[str],
170+
'borderColor': NotRequired[str],
171+
})
172+
163173
ComponentConfig = TypedDict('ComponentConfig', {
164174
'height_fraction': NotRequired[float],
165175
'dfvHeight': NotRequired[int], # temporary debugging prop
166176
'layoutType': NotRequired[Literal["autoHeight", "normal"]],
167177
'shortMode': NotRequired[bool],
168178
'selectionBackground': NotRequired[str],
169-
'className': NotRequired[str]
179+
'className': NotRequired[str],
180+
'theme': NotRequired[ThemeConfig],
170181
})
171182

172183
DFViewerConfig = TypedDict('DFViewerConfig', {

buckaroo/server/handlers.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,22 +151,23 @@ def _parse_request_body(self) -> dict:
151151
return body
152152

153153
def _validate_request(self, body: dict) -> tuple:
154-
"""Validate and extract session_id, path, mode, prompt, and no_browser from request.
154+
"""Validate and extract session_id, path, mode, prompt, no_browser, and component_config from request.
155155
156-
Returns (session_id, path, mode, prompt, no_browser) or a tuple of Nones on error.
156+
Returns (session_id, path, mode, prompt, no_browser, component_config) or a tuple of Nones on error.
157157
"""
158158
session_id = body.get("session")
159159
path = body.get("path")
160160

161161
if not session_id or not path:
162162
self.set_status(400)
163163
self.write({"error": "Missing 'session' or 'path'"})
164-
return None, None, None, None, None
164+
return None, None, None, None, None, None
165165

166166
mode = body.get("mode", "viewer")
167167
prompt = body.get("prompt", "")
168168
no_browser = bool(body.get("no_browser", False))
169-
return session_id, path, mode, prompt, no_browser
169+
component_config = body.get("component_config")
170+
return session_id, path, mode, prompt, no_browser, component_config
170171

171172
def _load_lazy_polars(self, session, path: str, ldf, metadata: dict):
172173
"""Set up lazy polars session state."""
@@ -237,14 +238,16 @@ async def post(self):
237238
if body is None:
238239
return
239240

240-
session_id, path, mode, prompt, no_browser = self._validate_request(body)
241+
session_id, path, mode, prompt, no_browser, component_config = self._validate_request(body)
241242
if session_id is None:
242243
return
243244

244245
sessions = self.application.settings["sessions"]
245246
session = sessions.get_or_create(session_id, path)
246247
session.mode = mode
247248
session.prompt = prompt
249+
if component_config:
250+
session.component_config = component_config
248251

249252
# Load data in appropriate mode
250253
file_obj, metadata = self._load_file_with_error_handling(path, is_lazy=(mode == "lazy"))
@@ -274,6 +277,16 @@ async def post(self):
274277
session.df_data_dict = display_state["df_data_dict"]
275278
session.df_meta = display_state["df_meta"]
276279

280+
# Merge component_config into df_display_args if provided
281+
if component_config and session.df_display_args:
282+
for key in session.df_display_args:
283+
dvc = session.df_display_args[key].get("df_viewer_config")
284+
if dvc is not None:
285+
dvc["component_config"] = {
286+
**dvc.get("component_config", {}),
287+
**component_config,
288+
}
289+
277290
# Notify connected clients and open browser
278291
self._push_state_to_clients(session, metadata)
279292
browser_action = "skipped" if no_browser else self._handle_browser_window(session_id)
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { test, expect } from '@playwright/test';
2+
import { waitForGrid } from './server-helpers';
3+
import * as fs from 'fs';
4+
import * as path from 'path';
5+
import * as os from 'os';
6+
import { fileURLToPath } from 'url';
7+
8+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
9+
const screenshotsDir = path.join(__dirname, '..', 'screenshots');
10+
11+
const PORT = 8701;
12+
const BASE = `http://localhost:${PORT}`;
13+
14+
function writeTempCsv(): string {
15+
const header = 'name,age,score';
16+
const rows = [
17+
'Alice,30,88.5',
18+
'Bob,25,92.3',
19+
'Charlie,35,76.1',
20+
'Diana,28,95.0',
21+
'Eve,32,81.7',
22+
];
23+
const content = [header, ...rows].join('\n') + '\n';
24+
const tmpPath = path.join(os.tmpdir(), `buckaroo_theme_${Date.now()}.csv`);
25+
fs.writeFileSync(tmpPath, content);
26+
return tmpPath;
27+
}
28+
29+
test.beforeAll(() => {
30+
fs.mkdirSync(screenshotsDir, { recursive: true });
31+
});
32+
33+
test('server: theme config applied via /load', async ({ page, request }) => {
34+
const csvPath = writeTempCsv();
35+
const session = `theme-${Date.now()}`;
36+
const resp = await request.post(`${BASE}/load`, {
37+
data: {
38+
session,
39+
path: csvPath,
40+
component_config: {
41+
theme: {
42+
accentColor: '#ff6600',
43+
backgroundColor: '#1a1a2e',
44+
colorScheme: 'dark',
45+
},
46+
},
47+
},
48+
});
49+
expect(resp.ok()).toBeTruthy();
50+
51+
await page.goto(`${BASE}/s/${session}`);
52+
await waitForGrid(page);
53+
54+
// Assert background color on the grid
55+
const gridBody = page.locator('.ag-body-viewport').first();
56+
const bg = await gridBody.evaluate(el => getComputedStyle(el).backgroundColor);
57+
expect(bg).toBe('rgb(26, 26, 46)'); // #1a1a2e
58+
59+
await page.screenshot({
60+
path: path.join(screenshotsDir, 'server-theme-custom.png'),
61+
fullPage: true,
62+
});
63+
64+
fs.unlinkSync(csvPath);
65+
});
66+
67+
test('server: no theme = default rendering', async ({ page, request }) => {
68+
const csvPath = writeTempCsv();
69+
const session = `no-theme-${Date.now()}`;
70+
const resp = await request.post(`${BASE}/load`, {
71+
data: { session, path: csvPath },
72+
});
73+
expect(resp.ok()).toBeTruthy();
74+
75+
await page.emulateMedia({ colorScheme: 'light' });
76+
await page.goto(`${BASE}/s/${session}`);
77+
await waitForGrid(page);
78+
79+
const gridBody = page.locator('.ag-body-viewport').first();
80+
const bg = await gridBody.evaluate(el => getComputedStyle(el).backgroundColor);
81+
expect(bg).toBe('rgb(255, 255, 255)');
82+
83+
fs.unlinkSync(csvPath);
84+
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { test, expect } from '@playwright/test';
2+
import { waitForCells } from './ag-pw-utils';
3+
import * as path from 'path';
4+
import * as fs from 'fs';
5+
import { fileURLToPath } from 'url';
6+
7+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
8+
const screenshotsDir = path.join(__dirname, '..', 'screenshots');
9+
10+
const STORYBOOK_BASE = 'http://localhost:6006/iframe.html?viewMode=story&id=';
11+
12+
// Ensure screenshots directory exists
13+
test.beforeAll(() => {
14+
fs.mkdirSync(screenshotsDir, { recursive: true });
15+
});
16+
17+
test('default story renders without theme overrides', async ({ page }) => {
18+
await page.emulateMedia({ colorScheme: 'light' });
19+
await page.goto(`${STORYBOOK_BASE}buckaroo-theme-themecustomization--default-no-theme`);
20+
await waitForCells(page);
21+
22+
const gridBody = page.locator('.ag-body-viewport').first();
23+
const bg = await gridBody.evaluate(el => getComputedStyle(el).backgroundColor);
24+
// Light mode default: white
25+
expect(bg).toBe('rgb(255, 255, 255)');
26+
});
27+
28+
test('custom accent color applied to selected column', async ({ page }) => {
29+
await page.goto(`${STORYBOOK_BASE}buckaroo-theme-themecustomization--custom-accent`);
30+
await waitForCells(page);
31+
32+
// Click column header to select it
33+
await page.locator('.ag-header-cell').nth(1).click();
34+
35+
// Assert the accent color is applied to cells
36+
const cell = page.locator('.ag-cell[col-id="a"]').first();
37+
await expect(cell).toHaveCSS('background-color', 'rgb(255, 102, 0)'); // #ff6600
38+
});
39+
40+
test('forced dark scheme ignores OS light preference', async ({ page }) => {
41+
await page.emulateMedia({ colorScheme: 'light' }); // OS says light
42+
await page.goto(`${STORYBOOK_BASE}buckaroo-theme-themecustomization--forced-dark`);
43+
await waitForCells(page);
44+
45+
// Grid should use dark background despite OS light mode
46+
const gridBody = page.locator('.ag-body-viewport').first();
47+
const bg = await gridBody.evaluate(el => getComputedStyle(el).backgroundColor);
48+
expect(bg).toBe('rgb(26, 26, 46)'); // #1a1a2e
49+
});
50+
51+
test('forced light scheme ignores OS dark preference', async ({ page }) => {
52+
await page.emulateMedia({ colorScheme: 'dark' }); // OS says dark
53+
await page.goto(`${STORYBOOK_BASE}buckaroo-theme-themecustomization--forced-light`);
54+
await waitForCells(page);
55+
56+
const gridBody = page.locator('.ag-body-viewport').first();
57+
const bg = await gridBody.evaluate(el => getComputedStyle(el).backgroundColor);
58+
expect(bg).toBe('rgb(250, 250, 250)'); // #fafafa
59+
});
60+
61+
test('full custom theme applies all properties', async ({ page }) => {
62+
await page.goto(`${STORYBOOK_BASE}buckaroo-theme-themecustomization--full-custom`);
63+
await waitForCells(page);
64+
65+
// Background
66+
const gridBody = page.locator('.ag-body-viewport').first();
67+
const bg = await gridBody.evaluate(el => getComputedStyle(el).backgroundColor);
68+
expect(bg).toBe('rgb(26, 26, 46)'); // #1a1a2e
69+
70+
// Screenshot for visual debugging
71+
await page.screenshot({
72+
path: path.join(screenshotsDir, 'theme-full-custom.png'),
73+
fullPage: true,
74+
});
75+
});
76+
77+
test('screenshot: default vs custom comparison', async ({ page }) => {
78+
await page.emulateMedia({ colorScheme: 'light' });
79+
await page.goto(`${STORYBOOK_BASE}buckaroo-theme-themecustomization--default-no-theme`);
80+
await waitForCells(page);
81+
await page.screenshot({
82+
path: path.join(screenshotsDir, 'theme-default-light.png'),
83+
fullPage: true,
84+
});
85+
86+
await page.goto(`${STORYBOOK_BASE}buckaroo-theme-themecustomization--forced-dark`);
87+
await waitForCells(page);
88+
await page.screenshot({
89+
path: path.join(screenshotsDir, 'theme-forced-dark.png'),
90+
fullPage: true,
91+
});
92+
});

packages/buckaroo-js-core/src/components/BuckarooWidgetInfinite.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ export function BuckarooInfiniteWidget({
183183
buckarooState={buckaroo_state}
184184
setBuckarooState={on_buckaroo_state}
185185
buckarooOptions={buckaroo_options}
186+
themeConfig={cDisp.df_viewer_config?.component_config?.theme}
186187
/>
187188
<DFViewerInfinite
188189
data_wrapper={data_wrapper}

packages/buckaroo-js-core/src/components/DCFCell.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export function WidgetDCFCell({
6161
buckarooState={buckaroo_state}
6262
setBuckarooState={on_buckaroo_state}
6363
buckarooOptions={buckaroo_options}
64+
themeConfig={cDisp.df_viewer_config?.component_config?.theme}
6465
/>
6566
<DFViewer
6667
df_data={dfData}

packages/buckaroo-js-core/src/components/DFViewerParts/DFViewerInfinite.tsx

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@ import {
3434
HeightStyleI,
3535
SetColumnFunc
3636
} from "./gridUtils";
37-
import { getThemeForScheme } from './gridUtils';
37+
import { getThemeForScheme, resolveColorScheme } from './gridUtils';
3838
import { useColorScheme } from '../useColorScheme';
39+
import type { ThemeConfig } from './gridUtils';
3940

4041
ModuleRegistry.registerModules([
4142
ClientSideRowModelModule,
@@ -167,13 +168,24 @@ export function DFViewerInfinite({
167168
)}, [hsCacheKey]
168169
);
169170
const defaultActiveCol:[string, string] = ["", ""];
170-
const colorScheme = useColorScheme();
171-
const defaultThemeClass = colorScheme === 'light' ? 'ag-theme-alpine' : 'ag-theme-alpine-dark';
172-
const divClass = df_viewer_config?.component_config?.className || defaultThemeClass;
171+
const osColorScheme = useColorScheme();
172+
const themeConfig = compConfig?.theme;
173+
const effectiveScheme = resolveColorScheme(osColorScheme, themeConfig);
174+
const defaultThemeClass = effectiveScheme === 'light' ? 'ag-theme-alpine' : 'ag-theme-alpine-dark';
175+
const divClass = `${defaultThemeClass} ${compConfig?.className || ''}`.trim();
176+
177+
const themeStyle: React.CSSProperties = {
178+
...hs.applicableStyle,
179+
...(themeConfig?.accentColor ? { '--bk-accent-color': themeConfig.accentColor } as any : {}),
180+
...(themeConfig?.accentHoverColor ? { '--bk-accent-hover-color': themeConfig.accentHoverColor } as any : {}),
181+
...(themeConfig?.backgroundColor ? { '--bk-bg-color': themeConfig.backgroundColor } as any : {}),
182+
...(themeConfig?.foregroundColor ? { '--bk-fg-color': themeConfig.foregroundColor } as any : {}),
183+
};
184+
173185
return (
174186
<div className={`df-viewer ${hs.classMode} ${hs.inIframe}`}>
175187
{error_info ? <pre>{error_info}</pre> : null}
176-
<div style={hs.applicableStyle}
188+
<div style={themeStyle}
177189
className={`theme-hanger ${divClass}`}>
178190
<DFViewerInfiniteInner
179191
data_wrapper={data_wrapper}
@@ -184,6 +196,8 @@ export function DFViewerInfinite({
184196
outside_df_params={outside_df_params}
185197
renderStartTime={renderStartTime}
186198
hs={hs}
199+
themeConfig={themeConfig}
200+
effectiveScheme={effectiveScheme}
187201
/>
188202
</div>
189203
</div>)
@@ -196,7 +210,9 @@ export function DFViewerInfiniteInner({
196210
setActiveCol,
197211
outside_df_params,
198212
renderStartTime: _renderStartTime,
199-
hs
213+
hs,
214+
themeConfig,
215+
effectiveScheme
200216
}: {
201217
data_wrapper: DatasourceOrRaw;
202218
df_viewer_config: DFViewerConfig;
@@ -208,7 +224,9 @@ export function DFViewerInfiniteInner({
208224
// them as keys to get updated data
209225
outside_df_params?: any;
210226
renderStartTime:any;
211-
hs:HeightStyleI
227+
hs:HeightStyleI;
228+
themeConfig?: ThemeConfig;
229+
effectiveScheme?: 'light' | 'dark';
212230
}) {
213231

214232

@@ -266,7 +284,7 @@ export function DFViewerInfiniteInner({
266284
}
267285
if (activeCol === field) {
268286
//return {background:selectBackground}
269-
return { background: AccentColor }
287+
return { background: themeConfig?.accentColor || AccentColor }
270288

271289
}
272290
return { background: "inherit" }
@@ -301,15 +319,15 @@ export function DFViewerInfiniteInner({
301319
[outside_df_params],
302320
);
303321

304-
const colorScheme = useColorScheme();
305-
const myTheme = useMemo(() => getThemeForScheme(colorScheme).withParams({
322+
const resolvedScheme = effectiveScheme || 'dark';
323+
const myTheme = useMemo(() => getThemeForScheme(resolvedScheme, themeConfig).withParams({
306324
headerRowBorder: true,
307325
headerColumnBorder: true,
308326
headerColumnResizeHandleWidth: 0,
309-
...(colorScheme === 'dark'
310-
? { backgroundColor: "#121212", oddRowBackgroundColor: '#3f3f3f' }
311-
: { backgroundColor: "#ffffff", oddRowBackgroundColor: '#f0f0f0' }),
312-
}), [colorScheme]);
327+
...(resolvedScheme === 'dark'
328+
? { backgroundColor: themeConfig?.backgroundColor || "#121212", oddRowBackgroundColor: themeConfig?.oddRowBackgroundColor || '#3f3f3f' }
329+
: { backgroundColor: themeConfig?.backgroundColor || "#ffffff", oddRowBackgroundColor: themeConfig?.oddRowBackgroundColor || '#f0f0f0' }),
330+
}), [resolvedScheme, themeConfig]);
313331
const gridOptions: GridOptions = useMemo( () => {
314332
return {
315333
...outerGridOptions(setActiveCol, df_viewer_config.extra_grid_config),

0 commit comments

Comments
 (0)