Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
13 changes: 12 additions & 1 deletion buckaroo/dataflow/styling_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,13 +155,24 @@
'default_renderer_columns': NotRequired[List[str]] # used to render index column values with string not the specified displayer
})

ThemeConfig = TypedDict('ThemeConfig', {
'colorScheme': NotRequired[Literal["light", "dark", "auto"]],
'accentColor': NotRequired[str],
'accentHoverColor': NotRequired[str],
'backgroundColor': NotRequired[str],
'foregroundColor': NotRequired[str],
'oddRowBackgroundColor': NotRequired[str],
'borderColor': NotRequired[str],
})

ComponentConfig = TypedDict('ComponentConfig', {
'height_fraction': NotRequired[float],
'dfvHeight': NotRequired[int], # temporary debugging prop
'layoutType': NotRequired[Literal["autoHeight", "normal"]],
'shortMode': NotRequired[bool],
'selectionBackground': NotRequired[str],
'className': NotRequired[str]
'className': NotRequired[str],
'theme': NotRequired[ThemeConfig],
})

DFViewerConfig = TypedDict('DFViewerConfig', {
Expand Down
23 changes: 18 additions & 5 deletions buckaroo/server/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,22 +149,23 @@ def _parse_request_body(self) -> dict:
return body

def _validate_request(self, body: dict) -> tuple:
"""Validate and extract session_id, path, mode, prompt, and no_browser from request.
"""Validate and extract session_id, path, mode, prompt, no_browser, and component_config from request.

Returns (session_id, path, mode, prompt, no_browser) or a tuple of Nones on error.
Returns (session_id, path, mode, prompt, no_browser, component_config) or a tuple of Nones on error.
"""
session_id = body.get("session")
path = body.get("path")

if not session_id or not path:
self.set_status(400)
self.write({"error": "Missing 'session' or 'path'"})
return None, None, None, None, None
return None, None, None, None, None, None

mode = body.get("mode", "viewer")
prompt = body.get("prompt", "")
no_browser = bool(body.get("no_browser", False))
return session_id, path, mode, prompt, no_browser
component_config = body.get("component_config")
return session_id, path, mode, prompt, no_browser, component_config

def _load_lazy_polars(self, session, path: str, ldf, metadata: dict):
"""Set up lazy polars session state."""
Expand Down Expand Up @@ -235,14 +236,16 @@ async def post(self):
if body is None:
return

session_id, path, mode, prompt, no_browser = self._validate_request(body)
session_id, path, mode, prompt, no_browser, component_config = self._validate_request(body)
if session_id is None:
return

sessions = self.application.settings["sessions"]
session = sessions.get_or_create(session_id, path)
session.mode = mode
session.prompt = prompt
if component_config:
session.component_config = component_config

# Load data in appropriate mode
file_obj, metadata = self._load_file_with_error_handling(path, is_lazy=(mode == "lazy"))
Expand Down Expand Up @@ -272,6 +275,16 @@ async def post(self):
session.df_data_dict = display_state["df_data_dict"]
session.df_meta = display_state["df_meta"]

# Merge component_config into df_display_args if provided
if component_config and session.df_display_args:
for key in session.df_display_args:
dvc = session.df_display_args[key].get("df_viewer_config")
if dvc is not None:
dvc["component_config"] = {
**dvc.get("component_config", {}),
**component_config,
Comment on lines +283 to +285

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Persist component_config in buckaroo dataflow state

LoadHandler.post only overlays component_config onto session.df_display_args here, but buckaroo interactions later rebuild df_display_args from session.dataflow in DataStreamHandler._handle_buckaroo_state_change (websocket_handler.py), which never receives this config. In server mode="buckaroo", theme/custom component settings from /load are therefore lost after the first state-changing action (for example changing cleaning/post-processing), so the new feature does not persist through normal usage.

Useful? React with 👍 / 👎.

}

# Notify connected clients and open browser
self._push_state_to_clients(session, metadata)
browser_action = "skipped" if no_browser else self._handle_browser_window(session_id)
Expand Down
84 changes: 84 additions & 0 deletions packages/buckaroo-js-core/pw-tests/theme-custom-server.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { test, expect } from '@playwright/test';
import { waitForGrid } from './server-helpers';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const screenshotsDir = path.join(__dirname, '..', 'screenshots');

const PORT = 8701;
const BASE = `http://localhost:${PORT}`;

function writeTempCsv(): string {
const header = 'name,age,score';
const rows = [
'Alice,30,88.5',
'Bob,25,92.3',
'Charlie,35,76.1',
'Diana,28,95.0',
'Eve,32,81.7',
];
const content = [header, ...rows].join('\n') + '\n';
const tmpPath = path.join(os.tmpdir(), `buckaroo_theme_${Date.now()}.csv`);
fs.writeFileSync(tmpPath, content);
return tmpPath;
}

test.beforeAll(() => {
fs.mkdirSync(screenshotsDir, { recursive: true });
});

test('server: theme config applied via /load', async ({ page, request }) => {
const csvPath = writeTempCsv();
const session = `theme-${Date.now()}`;
const resp = await request.post(`${BASE}/load`, {
data: {
session,
path: csvPath,
component_config: {
theme: {
accentColor: '#ff6600',
backgroundColor: '#1a1a2e',
colorScheme: 'dark',
},
},
},
});
expect(resp.ok()).toBeTruthy();

await page.goto(`${BASE}/s/${session}`);
await waitForGrid(page);

// Assert background color on the grid
const gridBody = page.locator('.ag-body-viewport').first();
const bg = await gridBody.evaluate(el => getComputedStyle(el).backgroundColor);
expect(bg).toBe('rgb(26, 26, 46)'); // #1a1a2e

await page.screenshot({
path: path.join(screenshotsDir, 'server-theme-custom.png'),
fullPage: true,
});

fs.unlinkSync(csvPath);
});

test('server: no theme = default rendering', async ({ page, request }) => {
const csvPath = writeTempCsv();
const session = `no-theme-${Date.now()}`;
const resp = await request.post(`${BASE}/load`, {
data: { session, path: csvPath },
});
expect(resp.ok()).toBeTruthy();

await page.emulateMedia({ colorScheme: 'light' });
await page.goto(`${BASE}/s/${session}`);
await waitForGrid(page);

const gridBody = page.locator('.ag-body-viewport').first();
const bg = await gridBody.evaluate(el => getComputedStyle(el).backgroundColor);
expect(bg).toBe('rgb(255, 255, 255)');

fs.unlinkSync(csvPath);
});
92 changes: 92 additions & 0 deletions packages/buckaroo-js-core/pw-tests/theme-custom.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { test, expect } from '@playwright/test';
import { waitForCells } from './ag-pw-utils';
import * as path from 'path';
import * as fs from 'fs';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const screenshotsDir = path.join(__dirname, '..', 'screenshots');

const STORYBOOK_BASE = 'http://localhost:6006/iframe.html?viewMode=story&id=';

// Ensure screenshots directory exists
test.beforeAll(() => {
fs.mkdirSync(screenshotsDir, { recursive: true });
});

test('default story renders without theme overrides', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'light' });
await page.goto(`${STORYBOOK_BASE}buckaroo-theme-themecustomization--default-no-theme`);
await waitForCells(page);

const gridBody = page.locator('.ag-body-viewport').first();
const bg = await gridBody.evaluate(el => getComputedStyle(el).backgroundColor);
// Light mode default: white
expect(bg).toBe('rgb(255, 255, 255)');
});

test('custom accent color applied to selected column', async ({ page }) => {
await page.goto(`${STORYBOOK_BASE}buckaroo-theme-themecustomization--custom-accent`);
await waitForCells(page);

// Click column header to select it
await page.locator('.ag-header-cell').nth(1).click();

// Assert the accent color is applied to cells
const cell = page.locator('.ag-cell[col-id="a"]').first();
await expect(cell).toHaveCSS('background-color', 'rgb(255, 102, 0)'); // #ff6600
});

test('forced dark scheme ignores OS light preference', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'light' }); // OS says light
await page.goto(`${STORYBOOK_BASE}buckaroo-theme-themecustomization--forced-dark`);
await waitForCells(page);

// Grid should use dark background despite OS light mode
const gridBody = page.locator('.ag-body-viewport').first();
const bg = await gridBody.evaluate(el => getComputedStyle(el).backgroundColor);
expect(bg).toBe('rgb(26, 26, 46)'); // #1a1a2e
});

test('forced light scheme ignores OS dark preference', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' }); // OS says dark
await page.goto(`${STORYBOOK_BASE}buckaroo-theme-themecustomization--forced-light`);
await waitForCells(page);

const gridBody = page.locator('.ag-body-viewport').first();
const bg = await gridBody.evaluate(el => getComputedStyle(el).backgroundColor);
expect(bg).toBe('rgb(250, 250, 250)'); // #fafafa
});

test('full custom theme applies all properties', async ({ page }) => {
await page.goto(`${STORYBOOK_BASE}buckaroo-theme-themecustomization--full-custom`);
await waitForCells(page);

// Background
const gridBody = page.locator('.ag-body-viewport').first();
const bg = await gridBody.evaluate(el => getComputedStyle(el).backgroundColor);
expect(bg).toBe('rgb(26, 26, 46)'); // #1a1a2e

// Screenshot for visual debugging
await page.screenshot({
path: path.join(screenshotsDir, 'theme-full-custom.png'),
fullPage: true,
});
});

test('screenshot: default vs custom comparison', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'light' });
await page.goto(`${STORYBOOK_BASE}buckaroo-theme-themecustomization--default-no-theme`);
await waitForCells(page);
await page.screenshot({
path: path.join(screenshotsDir, 'theme-default-light.png'),
fullPage: true,
});

await page.goto(`${STORYBOOK_BASE}buckaroo-theme-themecustomization--forced-dark`);
await waitForCells(page);
await page.screenshot({
path: path.join(screenshotsDir, 'theme-forced-dark.png'),
fullPage: true,
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ export function BuckarooInfiniteWidget({
buckarooState={buckaroo_state}
setBuckarooState={on_buckaroo_state}
buckarooOptions={buckaroo_options}
themeConfig={cDisp.df_viewer_config?.component_config?.theme}
/>
<DFViewerInfinite
data_wrapper={data_wrapper}
Expand Down
1 change: 1 addition & 0 deletions packages/buckaroo-js-core/src/components/DCFCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export function WidgetDCFCell({
buckarooState={buckaroo_state}
setBuckarooState={on_buckaroo_state}
buckarooOptions={buckaroo_options}
themeConfig={cDisp.df_viewer_config?.component_config?.theme}
/>
<DFViewer
df_data={dfData}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ import {
HeightStyleI,
SetColumnFunc
} from "./gridUtils";
import { getThemeForScheme } from './gridUtils';
import { getThemeForScheme, resolveColorScheme } from './gridUtils';
import { useColorScheme } from '../useColorScheme';
import type { ThemeConfig } from './gridUtils';

ModuleRegistry.registerModules([ClientSideRowModelModule]);
ModuleRegistry.registerModules([InfiniteRowModelModule]);
Expand Down Expand Up @@ -156,13 +157,24 @@ export function DFViewerInfinite({
)}, [hsCacheKey]
);
const defaultActiveCol:[string, string] = ["", ""];
const colorScheme = useColorScheme();
const defaultThemeClass = colorScheme === 'light' ? 'ag-theme-alpine' : 'ag-theme-alpine-dark';
const divClass = df_viewer_config?.component_config?.className || defaultThemeClass;
const osColorScheme = useColorScheme();
const themeConfig = compConfig?.theme;
const effectiveScheme = resolveColorScheme(osColorScheme, themeConfig);
const defaultThemeClass = effectiveScheme === 'light' ? 'ag-theme-alpine' : 'ag-theme-alpine-dark';
const divClass = `${defaultThemeClass} ${compConfig?.className || ''}`.trim();

const themeStyle: React.CSSProperties = {
...hs.applicableStyle,
...(themeConfig?.accentColor ? { '--bk-accent-color': themeConfig.accentColor } as any : {}),
...(themeConfig?.accentHoverColor ? { '--bk-accent-hover-color': themeConfig.accentHoverColor } as any : {}),
...(themeConfig?.backgroundColor ? { '--bk-bg-color': themeConfig.backgroundColor } as any : {}),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor forced colorScheme when backgroundColor is omitted

This style injection only sets --bk-bg-color when themeConfig.backgroundColor is provided, so a config like { colorScheme: 'dark' } leaves no background variable and .theme-hanger falls back to the OS media-query defaults in CSS. As a result, forced light/dark mode can still show the opposite wrapper/empty-grid background unless callers also set backgroundColor, which breaks the expected behavior of colorScheme override by itself.

Useful? React with 👍 / 👎.

...(themeConfig?.foregroundColor ? { '--bk-fg-color': themeConfig.foregroundColor } as any : {}),
};

return (
<div className={`df-viewer ${hs.classMode} ${hs.inIframe}`}>
{error_info ? <pre>{error_info}</pre> : null}
<div style={hs.applicableStyle}
<div style={themeStyle}
className={`theme-hanger ${divClass}`}>
<DFViewerInfiniteInner
data_wrapper={data_wrapper}
Expand All @@ -173,6 +185,8 @@ export function DFViewerInfinite({
outside_df_params={outside_df_params}
renderStartTime={renderStartTime}
hs={hs}
themeConfig={themeConfig}
effectiveScheme={effectiveScheme}
/>
</div>
</div>)
Expand All @@ -185,7 +199,9 @@ export function DFViewerInfiniteInner({
setActiveCol,
outside_df_params,
renderStartTime: _renderStartTime,
hs
hs,
themeConfig,
effectiveScheme
}: {
data_wrapper: DatasourceOrRaw;
df_viewer_config: DFViewerConfig;
Expand All @@ -197,7 +213,9 @@ export function DFViewerInfiniteInner({
// them as keys to get updated data
outside_df_params?: any;
renderStartTime:any;
hs:HeightStyleI
hs:HeightStyleI;
themeConfig?: ThemeConfig;
effectiveScheme?: 'light' | 'dark';
}) {


Expand Down Expand Up @@ -255,7 +273,7 @@ export function DFViewerInfiniteInner({
}
if (activeCol === field) {
//return {background:selectBackground}
return { background: AccentColor }
return { background: themeConfig?.accentColor || AccentColor }

}
return { background: "inherit" }
Expand Down Expand Up @@ -290,15 +308,15 @@ export function DFViewerInfiniteInner({
[outside_df_params],
);

const colorScheme = useColorScheme();
const myTheme = useMemo(() => getThemeForScheme(colorScheme).withParams({
const resolvedScheme = effectiveScheme || 'dark';
const myTheme = useMemo(() => getThemeForScheme(resolvedScheme, themeConfig).withParams({
headerRowBorder: true,
headerColumnBorder: true,
headerColumnResizeHandleWidth: 0,
...(colorScheme === 'dark'
? { backgroundColor: "#121212", oddRowBackgroundColor: '#3f3f3f' }
: { backgroundColor: "#ffffff", oddRowBackgroundColor: '#f0f0f0' }),
}), [colorScheme]);
...(resolvedScheme === 'dark'
? { backgroundColor: themeConfig?.backgroundColor || "#121212", oddRowBackgroundColor: themeConfig?.oddRowBackgroundColor || '#3f3f3f' }
: { backgroundColor: themeConfig?.backgroundColor || "#ffffff", oddRowBackgroundColor: themeConfig?.oddRowBackgroundColor || '#f0f0f0' }),
}), [resolvedScheme, themeConfig]);
const gridOptions: GridOptions = useMemo( () => {
return {
...outerGridOptions(setActiveCol, df_viewer_config.extra_grid_config),
Expand Down
Loading
Loading