From 76968346d1c63d696f6bdad3e697ec265898ee6a Mon Sep 17 00:00:00 2001 From: Shaun Patterson Date: Fri, 12 Jun 2026 12:11:01 -0400 Subject: [PATCH 1/3] feat: dark mode (light/dark/system) with persisted preference Co-Authored-By: Claude Fable 5 --- client/src/actions/ui.js | 8 + client/src/assets/css/theme-dark.scss | 416 ++++++++++++++++++++++++++ client/src/components/Sidebar.js | 32 ++ client/src/containers/App.js | 25 ++ client/src/containers/Editor.js | 25 ++ client/src/lib/theme.js | 30 ++ client/src/lib/theme.test.js | 41 +++ client/src/reducers/ui.js | 8 + client/src/reducers/ui.test.js | 32 ++ 9 files changed, 617 insertions(+) create mode 100644 client/src/assets/css/theme-dark.scss create mode 100644 client/src/lib/theme.js create mode 100644 client/src/lib/theme.test.js create mode 100644 client/src/reducers/ui.test.js diff --git a/client/src/actions/ui.js b/client/src/actions/ui.js index 42d9c66e..d606205b 100644 --- a/client/src/actions/ui.js +++ b/client/src/actions/ui.js @@ -5,6 +5,7 @@ export const SET_PANEL_SIZE = 'ui/SET_PANEL_SIZE' export const SET_PANEL_MINIMIZED = 'ui/SET_PANEL_MINIMIZED' +export const SET_THEME = 'ui/SET_THEME' export const CLICK_SIDEBAR_URL = 'mainframe/CLICK_SIDEBAR_URL' @@ -29,3 +30,10 @@ export function setPanelMinimized(minimized) { minimized, } } + +export function setTheme(theme) { + return { + type: SET_THEME, + theme, + } +} diff --git a/client/src/assets/css/theme-dark.scss b/client/src/assets/css/theme-dark.scss new file mode 100644 index 00000000..009a4347 --- /dev/null +++ b/client/src/assets/css/theme-dark.scss @@ -0,0 +1,416 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Dark theme. Activated by data-theme='dark' on (see containers/App.js). +// Scoped, targeted overrides of the app's major surfaces — not a full +// Bootstrap retheme. Keep the palette in the custom properties below. + +[data-theme="dark"] { + --bg: #1e1e24; + --bg-raised: #26262e; + --bg-elevated: #2f2f3a; + --text: #d8d8de; + --text-muted: #9a9aa5; + --border: #3d3d49; + --accent: #4c8eda; + + color-scheme: dark; + + body { + background-color: var(--bg); + color: var(--text); + } + + // Bootstrap reboot hardcodes pre { color: #212529 }. + pre { + color: var(--text); + } + + // ---------------------------------------------------------------- layout + + .main-content { + background-color: var(--bg); + color: var(--text); + } + + .click-capture { + background: #000; + } + + // ------------------------------------------------------------- side bar + // The sidebar is already dark; only fix the hover/active accents so they + // blend with the dark page background. + + .sidebar-menu li { + border-bottom-color: #3a3a44; + } + + // -------------------------------------------------------- editor panel + + .editor-panel { + border-color: var(--border); + + .header { + background-color: var(--bg-raised); + border-bottom-color: var(--border); + } + + .action { + border-color: var(--border); + color: #5a5a66; + + &.actionable { + background-color: var(--bg-elevated); + color: var(--text); + } + + &.actionable:hover { + background: #383846; + } + } + } + + .editor-radio { + background: var(--bg-raised); + } + + .btn-dgraph { + background-color: var(--bg-elevated); + border-color: var(--border); + color: var(--text-muted); + } + + // CodeMirror chrome around the dark 'material-darker' theme. Editor.scss + // sets .editor-panel-scoped gutter/cursor colors that outrank the CM theme + // (equal specificity, later order), so re-assert them here. + .cm-s-material-darker.CodeMirror { + background-color: #1c1c22; + } + + .cm-s-material-darker .CodeMirror-gutters { + background: #1c1c22; + border-right: 1px solid var(--border); + } + + .cm-s-material-darker .CodeMirror-linenumber { + color: #6a6a76; + } + + .cm-s-material-darker .CodeMirror-cursor { + background: rgba(216, 216, 222, 0.67); + } + + // ---------------------------------------------------- query variables bar + + .query-vars-editor { + background-color: var(--bg-raised); + border-top-color: var(--border); + + .btn { + background-color: var(--bg-elevated); + color: var(--text); + } + + .vars { + border-top-color: var(--border); + + .var:hover { + background-color: var(--bg-elevated); + } + } + } + + // ---------------------------------------------------------------- frames + + .frame-item { + background-color: var(--bg-raised); + border-color: var(--border); + + .frame-header { + background-color: var(--bg-raised); + border-bottom-color: var(--border); + + &:hover { + background: var(--bg-elevated); + } + + .query-icon { + color: var(--text-muted); + } + } + + .body .toolbar { + svg { + fill: #80808c; + } + + .active { + color: #fff; + + svg { + fill: #fff; + } + } + } + } + + .frame-item.collapsed .frame-header.active { + background-color: #2a3a51; + } + + .footer, + .partial-graph-footer { + background: var(--bg-raised); + border-top-color: var(--border); + } + + // ------------------------------------------------------- movable panels + + .vertical-panel-layout .separator, + .horizontal-panel-layout .separator { + background-color: var(--bg-elevated); + border-color: var(--border); + } + + .panel-layout .toolbar button.active { + background-color: var(--bg-elevated); + color: var(--text); + } + + // ------------------------------------------------------------ graph area + + .graph-container { + background-color: var(--bg); + background-image: radial-gradient(circle, #34343e 1px, transparent 1px); + } + + .graph { + background-color: var(--bg); + } + + .graph-search { + background: rgba(38, 38, 46, 0.95); + border-color: var(--border); + + input { + color: var(--text); + + &::placeholder { + color: var(--text-muted); + } + } + } + + .graph-control-btn { + background: rgba(38, 38, 46, 0.95); + border-color: var(--border); + color: var(--text-muted); + + &:hover { + background: var(--bg-elevated); + border-color: #4a4a58; + color: var(--text); + } + } + + .partial-render-info, + .graph-stats { + background: rgba(30, 30, 36, 0.9); + color: var(--text-muted); + } + + .vis-tooltip { + background-color: #34342a; + border-color: #56563e; + color: var(--text); + } + + // ------------------------------------------------- entity selector strip + + .entity-selector { + background: var(--bg-raised); + border-top-color: var(--border); + + .toggle { + background-color: var(--bg-raised); + } + } + + // ----------------------------------------------------- history strip + + .Previous-query-pre { + background-color: var(--bg-raised); + } + + // --------------------------------------------------- bootstrap surfaces + + .modal-content { + background-color: var(--bg-raised); + color: var(--text); + } + + .modal-header, + .modal-footer { + border-color: var(--border); + } + + .close { + color: var(--text); + text-shadow: none; + } + + .modal.server-connection .modal-body .url-input-box { + background-color: var(--bg-elevated); + } + + .modal.server-connection .modal-body .col-history { + box-shadow: 1px 0 var(--border); + } + + .list-group-item { + background-color: var(--bg-raised); + border-color: var(--border); + color: var(--text); + + &.active { + background-color: var(--accent); + border-color: var(--accent); + color: #fff; + } + } + + .dropdown-menu { + background-color: var(--bg-elevated); + border-color: var(--border); + } + + .dropdown-item { + color: var(--text); + + &:hover, + &:focus { + background-color: #383846; + color: #fff; + } + } + + .dropdown-divider { + border-top-color: var(--border); + } + + .form-control { + background-color: var(--bg); + border-color: var(--border); + color: var(--text); + + &:focus { + background-color: var(--bg); + border-color: var(--accent); + color: var(--text); + } + + &::placeholder { + color: var(--text-muted); + } + + &:disabled, + &[readonly] { + background-color: var(--bg-elevated); + } + } + + .input-group-text { + background-color: var(--bg-elevated); + border-color: var(--border); + color: var(--text-muted); + } + + .table { + color: var(--text); + + th, + td, + thead th { + border-color: var(--border); + } + } + + .table-striped tbody tr:nth-of-type(odd) { + background-color: rgba(255, 255, 255, 0.04); + } + + .table-hover tbody tr:hover { + background-color: rgba(255, 255, 255, 0.07); + color: var(--text); + } + + .card { + background-color: var(--bg-raised); + border-color: var(--border); + } + + .nav-tabs { + border-bottom-color: var(--border); + + .nav-link:hover, + .nav-link:focus { + border-color: var(--border) var(--border) var(--border); + } + + .nav-link.active { + background-color: var(--bg-raised); + border-color: var(--border) var(--border) var(--bg-raised); + color: var(--text); + } + } + + .alert-secondary { + background-color: var(--bg-elevated); + border-color: var(--border); + color: var(--text); + } + + .popover { + background-color: var(--bg-elevated); + border-color: var(--border); + + .popover-body { + color: var(--text); + } + } + + .text-muted { + color: var(--text-muted) !important; + } + + .btn-light, + .btn-outline-secondary { + background-color: var(--bg-elevated); + border-color: var(--border); + color: var(--text); + } + + // ----------------------------------------------- JSON / response viewer + // FrameCodeTab uses highlight.js with a light theme; keep the tokens + // readable on a dark surface. + + .hljs { + color: var(--text); + } + + .hljs-attr, + .hljs-attribute { + color: #8ab4f8; + } + + .hljs-string { + color: #9ccc8c; + } + + .hljs-number, + .hljs-literal { + color: #d8a657; + } +} diff --git a/client/src/components/Sidebar.js b/client/src/components/Sidebar.js index 43bc12dd..b3a6751d 100644 --- a/client/src/components/Sidebar.js +++ b/client/src/components/Sidebar.js @@ -14,7 +14,9 @@ import GraphIcon from './GraphIcon' import SantaHat from './SantaHat' import { checkHealth } from 'actions/connection' +import { setTheme } from 'actions/ui' import { FetchError, Fetching, OK, Unknown } from 'lib/constants' +import { THEME_SYSTEM, nextTheme } from 'lib/theme' import HealthDot from './HealthDot' import '../assets/css/Sidebar.scss' @@ -25,6 +27,7 @@ export default function Sidebar({ currentMenu, currentOverlay, onToggleMenu }) { const currentServer = useSelector( (state) => state.connection.serverHistory[0], ) + const themeSetting = useSelector((state) => state.ui.theme || THEME_SYSTEM) const dispatch = useDispatch() useInterval(() => dispatch(checkHealth({ unknownOnStart: false })), 30000) @@ -102,6 +105,31 @@ export default function Sidebar({ currentMenu, currentOverlay, onToggleMenu }) { } } + const renderThemeButton = () => { + const icons = { + light: 'fas fa-sun', + dark: 'fas fa-moon', + system: 'fas fa-adjust', + } + const next = nextTheme(themeSetting) + return ( +
  • + { + e.preventDefault() + dispatch(setTheme(next)) + }} + > + + + +
  • + ) + } + const renderConnectionButton = () => { const dgraphLogo = logo @@ -212,6 +240,10 @@ export default function Sidebar({ currentMenu, currentOverlay, onToggleMenu }) { fontAwesomeIcon: 'far fa-question-circle', label: 'Help', })} + +
  • + + {renderThemeButton()}
    { + const systemPrefersDark = !!this.darkMediaQuery?.matches + document.documentElement.dataset.theme = resolveTheme( + this.props.theme, + systemPrefersDark, + ) + } + getOverlayContent = (overlayUrl) => { if (overlayUrl === 'info') { return @@ -112,6 +136,7 @@ function mapStateToProps(state) { mainFrameUrl: state.ui.mainFrameUrl, overlayUrl: state.ui.overlayUrl, + theme: state.ui.theme, } } diff --git a/client/src/containers/Editor.js b/client/src/containers/Editor.js index bb832c8f..df2594a6 100644 --- a/client/src/containers/Editor.js +++ b/client/src/containers/Editor.js @@ -8,8 +8,10 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' import { useSelector } from 'react-redux' import { getDgraphClient } from 'lib/helpers' +import { resolveTheme } from 'lib/theme' import CodeMirror from './CodeMirror' +import 'codemirror/theme/material-darker.css' import '../assets/css/Editor.scss' function isJSON(value) { @@ -35,6 +37,21 @@ export default function Editor({ const isSettingContent = useRef(false) const allState = useSelector((state) => state) + const themeSetting = useSelector((state) => state.ui.theme) + + const [systemPrefersDark, setSystemPrefersDark] = useState( + () => !!window.matchMedia?.('(prefers-color-scheme: dark)').matches, + ) + + useEffect(() => { + const mediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)') + if (!mediaQuery?.addEventListener) { + return + } + const onChange = (e) => setSystemPrefersDark(e.matches) + mediaQuery.addEventListener('change', onChange) + return () => mediaQuery.removeEventListener('change', onChange) + }, []) const checkLayoutSize = () => { if (!_bodyRef.current) { @@ -134,6 +151,14 @@ export default function Editor({ useEditorEffect(() => editorInstance.setOption('mode', mode), [mode]) + useEditorEffect(() => { + const resolved = resolveTheme(themeSetting, systemPrefersDark) + editorInstance.setOption( + 'theme', + resolved === 'dark' ? 'material-darker' : 'neo', + ) + }, [themeSetting, systemPrefersDark]) + useEditorEffect( () => editorInstance.setOption('extraKeys', { diff --git a/client/src/lib/theme.js b/client/src/lib/theme.js new file mode 100644 index 00000000..90139c00 --- /dev/null +++ b/client/src/lib/theme.js @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export const THEME_LIGHT = 'light' +export const THEME_DARK = 'dark' +export const THEME_SYSTEM = 'system' + +// Order in which the sidebar toggle cycles through theme settings. +export const THEME_CYCLE = [THEME_LIGHT, THEME_DARK, THEME_SYSTEM] + +/** + * Resolves a theme setting ('light' | 'dark' | 'system') to the effective + * theme ('light' | 'dark'). Unknown or missing settings behave as 'system'. + */ +export function resolveTheme(setting, systemPrefersDark) { + if (setting === THEME_LIGHT || setting === THEME_DARK) { + return setting + } + return systemPrefersDark ? THEME_DARK : THEME_LIGHT +} + +/** + * Returns the next theme setting in the cycle light -> dark -> system. + */ +export function nextTheme(setting) { + const index = THEME_CYCLE.indexOf(setting) + return THEME_CYCLE[(index + 1) % THEME_CYCLE.length] +} diff --git a/client/src/lib/theme.test.js b/client/src/lib/theme.test.js new file mode 100644 index 00000000..25353d11 --- /dev/null +++ b/client/src/lib/theme.test.js @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { nextTheme, resolveTheme } from './theme' + +describe('resolveTheme', () => { + it('returns light when setting is light regardless of system preference', () => { + expect(resolveTheme('light', false)).toBe('light') + expect(resolveTheme('light', true)).toBe('light') + }) + + it('returns dark when setting is dark regardless of system preference', () => { + expect(resolveTheme('dark', false)).toBe('dark') + expect(resolveTheme('dark', true)).toBe('dark') + }) + + it('follows system preference when setting is system', () => { + expect(resolveTheme('system', false)).toBe('light') + expect(resolveTheme('system', true)).toBe('dark') + }) + + it('treats unknown or missing settings as system', () => { + expect(resolveTheme(undefined, true)).toBe('dark') + expect(resolveTheme(undefined, false)).toBe('light') + expect(resolveTheme('bogus', true)).toBe('dark') + }) +}) + +describe('nextTheme', () => { + it('cycles light -> dark -> system -> light', () => { + expect(nextTheme('light')).toBe('dark') + expect(nextTheme('dark')).toBe('system') + expect(nextTheme('system')).toBe('light') + }) + + it('starts the cycle at light for unknown settings', () => { + expect(nextTheme(undefined)).toBe('light') + }) +}) diff --git a/client/src/reducers/ui.js b/client/src/reducers/ui.js index a7d04e37..0a69bee0 100644 --- a/client/src/reducers/ui.js +++ b/client/src/reducers/ui.js @@ -9,7 +9,9 @@ import { CLICK_SIDEBAR_URL, SET_PANEL_MINIMIZED, SET_PANEL_SIZE, + SET_THEME, } from 'actions/ui' +import { THEME_SYSTEM } from 'lib/theme' const defaultState = { width: 100, @@ -17,6 +19,8 @@ const defaultState = { mainFrameUrl: '', overlayUrl: null, + + theme: THEME_SYSTEM, } const isMainFrameUrl = (sidebarMenu) => @@ -36,6 +40,10 @@ export default (state = defaultState, action) => draft.panelWidth = action.width break + case SET_THEME: + draft.theme = action.theme + break + case CLICK_SIDEBAR_URL: const url = action.url if (isMainFrameUrl(url)) { diff --git a/client/src/reducers/ui.test.js b/client/src/reducers/ui.test.js new file mode 100644 index 00000000..c5aa830d --- /dev/null +++ b/client/src/reducers/ui.test.js @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { setTheme } from 'actions/ui' +import reducer from './ui' + +describe('ui reducer theme', () => { + it('defaults the persisted theme setting to system', () => { + const state = reducer(undefined, { type: '@@INIT' }) + expect(state.theme).toBe('system') + }) + + it('handles setTheme', () => { + let state = reducer(undefined, setTheme('dark')) + expect(state.theme).toBe('dark') + + state = reducer(state, setTheme('light')) + expect(state.theme).toBe('light') + + state = reducer(state, setTheme('system')) + expect(state.theme).toBe('system') + }) + + it('does not affect other ui state', () => { + const initial = reducer(undefined, { type: '@@INIT' }) + const state = reducer(initial, setTheme('dark')) + expect(state.mainFrameUrl).toBe(initial.mainFrameUrl) + expect(state.overlayUrl).toBe(initial.overlayUrl) + }) +}) From 6b63f6aaca82a5d2a4baf58e1b9b7ac162a53fe3 Mon Sep 17 00:00:00 2001 From: Shaun Patterson Date: Fri, 12 Jun 2026 12:17:01 -0400 Subject: [PATCH 2/3] fix: unreadable editor tokens in dark mode (cm-invalidchar was black) Editor.scss paints .cm-invalidchar black on purpose: the graphql-ish editor mode flags valid DQL (dotted predicates like dgraph.type, numeric arguments) as invalid, and black-on-white makes those tokens read as normal text. On the dark background they were invisible. Mirror the intent under [data-theme='dark']: normal text color. Co-Authored-By: Claude Fable 5 --- client/src/assets/css/theme-dark.scss | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/client/src/assets/css/theme-dark.scss b/client/src/assets/css/theme-dark.scss index 009a4347..2f32eff6 100644 --- a/client/src/assets/css/theme-dark.scss +++ b/client/src/assets/css/theme-dark.scss @@ -102,6 +102,14 @@ background: rgba(216, 216, 222, 0.67); } + // Editor.scss deliberately paints .cm-invalidchar black: the graphql-ish + // mode flags plenty of valid DQL (dotted predicates, numeric args) as + // "invalid", and black-on-white makes those tokens read as normal text. + // Mirror that intent on dark - normal text color, not invisible black. + .cm-invalidchar { + color: var(--text); + } + // ---------------------------------------------------- query variables bar .query-vars-editor { From 2aebc41be6d71f41af7e78ab6bb313fea9d09d87 Mon Sep 17 00:00:00 2001 From: Shaun Patterson Date: Mon, 15 Jun 2026 21:14:27 -0400 Subject: [PATCH 3/3] fix: dark-mode styling for the multi-tab editor strip The EditorTabs strip used hardcoded light colors, leaving a bright bar above the editor in dark mode. Add [data-theme=dark] overrides so the strip, inactive tabs, hover and rename input use the dark palette, and the active tab blends into the editor header (--bg-raised) below it. (EditorTabs ships on the multi-tab branch; these rules are dormant until both features are present, e.g. on sp/all-features.) Co-Authored-By: Claude Fable 5 --- client/src/assets/css/theme-dark.scss | 47 +++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/client/src/assets/css/theme-dark.scss b/client/src/assets/css/theme-dark.scss index 2f32eff6..ed7cfc01 100644 --- a/client/src/assets/css/theme-dark.scss +++ b/client/src/assets/css/theme-dark.scss @@ -72,6 +72,53 @@ } } + // Multi-tab strip above the editor (EditorTabs). The light defaults leave a + // bright bar in dark mode; recolor the strip, tabs and active tab so the + // active tab blends into the editor header (--bg-raised) below it. + .editor-tabs { + background-color: var(--bg); + border-bottom-color: var(--border); + + .editor-tab { + color: var(--text-muted); + border-bottom-color: var(--border); + + &:hover { + background: var(--bg-elevated); + color: var(--text); + } + + &.active { + background: var(--bg-raised); + border-color: var(--border); + border-bottom-color: var(--bg-raised); + color: var(--text); + } + + .editor-tab-close { + color: var(--text-muted); + + &:hover { + color: var(--text); + } + } + + .editor-tab-rename-input { + background: var(--bg-elevated); + border-color: var(--border); + color: var(--text); + } + } + + .editor-tab-add { + color: var(--text-muted); + + &:hover { + color: var(--text); + } + } + } + .editor-radio { background: var(--bg-raised); }