From 238ed12e280c6f78110212d24397369b47ec16ed Mon Sep 17 00:00:00 2001 From: Miika Kuisma Date: Wed, 13 May 2026 08:54:23 +0300 Subject: [PATCH 1/6] - add dark mode support for UI components - fix https://github.com/HeyPuter/puter/issues/3098 --- .../src/ui/components/PuterColorPicker.js | 42 ++++++++ .../src/ui/components/PuterContextMenu.js | 97 ++++++++++++++++++- .../src/ui/components/PuterFontPicker.js | 45 +++++++++ .../src/ui/components/PuterMenubar.js | 14 +++ .../src/ui/components/PuterNotification.js | 25 +++++ 5 files changed, 219 insertions(+), 4 deletions(-) diff --git a/src/puter-js/src/ui/components/PuterColorPicker.js b/src/puter-js/src/ui/components/PuterColorPicker.js index f8311386d..1f9b84f74 100644 --- a/src/puter-js/src/ui/components/PuterColorPicker.js +++ b/src/puter-js/src/ui/components/PuterColorPicker.js @@ -176,6 +176,48 @@ 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; + } + } `; } diff --git a/src/puter-js/src/ui/components/PuterContextMenu.js b/src/puter-js/src/ui/components/PuterContextMenu.js index 989cdcb7a..dbdb1d13e 100644 --- a/src/puter-js/src/ui/components/PuterContextMenu.js +++ b/src/puter-js/src/ui/components/PuterContextMenu.js @@ -47,15 +47,16 @@ class PuterContextMenu extends PuterWebComponent { font-family: sans-serif; background: #FFF; color: #333; - border-radius: 2px; + border-radius: 4px; padding: 3px 0; min-width: 200px; background-color: rgb(255 255 255 / 92%); backdrop-filter: blur(3px); border: 1px solid #e6e4e466; - box-shadow: 0px 0px 15px #00000066; - padding-left: 6px; - padding-right: 6px; + box-shadow: 0px 3px 10px #00000044; + margin-top: 5px; + padding-left: 4px; + padding-right: 4px; padding-top: 4px; padding-bottom: 4px; user-select: none; @@ -335,6 +336,94 @@ class PuterContextMenu extends PuterWebComponent { width: 20px; 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 */ + + /* Safe-triangle: 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) { + 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 { + color: #e6e6e6; + } + .context-menu.safe-traverse .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 { + 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; + } + + /* Danger items */ + .menu-item.danger, + .menu-item.danger .icon { + color: #ff7b72; + } + + /* Divider */ + .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); + } + } `; } diff --git a/src/puter-js/src/ui/components/PuterFontPicker.js b/src/puter-js/src/ui/components/PuterFontPicker.js index d158dcc71..438dd8cc5 100644 --- a/src/puter-js/src/ui/components/PuterFontPicker.js +++ b/src/puter-js/src/ui/components/PuterFontPicker.js @@ -185,6 +185,51 @@ 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; + } + } `; } diff --git a/src/puter-js/src/ui/components/PuterMenubar.js b/src/puter-js/src/ui/components/PuterMenubar.js index 2b9b8324f..f1d531c65 100644 --- a/src/puter-js/src/ui/components/PuterMenubar.js +++ b/src/puter-js/src/ui/components/PuterMenubar.js @@ -86,6 +86,20 @@ 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; + } + } `; } diff --git a/src/puter-js/src/ui/components/PuterNotification.js b/src/puter-js/src/ui/components/PuterNotification.js index 215232686..869fa65a7 100644 --- a/src/puter-js/src/ui/components/PuterNotification.js +++ b/src/puter-js/src/ui/components/PuterNotification.js @@ -175,6 +175,31 @@ 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; + } + } `; } From 5a2071f8520b84622889d1ae20085706418ab92b Mon Sep 17 00:00:00 2001 From: Miika Kuisma Date: Wed, 13 May 2026 09:01:57 +0300 Subject: [PATCH 2/6] fix https://github.com/HeyPuter/puter/issues/3099 --- .../src/ui/components/PuterContextMenu.js | 60 ++++++++++++++----- .../src/ui/components/PuterMenubar.js | 3 + 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/puter-js/src/ui/components/PuterContextMenu.js b/src/puter-js/src/ui/components/PuterContextMenu.js index dbdb1d13e..faee57e6c 100644 --- a/src/puter-js/src/ui/components/PuterContextMenu.js +++ b/src/puter-js/src/ui/components/PuterContextMenu.js @@ -120,25 +120,37 @@ class PuterContextMenu extends PuterWebComponent { /* Safe-triangle: while the cursor traces a diagonal path toward an open submenu, suppress :hover highlight on intermediate - items so they don't flash blue. .focused and .has-open-submenu - (managed by JS) still highlight normally. */ - .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) { + items so they don't flash blue. + Keyboard-nav: after a keyboard navigation, suppress :hover on + the (now stale) item the mouse is still resting on so only + the keyboard-focused item highlights. Cleared on next + mousemove. .focused and .has-open-submenu (managed by JS) + still highlight normally. */ + .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) { background-color: transparent; color: #333; } .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.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: #333; } - .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .shortcut { + .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: #999; } - .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon svg { + .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon svg, + .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon svg { filter: none; } - .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon img { + .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(51, 51, 51)); } @@ -364,20 +376,27 @@ class PuterContextMenu extends PuterWebComponent { } /* Inactive icon SVGs use currentColor already; nothing to invert */ - /* Safe-triangle: 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) { + /* 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.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.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.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)); } @@ -654,10 +673,12 @@ class PuterContextMenu extends PuterWebComponent { document.addEventListener('pointerdown', this._outsideClickHandler, true); }, 0); - // Track mouse for safe-triangle submenu hover + // Track mouse for safe-triangle submenu hover. Also exits + // keyboard-nav mode: once the cursor moves, :hover should win again. this.#mouseTracker = (e) => { this.#mouseLocs.push({ x: e.clientX, y: e.clientY }); if ( this.#mouseLocs.length > 3 ) this.#mouseLocs.shift(); + this._setKeyboardNav(false); }; document.addEventListener('mousemove', this.#mouseTracker); @@ -666,6 +687,10 @@ class PuterContextMenu extends PuterWebComponent { if ( this.#activeSubmenu ) return; // let the deeper submenu handle const consumed = this._handleKey(e); if ( consumed ) { + // A handled key (nav, typeahead, etc.) means the user is + // driving by keyboard — suppress any stale :hover until the + // mouse next moves. + this._setKeyboardNav(true); e.preventDefault(); e.stopImmediatePropagation(); } @@ -798,12 +823,14 @@ class PuterContextMenu extends PuterWebComponent { clearTimeout(this.#submenuTimeout); this._cancelSubmenuClose(); this._showSubmenu(el, item.items); - // Focus first item in submenu + // Focus first item in submenu, and start it in keyboard-nav mode so a + // stale cursor position doesn't fight the keyboard focus. requestAnimationFrame(() => { const sub = this.#activeSubmenu && this.#activeSubmenu.element; if ( sub ) { const f = sub._focusableIndices(); if ( f.length ) sub._setFocusIndex(f[0]); + sub._setKeyboardNav(true); } }); return true; @@ -987,6 +1014,11 @@ class PuterContextMenu extends PuterWebComponent { if ( menu ) menu.classList.toggle('safe-traverse', on); } + _setKeyboardNav (on) { + const menu = this.$('.context-menu'); + if ( menu ) menu.classList.toggle('keyboard-nav', on); + } + _pointInRect (p, r) { return p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom; } diff --git a/src/puter-js/src/ui/components/PuterMenubar.js b/src/puter-js/src/ui/components/PuterMenubar.js index f1d531c65..7f07ffe9f 100644 --- a/src/puter-js/src/ui/components/PuterMenubar.js +++ b/src/puter-js/src/ui/components/PuterMenubar.js @@ -305,6 +305,9 @@ class PuterMenubar extends PuterWebComponent { if ( dd && typeof dd._focusableIndices === 'function' ) { const f = dd._focusableIndices(); if ( f.length ) dd._setFocusIndex(f[0]); + if ( typeof dd._setKeyboardNav === 'function' ) { + dd._setKeyboardNav(true); + } } }); } From f63c7d81420132cbeb13939499b2b16ffc620ba8 Mon Sep 17 00:00:00 2001 From: Miika Kuisma Date: Wed, 13 May 2026 09:27:44 +0300 Subject: [PATCH 3/6] - Fixes https://github.com/HeyPuter/puter/issues/3100 - Also fixes similar issues with navigating top level menubar items and submenus --- .../src/ui/components/PuterContextMenu.js | 32 +++++++++++--- .../src/ui/components/PuterMenubar.js | 42 ++++++++++++++++++- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/puter-js/src/ui/components/PuterContextMenu.js b/src/puter-js/src/ui/components/PuterContextMenu.js index faee57e6c..6ebd29ad9 100644 --- a/src/puter-js/src/ui/components/PuterContextMenu.js +++ b/src/puter-js/src/ui/components/PuterContextMenu.js @@ -709,9 +709,24 @@ class PuterContextMenu extends PuterWebComponent { case 'ArrowDown': this._moveFocus(+1); return true; - case 'ArrowUp': + case 'ArrowUp': { + // Root dropdown: ArrowUp on the first focusable item bubbles + // to the menubar, which closes the dropdown and re-focuses + // the parent menubar button. + if ( ! this._parentMenu ) { + const f = this._focusableIndices(); + if ( f.length && this.#focusedIndex === f[0] ) { + this.dispatchEvent(new CustomEvent('puter-menu-navigate', { + detail: { direction: 'up' }, + bubbles: true, + composed: true, + })); + return true; + } + } this._moveFocus(-1); return true; + } case 'Home': { const f = this._focusableIndices(); if ( f.length ) this._setFocusIndex(f[0]); @@ -822,14 +837,21 @@ class PuterContextMenu extends PuterWebComponent { if ( ! el ) return false; clearTimeout(this.#submenuTimeout); this._cancelSubmenuClose(); - this._showSubmenu(el, item.items); - // Focus first item in submenu, and start it in keyboard-nav mode so a - // stale cursor position doesn't fight the keyboard focus. + // Don't re-open an already-open submenu for this parent — it would + // wipe the user's focused item inside it. We only auto-focus the + // first sub-item when the submenu is freshly opened by this call. + const wasNewlyOpened = !this.#activeSubmenu || this.#activeSubmenu.parentEl !== el; + if ( wasNewlyOpened ) { + this._showSubmenu(el, item.items); + } requestAnimationFrame(() => { const sub = this.#activeSubmenu && this.#activeSubmenu.element; - if ( sub ) { + if ( ! sub ) return; + if ( wasNewlyOpened ) { const f = sub._focusableIndices(); if ( f.length ) sub._setFocusIndex(f[0]); + } + if ( typeof sub._setKeyboardNav === 'function' ) { sub._setKeyboardNav(true); } }); diff --git a/src/puter-js/src/ui/components/PuterMenubar.js b/src/puter-js/src/ui/components/PuterMenubar.js index 7f07ffe9f..6997f10cc 100644 --- a/src/puter-js/src/ui/components/PuterMenubar.js +++ b/src/puter-js/src/ui/components/PuterMenubar.js @@ -68,6 +68,11 @@ class PuterMenubar extends PuterWebComponent { .menu-button.focused { background-color: #e2e2e2; } + /* Suppress stale :hover during keyboard nav so only the focused + button highlights. Cleared on the next mousemove. */ + .menubar.keyboard-nav .menu-button:hover:not(.focused):not(.active) { + background-color: transparent; + } .menu-button:focus { outline: none; } @media (max-width: 480px) { .menubar { @@ -99,6 +104,9 @@ class PuterMenubar extends PuterWebComponent { .menu-button.focused { background-color: #3a3a3a; } + .menubar.keyboard-nav .menu-button:hover:not(.focused):not(.active) { + background-color: transparent; + } } `; } @@ -122,6 +130,9 @@ class PuterMenubar extends PuterWebComponent { if ( this._docPointerDownHandler ) { document.removeEventListener('pointerdown', this._docPointerDownHandler, true); } + if ( this._mouseMoveHandler ) { + document.removeEventListener('mousemove', this._mouseMoveHandler); + } const buttons = this.$$('.menu-button'); buttons.forEach((btn) => { @@ -182,6 +193,11 @@ class PuterMenubar extends PuterWebComponent { } }; document.addEventListener('pointerdown', this._docPointerDownHandler, true); + + // Once the user actually moves the mouse, exit keyboard-nav mode so + // :hover styling on menubar buttons works normally again. + this._mouseMoveHandler = () => this._setKeyboardNav(false); + document.addEventListener('mousemove', this._mouseMoveHandler); } _onGlobalKeyDown (e) { @@ -258,12 +274,14 @@ class PuterMenubar extends PuterWebComponent { this.#menubarActive = true; this.#focusedIndex = 0; this._renderButtonFocus(); + this._setKeyboardNav(true); } _deactivateMenubar () { this.#menubarActive = false; this.#focusedIndex = null; this._renderButtonFocus(); + this._setKeyboardNav(false); } _renderButtonFocus () { @@ -273,6 +291,11 @@ class PuterMenubar extends PuterWebComponent { }); } + _setKeyboardNav (on) { + const menubar = this.$('.menubar'); + if ( menubar ) menubar.classList.toggle('keyboard-nav', on); + } + _moveButtonFocus (delta, { swapDropdown = true } = {}) { if ( ! this.#items.length ) return; const n = this.#items.length; @@ -280,6 +303,7 @@ class PuterMenubar extends PuterWebComponent { const next = (cur + delta + n) % n; this.#focusedIndex = next; this._renderButtonFocus(); + this._setKeyboardNav(true); // If a dropdown is already open, swap to the new button's dropdown if ( swapDropdown && this.#activeDropdown ) { @@ -348,12 +372,23 @@ class PuterMenubar extends PuterWebComponent { this._deactivateMenubar(); } }); - // Keyboard navigate request bubbling from the context menu + // Keyboard navigate request bubbling from the context menu. + // Arrow-left/right at the menubar level closes the current dropdown + // and moves button focus only — the user must press ArrowDown (or + // Enter/Space) to open the adjacent dropdown. + // Arrow-up at the dropdown's first item closes it and returns + // focus to the same menubar button (which can re-open with ArrowDown). dropdown.addEventListener('puter-menu-navigate', (e) => { if ( ! e.detail ) return; + if ( e.detail.direction === 'up' ) { + this._closeDropdown(); + this._renderButtonFocus(); + this._setKeyboardNav(true); + return; + } const delta = e.detail.direction === 'right' ? +1 : -1; + this._closeDropdown(); this._moveButtonFocus(delta, { swapDropdown: false }); - this._openFocusedButton(true); }); document.body.appendChild(dropdown); @@ -387,6 +422,9 @@ class PuterMenubar extends PuterWebComponent { if ( this._docPointerDownHandler ) { document.removeEventListener('pointerdown', this._docPointerDownHandler, true); } + if ( this._mouseMoveHandler ) { + document.removeEventListener('mousemove', this._mouseMoveHandler); + } } _escapeHTML (str) { From 5483e32eab92e2fcc37adabe179f0552559d0b04 Mon Sep 17 00:00:00 2001 From: Miika Kuisma Date: Wed, 13 May 2026 09:37:36 +0300 Subject: [PATCH 4/6] fixes https://github.com/HeyPuter/puter/issues/3101 --- .../src/ui/components/PuterContextMenu.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/puter-js/src/ui/components/PuterContextMenu.js b/src/puter-js/src/ui/components/PuterContextMenu.js index 6ebd29ad9..870f220be 100644 --- a/src/puter-js/src/ui/components/PuterContextMenu.js +++ b/src/puter-js/src/ui/components/PuterContextMenu.js @@ -188,7 +188,6 @@ class PuterContextMenu extends PuterWebComponent { padding-bottom: 5px; cursor: default; height: auto; - pointer-events: none; } .divider hr { border: none; @@ -583,6 +582,20 @@ class PuterContextMenu extends PuterWebComponent { this.#mouseTracker = null; } + // Hovering a separator should drop the focus highlight from the + // previously-focused item — the cursor has clearly moved past it. + // It should also close any submenu opened from an item above, so + // the parent's .has-open-submenu highlight goes away too. + this.$$('.menu-item.divider').forEach((el) => { + el.addEventListener('mouseenter', () => { + this._setSafeTraverse(false); + this._clearFocus(); + clearTimeout(this.#submenuTimeout); + this._cancelSubmenuClose(); + this._hideActiveSubmenu(); + }); + }); + const menuItems = this.$$('.menu-item:not(.divider):not(.disabled)'); menuItems.forEach((el) => { From 85e45b30f594c1999ba0d31ec1f2eae66ad8542d Mon Sep 17 00:00:00 2001 From: Miika Kuisma Date: Wed, 13 May 2026 09:52:21 +0300 Subject: [PATCH 5/6] fixes https://github.com/HeyPuter/puter/issues/3102 --- .../src/ui/components/PuterContextMenu.js | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/puter-js/src/ui/components/PuterContextMenu.js b/src/puter-js/src/ui/components/PuterContextMenu.js index 870f220be..873aa0786 100644 --- a/src/puter-js/src/ui/components/PuterContextMenu.js +++ b/src/puter-js/src/ui/components/PuterContextMenu.js @@ -89,7 +89,11 @@ class PuterContextMenu extends PuterWebComponent { border-radius: 4px; } - /* Active item turns all children white */ + /* Active item turns all children white. + For .has-open-submenu the :hover branch above already covers + the hovered case; in the non-hovered grey state we want the + children to keep their default colors, so we don't include + .has-open-submenu here. */ .menu-item:hover:not(.disabled):not(.divider) .icon, .menu-item:hover:not(.disabled):not(.divider) .check, .menu-item:hover:not(.disabled):not(.divider) .submenu-arrow, @@ -99,22 +103,15 @@ class PuterContextMenu extends PuterWebComponent { .menu-item.focused:not(.disabled):not(.divider) .check, .menu-item.focused:not(.disabled):not(.divider) .submenu-arrow, .menu-item.focused:not(.disabled):not(.divider) .label, - .menu-item.focused:not(.disabled):not(.divider) .shortcut, - .menu-item.has-open-submenu .icon, - .menu-item.has-open-submenu .check, - .menu-item.has-open-submenu .submenu-arrow, - .menu-item.has-open-submenu .label, - .menu-item.has-open-submenu .shortcut { + .menu-item.focused:not(.disabled):not(.divider) .shortcut { color: white; } .menu-item:hover:not(.disabled):not(.divider) .icon svg, - .menu-item.focused:not(.disabled):not(.divider) .icon svg, - .menu-item.has-open-submenu .icon svg { + .menu-item.focused:not(.disabled):not(.divider) .icon svg { filter: brightness(0) invert(1); } .menu-item:hover:not(.disabled):not(.divider) .icon img, - .menu-item.focused:not(.disabled):not(.divider) .icon img, - .menu-item.has-open-submenu .icon img { + .menu-item.focused:not(.disabled):not(.divider) .icon img { filter: brightness(0) invert(1); } @@ -586,8 +583,20 @@ class PuterContextMenu extends PuterWebComponent { // previously-focused item — the cursor has clearly moved past it. // It should also close any submenu opened from an item above, so // the parent's .has-open-submenu highlight goes away too. + // Exception: if the cursor is on a diagonal path toward the open + // submenu, treat the divider like any other safe-traverse cell so + // we don't tear down the submenu mid-drag. this.$$('.menu-item.divider').forEach((el) => { el.addEventListener('mouseenter', () => { + if ( this.#activeSubmenu && this._isMouseHeadingToSubmenu(this.#activeSubmenu.element) ) { + this._setSafeTraverse(true); + if ( this.#submenuCloseTimer ) { + clearTimeout(this.#submenuCloseTimer); + this.#submenuCloseTimer = null; + } + this.#submenuCloseTimer = setTimeout(() => this._submenuCloseCheck(), 100); + return; + } this._setSafeTraverse(false); this._clearFocus(); clearTimeout(this.#submenuTimeout); @@ -912,6 +921,11 @@ class PuterContextMenu extends PuterWebComponent { this._cancelSubmenuClose(); parentEl.classList.add('has-open-submenu'); + // The parent now "owns" an open submenu; drop the .focused class so + // it paints in the dim has-open-submenu state instead of the bright + // focused/hover blue. :hover still keeps it blue when the cursor is + // directly over it. #focusedIndex is preserved for ArrowLeft restore. + parentEl.classList.remove('focused'); const submenu = document.createElement('puter-context-menu'); submenu.setAttribute('data-submenu', ''); From 9c7f30a150d414e31ba5f1da4533575d4e933a79 Mon Sep 17 00:00:00 2001 From: Miika Kuisma Date: Wed, 13 May 2026 10:08:59 +0300 Subject: [PATCH 6/6] Add `theme` attribute for each web component, which can be used for forcing dark or light theme (in case the application doesn't have dark mode support it might be odd if just menubar turns dark) --- src/puter-js/src/ui/PuterWebComponent.js | 55 ++++++ src/puter-js/src/ui/components.md | 20 +- .../src/ui/components/PuterColorPicker.js | 80 ++++---- .../src/ui/components/PuterContextMenu.js | 181 +++++++++--------- .../src/ui/components/PuterFontPicker.js | 86 ++++----- .../src/ui/components/PuterMenubar.js | 37 ++-- .../src/ui/components/PuterNotification.js | 47 +++-- 7 files changed, 292 insertions(+), 214 deletions(-) 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);