This is an automated email from the ASF dual-hosted git repository.

yao pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new 89a548711474 [SPARK-55766][UI] Support dark mode with Bootstrap 5 
theme toggle
89a548711474 is described below

commit 89a548711474e98a79a75d7f6413fdc09a821187
Author: Kent Yao <[email protected]>
AuthorDate: Sat Mar 7 02:10:32 2026 +0800

    [SPARK-55766][UI] Support dark mode with Bootstrap 5 theme toggle
    
    ### What changes were proposed in this pull request?
    
    Adds dark mode support to the Spark Web UI using Bootstrap 5 
`data-bs-theme` attribute and a theme toggle button.
    
    **Changes (8 files, +112/-9):**
    
    **Theme toggle (`webui.js`):**
    - `initThemeToggle()`: detects saved preference or system 
`prefers-color-scheme`, sets `data-bs-theme` on `<html>`
    - `updateThemeButton()`: swaps logo (`spark-logo.svg` ↔ 
`spark-logo-rev.svg`) and updates tooltip
    - Toggle persisted in `localStorage("spark-theme")` across page navigations
    
    **FOUC prevention (`UIUtils.scala`):**
    - Inline `<script>` in `<head>` sets theme before DOM renders — no flash of 
wrong theme
    - Added `data-bs-theme="light"` default on `<html>` tags in both 
`headerSparkPage` and `basicSparkPage`
    - Theme toggle button (◑ half-circle) in navbar
    
    **Logo dark mode (`spark-logo-rev.svg`):**
    - Added white-text version from 
[spark-website](https://github.com/apache/spark-website) repo for dark 
backgrounds
    - JS swaps logos on theme toggle
    
    **Dark mode CSS fixes:**
    - `webui.css`: `pre` text color uses `var(--bs-body-color)` for log pages
    - `timeline-view.css`: 33 lines of vis-timeline overrides (axis labels, 
grid lines, panel borders, item content) for dark mode
    - `spark-sql-viz.css`: side panel header uses theme variables, SVG text 
color adapts, edge labels use `var(--bs-secondary-color)`
    - `executorspage.js`: task duration text color `"black"` → 
`"var(--bs-body-color)"`
    - `spark-sql-viz.js`: removed hardcoded edge label fill color
    
    **Prerequisites (already merged):**
    - SPARK-55853: Migrated ~150 hardcoded CSS colors to BS5 CSS custom 
properties with `[data-bs-theme="dark"]` variants
    
    ### Why are the changes needed?
    
    Dark mode is a standard modern UI feature that reduces eye strain and 
improves readability in low-light environments. Bootstrap 5.3+ has built-in 
dark mode support via `data-bs-theme="dark"` — with the CSS variable migration 
(SPARK-55853) already done, adding the toggle is straightforward.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes — a ◑ theme toggle button appears in the navbar. Clicking it switches 
between light and dark mode. The preference is saved in localStorage and 
respects `prefers-color-scheme` as the initial default.
    
    ### How was this patch tested?
    
    - `lint-js` passes
    - `scalastyle` passes
    - `UIUtilsSuite` (8 tests) passes
    - Manual verification across all pages (Jobs, Stages, SQL, Executors, 
Environment, Storage)
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Yes, co-authored with GitHub Copilot.
    
    Closes #54648 from yaooqinn/SPARK-55766.
    
    Authored-by: Kent Yao <[email protected]>
    Signed-off-by: Kent Yao <[email protected]>
---
 .../org/apache/spark/ui/static/executorspage.js    |  2 +-
 .../org/apache/spark/ui/static/spark-logo-rev.svg  |  7 ++++
 .../org/apache/spark/ui/static/timeline-view.css   | 37 ++++++++++++++++++++
 .../resources/org/apache/spark/ui/static/webui.css |  1 +
 .../resources/org/apache/spark/ui/static/webui.js  | 39 ++++++++++++++++++++++
 .../main/scala/org/apache/spark/ui/UIUtils.scala   | 21 +++++++++---
 dev/.rat-excludes                                  |  1 +
 .../sql/execution/ui/static/spark-sql-viz.css      | 12 +++++--
 .../spark/sql/execution/ui/static/spark-sql-viz.js |  2 +-
 9 files changed, 113 insertions(+), 9 deletions(-)

diff --git 
a/core/src/main/resources/org/apache/spark/ui/static/executorspage.js 
b/core/src/main/resources/org/apache/spark/ui/static/executorspage.js
index 9867e69ebd98..b6de9297fdae 100644
--- a/core/src/main/resources/org/apache/spark/ui/static/executorspage.js
+++ b/core/src/main/resources/org/apache/spark/ui/static/executorspage.js
@@ -316,7 +316,7 @@ function totalDurationStyle(totalGCTime, totalDuration) {
 }
 
 function totalDurationColor(totalGCTime, totalDuration) {
-  return (totalGCTime > GCTimePercent * totalDuration) ? "white" : "black";
+  return (totalGCTime > GCTimePercent * totalDuration) ? "white" : 
"var(--bs-body-color)";
 }
 
 var sumOptionalColumns = [3, 4];
diff --git 
a/core/src/main/resources/org/apache/spark/ui/static/spark-logo-rev.svg 
b/core/src/main/resources/org/apache/spark/ui/static/spark-logo-rev.svg
new file mode 100644
index 000000000000..fc4f6790218d
--- /dev/null
+++ b/core/src/main/resources/org/apache/spark/ui/static/spark-logo-rev.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg"; width="68" height="36" viewBox="0 0 68 
36">
+    <g fill="none" fill-rule="evenodd">
+        <path fill="#E25A1C" d="M62.061 17.243c-.058.123-.085.186-.117.245-.85 
1.594-1.698 3.19-2.555 4.78-.087.159-.076.254.042.39 1.352 1.558 2.697 3.122 
4.044 
4.685.047.054.09.113.108.21-.394-.101-.788-.201-1.181-.304-1.634-.427-3.268-.853-4.9-1.285-.152-.04-.221.003-.297.128-.927
 1.528-1.86 3.053-2.792 
4.578-.048.08-.1.157-.202.223-.075-.407-.152-.814-.225-1.221l-.777-4.314c-.028-.156-.067-.31-.08-.467-.014-.148-.09-.203-.227-.245-1.925-.596-3.848-1.198-5.772-1.8-.084-.026-.167-.06-.
 [...]
+        <path fill="#FFF" d="M59.483 3.841c-1.193.002-2.386.008-3.58.003-.157 
0-.246.045-.334.177-1.412 2.122-2.83 4.239-4.248 
6.357-.045.068-.093.133-.174.246l-.902-6.767h-3.124c.037.3.069.59.107.88.305 
2.296.611 4.594.918 6.89.292 2.195.583 4.39.88 6.585.009.065.053.148.107.183 
1.075.69 2.154 1.376 3.232 2.062.016.01.038.011.094.027l-.974-7.324.038-.026 
5.113 
5.59.136-.772c.121-.698.237-1.396.367-2.092.026-.14-.011-.228-.106-.325-1.094-1.13-2.185-2.263-3.276-3.396l-.147-.159c.035-.055.
 [...]
+        <path fill="#FFF" fill-rule="nonzero" d="M62.9 
3.859v1.207h-.006l-.48-1.207h-.154l-.48 
1.207h-.007V3.86h-.242v1.446h.373l.438-1.099.43 1.099h.37V3.859h-.241zm-2.127 
1.253V3.859h-.242v1.253h-.46v.193h1.16v-.193h-.458M16.682 20.339h.72l-.17 
1.073-.55-1.073zm.832-.694h-1.196l-.38-.734h-.847l1.868 
3.443h.817l.636-3.443h-.785l-.113.734M21.793 21.66h-.426l-.143-.794h.425c.257 0 
.463.166.463.48 0 .208-.13.314-.319.314zm-1.032.694h1.12c.585 0 
.995-.345.995-.937 0-.744-.534-1.245-1.293-1. [...]
+    </g>
+</svg>
diff --git 
a/core/src/main/resources/org/apache/spark/ui/static/timeline-view.css 
b/core/src/main/resources/org/apache/spark/ui/static/timeline-view.css
index d9cb0b8f8192..5b81f4d6bd96 100644
--- a/core/src/main/resources/org/apache/spark/ui/static/timeline-view.css
+++ b/core/src/main/resources/org/apache/spark/ui/static/timeline-view.css
@@ -333,3 +333,40 @@ span.expand-task-assignment-timeline {
   list-style: none;
   margin-left: 15px;
 }
+
+/* Dark mode overrides for vis-timeline container */
+[data-bs-theme="dark"] .vis-timeline {
+  border-color: var(--bs-border-color);
+}
+
+[data-bs-theme="dark"] .vis-timeline .vis-panel.vis-bottom,
+[data-bs-theme="dark"] .vis-timeline .vis-panel.vis-center,
+[data-bs-theme="dark"] .vis-timeline .vis-panel.vis-top,
+[data-bs-theme="dark"] .vis-timeline .vis-panel.vis-left,
+[data-bs-theme="dark"] .vis-timeline .vis-panel.vis-right {
+  border-color: var(--bs-border-color);
+}
+
+[data-bs-theme="dark"] .vis-timeline .vis-time-axis .vis-text {
+  color: var(--bs-body-color);
+}
+
+[data-bs-theme="dark"] .vis-timeline .vis-time-axis .vis-grid.vis-minor {
+  border-color: var(--bs-border-color);
+}
+
+[data-bs-theme="dark"] .vis-timeline .vis-time-axis .vis-grid.vis-major {
+  border-color: var(--bs-border-color);
+}
+
+[data-bs-theme="dark"] .vis-timeline .vis-labelset .vis-label {
+  color: var(--bs-body-color);
+}
+
+[data-bs-theme="dark"] .vis-timeline .vis-labelset .vis-label .vis-inner {
+  color: var(--bs-body-color);
+}
+
+[data-bs-theme="dark"] .vis-timeline .vis-item .vis-item-content {
+  color: var(--bs-body-color);
+}
diff --git a/core/src/main/resources/org/apache/spark/ui/static/webui.css 
b/core/src/main/resources/org/apache/spark/ui/static/webui.css
index a5a9965c02c0..69ce54532274 100755
--- a/core/src/main/resources/org/apache/spark/ui/static/webui.css
+++ b/core/src/main/resources/org/apache/spark/ui/static/webui.css
@@ -157,6 +157,7 @@ span.rest-uri {
 
 pre {
   background-color: var(--bs-tertiary-bg);
+  color: var(--bs-body-color);
   font-size: 12px;
   line-height: 18px;
   padding: 6px;
diff --git a/core/src/main/resources/org/apache/spark/ui/static/webui.js 
b/core/src/main/resources/org/apache/spark/ui/static/webui.js
index dc877d9be55e..6edf2f5e009d 100644
--- a/core/src/main/resources/org/apache/spark/ui/static/webui.js
+++ b/core/src/main/resources/org/apache/spark/ui/static/webui.js
@@ -29,6 +29,45 @@ function setAppBasePath(path) {
 }
 /* eslint-enable no-unused-vars */
 
+// Theme toggle (dark/light mode)
+function updateThemeButton(btn, theme) {
+  btn.textContent = "\u25D1";
+  btn.setAttribute("title", theme === "dark" ? "Switch to light mode" : 
"Switch to dark mode");
+  // Swap logo for dark/light mode
+  document.querySelectorAll("img.spark-logo").forEach(function(img) {
+    var src = img.getAttribute("src") || "";
+    if (theme === "dark") {
+      img.setAttribute("src", src.replace("spark-logo.svg", 
"spark-logo-rev.svg"));
+    } else {
+      img.setAttribute("src", src.replace("spark-logo-rev.svg", 
"spark-logo.svg"));
+    }
+  });
+}
+
+function initThemeToggle() {
+  var saved = localStorage.getItem("spark-theme");
+  var theme = saved ||
+    (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : 
"light");
+  document.documentElement.setAttribute("data-bs-theme", theme);
+
+  var btn = document.getElementById("theme-toggle");
+  if (btn) {
+    updateThemeButton(btn, theme);
+    btn.addEventListener("click", function() {
+      var current = document.documentElement.getAttribute("data-bs-theme");
+      var next = current === "dark" ? "light" : "dark";
+      document.documentElement.setAttribute("data-bs-theme", next);
+      localStorage.setItem("spark-theme", next);
+      updateThemeButton(btn, next);
+    });
+  }
+}
+
+// Initialize theme toggle
+$(function() {
+  initThemeToggle();
+});
+
 // Persist BS5 collapse state in localStorage
 $(function() {
   $(document).on("shown.bs.collapse hidden.bs.collapse", ".collapsible-table", 
function(e) {
diff --git a/core/src/main/scala/org/apache/spark/ui/UIUtils.scala 
b/core/src/main/scala/org/apache/spark/ui/UIUtils.scala
index d12a7a84daad..55412cac6808 100644
--- a/core/src/main/scala/org/apache/spark/ui/UIUtils.scala
+++ b/core/src/main/scala/org/apache/spark/ui/UIUtils.scala
@@ -280,9 +280,13 @@ private[spark] object UIUtils extends Logging {
     }
     val helpButton: Seq[Node] = helpText.map(tooltip(_, 
"top")).getOrElse(Seq.empty)
 
-    <html>
+    <html data-bs-theme="light">
       <head>
         {commonHeaderNodes(request)}
+        <script nonce={CspNonce.get}>{Unparsed(
+          "document.documentElement.setAttribute('data-bs-theme'," +
+          "localStorage.getItem('spark-theme')||" +
+          
"(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'))")}</script>
         <script 
nonce={CspNonce.get}>setAppBasePath('{activeTab.basePath}')</script>
         {if (showVisualization) vizHeaderNodes(request) else Seq.empty}
         {if (useDataTables) dataTablesHeaderNodes(request) else Seq.empty}
@@ -295,7 +299,7 @@ private[spark] object UIUtils extends Logging {
           <div class="navbar-header">
             <div class="navbar-brand">
               <a href={prependBaseUri(request, "/")}>
-                <img src={prependBaseUri(request, "/static/spark-logo.svg")}
+                <img class="spark-logo" src={prependBaseUri(request, 
"/static/spark-logo.svg")}
                      alt="Spark Logo" height="36" />
                 <span class="version">{activeTab.appSparkVersion}</span>
               </a>
@@ -312,6 +316,8 @@ private[spark] object UIUtils extends Logging {
               <strong title={appName} 
class="text-nowrap">{shortAppName}</strong>
               <span class="text-nowrap">application UI</span>
             </span>
+            <button id="theme-toggle" class="btn btn-sm btn-link 
text-decoration-none fs-5 ms-2 p-0"
+                    type="button" title="Toggle dark mode"></button>
           </div>
         </nav>
         <div class="container-fluid">
@@ -339,9 +345,13 @@ private[spark] object UIUtils extends Logging {
       content: => Seq[Node],
       title: String,
       useDataTables: Boolean = false): Seq[Node] = {
-    <html>
+    <html data-bs-theme="light">
       <head>
         {commonHeaderNodes(request)}
+        <script nonce={CspNonce.get}>{Unparsed(
+          "document.documentElement.setAttribute('data-bs-theme'," +
+          "localStorage.getItem('spark-theme')||" +
+          
"(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'))")}</script>
         {if (useDataTables) dataTablesHeaderNodes(request) else Seq.empty}
         <link rel="shortcut icon"
               href={prependBaseUri(request, "/static/spark-logo.svg")}></link>
@@ -353,11 +363,14 @@ private[spark] object UIUtils extends Logging {
             <div class="col-12">
               <h3 class="align-middle d-inline-block">
                 <a class="text-decoration-none" href={prependBaseUri(request, 
"/")}>
-                  <img src={prependBaseUri(request, "/static/spark-logo.svg")}
+                  <img class="spark-logo" src={prependBaseUri(request, 
"/static/spark-logo.svg")}
                        alt="Spark Logo" height="36" />
                   <span class="version 
me-3">{org.apache.spark.SPARK_VERSION}</span>
                 </a>
                 {title}
+                <button id="theme-toggle"
+                        class="btn btn-sm btn-link text-decoration-none fs-5 
ms-2 p-0 align-middle"
+                        type="button" title="Toggle dark mode"></button>
               </h3>
             </div>
           </div>
diff --git a/dev/.rat-excludes b/dev/.rat-excludes
index 88a9e5d91558..e254267fdc69 100644
--- a/dev/.rat-excludes
+++ b/dev/.rat-excludes
@@ -143,4 +143,5 @@ 
core/src/main/resources/org/apache/spark/ui/static/package.json
 testCommitLog
 .*\.har
 spark-logo.svg
+spark-logo-rev.svg
 .nojekyll
diff --git 
a/sql/core/src/main/resources/org/apache/spark/sql/execution/ui/static/spark-sql-viz.css
 
b/sql/core/src/main/resources/org/apache/spark/sql/execution/ui/static/spark-sql-viz.css
index 66d15dd1d30b..55ba5aa47869 100644
--- 
a/sql/core/src/main/resources/org/apache/spark/sql/execution/ui/static/spark-sql-viz.css
+++ 
b/sql/core/src/main/resources/org/apache/spark/sql/execution/ui/static/spark-sql-viz.css
@@ -112,8 +112,8 @@ svg path.linked {
 }
 
 #plan-viz-details-panel .card-header {
-  background-color: #C3EBFF;
-  border-bottom-color: #3EC0FF;
+  background-color: var(--spark-sql-node-fill);
+  border-bottom-color: var(--spark-sql-cluster-stroke);
 }
 
 #plan-viz-details-panel .table th,
@@ -128,5 +128,11 @@ svg path.linked {
 /* Edge labels showing row counts */
 .edgeLabel text {
   font-size: 10px;
-  fill: #6c757d;
+  fill: var(--bs-secondary-color);
+}
+
+/* SVG text color for dark mode support */
+#plan-viz-graph .node text,
+#plan-viz-graph g.cluster text {
+  fill: var(--bs-body-color);
 }
diff --git 
a/sql/core/src/main/resources/org/apache/spark/sql/execution/ui/static/spark-sql-viz.js
 
b/sql/core/src/main/resources/org/apache/spark/sql/execution/ui/static/spark-sql-viz.js
index 007f2963ce15..584ffa89c94c 100644
--- 
a/sql/core/src/main/resources/org/apache/spark/sql/execution/ui/static/spark-sql-viz.js
+++ 
b/sql/core/src/main/resources/org/apache/spark/sql/execution/ui/static/spark-sql-viz.js
@@ -161,7 +161,7 @@ function preprocessGraphLayout(g) {
       curve: d3.curveBasis,
       label: edgeObj.label || "",
       style: "fill: none",
-      labelStyle: "font-size: 10px; fill: #6c757d;"
+      labelStyle: "font-size: 10px;"
     })
   })
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to