diff --git a/README.md b/README.md index 4da7891d..9daccb6e 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,7 @@ architecture model: | `generatr.site.externalTag` | Software systems containing this tag will be considered external | | | | `generatr.site.nestGroups` | Will show software systems in the left side navigator in collapsable groups | `false` | `true` | | `generatr.site.cdn` | Specifies the CDN base location for fetching NPM packages for browser runtime dependencies. Defaults to jsDelivr, but can be changed to e.g. an on-premise location. | `https://cdn.jsdelivr.net/npm` | `https://cdn.my-company/npm` | +| `generatr.site.theme` | Experimental: allows to force a light or dark theme or allows to switch between light and dark mode on the website with browser preference or menu item. Possible values are 'light', 'dark' or 'auto'. Note that the 'structurizr' exporter (see 'generatr.site.exporter' setting) generally works better for the dark theme. | `light` | `auto` | See the included example for usage of some those properties in the [C4 architecture model example](https://github.com/avisi-cloud/structurizr-site-generatr/blob/main/docs/example/workspace.dsl#L163). diff --git a/docs/example/workspace.dsl b/docs/example/workspace.dsl index ce0c94d6..b13f0555 100644 --- a/docs/example/workspace.dsl +++ b/docs/example/workspace.dsl @@ -182,10 +182,11 @@ workspace "Big Bank plc" "This is an example workspace to illustrate the key fea // default behaviour, if no generatr.markdown.flexmark.extensions property is specified, is to load the Tables extension only "generatr.markdown.flexmark.extensions" "Abbreviation,Admonition,AnchorLink,Attributes,Autolink,Definition,Emoji,Footnotes,GfmTaskList,GitLab,MediaTags,Tables,TableOfContents,Typographic" - "generatr.site.exporter" "c4" + "generatr.site.exporter" "structurizr" "generatr.site.externalTag" "External System" "generatr.site.nestGroups" "false" "generatr.site.cdn" "https://cdn.jsdelivr.net/npm" + "generatr.site.theme" "auto" } systemlandscape "SystemLandscape" { diff --git a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/SiteGenerator.kt b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/SiteGenerator.kt index 201ef461..bf54ea80 100644 --- a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/SiteGenerator.kt +++ b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/SiteGenerator.kt @@ -27,6 +27,7 @@ fun copySiteWideAssets(exportDir: File) { copySiteWideAsset(exportDir, "/css/treeview.css") copySiteWideAsset(exportDir, "/js/treeview.js") copySiteWideAsset(exportDir, "/js/katex-render.js") + copySiteWideAsset(exportDir, "/js/toggle-theme.js") } private fun copySiteWideAsset(exportDir: File, asset: String) { @@ -112,10 +113,6 @@ private fun generateStyle(context: GeneratorContext, branchDir: File) { color: $secondary!important; background-color: $primary!important; } - .input.has-site-branding { - color: dimgrey!important; - background-color: white!important; - } .input.has-site-branding:focus { border-color: $secondary!important; box-shadow: 0 0 0 0.125em $secondary; diff --git a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/model/HeaderBarViewModel.kt b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/model/HeaderBarViewModel.kt index 4626bf7e..005f825c 100644 --- a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/model/HeaderBarViewModel.kt +++ b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/model/HeaderBarViewModel.kt @@ -12,6 +12,7 @@ class HeaderBarViewModel(pageViewModel: PageViewModel, generatorContext: Generat .map { BranchHomeLinkViewModel(pageViewModel, it) } val currentBranch = generatorContext.currentBranch val version = generatorContext.version + val allowToggleTheme = pageViewModel.allowToggleTheme private fun logoPath(generatorContext: GeneratorContext) = generatorContext.workspace.views.configuration.properties diff --git a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/model/PageViewModel.kt b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/model/PageViewModel.kt index 9d79b8af..61c4f04b 100644 --- a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/model/PageViewModel.kt +++ b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/model/PageViewModel.kt @@ -25,7 +25,16 @@ abstract class PageViewModel(protected val generatorContext: GeneratorContext) { val includedSoftwareSystems = generatorContext.workspace.includedSoftwareSystems val configuration = generatorContext.workspace.views.configuration.properties val includeTreeview = configuration.getOrDefault("generatr.site.nestGroups", "false").toBoolean() + val theme = configuration.getOrDefault("generatr.site.theme", "light").toTheme() + val allowToggleTheme = theme == Theme.AUTO abstract val url: String abstract val pageSubTitle: String } + +fun String.toTheme() = when (this) { + "light" -> Theme.LIGHT + "dark" -> Theme.DARK + "auto" -> Theme.AUTO + else -> throw IllegalArgumentException("Unknown theme '$this', allowed values are 'light', 'dark' or 'auto'") +} diff --git a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/model/Theme.kt b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/model/Theme.kt new file mode 100644 index 00000000..4ea31210 --- /dev/null +++ b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/model/Theme.kt @@ -0,0 +1,5 @@ +package nl.avisi.structurizr.site.generatr.site.model + +enum class Theme { + LIGHT, DARK, AUTO +} diff --git a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/Page.kt b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/Page.kt index 5b63f6fc..ab0b4254 100644 --- a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/Page.kt +++ b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/Page.kt @@ -3,11 +3,19 @@ package nl.avisi.structurizr.site.generatr.site.views import kotlinx.html.* import nl.avisi.structurizr.site.generatr.site.asUrlToFile import nl.avisi.structurizr.site.generatr.site.model.PageViewModel +import nl.avisi.structurizr.site.generatr.site.model.Theme fun HTML.page(viewModel: PageViewModel, block: DIV.() -> Unit) { attributes["lang"] = "en" - attributes["data-theme"] = "light" - classes = setOf("has-background-light") + when (viewModel.theme) { + Theme.LIGHT -> { + attributes["data-theme"] = "light" + } + Theme.DARK -> { + attributes["data-theme"] = "dark" + } + Theme.AUTO -> { } + } headFragment(viewModel) bodyFragment(viewModel, block) @@ -24,6 +32,8 @@ private fun HTML.headFragment(viewModel: PageViewModel) { script(type = ScriptType.textJavaScript, src = "../" + "/modal.js".asUrlToFile(viewModel.url)) { } script(type = ScriptType.textJavaScript, src = "../" + "/svg-modal.js".asUrlToFile(viewModel.url)) { } script(type = ScriptType.textJavaScript, src = viewModel.cdn.svgpanzoomJs()) { } + if (viewModel.allowToggleTheme) + script(type = ScriptType.textJavaScript, src = "../" + "/toggle-theme.js".asUrlToFile(viewModel.url)) { } if (viewModel.includeTreeview) link(rel = "stylesheet", href = "../" + "/treeview.css".asUrlToFile(viewModel.url)) @@ -59,7 +69,7 @@ private fun HTML.bodyFragment(viewModel: PageViewModel, block: DIV.() -> Unit) { div(classes = "site-layout") { id = "site" menu(viewModel.menu, viewModel.includeTreeview) - div(classes = "container is-fluid has-background-white") { + div(classes = "container is-fluid") { block() } } diff --git a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/PageHeader.kt b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/PageHeader.kt index c362dfbd..bfe1cbc0 100644 --- a/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/PageHeader.kt +++ b/src/main/kotlin/nl/avisi/structurizr/site/generatr/site/views/PageHeader.kt @@ -24,7 +24,7 @@ fun BODY.pageHeader(viewModel: HeaderBarViewModel) { div(classes = "navbar-menu has-site-branding") { div(classes = "navbar-end") { div(classes = "navbar-item") { - input(classes = "input is-small is-rounded has-site-branding") { + input(classes = "input is-small is-rounded") { id = "search" type = InputType.search size = "30" @@ -46,6 +46,15 @@ fun BODY.pageHeader(viewModel: HeaderBarViewModel) { +branchLink.title } } + if (viewModel.allowToggleTheme) { + hr(classes = "navbar-divider") + a( + classes = "navbar-item", + ) { + onClick = "toggleTheme()" + +"Toggle theme" + } + } hr(classes = "navbar-divider") div(classes = "navbar-item has-text-grey-light") { span { +"v" } diff --git a/src/main/resources/assets/css/style.css b/src/main/resources/assets/css/style.css index b6e6a5fe..525c53ab 100644 --- a/src/main/resources/assets/css/style.css +++ b/src/main/resources/assets/css/style.css @@ -69,3 +69,15 @@ a.navbar-item:hover { color: #485fc7; border-bottom-color: #485fc7; } + +.tabs li { + font-weight: bold; +} + +.input { + color: dimgrey!important; + background-color: white!important; +} +.input::placeholder { + color: darkgrey!important; +} diff --git a/src/main/resources/assets/js/toggle-theme.js b/src/main/resources/assets/js/toggle-theme.js new file mode 100644 index 00000000..b17f385a --- /dev/null +++ b/src/main/resources/assets/js/toggle-theme.js @@ -0,0 +1,18 @@ +if (!localStorage.getItem("data-theme")) { + const prefersDarkMode = window.matchMedia && + window.matchMedia('(prefers-color-scheme: dark)').matches; + + localStorage.setItem("data-theme", prefersDarkMode ? "dark" : "light"); +} + +document.documentElement.setAttribute("data-theme", localStorage.getItem("data-theme")); + +function toggleTheme() { + if (localStorage.getItem("data-theme") === "light") { + document.documentElement.setAttribute("data-theme", "dark"); + localStorage.setItem("data-theme", "dark"); + } else { + document.documentElement.setAttribute("data-theme", "light"); + localStorage.setItem("data-theme", "light"); + } +} diff --git a/src/test/kotlin/nl/avisi/structurizr/site/generatr/site/model/HeaderBarViewModelTest.kt b/src/test/kotlin/nl/avisi/structurizr/site/generatr/site/model/HeaderBarViewModelTest.kt index 5dab8acb..71fa6939 100644 --- a/src/test/kotlin/nl/avisi/structurizr/site/generatr/site/model/HeaderBarViewModelTest.kt +++ b/src/test/kotlin/nl/avisi/structurizr/site/generatr/site/model/HeaderBarViewModelTest.kt @@ -5,6 +5,8 @@ import assertk.assertions.containsExactly import assertk.assertions.isEqualTo import assertk.assertions.isFalse import assertk.assertions.isTrue +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.TestFactory import kotlin.test.Test class HeaderBarViewModelTest : ViewModelTest() { @@ -67,4 +69,25 @@ class HeaderBarViewModelTest : ViewModelTest() { assertThat(viewModel.hasLogo).isFalse() } + + @TestFactory + fun `no dark mode`() = listOf( + "light" to false, + "dark" to false, + "auto" to true, + ).map { (theme, allowToggle) -> + DynamicTest.dynamicTest(theme) { + generatorContext.workspace.views.configuration.addProperty( + "generatr.site.theme", + theme + ) + + val viewModel = HeaderBarViewModel(object : PageViewModel(generatorContext) { + override val url: String = "/master/system" + override val pageSubTitle: String = "subtitle" + }, generatorContext) + + assertThat(viewModel.allowToggleTheme).isEqualTo(allowToggle) + } + } } diff --git a/src/test/kotlin/nl/avisi/structurizr/site/generatr/site/views/CDNTest.kt b/src/test/kotlin/nl/avisi/structurizr/site/generatr/site/views/CDNTest.kt index 4405dca0..8ebbd966 100644 --- a/src/test/kotlin/nl/avisi/structurizr/site/generatr/site/views/CDNTest.kt +++ b/src/test/kotlin/nl/avisi/structurizr/site/generatr/site/views/CDNTest.kt @@ -10,11 +10,11 @@ import org.junit.jupiter.api.TestFactory class CDNTest { @TestFactory - fun `cdn locations`() { + fun `cdn locations`() : List { val workspace = Workspace("workspace name", "") val cdn = CDN(workspace) - listOf( + return listOf( cdn.bulmaCss() to "/css/bulma.min.css", cdn.katexJs() to "/dist/katex.min.js", cdn.katexCss() to "/dist/katex.min.css",