Skip to content

Commit 37d2ff0

Browse files
carrossoniRampim
andauthored
feat: dark mode matching Databricks workspace theme (#16)
* feat: dark mode matching Databricks workspace theme Closes #14 ## What changed ### Theme system - `tailwind.config.js`: add `darkMode: 'class'` + convert all `dbx.*` color tokens to reference CSS custom properties - `index.css`: define `--dbx-*` variables for both `:root` (light) and `html.dark` (dark), matching the Databricks workspace dark palette - `ThemeContext.jsx` (new): React context with three modes: - `light` / `dark` — always-on - `system` — queries backend for the user's Databricks workspace theme (User Settings → Preferences → Appearance) via OAuth token, falls back to `prefers-color-scheme` if unavailable - `main.jsx`: wrap app with `ThemeProvider` ### Backend - `routes.py`: new `GET /api/workspace-appearance` endpoint that uses `X-Forwarded-Access-Token` (user's OAuth token injected by Databricks Apps) to query the workspace Settings API for the user's appearance preference; returns `{ theme: "light"|"dark"|null }` ### UI - `App.jsx`: remove Moon/Sun toggle from TopBar; settings gear only - `SettingsPage.jsx`: new **Appearance** section (first in sidebar) with a compact Theme dropdown — Light / Dark / Follow Databricks workspace - 22 components: replace every hardcoded hex color (`#161616`, `#F7F7F7`, `#EBEBEB`, …) with semantic `dbx-*` Tailwind tokens so the entire UI switches theme via CSS variables with no re-render Co-authored-by: Isaac * fix: address dark-mode review — hover flash and focus ring - ApiReferencePage: replace 4x `hover:bg-[#FAFAFA]` with `hover:bg-dbx-neutral-hover` — near-white was flashing over dark backgrounds on accordion button hovers - PlaygroundPage: replace `focus:ring-[#2272B4]/20` with `focus:ring-[rgba(34,114,180,0.2)]` — Tailwind v3 opacity modifiers (/20) require RGB-channel format to work with CSS variable tokens; explicit rgba() achieves the same result Co-authored-by: Isaac * fix: address auth model violation and race condition in workspace-appearance - Backend: drop DATABRICKS_TOKEN fallback in /workspace-appearance — per auth model, only the user's OBO token (X-Forwarded-Access-Token) should be used; substituting the SP token would return the SP's appearance, not the user's. Now returns {"theme": null} when no user token is present. - Frontend ThemeContext: distinguish "not yet fetched" (undefined) from "API returned no answer" (null) in workspaceThemeRef. Previously both states shared the null sentinel, so an OS theme-change event during the async window could be applied and then overwritten by the API response. Now the OS listener only fires when ref === null (API responded with no preference), leaving ref === undefined (in-flight) and 'light'/'dark' (definitive) untouched. Co-authored-by: Isaac * fix: review fixes — stale docstring, hover flash, theme cleanup, i18n - routes.py: remove misleading SP-token fallback from docstring - GatewayOverviewTab: replace hardcoded hover:bg-[#EBEBEB] with dbx token - ThemeContext: add .catch() and reset workspaceThemeRef on cleanup - PlaygroundPage: translate remaining Portuguese string to English Co-authored-by: Isaac * fix: theme cleanup race condition and dark-mode contrast on MCP required labels Reset workspaceThemeRef to undefined (not null) on effect cleanup to match the three-state contract and prevent OS listener from firing during async window. Migrate hardcoded #B91C1C to a new dbx-text-danger token with proper dark-mode contrast. Co-authored-by: Isaac * fix: hoist imports, restore default settings section, and improve dark-mode token coverage Move os/httpx imports to module level, restore Settings default to 'general', replace remaining hardcoded Tailwind colors with dbx-* tokens, and add useTheme guard for missing provider. Co-authored-by: Isaac * fix: add 10s overall deadline to workspace-appearance and tokenize red/danger colors Add deadline guard to get_workspace_appearance so sequential HTTP probes cannot exceed 10s total. Remove unrelated personal_compute endpoint from probe list. Add dbx-status-red-bg and dbx-danger-border CSS tokens and migrate all hardcoded bg-red-50/text-red-600 to theme-aware tokens. Co-authored-by: Isaac * fix: use get_running_loop, bound _extract_theme recursion, tokenize cache-hit color Replace deprecated asyncio.get_event_loop() with get_running_loop() for Python 3.12+ compatibility. Add depth limit to _extract_theme to prevent unbounded recursion. Replace hardcoded #FF3621 with db-lava token on cache-hit badge. Co-authored-by: Isaac --------- Co-authored-by: Luiz Carrossoni <carrossoni@gmail.com> Co-authored-by: lucas-rampimdesouza <lucas.rampim@outlook.com>
1 parent 2ac5115 commit 37d2ff0

27 files changed

+775
-483
lines changed

backend/app/api/routes.py

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22
API routes for the Genie Cache application.
33
"""
44

5+
import asyncio
56
import logging
7+
import os
68
import uuid
79
from fastapi import APIRouter, HTTPException, Request
810
from typing import List, Optional
911
from datetime import datetime
1012
from pydantic import BaseModel
13+
import httpx
1114
from app.models import (
1215
QueryRequest,
1316
QueryResponse,
@@ -246,8 +249,6 @@ async def health_check(req: Request):
246249
@router.get("/space-info/{space_id}")
247250
async def get_space_info(space_id: str, req: Request):
248251
"""Fetch Genie Space metadata (name, description) using the caller's token."""
249-
import httpx
250-
251252
token = req.headers.get('X-Forwarded-Access-Token') or settings.databricks_token
252253
if not token:
253254
raise HTTPException(status_code=401, detail="No token available to query Genie API")
@@ -397,3 +398,107 @@ async def clear_cache(req: Request, space_id: Optional[str] = None):
397398
raise HTTPException(status_code=500, detail=str(e))
398399

399400

401+
def _extract_theme(data: dict, _depth: int = 0) -> str | None:
402+
"""Try to extract a light/dark theme value from a Databricks API response dict."""
403+
if not isinstance(data, dict) or _depth > 2:
404+
return None
405+
for key in ["theme", "colorScheme", "color_scheme", "appearance", "mode", "uiTheme", "ui_theme"]:
406+
val = str(data.get(key, "")).lower().strip()
407+
if val in ("dark", "dark_theme", "dark_mode", "databricks_dark"):
408+
return "dark"
409+
if val in ("light", "light_theme", "light_mode", "databricks_light"):
410+
return "light"
411+
for v in data.values():
412+
if isinstance(v, dict):
413+
result = _extract_theme(v, _depth + 1)
414+
if result:
415+
return result
416+
return None
417+
418+
419+
@router.get("/workspace-appearance")
420+
async def get_workspace_appearance(request: Request):
421+
"""
422+
Detect the user's Databricks workspace theme preference via the Settings API.
423+
424+
Uses the user's OAuth token (X-Forwarded-Access-Token injected by Databricks Apps)
425+
to query user-level preferences. Returns {"theme": "light" | "dark" | null}.
426+
When no user token is present, returns null without attempting any fallback.
427+
"""
428+
user_token = request.headers.get("X-Forwarded-Access-Token", "").strip()
429+
host = os.environ.get("DATABRICKS_HOST", "")
430+
if not host or not user_token:
431+
return {"theme": None, "source": "not_configured"}
432+
433+
if not host.startswith("http"):
434+
host = f"https://{host}"
435+
436+
headers = {"Authorization": f"Bearer {user_token}"}
437+
438+
loop = asyncio.get_running_loop()
439+
deadline = loop.time() + 10.0
440+
441+
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
442+
# 1. Discover available setting types and look for appearance-related ones
443+
try:
444+
resp = await client.get(f"{host}/api/2.0/settings", headers=headers)
445+
if resp.status_code == 200:
446+
body = resp.json()
447+
setting_types = body.get("setting_types", [])
448+
for type_info in setting_types:
449+
if loop.time() > deadline:
450+
return {"theme": None, "source": "timeout"}
451+
type_name = (
452+
type_info.get("name")
453+
or type_info.get("setting_type_name")
454+
or ""
455+
)
456+
if any(k in type_name.lower() for k in ("appearance", "theme", "color", "dark")):
457+
try:
458+
r2 = await client.get(
459+
f"{host}/api/2.0/settings/types/{type_name}/names/default",
460+
headers=headers,
461+
)
462+
if r2.status_code == 200:
463+
theme = _extract_theme(r2.json())
464+
if theme:
465+
return {"theme": theme, "source": f"settings/{type_name}"}
466+
except Exception:
467+
pass
468+
except Exception:
469+
pass
470+
471+
# 2. Try known direct endpoints (workspace version dependent)
472+
known = [
473+
f"{host}/api/2.0/settings/types/workspace_appearance/names/default",
474+
f"{host}/api/2.0/settings/types/user_appearance/names/default",
475+
f"{host}/api/2.0/settings/types/notebook_appearance/names/default",
476+
]
477+
for url in known:
478+
if loop.time() > deadline:
479+
return {"theme": None, "source": "timeout"}
480+
try:
481+
resp = await client.get(url, headers=headers)
482+
if resp.status_code == 200:
483+
theme = _extract_theme(resp.json())
484+
if theme:
485+
return {"theme": theme, "source": url}
486+
except Exception:
487+
continue
488+
489+
# 3. Try SCIM /Me endpoint — some workspaces store preferences in extension attrs
490+
if loop.time() <= deadline:
491+
try:
492+
resp = await client.get(f"{host}/api/2.0/preview/scim/v2/Me", headers=headers)
493+
if resp.status_code == 200:
494+
data = resp.json()
495+
for key, val in data.items():
496+
if isinstance(val, dict):
497+
theme = _extract_theme(val)
498+
if theme:
499+
return {"theme": theme, "source": "scim_me"}
500+
except Exception:
501+
pass
502+
503+
return {"theme": None, "source": "not_found"}
504+

frontend/src/App.jsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,24 @@ import DebugPage from './components/debug/DebugPage'
1313
function TopBar({ onToggleSidebar }) {
1414
const navigate = useNavigate()
1515
return (
16-
<header className="h-[48px] min-h-[48px] w-full bg-[#F7F7F7] flex items-center justify-between px-3">
16+
<header className="h-[48px] min-h-[48px] w-full bg-dbx-sidebar flex items-center justify-between px-3 border-b border-dbx-border">
1717
<div className="flex items-center gap-1.5">
1818
<button
1919
onClick={onToggleSidebar}
20-
className="p-1.5 rounded hover:bg-[rgba(0,0,0,0.06)] transition-colors"
20+
className="p-1.5 rounded hover:bg-dbx-neutral-hover transition-colors"
2121
title="Toggle sidebar"
2222
>
23-
<PanelLeft size={18} className="text-[#6F6F6F]" />
23+
<PanelLeft size={18} className="text-dbx-text-secondary" />
2424
</button>
2525
<img src="/genie-icon-alt.svg" alt="Genie" width="24" height="24" />
26-
<span className="text-[16px] font-medium text-[#0B2026]" style={{ fontFamily: '"DM Sans", sans-serif' }}>Genie Cache Gateway</span>
26+
<span className="text-[16px] font-medium text-dbx-text" style={{ fontFamily: '"DM Sans", sans-serif' }}>Genie Cache Gateway</span>
2727
</div>
2828
<button
2929
onClick={() => navigate('/settings')}
30-
className="p-1.5 rounded hover:bg-[rgba(0,0,0,0.06)] transition-colors"
30+
className="p-1.5 rounded hover:bg-dbx-neutral-hover transition-colors"
3131
title="Settings"
3232
>
33-
<Settings size={18} className="text-[#6F6F6F]" />
33+
<Settings size={18} className="text-dbx-text-secondary" />
3434
</button>
3535
</header>
3636
)
@@ -40,7 +40,7 @@ function App() {
4040
const [sidebarOpen, setSidebarOpen] = useState(true)
4141

4242
return (
43-
<div className="flex flex-col h-screen bg-[#F7F7F7]">
43+
<div className="flex flex-col h-screen bg-dbx-sidebar">
4444
<TopBar onToggleSidebar={() => setSidebarOpen(v => !v)} />
4545
<div className="flex flex-1 min-h-0">
4646
<div
@@ -49,7 +49,7 @@ function App() {
4949
>
5050
<Sidebar />
5151
</div>
52-
<main className="flex-1 overflow-hidden rounded-lg bg-white border border-[#EBEBEB] mb-1 mr-1" style={{ boxShadow: 'rgba(0,0,0,0.05) 0px 2px 3px -1px, rgba(0,0,0,0.02) 0px 1px 0px 0px' }}>
52+
<main className="flex-1 overflow-hidden rounded-lg bg-dbx-bg border border-dbx-border mb-1 mr-1" style={{ boxShadow: 'rgba(0,0,0,0.05) 0px 2px 3px -1px, rgba(0,0,0,0.02) 0px 1px 0px 0px' }}>
5353
<div className="h-full overflow-auto">
5454
<Routes>
5555
<Route path="/" element={<GatewayListPage />} />

0 commit comments

Comments
 (0)