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`
${this._error}
` } - return html`
` + // -> Anything else an author might write is read as `auto`, which is the setting that has an + // answer for every page rather than a guess at what was meant + const theme = ['light', 'dark'].includes(this.theme) + ? this.theme + : this._darkMode.isDark + ? 'dark' + : 'light' + return html`
` } } diff --git a/blocks/block-mathjax/component.js b/blocks/block-mathjax/component.js index 211b9e18a..a11d5f818 100644 --- a/blocks/block-mathjax/component.js +++ b/blocks/block-mathjax/component.js @@ -48,6 +48,8 @@ import '@mathjax/src/js/input/tex/unicode/UnicodeConfiguration.js' import '@mathjax/src/js/input/tex/upgreek/UpgreekConfiguration.js' import '@mathjax/src/js/input/tex/verb/VerbConfiguration.js' +import { DarkMode } from '../shared/theme.js' + const PACKAGES = [ 'base', 'action', @@ -204,7 +206,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); } @@ -244,6 +246,8 @@ x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a} this.align = 'center' this._svg = '' this._error = '' + // -> Puts `dark` on this element for the styles above to key off + this._darkMode = new DarkMode(this) } /** diff --git a/blocks/block-plantuml/component.js b/blocks/block-plantuml/component.js index c1382b4d2..c3ef2f125 100644 --- a/blocks/block-plantuml/component.js +++ b/blocks/block-plantuml/component.js @@ -1,5 +1,6 @@ import { LitElement, html, css } from 'lit' import { deflateRaw } from 'pako' +import { DarkMode } from '../shared/theme.js' /** The default server, which is the one PlantUML runs for everybody. */ const DEFAULT_SERVER = 'https://www.plantuml.com/plantuml' @@ -138,7 +139,7 @@ Bob --> Alice : hi /* -> 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); } @@ -153,7 +154,7 @@ Bob --> Alice : hi color: #424242; font-size: 0.8em; } - :host-context(body.body--dark) .caption { + :host([dark]) .caption { color: rgba(255, 255, 255, 0.7); } @@ -207,6 +208,8 @@ Bob --> Alice : hi this.align = 'left' this._src = '' this._error = '' + // -> Puts `dark` on this element for the styles above to key off + this._darkMode = new DarkMode(this) } /** diff --git a/blocks/block-qr-code/component.js b/blocks/block-qr-code/component.js index 6a1dd611a..c299b0371 100644 --- a/blocks/block-qr-code/component.js +++ b/blocks/block-qr-code/component.js @@ -1,6 +1,7 @@ import { LitElement, html, css } from 'lit' import { unsafeSVG } from 'lit/directives/unsafe-svg.js' import { renderSVG } from 'uqr' +import { DarkMode } from '../shared/theme.js' /** * Block QR Code @@ -61,7 +62,7 @@ export class BlockQrCodeElement extends LitElement { */ background-color: #fff; } - :host-context(body.body--dark) .qr { + :host([dark]) .qr { border-color: rgba(255, 255, 255, 0.15); } @@ -123,6 +124,8 @@ export class BlockQrCodeElement extends LitElement { this.caption = '' this._svg = '' this._error = '' + // -> Puts `dark` on this element for the styles above to key off + this._darkMode = new DarkMode(this) } /** diff --git a/blocks/block-spoiler/component.js b/blocks/block-spoiler/component.js index 771eb3dac..75f97cc5f 100644 --- a/blocks/block-spoiler/component.js +++ b/blocks/block-spoiler/component.js @@ -1,4 +1,5 @@ import { LitElement, html, css } from 'lit' +import { DarkMode } from '../shared/theme.js' /** A crossed-out eye, drawn rather than fetched: it is the same picture on every spoiler there is. */ const EYE_OFF_SVG = html` @@ -110,7 +111,7 @@ export class BlockSpoilerElement extends LitElement { --spoiler-fg: #424242; --spoiler-hover: rgba(0, 0, 0, 0.04); } - :host-context(body.body--dark) { + :host([dark]) { --spoiler-border: rgba(255, 255, 255, 0.15); --spoiler-bg: #161b22; --spoiler-fg: rgba(255, 255, 255, 0.75); @@ -143,6 +144,8 @@ export class BlockSpoilerElement extends LitElement { this.label = 'Spoiler' this.hint = 'Click to show content' this._covered = true + // -> Puts `dark` on this element for the styles above to key off + this._darkMode = new DarkMode(this) } /** diff --git a/blocks/block-tabs/component.js b/blocks/block-tabs/component.js index 2623d8212..f2f73a771 100644 --- a/blocks/block-tabs/component.js +++ b/blocks/block-tabs/component.js @@ -1,5 +1,6 @@ import { LitElement, html, css } from 'lit' import { unsafeSVG } from 'lit/directives/unsafe-svg.js' +import { DarkMode } from '../shared/theme.js' /** * Asked of a block that might be hiding the element the event was dispatched on. @@ -84,7 +85,7 @@ Content of the second tab. 0 1px 3px rgb(0 0 0 / 0.1), 0 1px 2px rgb(0 0 0 / 0.06); } - :host-context(body.body--dark) .tabs { + :host([dark]) .tabs { box-shadow: 0 1px 3px rgb(0 0 0 / 0.5), 0 1px 2px rgb(0 0 0 / 0.35); @@ -129,7 +130,7 @@ Content of the second tab. background-color: rgb(255 255 255 / 0.5); color: var(--tabs-active-fg); } - :host-context(body.body--dark) .tab:hover:not(.is-active) { + :host([dark]) .tab:hover:not(.is-active) { background-color: rgb(255 255 255 / 0.05); } .tab:focus-visible { @@ -169,7 +170,7 @@ Content of the second tab. --tabs-active-fg: var(--q-primary, #1976d2); --tabs-panel-bg: #fff; } - :host-context(body.body--dark) { + :host([dark]) { --tabs-border: rgba(255, 255, 255, 0.15); --tabs-strip-bg: linear-gradient(to bottom, #1b212a, #12161d); --tabs-inactive-fg: rgba(255, 255, 255, 0.7); @@ -191,6 +192,8 @@ Content of the second tab. this._active = 0 // -> Bound once, so that removing the listener later takes the same function that was added this._onReveal = this._onReveal.bind(this) + // -> Puts `dark` on this element for the styles above to key off + this._darkMode = new DarkMode(this) } /** diff --git a/blocks/shared/theme.js b/blocks/shared/theme.js new file mode 100644 index 000000000..a13de2000 --- /dev/null +++ b/blocks/shared/theme.js @@ -0,0 +1,102 @@ +/** + * Dark mode, for blocks. + * + * The app keeps one source of truth for the theme: a `body--dark` / `body--light` class on , + * set by `frontend/src/composables/dark.js`. CSS inside a shadow root cannot see it. `:host-context()` + * is exactly the selector for that job and every block here used to key its dark mode off it -- but + * only Chromium ever shipped it. MDN has it deprecated, Firefox and Safari have never implemented it, + * and in an unsupported browser the rule simply never matches, so a block stayed light on a dark page + * with nothing to show that anything had gone wrong. + * + * So the class is read in JS instead and pushed into the blocks that ask for it. One observer serves + * the whole page however many blocks are on it, and stops again once the last of them has gone. + */ + +/** @type {Set<(dark: boolean) => void>} */ +const watchers = new Set() +let observer = null + +/** + * Whether the app is in dark mode at this moment. + */ +export function isDark() { + return document.body.classList.contains('body--dark') +} + +/** + * Call `onChange` with the new value whenever the theme changes, until the returned function is + * called. + * + * @param {(dark: boolean) => void} onChange + * @returns {() => void} stops the watching + */ +export function watchTheme(onChange) { + watchers.add(onChange) + if (!observer) { + observer = new MutationObserver(() => { + const dark = isDark() + for (const watcher of watchers) { + watcher(dark) + } + }) + // -> `body--light` is set in the same breath as `body--dark`, so one attribute is all to watch + observer.observe(document.body, { attributes: true, attributeFilter: ['class'] }) + } + return () => { + watchers.delete(onChange) + // -> Nothing left to tell, so nothing left to listen for + if (watchers.size < 1) { + observer?.disconnect() + observer = null + } + } +} + +/** + * A Lit reactive controller that keeps a `dark` attribute on its host in step with the app's theme. + * + * A block that only changes colour needs no more than to construct one -- `:host([dark])` in its + * styles then says everything `:host-context(body.body--dark)` used to, at a specificity one class + * lower, which is still above the `:host` rule holding the light values. One that has to act on the + * change rather than restyle for it -- redrawing in a library's own dark theme, say -- passes + * `onChange`, or reads `isDark` off the controller at the moment it needs it. + * + * The attribute is on the host and not on an element inside the shadow root because the host is the + * one element a block always has. Where the shadow tree is a library's to arrange, an attribute + * bound in `render()` risks being written over -- see the `data-theme` note in `block-map`. + */ +export class DarkMode { + /** + * @param {import('lit').ReactiveElement} host + * @param {{ attribute?: boolean, onChange?: (dark: boolean) => void }} [options] + * `attribute: false` for a block that resolves the theme itself and would find a second answer + * sitting on the host misleading. `onChange` is called after the host has been told to update. + */ + constructor(host, { attribute = true, onChange = null } = {}) { + this.host = host + this.isDark = isDark() + this._attribute = attribute + this._onChange = onChange + this._unwatch = null + host.addController(this) + } + + hostConnected() { + this._apply(isDark()) + this._unwatch = watchTheme((dark) => this._apply(dark)) + } + + hostDisconnected() { + this._unwatch?.() + this._unwatch = null + } + + _apply(dark) { + this.isDark = dark + if (this._attribute) { + this.host.toggleAttribute('dark', dark) + } + this.host.requestUpdate() + this._onChange?.(dark) + } +} diff --git a/frontend/src/components/FileManager.vue b/frontend/src/components/FileManager.vue index ec1235e57..10a9b3d7b 100644 --- a/frontend/src/components/FileManager.vue +++ b/frontend/src/components/FileManager.vue @@ -1420,12 +1420,19 @@ onBeforeUnmount(() => { } } + /* + Each pane states its own ink alongside its fill. Nothing above these sets a text color for dark + mode -- the app has no global `body--dark { color }` rule, and the panes are not `w-card`s, which + is where that pairing normally lives -- so anything that just inherits (the folder tree's labels, + a file's title, the size in the right-hand column) came out black on the dark fill. + */ &-left { @at-root .body--light & { background-color: $blue-grey-1; } @at-root .body--dark & { background-color: $dark-4; + color: #fff; } } @@ -1435,6 +1442,7 @@ onBeforeUnmount(() => { } @at-root .body--dark & { background-color: $dark-6; + color: #fff; } } @@ -1444,6 +1452,7 @@ onBeforeUnmount(() => { } @at-root .body--dark & { background-color: $dark-5; + color: #fff; } } diff --git a/frontend/src/components/GroupEditOverlay.vue b/frontend/src/components/GroupEditOverlay.vue index 86775695d..0ce28b641 100644 --- a/frontend/src/components/GroupEditOverlay.vue +++ b/frontend/src/components/GroupEditOverlay.vue @@ -179,7 +179,9 @@ - +
{{ t('admin.groups.rules') }}
- +
{{ t('admin.groups.users') }}
diff --git a/frontend/src/components/SideDialog.vue b/frontend/src/components/SideDialog.vue index 7a687970b..4f4dd1d55 100644 --- a/frontend/src/components/SideDialog.vue +++ b/frontend/src/components/SideDialog.vue @@ -105,6 +105,23 @@ const state = reactive({ } } + /* + The rail below hangs outside the panel, so the panel holding it must not clip. + + `WDialog` puts `overflow: auto` on `.w-dialog-panel` -- that is what rounds a centred dialog whose + inner bands would otherwise paint over its corners, and it keeps oversized content reachable. The + rail's containing block is the card INSIDE that panel, so the clip catches it and it disappeared + outright the moment that overflow arrived. + + Lifted only for the panel that actually carries a rail, rather than for every side panel: the + other one (`PageDataDialog`) still leans on the panel both to round it and to scroll a card wider + than the panel is. Nothing is given up here -- `PagePropertiesDialog` rounds its own toolbar and + scroll area, and that scroll area is what its body scrolls in. + */ + .w-dialog-panel:has(> .page-properties-dialog) { + overflow: visible; + } + /* The quick-jump rail, which sits outside the panel's left edge.