diff --git a/CLAUDE.md b/CLAUDE.md index 8f845beec..dcd560fc8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,9 +121,20 @@ Vite's `dynamicImportVarsOptions`. A block pulling in a heavy library is fine until its tag turns up in a page — and a library that still ships CommonJS works too, since the rollup config runs `@rollup/plugin-commonjs` after `resolve()`. -Blocks style themselves with `:host` / `:host-context(body.body--dark)` for dark mode and read the -theme colors via CSS custom properties (`var(--q-primary)` — the `--q-` prefix is historical; the -properties are declared in `css/tailwind.css` and rewritten at runtime for per-site theming). +Blocks style themselves off `:host` and read the theme colors via CSS custom properties +(`var(--q-primary)` — the `--q-` prefix is historical; the properties are declared in +`css/tailwind.css` and rewritten at runtime for per-site theming). + +**Dark mode goes through `blocks/shared/theme.js`, never `:host-context()`.** The app's source of +truth is the `body--dark` class on `
`, which CSS in a shadow root cannot see; `:host-context()` +is the selector for exactly that and is what every block used to use, but only Chromium ever shipped +it — MDN has it deprecated, Firefox and Safari never implemented it, and there it silently never +matches, so the block stayed light on a dark page. Instead construct a `DarkMode` controller +(`this._darkMode = new DarkMode(this)`) in the block's constructor and write `:host([dark])`; the +controller keeps that attribute in step, sharing one MutationObserver across every block on the page. +A block that must *act* on the change rather than restyle for it passes `onChange`, or reads +`.isDark` — `block-diagram` redraws mermaid in its own dark theme, `block-map` resolves a per-block +`theme` prop that can pin a map light on a dark page. ## Commands diff --git a/blocks/block-countdown/component.js b/blocks/block-countdown/component.js index ec72bffc8..84e519017 100644 --- a/blocks/block-countdown/component.js +++ b/blocks/block-countdown/component.js @@ -1,4 +1,5 @@ import { LitElement, html, css } from 'lit' +import { DarkMode } from '../shared/theme.js' /** * Block Countdown @@ -71,7 +72,7 @@ export class BlockCountdownElement extends LitElement { text-align: center; background-image: linear-gradient(to bottom, #fff, #fafafa); } - :host-context(body.body--dark) .countdown { + :host([dark]) .countdown { border-color: rgba(255, 255, 255, 0.15); background-image: linear-gradient(to bottom, #161b22, #0d1117); } @@ -95,7 +96,7 @@ export class BlockCountdownElement extends LitElement { border-radius: 5px; background-color: rgba(0, 0, 0, 0.04); } - :host-context(body.body--dark) .segment { + :host([dark]) .segment { background-color: rgba(255, 255, 255, 0.06); } @@ -175,6 +176,8 @@ export class BlockCountdownElement extends LitElement { this._error = '' this._target = null this._timer = null + // -> Puts `dark` on this element for the styles above to key off + this._darkMode = new DarkMode(this) } /** diff --git a/blocks/block-diagram/component.js b/blocks/block-diagram/component.js index 303f63ba8..18aced8e6 100644 --- a/blocks/block-diagram/component.js +++ b/blocks/block-diagram/component.js @@ -1,6 +1,7 @@ import { LitElement, html, css } from 'lit' import { unsafeSVG } from 'lit/directives/unsafe-svg.js' import mermaid from 'mermaid' +import { DarkMode } from '../shared/theme.js' /** * A number for the next drawing, so every one of them gets an id of its own. @@ -124,7 +125,7 @@ flowchart LR color: #424242; font-size: 0.8em; } - :host-context(body.body--dark) .caption { + :host([dark]) .caption { color: rgba(255, 255, 255, 0.7); } @@ -171,7 +172,19 @@ flowchart LR this.align = 'left' this._svg = '' this._error = '' - this._themeWatcher = null + /* + Two jobs at once: `dark` on this element is what the caption colour keys off, and the callback + is what redraws the diagram itself, since mermaid picks its colours as it draws and writes them + into the SVG. Only `auto` has anything to follow -- a diagram asked for a theme by name keeps + it either way -- and only once there is a source to draw, which `firstUpdated` reads. + */ + this._darkMode = new DarkMode(this, { + onChange: () => { + if (this.theme === 'auto' && this._source) { + this._draw() + } + } + }) /** The drawing being waited on, so a stale one cannot land after a newer one. */ this._drawing = 0 /** The source, and whether it came out of a fence. Both read from the body once, on first render. */ @@ -182,15 +195,15 @@ flowchart LR /** * The theme to draw in. * - * `auto` reads the class the app puts on the body, which is the same thing every block's CSS keys - * its dark mode off — a diagram cannot do it in CSS, because mermaid picks its colours while it - * draws and writes them into the SVG. + * `auto` follows the app, which every other block does in CSS off the `dark` attribute the same + * controller sets — a diagram cannot, because mermaid picks its colours while it draws and writes + * them into the SVG. */ _theme() { if (this.theme && this.theme !== 'auto') { return this.theme } - return document.body.classList.contains('body--dark') ? 'dark' : 'default' + return this._darkMode.isDark ? 'dark' : 'default' } /** @@ -251,18 +264,6 @@ flowchart LR return } this._draw() - - // -> Only `auto` has anything to follow; a diagram asked for a theme by name keeps it either way - if (this.theme === 'auto') { - this._themeWatcher = new MutationObserver(() => this._draw()) - this._themeWatcher.observe(document.body, { attributeFilter: ['class'] }) - } - } - - disconnectedCallback() { - super.disconnectedCallback() - this._themeWatcher?.disconnect() - this._themeWatcher = null } render() { diff --git a/blocks/block-index/component.js b/blocks/block-index/component.js index d6d48a176..4abf8d087 100644 --- a/blocks/block-index/component.js +++ b/blocks/block-index/component.js @@ -1,4 +1,5 @@ import { LitElement, html, css } from 'lit' +import { DarkMode } from '../shared/theme.js' /** * Block Index @@ -118,7 +119,7 @@ export class BlockIndexElement extends LitElement { align-items: stretch; justify-content: stretch; } - :host-context(body.body--dark) li { + :host([dark]) li { background-color: #222; background-image: linear-gradient(to bottom,#161b22, #0d1117); border-right: 1px solid rgba(0,0,0,.5); @@ -132,7 +133,7 @@ export class BlockIndexElement extends LitElement { border-left-color: var(--q-primary); cursor: pointer; } - :host-context(body.body--dark) li:hover { + :host([dark]) li:hover { background-image: linear-gradient(to bottom,#1e232a, #161b22); border-left-color: var(--q-primary); } @@ -162,10 +163,10 @@ export class BlockIndexElement extends LitElement { li a > svg path { fill: rgba(0,0,0,.2); } - :host-context(body.body--dark) li a > svg path { + :host([dark]) li a > svg path { fill: rgba(255,255,255,.2); } - li:hover a > svg path, :host-context(body.body--dark) li:hover a > svg path { + li:hover a > svg path, :host([dark]) li:hover a > svg path { fill: color-mix(in srgb, currentColor 50%, transparent); } @@ -240,6 +241,8 @@ export class BlockIndexElement extends LitElement { this.orderByDirection = 'asc' this.depth = 0 this.noResultMsg = 'No pages matching your query.' + // -> Puts `dark` on this element for the styles above to key off + this._darkMode = new DarkMode(this) } async connectedCallback() { diff --git a/blocks/block-infobox/component.js b/blocks/block-infobox/component.js index 57264759c..bd5cf2449 100644 --- a/blocks/block-infobox/component.js +++ b/blocks/block-infobox/component.js @@ -1,5 +1,6 @@ import { LitElement, html, css } from 'lit' import { load as parseYaml } from 'js-yaml' +import { DarkMode } from '../shared/theme.js' /** * Yes and no, drawn rather than spelled out. @@ -367,7 +368,7 @@ Website: https://montreal.ca --infobox-head-top: #f7f8fa; --infobox-rule: #e3e5e8; } - :host-context(body.body--dark) { + :host([dark]) { --infobox-border: rgba(255, 255, 255, 0.15); --infobox-bg: #161b22; --infobox-head: #1e232a; @@ -410,6 +411,8 @@ Website: https://montreal.ca this.imageCaption = '' this._entries = [] this._error = '' + // -> Puts `dark` on this element for the styles above to key off + this._darkMode = new DarkMode(this) } /** diff --git a/blocks/block-katex/component.js b/blocks/block-katex/component.js index dcbc657f4..f625bc910 100644 --- a/blocks/block-katex/component.js +++ b/blocks/block-katex/component.js @@ -11,6 +11,7 @@ import katexCss from 'katex/dist/katex.min.css' the same thing in both blocks. */ import 'katex/contrib/mhchem' +import { DarkMode } from '../shared/theme.js' /* KaTeX's stylesheet, split in two. @@ -119,7 +120,7 @@ x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a} font-size: 0.8em; text-align: center; } - :host-context(body.body--dark) .caption { + :host([dark]) .caption { color: rgba(255, 255, 255, 0.7); } @@ -160,6 +161,8 @@ x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a} this.align = 'center' this._markup = '' this._error = '' + // -> Puts `dark` on this element for the styles above to key off + this._darkMode = new DarkMode(this) } /** diff --git a/blocks/block-kroki/component.js b/blocks/block-kroki/component.js index f3423b7a4..5675222d1 100644 --- a/blocks/block-kroki/component.js +++ b/blocks/block-kroki/component.js @@ -1,5 +1,6 @@ import { LitElement, html, css } from 'lit' import { deflate } from 'pako' +import { DarkMode } from '../shared/theme.js' /** The default server, which is the one Kroki runs for everybody. */ const DEFAULT_SERVER = 'https://kroki.io' @@ -203,7 +204,7 @@ digraph G { /* -> A diagram wider than the column scrolls rather than shrinking to illegibility */ overflow-x: auto; } - :host-context(body.body--dark) .sheet { + :host([dark]) .sheet { border-color: rgba(255, 255, 255, 0.15); } @@ -234,7 +235,7 @@ digraph G { color: #424242; font-size: 0.8em; } - :host-context(body.body--dark) .caption { + :host([dark]) .caption { color: rgba(255, 255, 255, 0.7); } @@ -297,6 +298,8 @@ digraph G { this._src = '' this._unsized = false this._error = '' + // -> Puts `dark` on this element for the styles above to key off + this._darkMode = new DarkMode(this) } /** diff --git a/blocks/block-map/component.js b/blocks/block-map/component.js index 6484d90ef..4c9d441a2 100644 --- a/blocks/block-map/component.js +++ b/blocks/block-map/component.js @@ -3,6 +3,7 @@ import { LitElement, html, css, unsafeCSS } from 'lit' // apart with a commonjs plugin, and it has no `exports` map to pick the module build for us import * as L from 'leaflet/dist/leaflet-src.esm.js' import leafletCss from 'leaflet/dist/leaflet.css' +import { DarkMode } from '../shared/theme.js' /** * The marker, drawn rather than fetched. @@ -65,6 +66,14 @@ export class BlockMapElement extends LitElement { type: 'string', label: 'Marker Label', hint: 'Shown in a popup when the marker is clicked. The marker is drawn either way.' + }, + { + name: 'theme', + type: 'select', + label: 'Theme', + options: ['auto', 'light', 'dark'], + hint: 'auto follows the light or dark theme the reader is using.', + default: 'auto' } ] } @@ -90,20 +99,74 @@ export class BlockMapElement extends LitElement { margin-bottom: 16px; } + /* + The palette, as custom properties on the container so that the rules below are written once + and only the values are switched. + + Which set applies is decided on the data-theme attribute rather than on a class, because + Leaflet owns the class attribute of this element -- it writes leaflet-container, + leaflet-touch and the animation classes onto it -- and a Lit class binding sets the whole + attribute, so rebinding it (which the theme changing under the reader does) would take + Leaflet's own classes off with it. render() resolves auto to one of the two, so the + attribute in the DOM is always the palette actually in use. + */ + .map { + --tile-filter: none; + --edge: rgba(0, 0, 0, 0.1); + --surface: #f2efe9; + --control-bg: #fff; + --control-fg: #333; + --control-link: #0078a8; + } + .map[data-theme='dark'] { + /* + OpenStreetMap publishes one style and it is a light one, so a dark map is the light tiles + recoloured: inverted for the dark ground and light labels, turned back through half the + colour wheel so water reads as water again, and taken slightly off full contrast, which + the inversion otherwise exaggerates. An approximation -- greenery lands cooler than it + should -- but it costs no second tile provider and no second request. + + The filter is on the tile pane alone. Marker, popup and controls sit in panes of their + own and would come back inverted too. + */ + --tile-filter: invert(1) hue-rotate(180deg) brightness(0.92) contrast(0.9) saturate(0.85); + --edge: rgba(255, 255, 255, 0.15); + --surface: #16130f; + --control-bg: #2b2b2b; + --control-fg: #ddd; + --control-link: #6cb6d9; + } + .map { width: 100%; border-radius: 5px; - border: 1px solid rgba(0, 0, 0, 0.1); - background-color: #f2efe9; + border: 1px solid var(--edge); + background-color: var(--surface); } - :host-context(body.body--dark) .map { - border-color: rgba(255, 255, 255, 0.15); - background-color: #16130f; + + .map .leaflet-tile-pane { + filter: var(--tile-filter); } /* -> The tiles are somebody else's work and the licence asks for the credit to be visible */ .leaflet-container .leaflet-control-attribution { font-size: 10px; + /* -> Leaflet shows the credit through a translucent plate, which is worth keeping */ + background: color-mix(in srgb, var(--control-bg) 80%, transparent); + color: var(--control-fg); + } + .leaflet-container .leaflet-control-attribution a { + color: var(--control-link); + } + + /* -> Leaflet's zoom buttons are white by default, which is a lamp on a dark map */ + .leaflet-container .leaflet-bar a { + background-color: var(--control-bg); + color: var(--control-fg); + border-bottom-color: var(--edge); + } + .leaflet-container .leaflet-bar a:hover { + background-color: color-mix(in srgb, var(--control-bg) 90%, var(--control-fg)); } .error { @@ -148,6 +211,12 @@ export class BlockMapElement extends LitElement { */ label: { type: String }, + /** + * Which palette to draw in: `auto`, `light` or `dark` + * @type {string} + */ + theme: { type: String }, + // Internal Properties _error: { state: true } } @@ -160,8 +229,14 @@ export class BlockMapElement extends LitElement { this.zoom = 13 this.height = 400 this.label = '' + this.theme = 'auto' this._error = '' this._map = null + /* + No `dark` attribute on the host: this block settles the theme question itself, since the prop + can pin a map light on a dark page, and the answer it arrives at goes on `data-theme` below. + */ + this._darkMode = new DarkMode(this, { attribute: false }) } firstUpdated() { @@ -221,7 +296,17 @@ export class BlockMapElement extends LitElement { if (this._error) { return html`