Skip to content

Commit

Permalink
dynamic dark theme
Browse files Browse the repository at this point in the history
 * introduce `--dark-theme-class`: the CSS class to enable dark mode. When left unset prefers-color-scheme media query is used;
 * add test for combined light and dark themes (original test uses light theme in dark theme test, not clear why);

 When D2-produced SVG file is embedded into some documentation/site framework where dark mode is controller by CSS class, using `prefers-color-scheme` media query leads to inability to switch dark theme if needed.

 Media Query Level 5, where it is possible to use some class as condition, is not supported by any modern browser.
  • Loading branch information
develar committed Jan 22, 2024
1 parent d15d5b1 commit c2e881e
Show file tree
Hide file tree
Showing 44 changed files with 8,985 additions and 92 deletions.
2 changes: 2 additions & 0 deletions ci/release/changelogs/next.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#### Features 🚀

- Dark theme maybe enabled via a custom CSS class: `--dark-theme-class theme-dark`.

#### Improvements 🧹

#### Bugfixes ⛑️
4 changes: 4 additions & 0 deletions ci/release/template/man/d2.1
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ still be applied and this may produce unexpected results. We plan on resolving t
making style maps in D2 light/dark mode specific. See
.Lk https://github.com/terrastruct/d2/issues/831
.Ns .
.It Fl -dark-theme-class Ar
The CSS class to enable dark mode. When left unset prefers-color-scheme media query is used. See
.Lk https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme
.Ns .
.It Fl s , -sketch Ar false
Renders the diagram to look like it was sketched by hand
.Ns .
Expand Down
19 changes: 13 additions & 6 deletions d2cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
if err != nil {
return err
}
darkThemeClassFlag := ms.Opts.String("D2_DARK_THEME_CLASS", "dark-theme-class", "", "", "the CSS class to enable dark mode. When left unset prefers-color-scheme media query is used. See https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme.")
padFlag, err := ms.Opts.Int64("D2_PAD", "pad", "", d2svg.DEFAULT_PADDING, "pixels padded around the rendered diagram")
if err != nil {
return err
Expand Down Expand Up @@ -285,6 +286,10 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
ms.Log.Warn.Printf("--dark-theme cannot be used while exporting to another format other than .svg")
darkThemeFlag = nil
}
if darkThemeClassFlag != nil {
ms.Log.Warn.Printf("--dark-theme-class cannot be used while exporting to another format other than .svg")
darkThemeClassFlag = nil
}
}
var pw png.Playwright
if outputFormat.requiresPNGRenderer() {
Expand All @@ -301,12 +306,13 @@ func Run(ctx context.Context, ms *xmain.State) (err error) {
}

renderOpts := d2svg.RenderOpts{
Pad: padFlag,
Sketch: sketchFlag,
Center: centerFlag,
ThemeID: themeFlag,
DarkThemeID: darkThemeFlag,
Scale: scale,
Pad: padFlag,
Sketch: sketchFlag,
Center: centerFlag,
ThemeID: themeFlag,
DarkThemeID: darkThemeFlag,
DarkThemeClass: *darkThemeClassFlag,
Scale: scale,
}

if *watchFlag {
Expand Down Expand Up @@ -849,6 +855,7 @@ func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts
Center: opts.Center,
ThemeID: opts.ThemeID,
DarkThemeID: opts.DarkThemeID,
DarkThemeClass: opts.DarkThemeClass,
MasterID: opts.MasterID,
ThemeOverrides: opts.ThemeOverrides,
DarkThemeOverrides: opts.DarkThemeOverrides,
Expand Down
3 changes: 3 additions & 0 deletions d2lib/d2.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,9 @@ func applyConfigs(config *d2target.Config, compileOpts *CompileOptions, renderOp
if renderOpts.DarkThemeID == nil {
renderOpts.DarkThemeID = config.DarkThemeID
}
if renderOpts.DarkThemeClass == "" {
renderOpts.DarkThemeClass = *config.DarkThemeClass
}
if renderOpts.Sketch == nil {
renderOpts.Sketch = config.Sketch
}
Expand Down
2 changes: 1 addition & 1 deletion d2renderers/d2animate/d2animate.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func Wrap(rootDiagram *d2target.Diagram, svgs [][]byte, renderOpts d2svg.RenderO

d2svg.EmbedFonts(buf, diagramHash, svgsStr, rootDiagram.FontFamily, rootDiagram.GetNestedCorpus())

themeStylesheet, err := d2svg.ThemeCSS(diagramHash, renderOpts.ThemeID, renderOpts.DarkThemeID, renderOpts.ThemeOverrides, renderOpts.DarkThemeOverrides)
themeStylesheet, err := d2svg.ThemeCSS(diagramHash, renderOpts.ThemeID, renderOpts.DarkThemeID, renderOpts.DarkThemeClass, renderOpts.ThemeOverrides, renderOpts.DarkThemeOverrides)
if err != nil {
return nil, err
}
Expand Down
147 changes: 81 additions & 66 deletions d2renderers/d2svg/d2svg.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ type RenderOpts struct {
Center *bool
ThemeID *int64
DarkThemeID *int64
DarkThemeClass string
ThemeOverrides *d2target.ThemeOverrides
DarkThemeOverrides *d2target.ThemeOverrides
Font string
Expand Down Expand Up @@ -1758,6 +1759,7 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) {
pad := DEFAULT_PADDING
themeID := d2themescatalog.NeutralDefault.ID
darkThemeID := DEFAULT_DARK_THEME
darkThemeClass := ""
var scale *float64
if opts != nil {
if opts.Pad != nil {
Expand All @@ -1774,6 +1776,7 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) {
themeID = *opts.ThemeID
}
darkThemeID = opts.DarkThemeID
darkThemeClass = opts.DarkThemeClass
scale = opts.Scale
}

Expand Down Expand Up @@ -1856,7 +1859,7 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) {
upperBuf := &bytes.Buffer{}
if opts.MasterID == "" {
EmbedFonts(upperBuf, diagramHash, buf.String(), diagram.FontFamily, diagram.GetCorpus()) // EmbedFonts *must* run before `d2sketch.DefineFillPatterns`, but after all elements are appended to `buf`
themeStylesheet, err := ThemeCSS(diagramHash, &themeID, darkThemeID, opts.ThemeOverrides, opts.DarkThemeOverrides)
themeStylesheet, err := ThemeCSS(diagramHash, &themeID, darkThemeID, darkThemeClass, opts.ThemeOverrides, opts.DarkThemeOverrides)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -2018,96 +2021,108 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) {
}

// TODO include only colors that are being used to reduce size
func ThemeCSS(diagramHash string, themeID *int64, darkThemeID *int64, overrides, darkOverrides *d2target.ThemeOverrides) (stylesheet string, err error) {
func ThemeCSS(diagramHash string, themeID *int64, darkThemeID *int64, darkThemeClass string, overrides, darkOverrides *d2target.ThemeOverrides) (stylesheet string, err error) {
if themeID == nil {
themeID = &d2themescatalog.NeutralDefault.ID
}
out, err := singleThemeRulesets(diagramHash, *themeID, overrides)

classPrefix := "." + diagramHash
out, err := singleThemeRulesets(classPrefix, *themeID, overrides)
if err != nil {
return "", err
}

if darkThemeID != nil {
darkOut, err := singleThemeRulesets(diagramHash, *darkThemeID, darkOverrides)
hasThemeClass := len(darkThemeClass) != 0
if hasThemeClass {
classPrefix = "." + darkThemeClass + " " + classPrefix
}
darkOut, err := singleThemeRulesets(classPrefix, *darkThemeID, darkOverrides)
if err != nil {
return "", err
}
out += fmt.Sprintf("@media screen and (prefers-color-scheme:dark){%s}", darkOut)

if hasThemeClass {
out += darkOut
} else {
out += fmt.Sprintf("@media screen and (prefers-color-scheme:dark){%s}", darkOut)
}
}

return out, nil
}

func singleThemeRulesets(diagramHash string, themeID int64, overrides *d2target.ThemeOverrides) (rulesets string, err error) {
func singleThemeRulesets(classPrefix string, themeID int64, overrides *d2target.ThemeOverrides) (rulesets string, err error) {
out := ""
theme := d2themescatalog.Find(themeID)
theme.ApplyOverrides(overrides)

// Global theme colors
for _, property := range []string{"fill", "stroke", "background-color", "color"} {
out += fmt.Sprintf(`
.%s .%s-N1{%s:%s;}
.%s .%s-N2{%s:%s;}
.%s .%s-N3{%s:%s;}
.%s .%s-N4{%s:%s;}
.%s .%s-N5{%s:%s;}
.%s .%s-N6{%s:%s;}
.%s .%s-N7{%s:%s;}
.%s .%s-B1{%s:%s;}
.%s .%s-B2{%s:%s;}
.%s .%s-B3{%s:%s;}
.%s .%s-B4{%s:%s;}
.%s .%s-B5{%s:%s;}
.%s .%s-B6{%s:%s;}
.%s .%s-AA2{%s:%s;}
.%s .%s-AA4{%s:%s;}
.%s .%s-AA5{%s:%s;}
.%s .%s-AB4{%s:%s;}
.%s .%s-AB5{%s:%s;}`,
diagramHash,
%s .%s-N1{%s:%s;}
%s .%s-N2{%s:%s;}
%s .%s-N3{%s:%s;}
%s .%s-N4{%s:%s;}
%s .%s-N5{%s:%s;}
%s .%s-N6{%s:%s;}
%s .%s-N7{%s:%s;}
%s .%s-B1{%s:%s;}
%s .%s-B2{%s:%s;}
%s .%s-B3{%s:%s;}
%s .%s-B4{%s:%s;}
%s .%s-B5{%s:%s;}
%s .%s-B6{%s:%s;}
%s .%s-AA2{%s:%s;}
%s .%s-AA4{%s:%s;}
%s .%s-AA5{%s:%s;}
%s .%s-AB4{%s:%s;}
%s .%s-AB5{%s:%s;}`,
classPrefix,
property, property, theme.Colors.Neutrals.N1,
diagramHash,
classPrefix,
property, property, theme.Colors.Neutrals.N2,
diagramHash,
classPrefix,
property, property, theme.Colors.Neutrals.N3,
diagramHash,
classPrefix,
property, property, theme.Colors.Neutrals.N4,
diagramHash,
classPrefix,
property, property, theme.Colors.Neutrals.N5,
diagramHash,
classPrefix,
property, property, theme.Colors.Neutrals.N6,
diagramHash,
classPrefix,
property, property, theme.Colors.Neutrals.N7,
diagramHash,
classPrefix,
property, property, theme.Colors.B1,
diagramHash,
classPrefix,
property, property, theme.Colors.B2,
diagramHash,
classPrefix,
property, property, theme.Colors.B3,
diagramHash,
classPrefix,
property, property, theme.Colors.B4,
diagramHash,
classPrefix,
property, property, theme.Colors.B5,
diagramHash,
classPrefix,
property, property, theme.Colors.B6,
diagramHash,
classPrefix,
property, property, theme.Colors.AA2,
diagramHash,
classPrefix,
property, property, theme.Colors.AA4,
diagramHash,
classPrefix,
property, property, theme.Colors.AA5,
diagramHash,
classPrefix,
property, property, theme.Colors.AB4,
diagramHash,
classPrefix,
property, property, theme.Colors.AB5,
)
}

// Appendix
out += fmt.Sprintf(".appendix text.text{fill:%s}", theme.Colors.Neutrals.N1)
out += fmt.Sprintf("%s .appendix text.text{fill:%s}", classPrefix, theme.Colors.Neutrals.N1)

// Markdown specific rulesets
out += fmt.Sprintf(".md{--color-fg-default:%s;--color-fg-muted:%s;--color-fg-subtle:%s;--color-canvas-default:%s;--color-canvas-subtle:%s;--color-border-default:%s;--color-border-muted:%s;--color-neutral-muted:%s;--color-accent-fg:%s;--color-accent-emphasis:%s;--color-attention-subtle:%s;--color-danger-fg:%s;}",
out += fmt.Sprintf("%s .md{--color-fg-default:%s;--color-fg-muted:%s;--color-fg-subtle:%s;--color-canvas-default:%s;--color-canvas-subtle:%s;--color-border-default:%s;--color-border-muted:%s;--color-neutral-muted:%s;--color-accent-fg:%s;--color-accent-emphasis:%s;--color-attention-subtle:%s;--color-danger-fg:%s;}",
classPrefix,
theme.Colors.Neutrals.N1, theme.Colors.Neutrals.N2, theme.Colors.Neutrals.N3,
theme.Colors.Neutrals.N7, theme.Colors.Neutrals.N6,
theme.Colors.B1, theme.Colors.B2,
Expand All @@ -2123,105 +2138,105 @@ func singleThemeRulesets(diagramHash string, themeID int64, overrides *d2target.
if err != nil {
return "", err
}
out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.B1, lc, blendMode(lc))
out += fmt.Sprintf("%s .sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", classPrefix, color.B1, lc, blendMode(lc))
lc, err = color.LuminanceCategory(theme.Colors.B2)
if err != nil {
return "", err
}
out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.B2, lc, blendMode(lc))
out += fmt.Sprintf("%s .sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", classPrefix, color.B2, lc, blendMode(lc))
lc, err = color.LuminanceCategory(theme.Colors.B3)
if err != nil {
return "", err
}
out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.B3, lc, blendMode(lc))
out += fmt.Sprintf("%s .sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", classPrefix, color.B3, lc, blendMode(lc))
lc, err = color.LuminanceCategory(theme.Colors.B4)
if err != nil {
return "", err
}
out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.B4, lc, blendMode(lc))
out += fmt.Sprintf("%s .sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", classPrefix, color.B4, lc, blendMode(lc))
lc, err = color.LuminanceCategory(theme.Colors.B5)
if err != nil {
return "", err
}
out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.B5, lc, blendMode(lc))
out += fmt.Sprintf("%s .sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", classPrefix, color.B5, lc, blendMode(lc))
lc, err = color.LuminanceCategory(theme.Colors.B6)
if err != nil {
return "", err
}
out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.B6, lc, blendMode(lc))
out += fmt.Sprintf("%s .sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", classPrefix, color.B6, lc, blendMode(lc))

// AA
lc, err = color.LuminanceCategory(theme.Colors.AA2)
if err != nil {
return "", err
}
out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.AA2, lc, blendMode(lc))
out += fmt.Sprintf("%s .sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", classPrefix, color.AA2, lc, blendMode(lc))
lc, err = color.LuminanceCategory(theme.Colors.AA4)
if err != nil {
return "", err
}
out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.AA4, lc, blendMode(lc))
out += fmt.Sprintf("%s .sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", classPrefix, color.AA4, lc, blendMode(lc))
lc, err = color.LuminanceCategory(theme.Colors.AA5)
if err != nil {
return "", err
}
out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.AA5, lc, blendMode(lc))
out += fmt.Sprintf("%s .sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", classPrefix, color.AA5, lc, blendMode(lc))

// AB
lc, err = color.LuminanceCategory(theme.Colors.AB4)
if err != nil {
return "", err
}
out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.AB4, lc, blendMode(lc))
out += fmt.Sprintf("%s .sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", classPrefix, color.AB4, lc, blendMode(lc))
lc, err = color.LuminanceCategory(theme.Colors.AB5)
if err != nil {
return "", err
}
out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.AB5, lc, blendMode(lc))
out += fmt.Sprintf("%s .sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", classPrefix, color.AB5, lc, blendMode(lc))

// Neutrals
lc, err = color.LuminanceCategory(theme.Colors.Neutrals.N1)
if err != nil {
return "", err
}
out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.N1, lc, blendMode(lc))
out += fmt.Sprintf("%s .sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", classPrefix, color.N1, lc, blendMode(lc))
lc, err = color.LuminanceCategory(theme.Colors.Neutrals.N2)
if err != nil {
return "", err
}
out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.N2, lc, blendMode(lc))
out += fmt.Sprintf("%s .sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", classPrefix, color.N2, lc, blendMode(lc))
lc, err = color.LuminanceCategory(theme.Colors.Neutrals.N3)
if err != nil {
return "", err
}
out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.N3, lc, blendMode(lc))
out += fmt.Sprintf("%s .sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", classPrefix, color.N3, lc, blendMode(lc))
lc, err = color.LuminanceCategory(theme.Colors.Neutrals.N4)
if err != nil {
return "", err
}
out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.N4, lc, blendMode(lc))
out += fmt.Sprintf("%s .sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", classPrefix, color.N4, lc, blendMode(lc))
lc, err = color.LuminanceCategory(theme.Colors.Neutrals.N5)
if err != nil {
return "", err
}
out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.N5, lc, blendMode(lc))
out += fmt.Sprintf("%s .sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", classPrefix, color.N5, lc, blendMode(lc))
lc, err = color.LuminanceCategory(theme.Colors.Neutrals.N6)
if err != nil {
return "", err
}
out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.N6, lc, blendMode(lc))
out += fmt.Sprintf("%s .sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", classPrefix, color.N6, lc, blendMode(lc))
lc, err = color.LuminanceCategory(theme.Colors.Neutrals.N7)
if err != nil {
return "", err
}
out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", color.N7, lc, blendMode(lc))
out += fmt.Sprintf("%s .sketch-overlay-%s{fill:url(#streaks-%s);mix-blend-mode:%s}", classPrefix, color.N7, lc, blendMode(lc))

if theme.IsDark() {
out += ".light-code{display: none}"
out += ".dark-code{display: block}"
out += classPrefix + " .light-code{display: none}"
out += classPrefix + " .dark-code{display: block}"
} else {
out += ".light-code{display: block}"
out += ".dark-code{display: none}"
out += classPrefix + " .light-code{display: block}"
out += classPrefix + " .dark-code{display: none}"
}

return out, nil
Expand Down

0 comments on commit c2e881e

Please sign in to comment.