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 f8311386d..13024ea5c 100644
--- a/src/puter-js/src/ui/components/PuterColorPicker.js
+++ b/src/puter-js/src/ui/components/PuterColorPicker.js
@@ -176,6 +176,46 @@ class PuterColorPicker extends PuterWebComponent {
flex: 1;
}
}
+ :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 989cdcb7a..3e4446736 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;
@@ -88,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,
@@ -98,46 +103,51 @@ 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);
}
/* 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));
}
@@ -175,7 +185,6 @@ class PuterContextMenu extends PuterWebComponent {
padding-bottom: 5px;
cursor: default;
height: auto;
- pointer-events: none;
}
.divider hr {
border: none;
@@ -335,6 +344,102 @@ class PuterContextMenu extends PuterWebComponent {
width: 20px;
height: 20px;
}
+
+ /* 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 */
+ :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 */
+ :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 */
+ :host(.puter-theme-dark) .menu-item.danger,
+ :host(.puter-theme-dark) .menu-item.danger .icon {
+ color: #ff7b72;
+ }
+
+ /* Divider */
+ :host(.puter-theme-dark) .divider hr {
+ background: #444;
+ }
+
+ /* 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);
+ }
`;
}
@@ -475,6 +580,32 @@ 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.
+ // 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);
+ this._cancelSubmenuClose();
+ this._hideActiveSubmenu();
+ });
+ });
+
const menuItems = this.$$('.menu-item:not(.divider):not(.disabled)');
menuItems.forEach((el) => {
@@ -565,10 +696,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);
@@ -577,6 +710,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();
}
@@ -595,9 +732,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]);
@@ -708,14 +860,23 @@ class PuterContextMenu extends PuterWebComponent {
if ( ! el ) return false;
clearTimeout(this.#submenuTimeout);
this._cancelSubmenuClose();
- this._showSubmenu(el, item.items);
- // Focus first item in submenu
+ // 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);
+ }
});
return true;
}
@@ -761,10 +922,18 @@ 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', '');
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;
@@ -898,6 +1067,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;
}
@@ -1014,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 d158dcc71..dfecaa8f0 100644
--- a/src/puter-js/src/ui/components/PuterFontPicker.js
+++ b/src/puter-js/src/ui/components/PuterFontPicker.js
@@ -185,6 +185,49 @@ class PuterFontPicker extends PuterWebComponent {
flex: 1;
}
}
+ :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 2b9b8324f..0104d678a 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 {
@@ -86,6 +91,24 @@ class PuterMenubar extends PuterWebComponent {
flex-shrink: 0;
}
}
+ /* 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;
+ }
`;
}
@@ -108,6 +131,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) => {
@@ -168,6 +194,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) {
@@ -244,12 +275,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 () {
@@ -259,6 +292,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;
@@ -266,6 +304,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 ) {
@@ -291,6 +330,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);
+ }
}
});
}
@@ -311,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));
@@ -331,12 +376,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);
@@ -358,6 +414,7 @@ class PuterMenubar extends PuterWebComponent {
}
disconnectedCallback () {
+ super.disconnectedCallback();
this._closeDropdown();
clearTimeout(this._suppressClickTimer);
this._suppressClickFor = null;
@@ -370,6 +427,9 @@ class PuterMenubar extends PuterWebComponent {
if ( this._docPointerDownHandler ) {
document.removeEventListener('pointerdown', this._docPointerDownHandler, true);
}
+ if ( this._mouseMoveHandler ) {
+ document.removeEventListener('mousemove', this._mouseMoveHandler);
+ }
}
_escapeHTML (str) {
diff --git a/src/puter-js/src/ui/components/PuterNotification.js b/src/puter-js/src/ui/components/PuterNotification.js
index 215232686..9fc72f5a3 100644
--- a/src/puter-js/src/ui/components/PuterNotification.js
+++ b/src/puter-js/src/ui/components/PuterNotification.js
@@ -175,6 +175,29 @@ class PuterNotification extends PuterWebComponent {
width: auto;
}
}
+ :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;
+ }
`;
}
@@ -255,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);