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;