diff --git a/AGENTS.md b/AGENTS.md index 579f4e8..f74d2f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,9 @@ ssh fedoradev 'glib-compile-schemas ~/.local/share/gnome-shell/extensions/strata | `strata-daemon/src/db.rs` | SQLite schema, FTS5, insert, prune, search, thumbnail | | `strata-daemon/src/config.rs` | Data dir, DB path (`~/.local/share/strata/clipboard.db`) | | `strata@edu4rdshl.dev/extension.js` | Daemon lifecycle (PATH lookup + systemd detection), clipboard ingest, focus tracking, `_pushConfig` | -| `strata@edu4rdshl.dev/ui/panel.js` | Popup panel, lazy-load pagination, search, `_pageSize` | +| `strata@edu4rdshl.dev/ui/panel.js` | Popup panel, lazy-load pagination, search, `_pageSize`, theme class toggle (`_applyTheme`) | +| `strata@edu4rdshl.dev/stylesheet.css` | Dark theme (default, auto-loaded by GNOME) | +| `strata@edu4rdshl.dev/light.css` | Light theme overrides, scoped under `.strata-theme-light` | | `strata@edu4rdshl.dev/ui/clipboardItem.js` | Individual row widget | | `strata@edu4rdshl.dev/prefs.js` | Preferences window (Adw) | | `strata@edu4rdshl.dev/dbus.js` | D-Bus proxy definition and XML interface | @@ -84,10 +86,17 @@ Signals: `ItemAdded(id s, mime_type s, preview s)`, `ItemDeleted(id s)`, `Histor ## GSettings keys -`max-history`, `page-size`, `max-text-mb`, `max-image-mb`, `panel-position`, +`max-history`, `page-size`, `max-text-mb`, `max-image-mb`, `theme`, `panel-position`, `panel-width`, `panel-max-height`, `keyboard-shortcut`, `move-activated-to-top`, `excluded-apps`. +`theme` is `auto`/`light`/`dark`. `auto` follows `org.gnome.desktop.interface +color-scheme`. Light styling lives in `light.css`, scoped under a +`.strata-theme-light` class the panel toggles on its root box; `extension.js` +loads/unloads `light.css` via the St theme context. New non-`stylesheet.css` +CSS files must be added to the `pack` target as `--extra-source` (only +`stylesheet.css` is auto-included by `gnome-extensions pack`). + ## Conventions - No `--` em-dashes, no emojis in code comments or docs. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7076b56..ab3a9fa 100755 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -277,6 +277,23 @@ image. path in Strata executes clipboard content (no `spawn`, no `launch_uri`, no `show_uri`). +## Theming + +St's CSS engine has no custom properties (`var()`) and no reliable +`!important`, so the light theme is not a runtime-generated stylesheet. +Instead `stylesheet.css` is the dark theme (default, auto-loaded by GNOME), +and `light.css` carries light overrides with every rule scoped under a +`.strata-theme-light` ancestor class. That scoping makes each light rule +strictly more specific than its dark counterpart, so it wins +deterministically regardless of stylesheet load order. `extension.js` loads +`light.css` into the St theme context once at `enable()` (and re-loads it on +theme-context `changed`, since a Shell-theme swap drops dynamically loaded +sheets); it does nothing until the panel adds the class. The panel resolves +the effective theme from the `theme` setting (`auto` consults +`org.gnome.desktop.interface color-scheme`) and toggles the class on its +root box. Switching themes is one class toggle on an existing subtree, off +the ingest/render hot paths. + ## Wayland clipboard monitor The daemon contains a `wl-clipboard-rs` monitor for `ext-data-control-v1` diff --git a/CHANGELOG.md b/CHANGELOG.md index 743cd99..c626798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## [Unreleased] + +### Added + +- Light/dark theme support. A new **Theme** preference (Automatic / Light / + Dark) controls the panel palette; Automatic follows the system + `org.gnome.desktop.interface color-scheme`. The dark theme is unchanged. + Light styling lives in `light.css`, scoped under a `.strata-theme-light` + class toggled on the panel root box, loaded into the St theme context by + the extension. Switching is a single class toggle with no runtime cost on + the ingest/render paths. + +### Fixed + +- The keyboard shortcut now hides the panel when it is already open. The + binding was registered without `Shell.ActionMode.POPUP`, so while the + panel's modal grab was active the second press was swallowed and + `toggle()` never ran. +- The search placeholder ("Search...") is now legible in the light theme; it + previously inherited a light-on-dark Shell color. + +### Packaging + +- `make pack` now bundles `light.css` (`--extra-source`); only + `stylesheet.css` is auto-included by `gnome-extensions pack`. + +--- + ## [0.4.0] - 2026-05-26 ### Performance diff --git a/Makefile b/Makefile index 4780da6..96bc59f 100644 --- a/Makefile +++ b/Makefile @@ -37,6 +37,7 @@ install: schemas pack: schemas gnome-extensions pack $(EXTENSION_UUID) \ --extra-source=ui \ + --extra-source=light.css \ --force @echo "Packed: $(EXTENSION_UUID).shell-extension.zip" diff --git a/strata@edu4rdshl.dev/README.md b/strata@edu4rdshl.dev/README.md index 0636903..7b58380 100644 --- a/strata@edu4rdshl.dev/README.md +++ b/strata@edu4rdshl.dev/README.md @@ -52,6 +52,7 @@ Open with `gnome-extensions prefs strata@edu4rdshl.dev`: - **Maximum image size** (1–100 MB, larger items are not stored) **Appearance** +- **Theme** (Automatic / Light / Dark; Automatic follows the system light/dark preference) - **Panel width** (pixels) - **Panel max height** (pixels, list scrolls past this) - **Move activated item to top** diff --git a/strata@edu4rdshl.dev/extension.js b/strata@edu4rdshl.dev/extension.js index f04a838..7e99c68 100644 --- a/strata@edu4rdshl.dev/extension.js +++ b/strata@edu4rdshl.dev/extension.js @@ -76,6 +76,10 @@ export default class StrataExtension extends Extension { this._settings.connect('changed::max-image-mb', () => { this._readSizeLimits(); this._pushConfig(); }), ]; + // Load the light theme overrides into the Shell theme context. They stay + // inert until the panel toggles the `.strata-theme-light` class (panel.js). + this._loadThemeStylesheet(); + // 1. Top-bar indicator icon. this._addIndicator(); @@ -119,6 +123,7 @@ export default class StrataExtension extends Extension { this._panel = null; this._indicator?.destroy(); this._indicator = null; + this._unloadThemeStylesheet(); this._stopDaemon(); this._proxy = null; this._settings = null; @@ -566,11 +571,15 @@ export default class StrataExtension extends Extension { _registerShortcut() { + // POPUP is included so the shortcut still fires while the panel's own + // modal grab is active (pushModal sets actionMode POPUP). Without it the + // second press is swallowed by the grab and toggle() never runs, so the + // panel could open but not close via the shortcut. Main.wm.addKeybinding( 'keyboard-shortcut', this._settings, Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, - Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW, + Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW | Shell.ActionMode.POPUP, () => this._panel?.toggle() ); } @@ -578,4 +587,41 @@ export default class StrataExtension extends Extension { _unregisterShortcut() { Main.wm.removeKeybinding('keyboard-shortcut'); } + + + /** Load light.css into the global St theme context. Scoped under + * `.strata-theme-light`, so it does nothing until the panel adds that + * class. Reloaded on theme-context changes (a Shell-theme switch drops + * dynamically loaded stylesheets). */ + _loadThemeStylesheet() { + try { + const themeContext = St.ThemeContext.get_for_stage(global.stage); + this._lightCssFile = this.dir.get_child('light.css'); + themeContext.get_theme().load_stylesheet(this._lightCssFile); + this._stThemeChangedId = themeContext.connect('changed', () => { + try { + themeContext.get_theme().load_stylesheet(this._lightCssFile); + } catch (e) { + console.error('[Strata] light.css reload failed:', e); + } + }); + } catch (e) { + console.error('[Strata] Failed to load light.css:', e); + } + } + + _unloadThemeStylesheet() { + try { + const themeContext = St.ThemeContext.get_for_stage(global.stage); + if (this._stThemeChangedId) { + themeContext.disconnect(this._stThemeChangedId); + this._stThemeChangedId = null; + } + if (this._lightCssFile) + themeContext.get_theme().unload_stylesheet(this._lightCssFile); + } catch (e) { + console.error('[Strata] Failed to unload light.css:', e); + } + this._lightCssFile = null; + } } diff --git a/strata@edu4rdshl.dev/light.css b/strata@edu4rdshl.dev/light.css new file mode 100644 index 0000000..6d14993 --- /dev/null +++ b/strata@edu4rdshl.dev/light.css @@ -0,0 +1,143 @@ +/* Strata light theme. + * + * Loaded into the Shell theme context by extension.js and activated by toggling + * the `.strata-theme-light` class on the panel root box (see ui/panel.js). + * Every rule is scoped under `.strata-theme-light` so it is strictly more + * specific than its counterpart in stylesheet.css and wins deterministically, + * regardless of stylesheet load order. The dark theme (stylesheet.css) is left + * untouched: with no class present, none of these rules apply. + * + * Colors follow GNOME Adwaita (blue #3584e4, link #1a5fb4, amber #b5830a, + * red #e01b24) and are picked for contrast on a light panel. + * + * Performance rule (same as stylesheet.css): NO `transition: all`, NO + * Clutter/CSS animations. Only background-color transitions on hover. + */ + +/* ── Panel container ────────────────────────────────────────────────────── */ +.strata-panel.strata-theme-light { + background-color: rgba(250, 250, 250, 0.98); + border-color: rgba(0, 0, 0, 0.14); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.28); +} + +/* ── Header ─────────────────────────────────────────────────────────────── */ +.strata-theme-light .strata-title { + color: rgba(0, 0, 0, 0.87); +} + +.strata-theme-light .strata-clear-btn { + border-color: rgba(0, 0, 0, 0.18); + color: rgba(0, 0, 0, 0.55); +} + +.strata-theme-light .strata-clear-btn:hover { + background-color: rgba(0, 0, 0, 0.07); + color: rgba(0, 0, 0, 0.85); +} + +/* ── Search box ─────────────────────────────────────────────────────────── */ +.strata-theme-light .strata-search { + background-color: rgba(0, 0, 0, 0.05); + border-color: rgba(0, 0, 0, 0.14); + color: rgba(0, 0, 0, 0.87); + caret-color: rgba(0, 0, 0, 0.8); +} + +.strata-theme-light .strata-search:focus { + border-color: rgba(53, 132, 228, 0.8); + background-color: rgba(0, 0, 0, 0.035); +} + +/* Placeholder ("Search...") text - legible dim gray on the light panel. */ +.strata-theme-light .strata-search-hint { + color: rgba(0, 0, 0, 0.55); +} + +/* ── Clipboard item: hover / focus (Adwaita blue) ───────────────────────── */ +.strata-theme-light .strata-item:hover, +.strata-theme-light .strata-item-hovered { + background-color: rgba(53, 132, 228, 0.14); + border-color: rgba(53, 132, 228, 0.45); +} + +.strata-theme-light .strata-item:focus, +.strata-theme-light .strata-item-focused { + background-color: rgba(53, 132, 228, 0.16); + border-color: rgba(53, 132, 228, 0.65); +} + +.strata-theme-light .strata-item:hover:focus, +.strata-theme-light .strata-item-focused.strata-item-hovered { + background-color: rgba(53, 132, 228, 0.24); + border-color: rgba(53, 132, 228, 0.78); +} + +.strata-theme-light .strata-item:focus .strata-item-text, +.strata-theme-light .strata-item-focused .strata-item-text { + color: rgba(0, 0, 0, 0.95); +} + +/* ── Active item (warm amber, darkened for light bg) ────────────────────── */ +.strata-theme-light .strata-item-active { + background-color: rgba(181, 131, 10, 0.16); + border-color: rgba(181, 131, 10, 0.50); +} + +.strata-theme-light .strata-item-active .strata-item-text { + color: rgba(120, 82, 0, 1.0); +} + +.strata-theme-light .strata-item-active.strata-item-hovered { + background-color: rgba(181, 131, 10, 0.24); +} + +.strata-theme-light .strata-item-active.strata-item-focused, +.strata-theme-light .strata-item-active:focus { + background-color: rgba(53, 132, 228, 0.20); + border-color: rgba(53, 132, 228, 0.65); +} + +.strata-theme-light .strata-item-active.strata-item-focused .strata-item-text, +.strata-theme-light .strata-item-active:focus .strata-item-text { + color: rgba(0, 0, 0, 0.95); +} + +.strata-theme-light .strata-item:active { + background-color: rgba(0, 0, 0, 0.10); +} + +/* ── Item icon / thumbnail ──────────────────────────────────────────────── */ +.strata-theme-light .strata-item-icon { + color: rgba(0, 0, 0, 0.55); +} + +.strata-theme-light .strata-item-thumb { + border-color: rgba(0, 0, 0, 0.15); +} + +/* ── Text content ───────────────────────────────────────────────────────── */ +.strata-theme-light .strata-item-text { + color: rgba(0, 0, 0, 0.85); +} + +.strata-theme-light .strata-item-url { + color: rgba(26, 95, 180, 1.0); +} + +.strata-theme-light .strata-item-subtext { + color: rgba(0, 0, 0, 0.55); +} + +/* ── Delete button (Adwaita red) ────────────────────────────────────────── */ +.strata-theme-light .strata-item:hover .strata-item-delete, +.strata-theme-light .strata-item:focus .strata-item-delete, +.strata-theme-light .strata-item-focused .strata-item-delete, +.strata-theme-light .strata-item-hovered .strata-item-delete { + color: rgba(224, 27, 36, 0.75); +} + +.strata-theme-light .strata-item-delete:hover { + background-color: rgba(224, 27, 36, 0.13); + color: rgba(224, 27, 36, 1.0); +} diff --git a/strata@edu4rdshl.dev/prefs.js b/strata@edu4rdshl.dev/prefs.js index 12fe690..fc5d8bd 100644 --- a/strata@edu4rdshl.dev/prefs.js +++ b/strata@edu4rdshl.dev/prefs.js @@ -92,6 +92,29 @@ export default class StrataPreferences extends ExtensionPreferences { // ── Appearance ─────────────────────────────────────────────────────── const appearanceGroup = new Adw.PreferencesGroup({ title: 'Appearance' }); + const themes = [ + { id: 'auto', label: 'Automatic' }, + { id: 'light', label: 'Light' }, + { id: 'dark', label: 'Dark' }, + ]; + const themeRow = new Adw.ComboRow({ + title: 'Theme', + subtitle: 'Automatic follows the system light/dark preference', + model: Gtk.StringList.new(themes.map(t => t.label)), + }); + const currentTheme = settings.get_string('theme'); + const currentThemeIdx = themes.findIndex(t => t.id === currentTheme); + themeRow.selected = currentThemeIdx >= 0 ? currentThemeIdx : 0; + themeRow.connect('notify::selected', () => { + settings.set_string('theme', themes[themeRow.selected].id); + }); + settings.connect('changed::theme', () => { + const idx = themes.findIndex(t => t.id === settings.get_string('theme')); + if (idx >= 0 && themeRow.selected !== idx) + themeRow.selected = idx; + }); + appearanceGroup.add(themeRow); + const panelWidthRow = new Adw.SpinRow({ title: 'Panel width', subtitle: 'Width of the clipboard panel in pixels', diff --git a/strata@edu4rdshl.dev/schemas/org.gnome.shell.extensions.strata.gschema.xml b/strata@edu4rdshl.dev/schemas/org.gnome.shell.extensions.strata.gschema.xml index ad37c85..0c51ab5 100644 --- a/strata@edu4rdshl.dev/schemas/org.gnome.shell.extensions.strata.gschema.xml +++ b/strata@edu4rdshl.dev/schemas/org.gnome.shell.extensions.strata.gschema.xml @@ -52,6 +52,17 @@ When enabled, clicking or pressing Enter on a history item moves it to position 1 in the list. + + + + + + + 'auto' + Panel color theme + Auto follows the system light/dark color scheme (org.gnome.desktop.interface color-scheme). Light and Dark force a fixed theme. + + diff --git a/strata@edu4rdshl.dev/ui/clipboardItem.js b/strata@edu4rdshl.dev/ui/clipboardItem.js index 01a2b0f..5ad7c60 100644 --- a/strata@edu4rdshl.dev/ui/clipboardItem.js +++ b/strata@edu4rdshl.dev/ui/clipboardItem.js @@ -85,16 +85,14 @@ export const ClipboardItem = GObject.registerClass({ // Make the item text bold when keyboard-focused so the selected item // is immediately obvious during arrow-key navigation. // We add/remove an explicit style class because CSS :focus pseudo-class - // can be unreliable in GNOME Shell extensions. + // can be unreliable in GNOME Shell extensions. The bold + focus text + // color live in CSS (.strata-item-focused .strata-item-text) so each + // theme (dark/light) can color them; we only toggle the class here. this.connect('key-focus-in', () => { this.add_style_class_name('strata-item-focused'); - if (this._mainLabel) - this._mainLabel.style = 'font-weight: bold; color: rgba(255, 255, 255, 1.0);'; }); this.connect('key-focus-out', () => { this.remove_style_class_name('strata-item-focused'); - if (this._mainLabel) - this._mainLabel.style = null; }); } @@ -250,7 +248,6 @@ export const ClipboardItem = GObject.registerClass({ }); labelMain.clutter_text.line_wrap = false; labelMain.clutter_text.ellipsize = 3; // PANGO_ELLIPSIZE_END - this._mainLabel = labelMain; // held for key-focus bold styling box.add_child(labelMain); if (subText) { diff --git a/strata@edu4rdshl.dev/ui/panel.js b/strata@edu4rdshl.dev/ui/panel.js index c4a418e..2672f5e 100644 --- a/strata@edu4rdshl.dev/ui/panel.js +++ b/strata@edu4rdshl.dev/ui/panel.js @@ -1,6 +1,7 @@ /* panel.js - Strata clipboard popup panel. */ import GLib from 'gi://GLib'; +import Gio from 'gi://Gio'; import St from 'gi://St'; import Clutter from 'gi://Clutter'; import Meta from 'gi://Meta'; @@ -42,6 +43,17 @@ export class StrataPanel { this._activeWidget = null; // most recently copied item this._buildUI(); + + // Theme: apply light/dark to the panel box and react to changes. + // The light.css overrides (loaded by extension.js) only take effect + // while the `.strata-theme-light` class is present on `_box`. + this._interfaceSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' }); + this._applyTheme(); + this._themeChangedId = this._settings.connect('changed::theme', + () => this._applyTheme()); + this._colorSchemeChangedId = this._interfaceSettings.connect('changed::color-scheme', + () => this._applyTheme()); + // Trigger the initial load as soon as the daemon owns its bus name. // At extension boot the daemon may still be starting (its D-Bus name // not yet owned); the proxy fires notify::g-name-owner when that @@ -53,6 +65,24 @@ export class StrataPanel { () => this._tryInitialLoad()); } + /** True when the effective theme is light. `auto` follows the system + * color-scheme (anything other than prefer-dark counts as light). */ + _effectiveIsLight() { + const mode = this._settings.get_string('theme'); + if (mode === 'light') return true; + if (mode === 'dark') return false; + return this._interfaceSettings.get_string('color-scheme') !== 'prefer-dark'; + } + + /** Toggle the light-theme class on the panel root box. */ + _applyTheme() { + if (!this._box) return; + if (this._effectiveIsLight()) + this._box.add_style_class_name('strata-theme-light'); + else + this._box.remove_style_class_name('strata-theme-light'); + } + _tryInitialLoad() { if (this._initialLoaded) return; if (!this._proxy.g_name_owner) return; @@ -146,6 +176,10 @@ export class StrataPanel { x_expand: true, can_focus: true, }); + // Tag the placeholder label so the light theme can recolor it. By + // default it inherits a light-on-dark Shell color that is illegible on + // the light panel; dark mode is unaffected (no base rule). + this._searchEntry.get_hint_actor()?.add_style_class_name('strata-search-hint'); this._searchEntry.get_clutter_text().connect('text-changed', () => { this._scheduleSearch(this._searchEntry.get_text()); }); @@ -311,6 +345,15 @@ export class StrataPanel { this._settings.disconnect(this._pageSizeChangedId); this._pageSizeChangedId = 0; } + if (this._themeChangedId) { + this._settings.disconnect(this._themeChangedId); + this._themeChangedId = 0; + } + if (this._colorSchemeChangedId && this._interfaceSettings) { + this._interfaceSettings.disconnect(this._colorSchemeChangedId); + this._colorSchemeChangedId = 0; + } + this._interfaceSettings = null; if (this._nameOwnerId) { this._proxy.disconnect(this._nameOwnerId); this._nameOwnerId = 0;