Skip to content

Commit ec334f2

Browse files
haileyokcmtttclaudejuanmrad
authored
[osprey-ui] Phase 3 of UI 2.0 — re-PR onto main (replaces #242) (#246)
Co-authored-by: Leon Shi <101139283+cmttt@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Juan Mrad <juansmrad@gmail.com>
1 parent b985965 commit ec334f2

10 files changed

Lines changed: 242 additions & 64 deletions

File tree

osprey_ui/public/index.html

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,21 @@
1818
Learn how to configure a non-root public URL by running `npm run build`.
1919
-->
2020
<title>Osprey</title>
21+
<script>
22+
// Apply theme before paint so dark-mode users don't see a flash of light
23+
// mode. Mirrors getInitialMode() in stores/ThemeStore.tsx — keep in sync.
24+
(function () {
25+
try {
26+
var stored = window.localStorage.getItem('osprey-ui-dark-theme');
27+
var isDark =
28+
stored === 'true' ||
29+
(stored === null &&
30+
window.matchMedia &&
31+
window.matchMedia('(prefers-color-scheme: dark)').matches);
32+
if (isDark) document.documentElement.classList.add('dark-theme');
33+
} catch (e) {}
34+
})();
35+
</script>
2136
</head>
2237
<body>
2338
<noscript>You need to enable JavaScript to run this app.</noscript>

osprey_ui/src/App.tsx

Lines changed: 80 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as React from 'react';
2-
import { App as AntdApp, ConfigProvider, Spin } from 'antd';
2+
import { App as AntdApp, ConfigProvider, Spin, theme } from 'antd';
33
import { Router, Switch, Route } from 'react-router-dom';
44

55
import { getApplicationConfig } from './actions/ConfigActions';
@@ -15,6 +15,7 @@ import SavedQueries from './components/saved_queries/SavedQueries';
1515
import SavedQueryBar from './components/saved_queries/SavedQueryBar';
1616
import usePromiseResult from './hooks/usePromiseResult';
1717
import useApplicationConfigStore from './stores/ApplicationConfigStore';
18+
import useThemeStore from './stores/ThemeStore';
1819
import { history } from './stores/QueryStore';
1920
import { renderFromPromiseResult } from './utils/PromiseResultUtils';
2021

@@ -26,71 +27,97 @@ import { BulkActionPage } from './components/bulk_actions/BulkActionPage';
2627

2728
const AppRouter: React.FC = () => {
2829
const updateApplicationConfig = useApplicationConfigStore((state) => state.updateApplicationConfig);
30+
const themeMode = useThemeStore((state) => {
31+
return state.mode;
32+
});
2933

3034
const applicationConfigResult = usePromiseResult(async () => {
3135
const appConfig = await getApplicationConfig();
3236
updateApplicationConfig(appConfig);
3337
});
3438

35-
const theme = React.useMemo(() => {
39+
const isDark = themeMode === 'dark';
40+
// Mirror of CSS tokens in Colors.module.css. Antd's algorithm needs concrete
41+
// color strings at render time to compute its derivatives, so we can't pass
42+
// var(--*) directly. Keep these literals in sync with their tokens:
43+
// colorPrimary ↔ --brand-primary
44+
// Menu.itemSelectedColor ↔ --text-dark-primary
45+
const themeConfig = React.useMemo(() => {
3646
return {
37-
token: {
38-
colorPrimary:
39-
getComputedStyle(document.documentElement).getPropertyValue('--brand-primary').trim() || '#1227ce',
47+
token: { colorPrimary: isDark ? '#4858e0' : '#1227ce' },
48+
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
49+
components: {
50+
Menu: {
51+
collapsedWidth: 56,
52+
...(isDark ? { itemSelectedColor: '#ebebeb' } : {}),
53+
},
4054
},
41-
components: { Menu: { collapsedWidth: 56 } },
4255
};
43-
}, []);
56+
}, [isDark]);
4457

45-
return renderFromPromiseResult(applicationConfigResult, () => (
46-
<ConfigProvider theme={theme}>
47-
<AntdApp>
48-
<Router history={history}>
49-
<Switch>
50-
<Route path="/events/:eventId">
51-
<EventPage />
52-
</Route>
53-
<Route>
54-
<NavBar>
55-
<Route exact path={[Routes.SAVED_QUERY, Routes.SAVED_QUERY_LATEST]}>
56-
<SavedQueryBar />
57-
</Route>
58-
<Route exact path={Routes.ENTITY}>
59-
<EntityViewBar />
60-
</Route>
61-
<Switch>
62-
<Route path={Routes.QUERY_HISTORY}>
63-
<QueryHistory />
64-
</Route>
65-
<Route path={Routes.SAVED_QUERIES}>
66-
<SavedQueries />
67-
</Route>
68-
<Route path={Routes.DOCS_UDFS}>
69-
<UdfDocsView />
70-
</Route>
71-
<Route path={Routes.BULK_JOB_HISTORY}>
72-
<BulkJobHistoryView />
73-
</Route>
74-
<Route path={Routes.RULES_VISUALIZER}>
75-
<RulesVisualizerView />
76-
</Route>
77-
<Route exact path={[Routes.ENTITY, Routes.HOME, Routes.SAVED_QUERY]}>
78-
<QueryView />
58+
React.useLayoutEffect(() => {
59+
// Cold-start theme is set by the inline script in public/index.html so the
60+
// first paint matches the stored preference. This effect keeps the class in
61+
// sync when the user toggles themes.
62+
const root = document.documentElement;
63+
if (isDark) {
64+
root.classList.add('dark-theme');
65+
} else {
66+
root.classList.remove('dark-theme');
67+
}
68+
}, [isDark]);
69+
70+
return (
71+
<ConfigProvider theme={themeConfig}>
72+
{renderFromPromiseResult(applicationConfigResult, () => (
73+
<AntdApp>
74+
<Router history={history}>
75+
<Switch>
76+
<Route path="/events/:eventId">
77+
<EventPage />
78+
</Route>
79+
<Route>
80+
<NavBar>
81+
<Route exact path={[Routes.SAVED_QUERY, Routes.SAVED_QUERY_LATEST]}>
82+
<SavedQueryBar />
7983
</Route>
80-
<Route exact path={Routes.SAVED_QUERY_LATEST}>
81-
<div className={styles.spinner}>
82-
<Spin size="large" />
83-
</div>
84+
<Route exact path={Routes.ENTITY}>
85+
<EntityViewBar />
8486
</Route>
85-
<Route exact path={Routes.BULK_ACTION} component={BulkActionPage} />
86-
</Switch>
87-
</NavBar>
88-
</Route>
89-
</Switch>
90-
</Router>
91-
</AntdApp>
87+
<Switch>
88+
<Route path={Routes.QUERY_HISTORY}>
89+
<QueryHistory />
90+
</Route>
91+
<Route path={Routes.SAVED_QUERIES}>
92+
<SavedQueries />
93+
</Route>
94+
<Route path={Routes.DOCS_UDFS}>
95+
<UdfDocsView />
96+
</Route>
97+
<Route path={Routes.BULK_JOB_HISTORY}>
98+
<BulkJobHistoryView />
99+
</Route>
100+
<Route path={Routes.RULES_VISUALIZER}>
101+
<RulesVisualizerView />
102+
</Route>
103+
<Route exact path={[Routes.ENTITY, Routes.HOME, Routes.SAVED_QUERY]}>
104+
<QueryView />
105+
</Route>
106+
<Route exact path={Routes.SAVED_QUERY_LATEST}>
107+
<div className={styles.spinner}>
108+
<Spin size="large" />
109+
</div>
110+
</Route>
111+
<Route exact path={Routes.BULK_ACTION} component={BulkActionPage} />
112+
</Switch>
113+
</NavBar>
114+
</Route>
115+
</Switch>
116+
</Router>
117+
</AntdApp>
118+
))}
92119
</ConfigProvider>
93-
));
120+
);
94121
};
95122

96123
export default AppRouter;

osprey_ui/src/components/bulk_actions/BulkActionTable.module.css

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,28 @@
99
.jobTable {
1010
width: 100%;
1111
border-collapse: collapse;
12-
background-color: white;
12+
background-color: var(--background-primary);
1313
font-size: 14px;
1414
}
1515

1616
.tableHeader {
17-
background-color: #f8f9fa;
18-
border-bottom: 2px solid #dee2e6;
17+
background-color: var(--background-secondary);
18+
border-bottom: 2px solid var(--divider);
1919
}
2020

2121
.tableHeader th {
2222
padding: 12px 16px;
2323
text-align: left;
2424
font-weight: 600;
25-
color: #495057;
25+
color: var(--text-light-primary);
2626
}
2727

2828
.tableRow {
29-
border-bottom: 1px solid #dee2e6;
29+
border-bottom: 1px solid var(--divider);
3030
}
3131

3232
.tableRow:hover {
33-
background-color: #f8f9fa;
33+
background-color: var(--background-secondary);
3434
}
3535

3636
.tableCell {

osprey_ui/src/components/navigation/NavBar.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import Logo from '../../assets/Logo';
1717
import useApplicationConfigStore from '../../stores/ApplicationConfigStore';
1818

1919
import { Routes } from '../../Constants';
20+
import ThemeToggle from '../../uikit/ThemeToggle';
2021
import styles from './NavBar.module.css';
2122

2223
const SIDEBAR_STORAGE_KEY = 'osprey-sidebar-expanded';
@@ -154,7 +155,9 @@ const NavBar = ({ children }: { children: React.ReactNode }) => {
154155
<div className={styles.mainColumn}>
155156
<header className={styles.topBar}>
156157
<div className={styles.topBarLeft} />
157-
<div className={styles.topBarRight} />
158+
<div className={styles.topBarRight}>
159+
<ThemeToggle />
160+
</div>
158161
</header>
159162
<main className={styles.contentWrapper}>{children}</main>
160163
</div>

osprey_ui/src/components/query_view/QueryListPanel.module.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
}
3030

3131
.regexButton {
32-
--color: black;
32+
--color: var(--icon-primary);
3333
margin: 0;
3434
padding: 0 6px;
3535
background: none;

osprey_ui/src/index.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,12 @@ code {
2525
.ant-app {
2626
height: 100%;
2727
}
28+
29+
.dark-theme .ant-spin-text {
30+
color: var(--text-dark-primary);
31+
text-shadow: none !important;
32+
}
33+
34+
.dark-theme .ant-spin-dot-item {
35+
background-color: var(--text-dark-primary);
36+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import create from 'zustand';
2+
3+
export type ThemeMode = 'light' | 'dark';
4+
5+
const STORAGE_KEY = 'osprey-ui-dark-theme';
6+
7+
const getInitialMode = (): ThemeMode => {
8+
if (typeof window === 'undefined') return 'light';
9+
const stored = window.localStorage.getItem(STORAGE_KEY);
10+
if (stored === 'true') return 'dark';
11+
if (stored === 'false') return 'light';
12+
// No localStorage value: honor the OS preference.
13+
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
14+
return 'dark';
15+
}
16+
return 'light';
17+
};
18+
19+
type ThemeStore = {
20+
mode: ThemeMode;
21+
setMode: (mode: ThemeMode) => void;
22+
toggleMode: () => void;
23+
};
24+
25+
const persist = (mode: ThemeMode) => {
26+
window.localStorage.setItem(STORAGE_KEY, String(mode === 'dark'));
27+
};
28+
29+
const useThemeStore = create<ThemeStore>((set) => ({
30+
mode: getInitialMode(),
31+
setMode: (mode) => {
32+
persist(mode);
33+
set({ mode });
34+
},
35+
toggleMode: () => {
36+
return set((state) => {
37+
const next: ThemeMode = state.mode === 'dark' ? 'light' : 'dark';
38+
persist(next);
39+
return { mode: next };
40+
});
41+
},
42+
}));
43+
44+
export default useThemeStore;

osprey_ui/src/styles/Colors.module.css

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
--background-secondary: #f7f8f9;
55
--background-secondary-alt: #f2f3f5;
66
--background-tertiary: #dbdcdf;
7+
--background-base-lowest: #ffffff;
8+
--background-base-low: #fafbfc;
79
--background-base-lower: #f0f1f3;
810

911
/* Text for Light Backgrounds */
@@ -48,3 +50,56 @@
4850
/* Dividers */
4951
--divider: #dbdcdf;
5052
}
53+
54+
:root:global(.dark-theme) {
55+
/* Backgrounds */
56+
--background-primary: #14161a;
57+
--background-secondary: #1a1d22;
58+
--background-secondary-alt: #1e2228;
59+
--background-tertiary: #2a2f37;
60+
--background-base-lowest: #14161a;
61+
--background-base-low: #1a1d22;
62+
--background-base-lower: #0f1115;
63+
64+
/* Text for Light Backgrounds (used on light surfaces; in dark mode these surfaces are rare) */
65+
--text-light-headings-primary: #ebebeb;
66+
--text-light-headings-secondary: #cdd0d6;
67+
--text-light-primary: #e3e5e8;
68+
--text-light-secondary: #a8aeb8;
69+
--text-light-disabled: #5f626d;
70+
71+
/* Text for Dark Backgrounds (the primary text in dark mode) */
72+
--text-dark-headings: #ffffff;
73+
--text-dark-primary: #ebebeb;
74+
--text-dark-secondary: #cdd0d6;
75+
--text-dark-disabled: #858e9b;
76+
77+
/* Brand — lighter shade in dark mode for AA contrast */
78+
--brand-primary: #4858e0;
79+
--brand-dark: #1227ce;
80+
81+
/* Links */
82+
--link-primary: #5aa9ff;
83+
--link-dark: #0075e0;
84+
--link-muted: #1f3a5e;
85+
--link-white: #ffffff;
86+
--link-grey: #cdd0d6;
87+
--link-light-hover: #1f3a5e;
88+
89+
/* Status */
90+
--success-primary: #6dbc4e;
91+
--success-muted: #1f3a1c;
92+
--error-primary: #e37671;
93+
--error-muted: #3a1c1c;
94+
--warning-primary: #faad14;
95+
--warning-muted: #3a2e0e;
96+
97+
/* Icons */
98+
--icon-primary: #a8aeb8;
99+
--icon-active: #ffffff;
100+
--icon-hover: #cdd0d6;
101+
--icon-muted: #5f626d;
102+
103+
/* Dividers */
104+
--divider: #2a2f37;
105+
}

osprey_ui/src/uikit/OspreyButton.module.css

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,13 @@
4141
}
4242

4343
.ghost {
44-
background-color: var(--background-primary);
44+
background-color: var(--background-base-low);
4545
color: var(--text-light-secondary);
46-
border: 1px solid var(--background-tertiary);
46+
border: 1px solid var(--background-base-lowest);
4747
}
4848

4949
.ghost:hover {
50-
background-color: var(--background-secondary);
50+
background-color: var(--background-base-lower);
5151
color: var(--text-light-primary);
5252
border: 1px solid var(--divider);
5353
}

0 commit comments

Comments
 (0)