diff --git a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/assetPipeline/AssetPipeline.java b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/assetPipeline/AssetPipeline.java
index 78c47bc36f5..40b5f0db06d 100644
--- a/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/assetPipeline/AssetPipeline.java
+++ b/grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/assetPipeline/AssetPipeline.java
@@ -105,6 +105,7 @@ public void apply(GeneratorContext generatorContext) {
"grails-app/assets/images/spring-boot.svg",
"grails-app/assets/javascripts/application.js",
+ "grails-app/assets/javascripts/theme.js",
"grails-app/assets/javascripts/welcome.js",
"grails-app/assets/stylesheets/application.css",
diff --git a/grails-forge/grails-forge-core/src/main/resources/assets/javascripts/theme.js b/grails-forge/grails-forge-core/src/main/resources/assets/javascripts/theme.js
new file mode 100644
index 00000000000..dc9a8192866
--- /dev/null
+++ b/grails-forge/grails-forge-core/src/main/resources/assets/javascripts/theme.js
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Light / dark / auto color-mode switcher for Bootstrap 5.3's `data-bs-theme`.
+// The preference is persisted in localStorage; "auto" follows the operating system.
+// This file is loaded in the document
so the theme is applied before the
+// page paints, avoiding a flash of the wrong theme.
+(() => {
+ 'use strict'
+
+ const STORAGE_KEY = 'theme'
+ const prefersDark = () => window.matchMedia('(prefers-color-scheme: dark)').matches
+ const storedTheme = () => localStorage.getItem(STORAGE_KEY)
+ const preferredTheme = () => storedTheme() || 'auto'
+ const icons = { light: 'bi-sun-fill', dark: 'bi-moon-stars-fill', auto: 'bi-circle-half' }
+
+ const applyTheme = theme => {
+ const resolved = theme === 'auto' ? (prefersDark() ? 'dark' : 'light') : theme
+ document.documentElement.setAttribute('data-bs-theme', resolved)
+ }
+
+ // Apply immediately (before DOMContentLoaded) to avoid a flash of the wrong theme.
+ applyTheme(preferredTheme())
+
+ const showActiveTheme = theme => {
+ document.querySelectorAll('[data-bs-theme-value]').forEach(button => {
+ const active = button.getAttribute('data-bs-theme-value') === theme
+ button.classList.toggle('active', active)
+ button.setAttribute('aria-pressed', String(active))
+ const check = button.querySelector('.bi-check')
+ if (check) {
+ check.classList.toggle('d-none', !active)
+ }
+ })
+ document.querySelectorAll('.theme-icon-active').forEach(icon => {
+ Object.values(icons).forEach(cls => icon.classList.remove(cls))
+ icon.classList.add(icons[theme] || icons.auto)
+ })
+ }
+
+ // Re-resolve "auto" when the OS preference changes.
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
+ if (preferredTheme() === 'auto') {
+ applyTheme('auto')
+ }
+ })
+
+ window.addEventListener('DOMContentLoaded', () => {
+ showActiveTheme(preferredTheme())
+ document.querySelectorAll('[data-bs-theme-value]').forEach(button => {
+ button.addEventListener('click', () => {
+ const theme = button.getAttribute('data-bs-theme-value')
+ localStorage.setItem(STORAGE_KEY, theme)
+ applyTheme(theme)
+ showActiveTheme(theme)
+ })
+ })
+ })
+})()
diff --git a/grails-forge/grails-forge-core/src/main/resources/gsp/index.gsp b/grails-forge/grails-forge-core/src/main/resources/gsp/index.gsp
index 3581e0e031f..3632764b42f 100644
--- a/grails-forge/grails-forge-core/src/main/resources/gsp/index.gsp
+++ b/grails-forge/grails-forge-core/src/main/resources/gsp/index.gsp
@@ -216,7 +216,7 @@
-
+
@@ -240,7 +240,7 @@
-
+
-
+
+
@@ -16,6 +17,31 @@
+
diff --git a/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/feature/view/GrailsGspSpec.groovy b/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/feature/view/GrailsGspSpec.groovy
index e8f2966713f..758a24671dc 100644
--- a/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/feature/view/GrailsGspSpec.groovy
+++ b/grails-forge/grails-forge-core/src/test/groovy/org/grails/forge/feature/view/GrailsGspSpec.groovy
@@ -81,6 +81,26 @@ class GrailsGspSpec extends ApplicationContextSpec implements CommandOutputFixtu
output.containsKey("grails-app/views/notFound.gsp")
}
+ void "test default layout includes the light/dark/auto theme selector"() {
+ when:
+ final def output = generate(ApplicationType.WEB, new Options(DevelopmentReloading.DEVTOOLS))
+ final String layout = output["grails-app/views/layouts/main.gsp"]
+
+ then: "the theme switcher script is shipped and loaded in the head to avoid a flash of the wrong theme"
+ output.containsKey("grails-app/assets/javascripts/theme.js")
+ layout.contains('')
+
+ and: "the navbar offers light, dark and auto options"
+ layout.contains('data-bs-theme-value="light"')
+ layout.contains('data-bs-theme-value="dark"')
+ layout.contains('data-bs-theme-value="auto"')
+
+ and: "the plugins table header does not force a fixed light background (stays theme-adaptive)"
+ final String index = output["grails-app/views/index.gsp"]
+ !index.contains('table-light')
+ index.contains('')
+ }
+
@Unroll
void "test grails-gsp gradle plugins and dependencies are present for #applicationType application"() {
when:
diff --git a/grails-profiles/web/skeleton/grails-app/assets/javascripts/theme.js b/grails-profiles/web/skeleton/grails-app/assets/javascripts/theme.js
new file mode 100644
index 00000000000..dc9a8192866
--- /dev/null
+++ b/grails-profiles/web/skeleton/grails-app/assets/javascripts/theme.js
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Light / dark / auto color-mode switcher for Bootstrap 5.3's `data-bs-theme`.
+// The preference is persisted in localStorage; "auto" follows the operating system.
+// This file is loaded in the document so the theme is applied before the
+// page paints, avoiding a flash of the wrong theme.
+(() => {
+ 'use strict'
+
+ const STORAGE_KEY = 'theme'
+ const prefersDark = () => window.matchMedia('(prefers-color-scheme: dark)').matches
+ const storedTheme = () => localStorage.getItem(STORAGE_KEY)
+ const preferredTheme = () => storedTheme() || 'auto'
+ const icons = { light: 'bi-sun-fill', dark: 'bi-moon-stars-fill', auto: 'bi-circle-half' }
+
+ const applyTheme = theme => {
+ const resolved = theme === 'auto' ? (prefersDark() ? 'dark' : 'light') : theme
+ document.documentElement.setAttribute('data-bs-theme', resolved)
+ }
+
+ // Apply immediately (before DOMContentLoaded) to avoid a flash of the wrong theme.
+ applyTheme(preferredTheme())
+
+ const showActiveTheme = theme => {
+ document.querySelectorAll('[data-bs-theme-value]').forEach(button => {
+ const active = button.getAttribute('data-bs-theme-value') === theme
+ button.classList.toggle('active', active)
+ button.setAttribute('aria-pressed', String(active))
+ const check = button.querySelector('.bi-check')
+ if (check) {
+ check.classList.toggle('d-none', !active)
+ }
+ })
+ document.querySelectorAll('.theme-icon-active').forEach(icon => {
+ Object.values(icons).forEach(cls => icon.classList.remove(cls))
+ icon.classList.add(icons[theme] || icons.auto)
+ })
+ }
+
+ // Re-resolve "auto" when the OS preference changes.
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
+ if (preferredTheme() === 'auto') {
+ applyTheme('auto')
+ }
+ })
+
+ window.addEventListener('DOMContentLoaded', () => {
+ showActiveTheme(preferredTheme())
+ document.querySelectorAll('[data-bs-theme-value]').forEach(button => {
+ button.addEventListener('click', () => {
+ const theme = button.getAttribute('data-bs-theme-value')
+ localStorage.setItem(STORAGE_KEY, theme)
+ applyTheme(theme)
+ showActiveTheme(theme)
+ })
+ })
+ })
+})()
diff --git a/grails-profiles/web/skeleton/grails-app/views/index.gsp b/grails-profiles/web/skeleton/grails-app/views/index.gsp
index 3581e0e031f..3632764b42f 100644
--- a/grails-profiles/web/skeleton/grails-app/views/index.gsp
+++ b/grails-profiles/web/skeleton/grails-app/views/index.gsp
@@ -216,7 +216,7 @@
-
+
@@ -240,7 +240,7 @@
-
+
-
+
|
+
@@ -16,6 +17,31 @@
+
| |