diff --git a/src/puter-js/src/ui/PuterWebComponent.js b/src/puter-js/src/ui/PuterWebComponent.js index a369e2144..f7b2a7e1e 100644 --- a/src/puter-js/src/ui/PuterWebComponent.js +++ b/src/puter-js/src/ui/PuterWebComponent.js @@ -12,9 +12,64 @@ class PuterWebComponent extends (globalThis.HTMLElement || Object) { } connectedCallback () { + this._setupThemeWatchers(); + this._applyTheme(); this._rerender(); } + disconnectedCallback () { + this._teardownThemeWatchers(); + } + + _setupThemeWatchers () { + if ( typeof globalThis.MutationObserver !== 'undefined' && ! this._themeObserver ) { + this._themeObserver = new globalThis.MutationObserver(() => this._applyTheme()); + this._themeObserver.observe(this, { attributes: true, attributeFilter: ['theme'] }); + } + if ( typeof globalThis.matchMedia === 'function' && ! this._themeMediaQuery ) { + this._themeMediaQuery = globalThis.matchMedia('(prefers-color-scheme: dark)'); + this._themeMediaListener = () => this._applyTheme(); + if ( this._themeMediaQuery.addEventListener ) { + this._themeMediaQuery.addEventListener('change', this._themeMediaListener); + } + } + } + + _teardownThemeWatchers () { + if ( this._themeObserver ) { + this._themeObserver.disconnect(); + this._themeObserver = null; + } + if ( this._themeMediaQuery && this._themeMediaListener && this._themeMediaQuery.removeEventListener ) { + this._themeMediaQuery.removeEventListener('change', this._themeMediaListener); + } + this._themeMediaQuery = null; + this._themeMediaListener = null; + } + + /** + * Resolves the effective theme from the `theme` attribute and the system + * preference, then toggles a `.puter-theme-dark` class on the host so + * components can style with `:host(.puter-theme-dark) ...`. + * theme="dark" → always dark + * theme="light" → always light + * unset / other → follow system (prefers-color-scheme) + */ + _applyTheme () { + if ( typeof this.getAttribute !== 'function' ) return; + const themeAttr = this.getAttribute('theme'); + let isDark; + if ( themeAttr === 'dark' ) { + isDark = true; + } else if ( themeAttr === 'light' ) { + isDark = false; + } else { + isDark = typeof globalThis.matchMedia === 'function' + && globalThis.matchMedia('(prefers-color-scheme: dark)').matches; + } + this.classList.toggle('puter-theme-dark', isDark); + } + _rerender () { if ( ! this.shadowRoot ) return; this.shadowRoot.innerHTML = `${this.render()}`; diff --git a/src/puter-js/src/ui/components.md b/src/puter-js/src/ui/components.md index f41cfe792..555eafa6b 100644 --- a/src/puter-js/src/ui/components.md +++ b/src/puter-js/src/ui/components.md @@ -340,7 +340,25 @@ All components render inside Shadow DOM and match puter.com's native GUI appeara ## Dark mode -Components with `@media (prefers-color-scheme: dark)` rules auto-adapt when the OS is in dark mode: ``, ``, ``, ``, ``. +By default, components that ship dark-mode styles auto-adapt to the OS setting (`prefers-color-scheme: dark`): ``, ``, ``, ``, ``. + +To force a specific theme regardless of the OS preference, set the `theme` attribute on the component: + +| Value | Behavior | +|-------|----------| +| `dark` | Always render in dark theme | +| `light` | Always render in light theme | +| _unset_ | Follow the system `prefers-color-scheme` (default) | + +```html + + + + + +``` + +The attribute is live — changing it at runtime re-paints the component immediately. For `` and ``, the `theme` is forwarded to any dropdowns and submenus they spawn, so the whole tree stays in sync. ## Responsive / mobile diff --git a/src/puter-js/src/ui/components/PuterColorPicker.js b/src/puter-js/src/ui/components/PuterColorPicker.js index 1f9b84f74..13024ea5c 100644 --- a/src/puter-js/src/ui/components/PuterColorPicker.js +++ b/src/puter-js/src/ui/components/PuterColorPicker.js @@ -176,47 +176,45 @@ class PuterColorPicker extends PuterWebComponent { flex: 1; } } - @media (prefers-color-scheme: dark) { - .picker-body { - background-color: rgba(40, 44, 52, .95); - color: #e6e6e6; - box-shadow: 0px 0px 15px #000000aa; - } - .preview { - border-color: #555; - } - .header-label { - color: #aaa; - } - .hex-input { - background-color: #1f1f1f; - border-color: #555; - color: #e6e6e6; - } - .native-color-row { - background: rgba(255, 255, 255, 0.05); - border-color: #555; - } - .native-color-row label { - color: #aaa; - } - input[type="color"] { - border-color: #555; - } - .swatch { - border-color: rgba(255, 255, 255, 0.1); - } - .btn { - color: #e6e6e6; - border-color: #555; - background: linear-gradient(#4a4a4a, #3a3a3a); - box-shadow: inset 0px 1px 0px rgb(255 255 255 / 8%), 0 1px 2px rgb(0 0 0 / 25%); - } - .btn:active { - background-color: #333; - border-color: #444; - color: #999; - } + :host(.puter-theme-dark) .picker-body { + background-color: rgba(40, 44, 52, .95); + color: #e6e6e6; + box-shadow: 0px 0px 15px #000000aa; + } + :host(.puter-theme-dark) .preview { + border-color: #555; + } + :host(.puter-theme-dark) .header-label { + color: #aaa; + } + :host(.puter-theme-dark) .hex-input { + background-color: #1f1f1f; + border-color: #555; + color: #e6e6e6; + } + :host(.puter-theme-dark) .native-color-row { + background: rgba(255, 255, 255, 0.05); + border-color: #555; + } + :host(.puter-theme-dark) .native-color-row label { + color: #aaa; + } + :host(.puter-theme-dark) input[type="color"] { + border-color: #555; + } + :host(.puter-theme-dark) .swatch { + border-color: rgba(255, 255, 255, 0.1); + } + :host(.puter-theme-dark) .btn { + color: #e6e6e6; + border-color: #555; + background: linear-gradient(#4a4a4a, #3a3a3a); + box-shadow: inset 0px 1px 0px rgb(255 255 255 / 8%), 0 1px 2px rgb(0 0 0 / 25%); + } + :host(.puter-theme-dark) .btn:active { + background-color: #333; + border-color: #444; + color: #999; } `; } diff --git a/src/puter-js/src/ui/components/PuterContextMenu.js b/src/puter-js/src/ui/components/PuterContextMenu.js index 873aa0786..3e4446736 100644 --- a/src/puter-js/src/ui/components/PuterContextMenu.js +++ b/src/puter-js/src/ui/components/PuterContextMenu.js @@ -345,99 +345,100 @@ class PuterContextMenu extends PuterWebComponent { height: 20px; } - @media (prefers-color-scheme: dark) { - .context-menu { - background: #2d2d2d; - background-color: rgb(45 45 45 / 94%); - color: #e6e6e6; - border-color: #00000080; - box-shadow: 0px 0px 15px #000000aa; - } - .menu-item { - color: #e6e6e6; - } - /* Inactive items: icon/check/shortcut/arrow tones */ - .icon, - .check { - color: #e6e6e6; - } - .submenu-arrow { - color: #b0b0b0; - } - .shortcut { - color: #888; - } - .icon img { - filter: drop-shadow(0px 0px 0.3px rgb(230, 230, 230)); - } - /* Inactive icon SVGs use currentColor already; nothing to invert */ + /* Dark theme — applied when system prefers dark and no light + override is set, or when theme="dark" is forced. The base + class toggles .puter-theme-dark on the host accordingly. */ + :host(.puter-theme-dark) .context-menu { + background: #2d2d2d; + background-color: rgb(45 45 45 / 94%); + color: #e6e6e6; + border-color: #00000080; + box-shadow: 0px 0px 15px #000000aa; + } + :host(.puter-theme-dark) .menu-item { + color: #e6e6e6; + } + /* Inactive items: icon/check/shortcut/arrow tones */ + :host(.puter-theme-dark) .icon, + :host(.puter-theme-dark) .check { + color: #e6e6e6; + } + :host(.puter-theme-dark) .submenu-arrow { + color: #b0b0b0; + } + :host(.puter-theme-dark) .shortcut { + color: #888; + } + :host(.puter-theme-dark) .icon img { + filter: drop-shadow(0px 0px 0.3px rgb(230, 230, 230)); + } + /* Inactive icon SVGs use currentColor already; nothing to invert */ - /* Safe-triangle / keyboard-nav: non-active hover restored colors should match dark */ - .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider), - .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) { - color: #e6e6e6; - } - .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon, - .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .check, - .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .submenu-arrow, - .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .label, - .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon, - .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .check, - .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .submenu-arrow, - .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .label { - color: #e6e6e6; - } - .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .shortcut, - .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .shortcut { - color: #888; - } - .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon img, - .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon img { - filter: drop-shadow(0px 0px 0.3px rgb(230, 230, 230)); - } + /* Safe-triangle / keyboard-nav: non-active hover restored colors should match dark */ + :host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider), + :host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) { + color: #e6e6e6; + } + :host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon, + :host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .check, + :host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .submenu-arrow, + :host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .label, + :host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon, + :host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .check, + :host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .submenu-arrow, + :host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .label { + color: #e6e6e6; + } + :host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .shortcut, + :host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .shortcut { + color: #888; + } + :host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon img, + :host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon img { + filter: drop-shadow(0px 0px 0.3px rgb(230, 230, 230)); + } - /* Submenu-open parent (no hover): subtle dark highlight */ - .menu-item.has-open-submenu:not(:hover) { - background-color: #3f3f3f; - color: #e6e6e6; - } - .menu-item.has-open-submenu:not(:hover) .icon, - .menu-item.has-open-submenu:not(:hover) .icon svg, - .menu-item.has-open-submenu:not(:hover) .icon img { - color: #e6e6e6; - } + /* Submenu-open parent (no hover): subtle dark highlight */ + :host(.puter-theme-dark) .menu-item.has-open-submenu:not(:hover) { + background-color: #3f3f3f; + color: #e6e6e6; + } + :host(.puter-theme-dark) .menu-item.has-open-submenu:not(:hover) .icon, + :host(.puter-theme-dark) .menu-item.has-open-submenu:not(:hover) .icon svg, + :host(.puter-theme-dark) .menu-item.has-open-submenu:not(:hover) .icon img { + color: #e6e6e6; + } - /* Danger items */ - .menu-item.danger, - .menu-item.danger .icon { - color: #ff7b72; - } + /* Danger items */ + :host(.puter-theme-dark) .menu-item.danger, + :host(.puter-theme-dark) .menu-item.danger .icon { + color: #ff7b72; + } - /* Divider */ - .divider hr { - background: #444; - } + /* Divider */ + :host(.puter-theme-dark) .divider hr { + background: #444; + } - /* Sheet mode (mobile) */ - :host(.sheet-mode) .context-menu { - background-color: rgb(40 40 40 / 96%); - box-shadow: 0 -6px 24px rgba(0, 0, 0, 0.45); - } - :host(.sheet-mode) .menu-item:hover:not(.disabled):not(.divider) { - background-color: rgba(0, 122, 255, 0.22); - } - :host(.sheet-mode) .menu-item:active:not(.disabled):not(.divider) { - background-color: rgba(0, 122, 255, 0.35); - } - :host(.sheet-mode) .menu-item:hover:not(.disabled):not(.divider) .label, - :host(.sheet-mode) .menu-item:hover:not(.disabled):not(.divider) .icon, - :host(.sheet-mode) .menu-item:active:not(.disabled):not(.divider) .label, - :host(.sheet-mode) .menu-item:active:not(.disabled):not(.divider) .icon { - color: #e6e6e6; - } - :host(.sheet-mode) .divider hr { - background: rgba(255, 255, 255, 0.15); - } + /* Sheet mode (mobile) */ + :host(.puter-theme-dark.sheet-mode) .context-menu { + background-color: rgb(40 40 40 / 96%); + box-shadow: 0 -6px 24px rgba(0, 0, 0, 0.45); + } + :host(.puter-theme-dark.sheet-mode) .menu-item:hover:not(.disabled):not(.divider) { + background-color: rgba(0, 122, 255, 0.22); + } + :host(.puter-theme-dark.sheet-mode) .menu-item:active:not(.disabled):not(.divider) { + background-color: rgba(0, 122, 255, 0.35); + } + :host(.puter-theme-dark.sheet-mode) .menu-item:hover:not(.disabled):not(.divider) .label, + :host(.puter-theme-dark.sheet-mode) .menu-item:hover:not(.disabled):not(.divider) .icon, + :host(.puter-theme-dark.sheet-mode) .menu-item:active:not(.disabled):not(.divider) .label, + :host(.puter-theme-dark.sheet-mode) .menu-item:active:not(.disabled):not(.divider) .icon { + color: #e6e6e6; + } + :host(.puter-theme-dark.sheet-mode) .divider hr { + background: rgba(255, 255, 255, 0.15); } `; } @@ -930,6 +931,9 @@ class PuterContextMenu extends PuterWebComponent { const submenu = document.createElement('puter-context-menu'); submenu.setAttribute('data-submenu', ''); submenu.setAttribute('data-parent-managed', ''); + // Forward any forced theme so the submenu paints in the same theme. + const themeAttr = this.getAttribute('theme'); + if ( themeAttr ) submenu.setAttribute('theme', themeAttr); submenu.items = items; submenu._parentMenu = this; submenu._parentItemEl = parentEl; @@ -1184,6 +1188,7 @@ class PuterContextMenu extends PuterWebComponent { } disconnectedCallback () { + super.disconnectedCallback(); if ( this._outsideClickHandler ) { document.removeEventListener('pointerdown', this._outsideClickHandler, true); } diff --git a/src/puter-js/src/ui/components/PuterFontPicker.js b/src/puter-js/src/ui/components/PuterFontPicker.js index 438dd8cc5..dfecaa8f0 100644 --- a/src/puter-js/src/ui/components/PuterFontPicker.js +++ b/src/puter-js/src/ui/components/PuterFontPicker.js @@ -185,50 +185,48 @@ class PuterFontPicker extends PuterWebComponent { flex: 1; } } - @media (prefers-color-scheme: dark) { - .picker-body { - background-color: rgba(40, 44, 52, .95); - color: #e6e6e6; - box-shadow: 0px 0px 15px #000000aa; - } - .title { - color: #e6e6e6; - text-shadow: none; - } - .search { - background-color: #1f1f1f; - border-color: #555; - color: #e6e6e6; - } - .font-list { - background-color: #1f1f1f; - border-color: #555; - } - .font-item { - color: #e6e6e6; - } - .font-item:hover { - background: rgba(255, 255, 255, 0.06); - } - .font-name-label { - color: #888; - } - .preview { - background: #1f1f1f; - border-color: #555; - color: #e6e6e6; - } - .btn { - color: #e6e6e6; - border-color: #555; - background: linear-gradient(#4a4a4a, #3a3a3a); - box-shadow: inset 0px 1px 0px rgb(255 255 255 / 8%), 0 1px 2px rgb(0 0 0 / 25%); - } - .btn:active { - background-color: #333; - border-color: #444; - color: #999; - } + :host(.puter-theme-dark) .picker-body { + background-color: rgba(40, 44, 52, .95); + color: #e6e6e6; + box-shadow: 0px 0px 15px #000000aa; + } + :host(.puter-theme-dark) .title { + color: #e6e6e6; + text-shadow: none; + } + :host(.puter-theme-dark) .search { + background-color: #1f1f1f; + border-color: #555; + color: #e6e6e6; + } + :host(.puter-theme-dark) .font-list { + background-color: #1f1f1f; + border-color: #555; + } + :host(.puter-theme-dark) .font-item { + color: #e6e6e6; + } + :host(.puter-theme-dark) .font-item:hover { + background: rgba(255, 255, 255, 0.06); + } + :host(.puter-theme-dark) .font-name-label { + color: #888; + } + :host(.puter-theme-dark) .preview { + background: #1f1f1f; + border-color: #555; + color: #e6e6e6; + } + :host(.puter-theme-dark) .btn { + color: #e6e6e6; + border-color: #555; + background: linear-gradient(#4a4a4a, #3a3a3a); + box-shadow: inset 0px 1px 0px rgb(255 255 255 / 8%), 0 1px 2px rgb(0 0 0 / 25%); + } + :host(.puter-theme-dark) .btn:active { + background-color: #333; + border-color: #444; + color: #999; } `; } diff --git a/src/puter-js/src/ui/components/PuterMenubar.js b/src/puter-js/src/ui/components/PuterMenubar.js index 6997f10cc..0104d678a 100644 --- a/src/puter-js/src/ui/components/PuterMenubar.js +++ b/src/puter-js/src/ui/components/PuterMenubar.js @@ -91,22 +91,23 @@ class PuterMenubar extends PuterWebComponent { flex-shrink: 0; } } - @media (prefers-color-scheme: dark) { - .menubar { - background-color: #2a2a2a; - border-bottom-color: #3a3a3a; - } - .menu-button { - color: #e6e6e6; - } - .menu-button:hover, - .menu-button.active, - .menu-button.focused { - background-color: #3a3a3a; - } - .menubar.keyboard-nav .menu-button:hover:not(.focused):not(.active) { - background-color: transparent; - } + /* Dark theme — applied when system prefers dark and no light + override is set, or when theme="dark" is forced. The base + class toggles .puter-theme-dark on the host accordingly. */ + :host(.puter-theme-dark) .menubar { + background-color: #2a2a2a; + border-bottom-color: #3a3a3a; + } + :host(.puter-theme-dark) .menu-button { + color: #e6e6e6; + } + :host(.puter-theme-dark) .menu-button:hover, + :host(.puter-theme-dark) .menu-button.active, + :host(.puter-theme-dark) .menu-button.focused { + background-color: #3a3a3a; + } + :host(.puter-theme-dark) .menubar.keyboard-nav .menu-button:hover:not(.focused):not(.active) { + background-color: transparent; } `; } @@ -352,6 +353,9 @@ class PuterMenubar extends PuterWebComponent { const rect = buttonEl.getBoundingClientRect(); const dropdown = document.createElement('puter-context-menu'); dropdown.setAttribute('data-submenu', ''); // skip mobile sheet behavior + // Forward any forced theme so the dropdown paints in the same theme. + const themeAttr = this.getAttribute('theme'); + if ( themeAttr ) dropdown.setAttribute('theme', themeAttr); dropdown.items = item.items; dropdown.setAttribute('x', String(rect.left)); dropdown.setAttribute('y', String(rect.bottom)); @@ -410,6 +414,7 @@ class PuterMenubar extends PuterWebComponent { } disconnectedCallback () { + super.disconnectedCallback(); this._closeDropdown(); clearTimeout(this._suppressClickTimer); this._suppressClickFor = null; diff --git a/src/puter-js/src/ui/components/PuterNotification.js b/src/puter-js/src/ui/components/PuterNotification.js index 869fa65a7..9fc72f5a3 100644 --- a/src/puter-js/src/ui/components/PuterNotification.js +++ b/src/puter-js/src/ui/components/PuterNotification.js @@ -175,30 +175,28 @@ class PuterNotification extends PuterWebComponent { width: auto; } } - @media (prefers-color-scheme: dark) { - .notification { - background: #2d2d2dcd; - border-color: #3a3a3a; - box-shadow: 0px 0px 17px -6px #000; - } - .close-btn { - background: #3a3a3a; - color: #ccc; - filter: drop-shadow(0px 0px 0.5px rgb(230, 230, 230)); - } - .close-btn:hover { - background: #4a4a4a; - color: #fff; - } - .icon-area { - filter: drop-shadow(0px 0px 0.5px rgb(230, 230, 230)); - } - .title { - color: #e6e6e6; - } - .text { - color: #b0b0b0; - } + :host(.puter-theme-dark) .notification { + background: #2d2d2dcd; + border-color: #3a3a3a; + box-shadow: 0px 0px 17px -6px #000; + } + :host(.puter-theme-dark) .close-btn { + background: #3a3a3a; + color: #ccc; + filter: drop-shadow(0px 0px 0.5px rgb(230, 230, 230)); + } + :host(.puter-theme-dark) .close-btn:hover { + background: #4a4a4a; + color: #fff; + } + :host(.puter-theme-dark) .icon-area { + filter: drop-shadow(0px 0px 0.5px rgb(230, 230, 230)); + } + :host(.puter-theme-dark) .title { + color: #e6e6e6; + } + :host(.puter-theme-dark) .text { + color: #b0b0b0; } `; } @@ -280,6 +278,7 @@ class PuterNotification extends PuterWebComponent { } disconnectedCallback () { + super.disconnectedCallback(); if ( this._dismissTimer ) clearTimeout(this._dismissTimer); const idx = activeNotifications.indexOf(this); if ( idx !== -1 ) activeNotifications.splice(idx, 1);