diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..17543c3
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,34 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ - run: npm install
+ - run: npm run lint
+
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install GNOME tooling
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libglib2.0-bin gnome-shell-common gnome-shell gettext
+ - name: Validate GSettings schema
+ run: glib-compile-schemas --strict --dry-run schemas/
+ - name: Pack extension
+ run: make pack
+ - uses: actions/upload-artifact@v4
+ with:
+ name: extension-zip
+ path: dist/*.shell-extension.zip
diff --git a/.gitignore b/.gitignore
index c816185..59a5e03 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,5 @@
-.claude
\ No newline at end of file
+.claude
+schemas/gschemas.compiled
+dist/
+node_modules/
+po/*.mo
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..12ec3b3
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,42 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [2.0] - Unreleased
+
+### Added
+- Independent border thickness per side (top/bottom/left/right); setting a side to 0
+ disables it entirely.
+- Named profiles: save the current appearance as a profile (e.g. "Google Meet") and
+ switch between profiles from the panel menu or the preferences window.
+- Optional automatic profile switching based on the focused window (match by
+ application ID or a regular expression on the window title).
+- Build tooling: `Makefile` (build/lint/pot/pack/install/nested), ESLint config,
+ GitHub Actions CI, and gettext translation scaffolding (`po/`).
+- `CONTRIBUTING.md` and this changelog.
+- Brazilian Portuguese translation.
+
+### Changed
+- Borders are now drawn with `St.Widget` and CSS colors instead of the deprecated
+ `Clutter.Color` API (removed in GNOME 47).
+- The color picker in preferences uses `Gtk.ColorDialogButton` instead of the
+ deprecated `Gtk.ColorButton`.
+- The preferences keyboard shortcut is now only active in normal mode
+ (`Shell.ActionMode.NORMAL`).
+- `make install` refuses to run when the extension is installed as a symlink into
+ the repository, because `gnome-extensions install --force` would delete the
+ repository contents through the symlink.
+
+### Fixed
+- Toggling "Maximize Brightness" in preferences now takes effect immediately.
+- `schemas/gschemas.compiled` is no longer tracked in the repository.
+
+## [1] - 2025
+
+### Added
+- Initial release: adjustable white screen borders for videocall lighting, with
+ thickness, opacity, color, temperature, multi-monitor support, automatic screen
+ brightness maximization, and a panel indicator.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..d5e6718
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,107 @@
+# Contributing to Lighter
+
+Thanks for your interest in contributing! Lighter is a GNOME Shell extension written in
+GJS (GNOME JavaScript) using ES modules, targeting GNOME 45+.
+
+## Development setup
+
+Build dependencies: `glib2` (for `glib-compile-schemas`) and `gettext` (for
+`make pot` and for compiling translations at pack time) — on Debian/Ubuntu:
+`sudo apt install gettext`.
+
+```bash
+git clone https://github.com/joaoferrete/Lighter.git
+cd Lighter
+make install # packs and installs the extension for your user
+```
+
+Alternatively, for a symlink-based workflow (edits picked up on shell restart):
+
+```bash
+ln -s "$PWD" ~/.local/share/gnome-shell/extensions/lighter@gnome-shell-extensions.ferrete.com
+make build # compiles the GSettings schema locally
+```
+
+> **Warning:** pick ONE of the two workflows. Never run `make install` while the
+> extension is installed as a symlink into your working copy —
+> `gnome-extensions install --force` deletes the previous installation, and with a
+> symlink that means deleting your repository's contents. `make install` detects
+> this and refuses to run, but don't bypass it.
+
+Enable it with:
+
+```bash
+gnome-extensions enable lighter@gnome-shell-extensions.ferrete.com
+```
+
+## Testing your changes
+
+The safest way to test is a nested GNOME Shell session (Wayland):
+
+```bash
+make nested
+```
+
+Inside the nested session, enable the extension and open its preferences
+(`gnome-extensions prefs lighter@...`).
+
+On X11 you can instead reload the running shell with `Alt+F2`, type `r`, press Enter.
+On Wayland (non-nested) you must log out and back in.
+
+Watch the logs while testing:
+
+```bash
+journalctl -f -o cat /usr/bin/gnome-shell
+```
+
+And watch settings changes live:
+
+```bash
+dconf watch /org/gnome/shell/extensions/lighter/
+```
+
+## Code style
+
+We follow the [GNOME Shell JavaScript style](https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/main/docs/js-coding-style.md):
+4-space indent, single quotes, semicolons, `camelCase`.
+
+Run the linter before submitting:
+
+```bash
+npm install
+npm run lint
+```
+
+A few project rules:
+
+- `preferences/` and `prefs.js` run in a GTK process: never import anything from
+ `resource:///org/gnome/shell/ui/*` there.
+- `extension.js` and shell-side modules in `lib/` run inside the compositor: never
+ import `Gtk`/`Adw` there.
+- `lib/colorUtils.js` and `lib/profileManager.js` are shared by both sides: keep them
+ free of both Shell UI and GTK imports (only `GLib`/`Gio`).
+- Everything created in `enable()` must be destroyed/disconnected in `disable()`
+ (signals, timeouts, actors, keybindings). This is a hard requirement for
+ extensions.gnome.org review.
+- User-visible strings must be wrapped in `_()` for translation.
+
+## Translations
+
+1. Regenerate the template: `make pot`
+2. Create or update your language file, e.g.:
+ `msginit -i po/lighter.pot -o po/pt_BR.po -l pt_BR.UTF-8` (new) or
+ `msgmerge -U po/pt_BR.po po/lighter.pot` (update)
+3. Translate the entries and open a pull request. Compiled `.mo` files are generated
+ automatically at pack time — do not commit them.
+
+## Submitting changes
+
+1. Fork and create a topic branch.
+2. Make your changes, run `npm run lint`, and test in a nested session.
+3. Update `CHANGELOG.md` under the *Unreleased* heading.
+4. Open a pull request describing what changed and why.
+
+## Reporting bugs
+
+Open an issue with your GNOME Shell version (`gnome-shell --version`), the extension
+version, whether you're on Wayland or X11, and any relevant `journalctl` output.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..0d35de2
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,57 @@
+UUID = lighter@gnome-shell-extensions.ferrete.com
+ZIP = dist/$(UUID).shell-extension.zip
+EXT_DIR = $(HOME)/.local/share/gnome-shell/extensions/$(UUID)
+
+SOURCES = extension.js prefs.js $(wildcard lib/*.js) $(wildcard preferences/*.js)
+
+.PHONY: all build lint pot pack install uninstall nested clean
+
+all: build
+
+# Compile schemas for local development (a symlinked install needs this;
+# `gnome-extensions install` compiles them automatically since GNOME 44)
+build:
+ glib-compile-schemas --strict schemas/
+
+lint:
+ npx eslint $(SOURCES)
+
+pot:
+ mkdir -p po
+ xgettext --from-code=UTF-8 --keyword=_ --keyword=N_ \
+ --package-name=lighter --output=po/lighter.pot $(SOURCES)
+
+pack:
+ mkdir -p dist
+ gnome-extensions pack --force --out-dir=dist \
+ --extra-source=lib \
+ --extra-source=preferences \
+ --extra-source=LICENSE \
+ --podir=po
+
+# DANGER: `gnome-extensions install --force` deletes the existing installation
+# before extracting the zip. If the installation is a symlink into this repo,
+# that deletes the repo contents THROUGH the symlink. Refuse to proceed.
+install: pack
+ @if [ -L "$(EXT_DIR)" ]; then \
+ echo "ERROR: $(EXT_DIR) is a symlink (likely into this repo)."; \
+ echo "Running 'gnome-extensions install --force' would delete the"; \
+ echo "symlink target's contents — i.e. this repository."; \
+ echo "If you use the symlink workflow, just run 'make build' and"; \
+ echo "restart GNOME Shell. To switch to zip installs, first run:"; \
+ echo " rm \"$(EXT_DIR)\""; \
+ exit 1; \
+ fi
+ gnome-extensions install --force $(ZIP)
+ @echo "Restart GNOME Shell (or log out/in on Wayland) and enable with:"
+ @echo " gnome-extensions enable $(UUID)"
+
+uninstall:
+ gnome-extensions uninstall $(UUID)
+
+# Run a nested GNOME Shell session for testing (Wayland)
+nested:
+ dbus-run-session -- gnome-shell --nested --wayland
+
+clean:
+ rm -rf dist schemas/gschemas.compiled
diff --git a/README.md b/README.md
index 877acd4..e33232b 100644
--- a/README.md
+++ b/README.md
@@ -1,69 +1,99 @@
# Lighter (GNOME Extension)
-
+
+
+
-**Lighter** is a GNOME Shell Extension that improves your videocall lighting at night by adding adjustable white borders around your screen edges. Perfect for laptop users who need extra illumination without external gear.
+**Lighter** is a GNOME Shell extension that improves your videocall lighting at night by
+adding adjustable light borders around your screen edges — a software ring light. Perfect
+for laptop users who need extra illumination without external gear.
+
+
+
+
## Features
-- **Adjustable Thickness**: From thin lines to thick light bars.
-- **Customizable Color**: Full RGB color picker support (warm/cool white, or any color).
-- **Opacity Control**: Adjust the brightness to your comfort level.
-- **Multi-Monitor Support**: Show on the primary monitor or all screens.
-- **Seamless Integration**: Native GNOME Shell feel with a toggle in the top bar.
-- **Convenient Shortcut**: Quick access to settings with `Ctrl + Alt + L`.
+
+- **Per-side thickness**: Adjust each border (top, bottom, left, right) independently,
+ or keep them linked. Set a side to 0 to disable it — e.g. only bottom + sides for a
+ lower fill light.
+- **Profiles**: Save your setups as named profiles (e.g. *Google Meet*) and switch
+ between them from the panel menu or the preferences window.
+- **Automatic profile switching** *(optional)*: Apply a profile automatically when a
+ matching window is focused — match by application ID or by a regex on the window
+ title (works for Meet running in a browser tab).
+- **Customizable color & temperature**: Full RGB color picker, plus a warm↔cold
+ temperature slider when the color is white.
+- **Opacity control**: Adjust the light intensity to your comfort level.
+- **Maximize brightness**: Optionally push the screen backlight to 100% while active,
+ restoring your previous level when turned off.
+- **Multi-monitor support**: Show on the primary monitor or all screens.
+- **Seamless integration**: Native GNOME Shell feel with a toggle in the top bar.
+- **Convenient shortcut**: `Ctrl + Alt + L` opens the preferences.
## Requirements
- GNOME Shell 45, 46, or 47
-- `glib2` (provides `glib-compile-schemas`, usually pre-installed on GNOME systems)
-On Debian/Ubuntu, ensure the required packages are installed:
+## Installation
+
+### From source
+
```bash
-sudo apt install gnome-shell-extensions glib-2.0-dev
+git clone https://github.com/joaoferrete/Lighter.git
+cd Lighter
+make install
```
-On Fedora:
+Then restart GNOME Shell (log out/in on Wayland, or `Alt + F2` → `r` → `Enter` on X11)
+and enable the extension:
+
```bash
-sudo dnf install gnome-shell gnome-extensions-app glib2-devel
+gnome-extensions enable lighter@gnome-shell-extensions.ferrete.com
```
-## Installation & Development
+## Usage
-To run this extension locally:
+- Click the ☀ indicator in the top bar to toggle the light, pick a profile, or adjust
+ the color temperature.
+- Open the full preferences with `Ctrl + Alt + L` or:
-### 1. Clone & Link
-Clone this repository and create a symbolic link in the GNOME extensions directory:
-```bash
-mkdir -p ~/.local/share/gnome-shell/extensions
-ln -s "$(pwd)" ~/.local/share/gnome-shell/extensions/lighter@gnome-shell-extensions.ferrete.com
-```
+ ```bash
+ gnome-extensions prefs lighter@gnome-shell-extensions.ferrete.com
+ ```
-### 2. Compile Schemas
-The settings schema must be compiled for the extension to work:
-```bash
-glib-compile-schemas schemas/
-```
+### Profiles
-### 3. Restart GNOME Shell
-- **X11**: Press `Alt + F2`, type `r`, and hit `Enter`.
-- **Wayland**: Log out and log back in, or restart your session.
+On the **Profiles** page of the preferences, click **+** to save the current settings
+as a named profile. Each profile stores thickness, opacity, color, temperature,
+brightness, and monitor options.
-### 4. Enable the Extension
-Once the shell has restarted, enable the extension:
-```bash
-gnome-extensions enable lighter@gnome-shell-extensions.ferrete.com
-```
+To switch profiles automatically, enable **Auto-switch profiles** and give a profile a
+match rule — an application ID (e.g. `firefox.desktop`) and/or a window title regex
+(e.g. `Meet`). When a focused window matches, the profile is applied; when nothing
+matches, the optional *default profile* is applied. Manual selection always works and
+takes effect immediately.
+
+## Development
+
+See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide. Quick start:
-### 5. Open Preferences
-To configure the borders (thickness, color, etc.), open the preferences window from the GNOME Extensions app or run:
```bash
-gnome-extensions prefs lighter@gnome-shell-extensions.ferrete.com
+make install # pack and install locally
+make nested # test in a nested GNOME Shell session (Wayland)
+make lint # run ESLint (requires `npm install` once)
+make pot # regenerate the translation template
```
-**Shortcut:** You can also press `Ctrl + Alt + L` at any time to open the preferences directly.
-
### Troubleshooting
-If the extension shows as ERROR state after code changes, you must restart GNOME Shell (step 3) to reload the updated code. Check logs with:
+
+If the extension shows an ERROR state after code changes, restart GNOME Shell to reload
+the updated code. Check logs with:
+
```bash
journalctl /usr/bin/gnome-shell -b | grep -i lighter
```
+
+## License
+
+[GPL-3.0](LICENSE)
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..465c9f1
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,47 @@
+import js from '@eslint/js';
+import globals from 'globals';
+
+// Style rules follow GNOME Shell conventions:
+// https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/main/lint/
+export default [
+ js.configs.recommended,
+ {
+ languageOptions: {
+ ecmaVersion: 2022,
+ sourceType: 'module',
+ globals: {
+ ...globals.es2021,
+ // GJS globals
+ ARGV: 'readonly',
+ imports: 'readonly',
+ console: 'readonly',
+ log: 'readonly',
+ logError: 'readonly',
+ print: 'readonly',
+ printerr: 'readonly',
+ TextDecoder: 'readonly',
+ TextEncoder: 'readonly',
+ setTimeout: 'readonly',
+ clearTimeout: 'readonly',
+ setInterval: 'readonly',
+ clearInterval: 'readonly',
+ // GNOME Shell global context object
+ global: 'readonly',
+ },
+ },
+ rules: {
+ 'camelcase': ['error', {properties: 'never', allow: ['^vfunc_', '^on_']}],
+ 'comma-dangle': ['error', 'always-multiline'],
+ 'eqeqeq': 'error',
+ 'indent': ['error', 4, {SwitchCase: 1}],
+ 'no-unused-vars': ['error', {varsIgnorePattern: '^_', argsIgnorePattern: '^_'}],
+ 'no-var': 'error',
+ 'prefer-const': 'error',
+ 'quotes': ['error', 'single', {avoidEscape: true}],
+ 'semi': ['error', 'always'],
+ },
+ },
+ {
+ ignores: ['dist/', 'node_modules/'],
+ },
+];
diff --git a/extension.js b/extension.js
index c2d454b..c6e6f57 100644
--- a/extension.js
+++ b/extension.js
@@ -1,167 +1,59 @@
-import Clutter from 'gi://Clutter';
-import GObject from 'gi://GObject';
-import Gio from 'gi://Gio';
-import St from 'gi://St';
-import * as Main from 'resource:///org/gnome/shell/ui/main.js';
-import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
-import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
-import * as Slider from 'resource:///org/gnome/shell/ui/slider.js';
import Meta from 'gi://Meta';
import Shell from 'gi://Shell';
-import {Extension, gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js';
-
-const BrightnessProxy = Gio.DBusProxy.makeProxyWrapper(
- '' +
- ' ' +
- ' ' +
- ' ' +
- ''
-);
-
-const LightBorder = GObject.registerClass(
- class LightBorder extends Clutter.Actor {
- _init(params) {
- super._init(params);
- this.set_reactive(false); // Click-through
- }
- }
-);
-
-const LighterSliderItem = GObject.registerClass(
- class LighterSliderItem extends PopupMenu.PopupBaseMenuItem {
- _init(value) {
- super._init({
- activate: false,
- can_focus: false,
- });
-
- this._slider = new Slider.Slider(value);
- this.add_child(this._slider);
-
- // Allow slider to expand
- this._slider.x_expand = true;
- }
-
- get value() {
- return this._slider.value;
- }
-
- set value(v) {
- this._slider.value = v;
- }
-
- connectSlider(signal, callback) {
- if (signal === 'value-changed') {
- return this._slider.connect('notify::value', () => {
- callback(this._slider, this._slider.value);
- });
- }
- return this._slider.connect(signal, callback);
- }
- }
-);
-
-const LighterIndicator = GObject.registerClass(
- class LighterIndicator extends PanelMenu.Button {
- _init(extension) {
- super._init(0.0, 'Lighter');
- this._extension = extension;
- this._settings = extension.getSettings();
-
- const icon = new St.Icon({
- icon_name: 'display-brightness-symbolic',
- style_class: 'system-status-icon',
- });
- this.add_child(icon);
-
- // Active Toggle
- this._activeItem = new PopupMenu.PopupSwitchMenuItem(_('Active'), this._settings.get_boolean('enabled'));
- this._activeItem.connect('toggled', (item, state) => {
- this._settings.set_boolean('enabled', state);
- });
- this.menu.addMenuItem(this._activeItem);
-
- // Temperature Slider
- this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
- const tempItem = new PopupMenu.PopupMenuItem(_('Temperature'));
- tempItem.reactive = false;
- this.menu.addMenuItem(tempItem);
-
- this._tempSlider = new LighterSliderItem(this._settings.get_int('temperature') / 100);
- this._tempSlider.connectSlider('value-changed', (slider, value) => {
- this._settings.set_int('temperature', Math.round(value * 100));
- });
- this.menu.addMenuItem(this._tempSlider);
-
- // Settings Button
- this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
- const settingsItem = new PopupMenu.PopupMenuItem(_('Settings'));
- settingsItem.connect('activate', () => {
- this._extension.openPreferences();
- });
- this.menu.addMenuItem(settingsItem);
-
- // Sync UI with settings
- this._changedId = this._settings.connect('changed', (settings, key) => {
- if (key === 'enabled')
- this._activeItem.setToggleState(settings.get_boolean('enabled'));
- else if (key === 'temperature')
- this._tempSlider.value = settings.get_int('temperature') / 100;
- });
- }
-
- destroy() {
- if (this._changedId) {
- this._settings.disconnect(this._changedId);
- this._changedId = 0;
- }
- super.destroy();
- }
- }
-);
+import * as Main from 'resource:///org/gnome/shell/ui/main.js';
+import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
+
+import {BorderManager} from './lib/borderManager.js';
+import {BrightnessController} from './lib/brightness.js';
+import {LighterIndicator} from './lib/indicator.js';
+import {ProfileManager} from './lib/profileManager.js';
+import {WindowWatcher} from './lib/windowWatcher.js';
+import {migrateSettings} from './lib/migrations.js';
+
+// Keys whose changes require redrawing the borders
+const SYNC_KEYS = [
+ 'enabled',
+ 'thickness-top',
+ 'thickness-bottom',
+ 'thickness-left',
+ 'thickness-right',
+ 'opacity',
+ 'color',
+ 'temperature',
+ 'multi-monitor',
+ 'maximize-brightness',
+];
export default class LighterExtension extends Extension {
enable() {
try {
- console.log('Lighter Extension Enabled v1.4 - Fixed color parsing');
this._settings = this.getSettings();
- this._borders = [];
- this._oldBrightness = -1;
-
- // Indicator
- this._indicator = new LighterIndicator(this);
+ migrateSettings(this._settings);
+
+ this._brightness = new BrightnessController();
+ this._borders = new BorderManager(this._settings);
+ this._profiles = new ProfileManager(this._settings);
+
+ this._indicator = new LighterIndicator(this, this._profiles);
Main.panel.addToStatusArea(this.uuid, this._indicator);
- // Keybinding
Main.wm.addKeybinding(
'open-prefs-shortcut',
this._settings,
Meta.KeyBindingFlags.NONE,
- Shell.ActionMode.ALL,
- () => {
- this.openPreferences();
- }
- );
-
- // Brightness Proxy
- this._proxy = new BrightnessProxy(
- Gio.DBus.session,
- 'org.gnome.SettingsDaemon.Power',
- '/org/gnome/SettingsDaemon/Power/Screen'
+ Shell.ActionMode.NORMAL,
+ () => this.openPreferences(),
);
- // Listen for internal changes
- this._settingsIds = [
- this._settings.connect('changed::enabled', () => this._sync()),
- this._settings.connect('changed::thickness', () => this._sync()),
- this._settings.connect('changed::opacity', () => this._sync()),
- this._settings.connect('changed::color', () => this._sync()),
- this._settings.connect('changed::temperature', () => this._sync()),
- this._settings.connect('changed::multi-monitor', () => this._sync()),
- ];
+ this._settingsId = this._settings.connect('changed', (settings, key) => {
+ if (SYNC_KEYS.includes(key))
+ this._sync();
+ });
+ this._monitorsChangedId = Main.layoutManager.connect(
+ 'monitors-changed', () => this._sync());
- // Also sync on monitor changes
- this._monitorsChangedId = Main.layoutManager.connect('monitors-changed', () => this._sync());
+ this._watcher = new WindowWatcher(this._settings, this._profiles);
+ this._watcher.enable();
this._sync();
} catch (e) {
@@ -171,164 +63,49 @@ export default class LighterExtension extends Extension {
}
disable() {
- this._restoreBrightness();
- this._settingsIds.forEach(id => this._settings.disconnect(id));
- Main.layoutManager.disconnect(this._monitorsChangedId);
-
- if (this._indicator) {
- this._indicator.destroy();
- this._indicator = null;
- }
-
- this._clearBorders();
- this._settings = null;
- this._proxy = null;
- Main.wm.removeKeybinding('open-prefs-shortcut');
- }
-
- _restoreBrightness() {
- if (this._oldBrightness !== -1 && this._proxy) {
- this._proxy.Brightness = this._oldBrightness;
- this._oldBrightness = -1;
+ if (this._watcher) {
+ this._watcher.disable();
+ this._watcher = null;
}
- }
-
- _clearBorders() {
- this._borders.forEach(b => b.destroy());
- this._borders = [];
- }
- _sync() {
- this._clearBorders();
-
- const enabled = this._settings.get_boolean('enabled');
-
- // Handle brightness
- if (enabled && this._settings.get_boolean('maximize-brightness')) {
- if (this._oldBrightness === -1 && this._proxy) {
- this._oldBrightness = this._proxy.Brightness;
- this._proxy.Brightness = 100;
- }
- } else {
- this._restoreBrightness();
+ if (this._settingsId) {
+ this._settings.disconnect(this._settingsId);
+ this._settingsId = 0;
}
- if (!enabled)
- return;
-
- const thickness = this._settings.get_int('thickness');
- const opacity = this._settings.get_double('opacity');
- const colorStr = this._settings.get_string('color');
- const multiMonitor = this._settings.get_boolean('multi-monitor');
- const temperature = this._settings.get_int('temperature');
-
- let color;
- // If color is default white, use temperature for warm/cool tint
- const parsed = this._parseColor(colorStr);
- if (parsed.red === 255 && parsed.green === 255 && parsed.blue === 255) {
- color = this._getColorFromTemperature(temperature);
- } else {
- color = parsed;
+ if (this._monitorsChangedId) {
+ Main.layoutManager.disconnect(this._monitorsChangedId);
+ this._monitorsChangedId = 0;
}
- color.alpha = Math.floor(opacity * 255);
-
- const monitors = multiMonitor ? Main.layoutManager.monitors : [Main.layoutManager.primaryMonitor];
- for (const monitor of monitors) {
- this._createBordersForMonitor(monitor, thickness, color);
+ if (this._indicator) {
+ this._indicator.destroy();
+ this._indicator = null;
}
- }
- _parseColor(str) {
- try {
- // Handle hex format: #rrggbb
- const hexMatch = str.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
- if (hexMatch) {
- return new Clutter.Color({
- red: parseInt(hexMatch[1], 16),
- green: parseInt(hexMatch[2], 16),
- blue: parseInt(hexMatch[3], 16),
- alpha: 255,
- });
- }
- // Handle rgb/rgba format: rgb(r,g,b) or rgba(r,g,b,a)
- const rgbMatch = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
- if (rgbMatch) {
- return new Clutter.Color({
- red: parseInt(rgbMatch[1]),
- green: parseInt(rgbMatch[2]),
- blue: parseInt(rgbMatch[3]),
- alpha: 255,
- });
- }
- } catch (e) {
- console.error(`Lighter: Failed to parse color "${str}": ${e}`);
+ if (this._borders) {
+ this._borders.destroy();
+ this._borders = null;
}
- return new Clutter.Color({ red: 255, green: 255, blue: 255, alpha: 255 });
- }
- _getColorFromTemperature(temp) {
- // Linear interpolation:
- // 0 (Warm): [255, 147, 41]
- // 50 (Neutral): [255, 255, 255]
- // 100 (Cold): [201, 226, 255]
-
- let r, g, b;
- if (temp <= 50) {
- const factor = temp / 50;
- r = 255;
- g = 147 + (255 - 147) * factor;
- b = 41 + (255 - 41) * factor;
- } else {
- const factor = (temp - 50) / 50;
- r = 255 - (255 - 201) * factor;
- g = 255 - (255 - 226) * factor;
- b = 255;
+ if (this._brightness) {
+ this._brightness.destroy();
+ this._brightness = null;
}
- return new Clutter.Color({
- red: Math.floor(r),
- green: Math.floor(g),
- blue: Math.floor(b)
- });
+ Main.wm.removeKeybinding('open-prefs-shortcut');
+ this._profiles = null;
+ this._settings = null;
}
- _createBordersForMonitor(monitor, thickness, color) {
- const {x, y, width, height} = monitor;
-
- // Create 4 actors for this monitor
- const borders = [
- this._addBorder(x, y, width, thickness, color), // Top
- this._addBorder(x, y + height - thickness, width, thickness, color), // Bottom
- this._addBorder(x, y + thickness, thickness, height - 2 * thickness, color), // Left
- this._addBorder(x + width - thickness, y + thickness, thickness, height - 2 * thickness, color), // Right
- ];
-
- // Animate them in
- const targetOpacity = color.alpha;
- borders.forEach(b => {
- b.opacity = 0;
- b.ease({
- opacity: targetOpacity,
- duration: 500,
- mode: Clutter.AnimationMode.EASE_OUT_QUAD
- });
- });
- }
+ _sync() {
+ const enabled = this._settings.get_boolean('enabled');
- _addBorder(x, y, width, height, color) {
- const border = new LightBorder({
- x, y, width, height,
- background_color: color
- });
-
- // Add as chrome (Always on Top)
- Main.layoutManager.addChrome(border, {
- affectsInputRegion: false,
- trackFullscreen: true
- });
+ if (enabled && this._settings.get_boolean('maximize-brightness'))
+ this._brightness.maximize();
+ else
+ this._brightness.restore();
- this._borders.push(border);
- return border;
+ this._borders.sync();
}
}
diff --git a/lib/borderManager.js b/lib/borderManager.js
new file mode 100644
index 0000000..7d86d9f
--- /dev/null
+++ b/lib/borderManager.js
@@ -0,0 +1,108 @@
+import Clutter from 'gi://Clutter';
+import GObject from 'gi://GObject';
+import St from 'gi://St';
+import * as Main from 'resource:///org/gnome/shell/ui/main.js';
+
+import {resolveColor, toBackgroundCss} from './colorUtils.js';
+
+const FADE_IN_MS = 500;
+
+const LightBorder = GObject.registerClass(
+ class LightBorder extends St.Widget {
+ _init(params) {
+ super._init(params);
+ this.set_reactive(false); // Click-through
+ }
+ },
+);
+
+/**
+ * Draws the light borders along the monitor edges based on the current settings.
+ * Profiles are invisible to this class: it only reads the flat appearance keys.
+ */
+export class BorderManager {
+ constructor(settings) {
+ this._settings = settings;
+ this._borders = [];
+ }
+
+ sync() {
+ this.clear();
+
+ if (!this._settings.get_boolean('enabled'))
+ return;
+
+ const sides = {
+ top: this._settings.get_int('thickness-top'),
+ bottom: this._settings.get_int('thickness-bottom'),
+ left: this._settings.get_int('thickness-left'),
+ right: this._settings.get_int('thickness-right'),
+ };
+ const color = resolveColor(
+ this._settings.get_string('color'),
+ this._settings.get_int('temperature'));
+ const cssColor = toBackgroundCss(color);
+ const targetOpacity = Math.floor(this._settings.get_double('opacity') * 255);
+
+ const monitors = (this._settings.get_boolean('multi-monitor')
+ ? Main.layoutManager.monitors
+ : [Main.layoutManager.primaryMonitor]).filter(m => m);
+
+ for (const monitor of monitors)
+ this._createBordersForMonitor(monitor, sides, cssColor, targetOpacity);
+ }
+
+ clear() {
+ this._borders.forEach(b => b.destroy());
+ this._borders = [];
+ }
+
+ destroy() {
+ this.clear();
+ this._borders = null;
+ this._settings = null;
+ }
+
+ _createBordersForMonitor(monitor, sides, cssColor, targetOpacity) {
+ const {x, y, width, height} = monitor;
+ const {top, bottom, left, right} = sides;
+ // Left/right bars fill the space between the horizontal bars; a side
+ // with thickness 0 is simply not drawn.
+ const sideHeight = Math.max(0, height - top - bottom);
+
+ const borders = [];
+ if (top > 0)
+ borders.push(this._addBorder(x, y, width, top, cssColor));
+ if (bottom > 0)
+ borders.push(this._addBorder(x, y + height - bottom, width, bottom, cssColor));
+ if (left > 0 && sideHeight > 0)
+ borders.push(this._addBorder(x, y + top, left, sideHeight, cssColor));
+ if (right > 0 && sideHeight > 0)
+ borders.push(this._addBorder(x + width - right, y + top, right, sideHeight, cssColor));
+
+ borders.forEach(b => {
+ b.opacity = 0;
+ b.ease({
+ opacity: targetOpacity,
+ duration: FADE_IN_MS,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ });
+ }
+
+ _addBorder(x, y, width, height, cssColor) {
+ const border = new LightBorder({
+ x, y, width, height,
+ style: cssColor,
+ });
+
+ // Add as chrome (Always on Top)
+ Main.layoutManager.addChrome(border, {
+ affectsInputRegion: false,
+ trackFullscreen: true,
+ });
+
+ this._borders.push(border);
+ return border;
+ }
+}
diff --git a/lib/brightness.js b/lib/brightness.js
new file mode 100644
index 0000000..213eeb6
--- /dev/null
+++ b/lib/brightness.js
@@ -0,0 +1,43 @@
+import Gio from 'gi://Gio';
+
+const BrightnessProxy = Gio.DBusProxy.makeProxyWrapper(
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ '',
+);
+
+/**
+ * Controls the screen backlight through gnome-settings-daemon, remembering the
+ * user's brightness so it can be restored later.
+ */
+export class BrightnessController {
+ constructor() {
+ this._saved = -1;
+ this._proxy = new BrightnessProxy(
+ Gio.DBus.session,
+ 'org.gnome.SettingsDaemon.Power',
+ '/org/gnome/SettingsDaemon/Power/Screen',
+ );
+ }
+
+ maximize() {
+ if (this._saved === -1 && this._proxy) {
+ this._saved = this._proxy.Brightness;
+ this._proxy.Brightness = 100;
+ }
+ }
+
+ restore() {
+ if (this._saved !== -1 && this._proxy) {
+ this._proxy.Brightness = this._saved;
+ this._saved = -1;
+ }
+ }
+
+ destroy() {
+ this.restore();
+ this._proxy = null;
+ }
+}
diff --git a/lib/colorUtils.js b/lib/colorUtils.js
new file mode 100644
index 0000000..bc8bf1d
--- /dev/null
+++ b/lib/colorUtils.js
@@ -0,0 +1,80 @@
+// Pure color helpers shared by the shell process and the preferences process.
+// Keep this module free of Shell UI and GTK imports.
+
+/**
+ * Parse a `#rrggbb` or `rgb()/rgba()` string into a plain color object.
+ *
+ * @param {string} str the color string
+ * @returns {{r: number, g: number, b: number}} the parsed color (white on failure)
+ */
+export function parseColor(str) {
+ try {
+ const hexMatch = str.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
+ if (hexMatch) {
+ return {
+ r: parseInt(hexMatch[1], 16),
+ g: parseInt(hexMatch[2], 16),
+ b: parseInt(hexMatch[3], 16),
+ };
+ }
+
+ const rgbMatch = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
+ if (rgbMatch) {
+ return {
+ r: parseInt(rgbMatch[1]),
+ g: parseInt(rgbMatch[2]),
+ b: parseInt(rgbMatch[3]),
+ };
+ }
+ } catch (e) {
+ console.error(`Lighter: Failed to parse color "${str}": ${e}`);
+ }
+ return {r: 255, g: 255, b: 255};
+}
+
+/**
+ * Map a temperature value to a color.
+ * Linear interpolation: 0 (warm) [255, 147, 41] → 50 (neutral) [255, 255, 255]
+ * → 100 (cold) [201, 226, 255].
+ *
+ * @param {number} temp temperature in the 0–100 range
+ * @returns {{r: number, g: number, b: number}} the interpolated color
+ */
+export function colorFromTemperature(temp) {
+ let r, g, b;
+ if (temp <= 50) {
+ const factor = temp / 50;
+ r = 255;
+ g = 147 + (255 - 147) * factor;
+ b = 41 + (255 - 41) * factor;
+ } else {
+ const factor = (temp - 50) / 50;
+ r = 255 - (255 - 201) * factor;
+ g = 255 - (255 - 226) * factor;
+ b = 255;
+ }
+
+ return {r: Math.floor(r), g: Math.floor(g), b: Math.floor(b)};
+}
+
+/**
+ * Resolve the effective border color: pure white means "use the temperature".
+ *
+ * @param {string} colorStr the configured color string
+ * @param {number} temperature temperature in the 0–100 range
+ * @returns {{r: number, g: number, b: number}} the effective color
+ */
+export function resolveColor(colorStr, temperature) {
+ const parsed = parseColor(colorStr);
+ if (parsed.r === 255 && parsed.g === 255 && parsed.b === 255)
+ return colorFromTemperature(temperature);
+ return parsed;
+}
+
+/**
+ * @param {{r: number, g: number, b: number}} color a plain color object
+ * @returns {string} a CSS background-color declaration
+ */
+export function toBackgroundCss(color) {
+ return `background-color: rgb(${color.r}, ${color.g}, ${color.b});`;
+}
diff --git a/lib/indicator.js b/lib/indicator.js
new file mode 100644
index 0000000..6174126
--- /dev/null
+++ b/lib/indicator.js
@@ -0,0 +1,145 @@
+import GObject from 'gi://GObject';
+import St from 'gi://St';
+import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
+import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
+import * as Slider from 'resource:///org/gnome/shell/ui/slider.js';
+import {gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js';
+
+const LighterSliderItem = GObject.registerClass(
+ class LighterSliderItem extends PopupMenu.PopupBaseMenuItem {
+ _init(value) {
+ super._init({
+ activate: false,
+ can_focus: false,
+ });
+
+ this._slider = new Slider.Slider(value);
+ this.add_child(this._slider);
+
+ // Allow slider to expand
+ this._slider.x_expand = true;
+ }
+
+ get value() {
+ return this._slider.value;
+ }
+
+ set value(v) {
+ this._slider.value = v;
+ }
+
+ connectSlider(signal, callback) {
+ if (signal === 'value-changed') {
+ return this._slider.connect('notify::value', () => {
+ callback(this._slider, this._slider.value);
+ });
+ }
+ return this._slider.connect(signal, callback);
+ }
+ },
+);
+
+export const LighterIndicator = GObject.registerClass(
+ class LighterIndicator extends PanelMenu.Button {
+ _init(extension, profileManager) {
+ super._init(0.0, 'Lighter');
+ this._extension = extension;
+ this._settings = extension.getSettings();
+ this._profiles = profileManager;
+
+ const icon = new St.Icon({
+ icon_name: 'display-brightness-symbolic',
+ style_class: 'system-status-icon',
+ });
+ this.add_child(icon);
+
+ // Active Toggle
+ this._activeItem = new PopupMenu.PopupSwitchMenuItem(_('Active'),
+ this._settings.get_boolean('enabled'));
+ this._activeItem.connect('toggled', (item, state) => {
+ this._settings.set_boolean('enabled', state);
+ });
+ this.menu.addMenuItem(this._activeItem);
+
+ // Profiles
+ this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+ this._profileSubMenu = new PopupMenu.PopupSubMenuMenuItem(_('Profile'));
+ this.menu.addMenuItem(this._profileSubMenu);
+
+ this._autoSwitchItem = new PopupMenu.PopupSwitchMenuItem(_('Auto-switch profile'),
+ this._settings.get_boolean('auto-switch'));
+ this._autoSwitchItem.connect('toggled', (item, state) => {
+ this._settings.set_boolean('auto-switch', state);
+ });
+ this.menu.addMenuItem(this._autoSwitchItem);
+
+ // Temperature Slider
+ this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+ const tempItem = new PopupMenu.PopupMenuItem(_('Temperature'));
+ tempItem.reactive = false;
+ this.menu.addMenuItem(tempItem);
+
+ this._tempSlider = new LighterSliderItem(this._settings.get_int('temperature') / 100);
+ this._tempSlider.connectSlider('value-changed', (slider, value) => {
+ this._settings.set_int('temperature', Math.round(value * 100));
+ });
+ this.menu.addMenuItem(this._tempSlider);
+
+ // Settings Button
+ this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+ const settingsItem = new PopupMenu.PopupMenuItem(_('Settings'));
+ settingsItem.connect('activate', () => {
+ this._extension.openPreferences();
+ });
+ this.menu.addMenuItem(settingsItem);
+
+ this._rebuildProfileMenu();
+
+ // Sync UI with settings
+ this._changedId = this._settings.connect('changed', (settings, key) => {
+ if (key === 'enabled')
+ this._activeItem.setToggleState(settings.get_boolean('enabled'));
+ else if (key === 'temperature')
+ this._tempSlider.value = settings.get_int('temperature') / 100;
+ else if (key === 'auto-switch')
+ this._autoSwitchItem.setToggleState(settings.get_boolean('auto-switch'));
+ else if (key === 'profiles' || key === 'active-profile')
+ this._rebuildProfileMenu();
+ });
+ }
+
+ _rebuildProfileMenu() {
+ this._profileSubMenu.menu.removeAll();
+
+ const profiles = this._profiles.getProfiles();
+ const hasProfiles = profiles.length > 0;
+ this._profileSubMenu.visible = hasProfiles;
+ this._autoSwitchItem.visible = hasProfiles;
+ if (!hasProfiles)
+ return;
+
+ const activeId = this._settings.get_string('active-profile');
+ for (const profile of profiles) {
+ const item = new PopupMenu.PopupMenuItem(profile.name);
+ item.setOrnament(profile.id === activeId
+ ? PopupMenu.Ornament.DOT
+ : PopupMenu.Ornament.NONE);
+ item.connect('activate', () => {
+ this._profiles.applyProfile(profile.id);
+ });
+ this._profileSubMenu.menu.addMenuItem(item);
+ }
+ }
+
+ destroy() {
+ if (this._changedId) {
+ this._settings.disconnect(this._changedId);
+ this._changedId = 0;
+ }
+ this._settings = null;
+ this._profiles = null;
+ this._extension = null;
+ super.destroy();
+ }
+ },
+);
diff --git a/lib/migrations.js b/lib/migrations.js
new file mode 100644
index 0000000..5fe21c4
--- /dev/null
+++ b/lib/migrations.js
@@ -0,0 +1,18 @@
+const CURRENT_VERSION = 2;
+
+/**
+ * Upgrade settings written by older versions of the extension.
+ * Version 2 replaced the single `thickness` key with per-side keys.
+ *
+ * @param {Gio.Settings} settings the extension settings
+ */
+export function migrateSettings(settings) {
+ if (settings.get_int('settings-version') >= CURRENT_VERSION)
+ return;
+
+ const thickness = settings.get_int('thickness');
+ for (const side of ['top', 'bottom', 'left', 'right'])
+ settings.set_int(`thickness-${side}`, thickness);
+
+ settings.set_int('settings-version', CURRENT_VERSION);
+}
diff --git a/lib/profileManager.js b/lib/profileManager.js
new file mode 100644
index 0000000..437df5f
--- /dev/null
+++ b/lib/profileManager.js
@@ -0,0 +1,178 @@
+import GLib from 'gi://GLib';
+
+// Appearance keys captured in a profile, with their GSettings type.
+// Shared by the shell process and the preferences process — keep this module
+// free of Shell UI and GTK imports.
+export const PROFILE_KEY_TYPES = {
+ 'thickness-top': 'i',
+ 'thickness-bottom': 'i',
+ 'thickness-left': 'i',
+ 'thickness-right': 'i',
+ 'thickness-linked': 'b',
+ 'opacity': 'd',
+ 'color': 's',
+ 'temperature': 'i',
+ 'maximize-brightness': 'b',
+ 'multi-monitor': 'b',
+};
+
+export const PROFILE_KEYS = Object.keys(PROFILE_KEY_TYPES);
+
+const FORMAT_VERSION = 1;
+
+/**
+ * Stores named snapshots of the appearance settings in the `profiles` JSON key.
+ *
+ * The flat settings keys remain the source of truth for what is on screen;
+ * applying a profile copies its values onto them in a single batch, so all
+ * existing bindings and listeners keep working unchanged.
+ */
+export class ProfileManager {
+ constructor(settings) {
+ this._settings = settings;
+ }
+
+ /**
+ * @returns {object[]} the saved profiles (empty on missing/invalid JSON)
+ */
+ getProfiles() {
+ try {
+ const parsed = JSON.parse(this._settings.get_string('profiles'));
+ if (Array.isArray(parsed))
+ return parsed;
+ if (parsed && Array.isArray(parsed.profiles))
+ return parsed.profiles;
+ } catch (e) {
+ console.error(`Lighter: Invalid profiles JSON, ignoring: ${e}`);
+ }
+ return [];
+ }
+
+ getProfile(id) {
+ return this.getProfiles().find(p => p.id === id) ?? null;
+ }
+
+ /**
+ * Snapshot the current flat settings as a new profile.
+ *
+ * @param {string} name the profile name
+ * @returns {object} the created profile
+ */
+ createProfile(name) {
+ const profile = {
+ id: GLib.uuid_string_random(),
+ name,
+ settings: this._snapshotSettings(),
+ match: {enabled: false, 'app-ids': [], 'title-regex': '', priority: 0},
+ };
+ this._save([...this.getProfiles(), profile]);
+ return profile;
+ }
+
+ /**
+ * Shallow-merge a patch ({name, settings, match}) into a profile.
+ *
+ * @param {string} id the profile id
+ * @param {object} patch fields to replace
+ * @returns {object|null} the updated profile
+ */
+ updateProfile(id, patch) {
+ const profiles = this.getProfiles();
+ const profile = profiles.find(p => p.id === id);
+ if (!profile)
+ return null;
+
+ if (patch.name !== undefined)
+ profile.name = patch.name;
+ if (patch.settings !== undefined)
+ profile.settings = patch.settings;
+ if (patch.match !== undefined)
+ profile.match = {...profile.match, ...patch.match};
+
+ this._save(profiles);
+ return profile;
+ }
+
+ /** Re-snapshot the current flat settings into an existing profile.
+ *
+ * @param {string} id the profile id
+ * @returns {object|null} the updated profile
+ */
+ updateProfileFromCurrentSettings(id) {
+ return this.updateProfile(id, {settings: this._snapshotSettings()});
+ }
+
+ deleteProfile(id) {
+ this._save(this.getProfiles().filter(p => p.id !== id));
+
+ if (this._settings.get_string('active-profile') === id)
+ this._settings.set_string('active-profile', '');
+ if (this._settings.get_string('default-profile') === id)
+ this._settings.set_string('default-profile', '');
+ }
+
+ duplicateProfile(id) {
+ const source = this.getProfile(id);
+ if (!source)
+ return null;
+
+ const copy = JSON.parse(JSON.stringify(source));
+ copy.id = GLib.uuid_string_random();
+ copy.name = `${source.name} (copy)`;
+ this._save([...this.getProfiles(), copy]);
+ return copy;
+ }
+
+ /**
+ * Copy a profile's values onto the flat settings keys in a single batch,
+ * and mark it as the active profile. Fields missing from the profile
+ * (saved by older versions) keep their current values.
+ *
+ * @param {string} id the profile id
+ * @returns {boolean} whether the profile existed and was applied
+ */
+ applyProfile(id) {
+ const profile = this.getProfile(id);
+ if (!profile)
+ return false;
+
+ this._settings.delay();
+ for (const [key, type] of Object.entries(PROFILE_KEY_TYPES)) {
+ if (profile.settings?.[key] !== undefined)
+ this._setValue(key, type, profile.settings[key]);
+ }
+ this._settings.set_string('active-profile', profile.id);
+ this._settings.apply();
+ return true;
+ }
+
+ _snapshotSettings() {
+ const snapshot = {};
+ for (const [key, type] of Object.entries(PROFILE_KEY_TYPES))
+ snapshot[key] = this._getValue(key, type);
+ return snapshot;
+ }
+
+ _save(profiles) {
+ this._settings.set_string('profiles',
+ JSON.stringify({version: FORMAT_VERSION, profiles}));
+ }
+
+ _getValue(key, type) {
+ switch (type) {
+ case 'b': return this._settings.get_boolean(key);
+ case 'i': return this._settings.get_int(key);
+ case 'd': return this._settings.get_double(key);
+ default: return this._settings.get_string(key);
+ }
+ }
+
+ _setValue(key, type, value) {
+ switch (type) {
+ case 'b': this._settings.set_boolean(key, value); break;
+ case 'i': this._settings.set_int(key, value); break;
+ case 'd': this._settings.set_double(key, value); break;
+ default: this._settings.set_string(key, value);
+ }
+ }
+}
diff --git a/lib/windowWatcher.js b/lib/windowWatcher.js
new file mode 100644
index 0000000..d628679
--- /dev/null
+++ b/lib/windowWatcher.js
@@ -0,0 +1,165 @@
+import GLib from 'gi://GLib';
+import Shell from 'gi://Shell';
+
+const DEBOUNCE_MS = 300;
+
+/**
+ * Watches the focused window and applies the first matching profile.
+ *
+ * Listens to focus changes and to title changes of the focused window —
+ * switching browser tabs changes the title without changing focus, which is
+ * how a "Google Meet" profile can kick in when its tab is selected.
+ */
+export class WindowWatcher {
+ constructor(settings, profileManager) {
+ this._settings = settings;
+ this._profiles = profileManager;
+ this._focusId = 0;
+ this._titleId = 0;
+ this._titleWindow = null;
+ this._debounceId = 0;
+ this._settingsIds = null;
+ this._regexCache = new Map();
+ }
+
+ enable() {
+ this._focusId = global.display.connect('notify::focus-window',
+ () => this._onFocusChanged());
+ this._settingsIds = [
+ this._settings.connect('changed::profiles', () => {
+ this._regexCache.clear();
+ this._scheduleEvaluate();
+ }),
+ this._settings.connect('changed::auto-switch',
+ () => this._scheduleEvaluate()),
+ this._settings.connect('changed::default-profile',
+ () => this._scheduleEvaluate()),
+ ];
+ this._onFocusChanged();
+ }
+
+ disable() {
+ if (this._debounceId) {
+ GLib.source_remove(this._debounceId);
+ this._debounceId = 0;
+ }
+ this._disconnectTitle();
+ if (this._focusId) {
+ global.display.disconnect(this._focusId);
+ this._focusId = 0;
+ }
+ if (this._settingsIds) {
+ this._settingsIds.forEach(id => this._settings.disconnect(id));
+ this._settingsIds = null;
+ }
+ this._regexCache.clear();
+ this._settings = null;
+ this._profiles = null;
+ }
+
+ _disconnectTitle() {
+ if (this._titleWindow && this._titleId) {
+ try {
+ this._titleWindow.disconnect(this._titleId);
+ } catch {
+ // The window may already be gone
+ }
+ }
+ this._titleWindow = null;
+ this._titleId = 0;
+ }
+
+ _onFocusChanged() {
+ this._disconnectTitle();
+
+ const win = global.display.focus_window;
+ if (win) {
+ this._titleWindow = win;
+ this._titleId = win.connect('notify::title',
+ () => this._scheduleEvaluate());
+ }
+ this._scheduleEvaluate();
+ }
+
+ _scheduleEvaluate() {
+ if (this._debounceId)
+ GLib.source_remove(this._debounceId);
+ this._debounceId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, DEBOUNCE_MS, () => {
+ this._debounceId = 0;
+ this._evaluate();
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+
+ _evaluate() {
+ if (!this._settings.get_boolean('auto-switch'))
+ return;
+
+ const win = global.display.focus_window;
+ const target = this._resolveProfile(win) ?? this._defaultProfile();
+ // Compare against the active profile (not the last auto-applied one) so
+ // that refocusing a matching window wins over an earlier manual switch.
+ if (target && target.id !== this._settings.get_string('active-profile'))
+ this._profiles.applyProfile(target.id);
+ }
+
+ _defaultProfile() {
+ const id = this._settings.get_string('default-profile');
+ return id ? this._profiles.getProfile(id) : null;
+ }
+
+ /**
+ * Find the highest-priority profile whose match rule fits the window.
+ * A rule matches if any of its app ids fits OR its title regex matches.
+ *
+ * @param {Meta.Window|null} win the focused window
+ * @returns {object|null} the matching profile
+ */
+ _resolveProfile(win) {
+ if (!win)
+ return null;
+
+ const app = Shell.WindowTracker.get_default().get_window_app(win);
+ const appId = app?.get_id() ?? '';
+ const wmClass = win.get_wm_class() ?? '';
+ const title = win.get_title() ?? '';
+
+ const candidates = this._profiles.getProfiles()
+ .filter(p => p.match?.enabled)
+ .sort((a, b) => (b.match.priority ?? 0) - (a.match.priority ?? 0));
+
+ for (const profile of candidates) {
+ const appHit = profile.match['app-ids']
+ ?.some(id => this._matchesApp(id, appId, wmClass));
+ const regex = this._regexFor(profile);
+ const titleHit = regex ? regex.test(title) : false;
+ if (appHit || titleHit)
+ return profile;
+ }
+ return null;
+ }
+
+ _matchesApp(pattern, appId, wmClass) {
+ const normalize = s => s.toLowerCase().replace(/\.desktop$/, '');
+ const wanted = normalize(pattern.trim());
+ return wanted !== '' &&
+ (wanted === normalize(appId) || wanted === normalize(wmClass));
+ }
+
+ _regexFor(profile) {
+ const pattern = profile.match?.['title-regex'] ?? '';
+ if (!pattern)
+ return null;
+
+ if (!this._regexCache.has(profile.id)) {
+ let regex = null;
+ try {
+ regex = new RegExp(pattern, 'i');
+ } catch {
+ console.error(`Lighter: Invalid title regex for profile "${profile.name}": ${pattern}`);
+ }
+ this._regexCache.set(profile.id, regex);
+ }
+ return this._regexCache.get(profile.id);
+ }
+}
diff --git a/metadata.json b/metadata.json
index bf0fa98..a498a5b 100644
--- a/metadata.json
+++ b/metadata.json
@@ -8,6 +8,8 @@
"47"
],
"settings-schema": "org.gnome.shell.extensions.lighter",
+ "gettext-domain": "lighter",
"url": "https://github.com/joaoferrete/Lighter",
- "version": 1
+ "version": 1,
+ "version-name": "2.0"
}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..28eec20
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1111 @@
+{
+ "name": "lighter",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "lighter",
+ "devDependencies": {
+ "@eslint/js": "^9.0.0",
+ "eslint": "^9.0.0",
+ "globals": "^15.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.21.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz",
+ "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.7",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.5"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz",
+ "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.14.0",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.3.0",
+ "minimatch": "^3.1.5",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/globals": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "9.39.5",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz",
+ "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/types": "^0.15.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.2",
+ "@humanfs/types": "^0.15.0",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/types": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/acorn": {
+ "version": "8.17.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
+ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.16",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
+ "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "9.39.5",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz",
+ "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.2",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
+ "@eslint/eslintrc": "^3.3.6",
+ "@eslint/js": "9.39.5",
+ "@eslint/plugin-kit": "^0.4.1",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.14.0",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.5",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "15.15.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
+ "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
+ "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..dce5b4a
--- /dev/null
+++ b/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "lighter",
+ "private": true,
+ "type": "module",
+ "description": "Dev tooling for the Lighter GNOME Shell extension (the extension itself has no runtime npm dependencies)",
+ "scripts": {
+ "lint": "eslint extension.js prefs.js lib preferences"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.0.0",
+ "eslint": "^9.0.0",
+ "globals": "^15.0.0"
+ }
+}
diff --git a/po/lighter.pot b/po/lighter.pot
new file mode 100644
index 0000000..8aa439f
--- /dev/null
+++ b/po/lighter.pot
@@ -0,0 +1,243 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the lighter package.
+# FIRST AUTHOR , YEAR.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: lighter\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2026-07-14 11:00-0300\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME \n"
+"Language-Team: LANGUAGE \n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=CHARSET\n"
+"Content-Transfer-Encoding: 8bit\n"
+
+#: lib/indicator.js:57 preferences/generalPage.js:30
+msgid "Active"
+msgstr ""
+
+#: lib/indicator.js:66
+msgid "Profile"
+msgstr ""
+
+#: lib/indicator.js:69
+msgid "Auto-switch profile"
+msgstr ""
+
+#: lib/indicator.js:78 preferences/generalPage.js:149
+msgid "Temperature"
+msgstr ""
+
+#: lib/indicator.js:90
+msgid "Settings"
+msgstr ""
+
+#: preferences/generalPage.js:18 preferences/generalPage.js:24
+msgid "General"
+msgstr ""
+
+#: preferences/generalPage.js:25
+msgid "Control the main lighting parameters"
+msgstr ""
+
+#: preferences/generalPage.js:31
+msgid "Turn the screen borders on or off"
+msgstr ""
+
+#: preferences/generalPage.js:37
+msgid "Maximize Brightness"
+msgstr ""
+
+#: preferences/generalPage.js:38
+msgid "Automatically set screen brightness to 100% when active"
+msgstr ""
+
+#: preferences/generalPage.js:44
+msgid "Opacity"
+msgstr ""
+
+#: preferences/generalPage.js:45
+msgid "Adjust the brightness of the light"
+msgstr ""
+
+#: preferences/generalPage.js:57
+msgid "Thickness"
+msgstr ""
+
+#: preferences/generalPage.js:58
+msgid "Width of each border in pixels; 0 disables that side"
+msgstr ""
+
+#: preferences/generalPage.js:63
+msgid "Link sides"
+msgstr ""
+
+#: preferences/generalPage.js:64
+msgid "Changing one side updates all sides"
+msgstr ""
+
+#: preferences/generalPage.js:70
+msgid "Top"
+msgstr ""
+
+#: preferences/generalPage.js:71
+msgid "Bottom"
+msgstr ""
+
+#: preferences/generalPage.js:72
+msgid "Left"
+msgstr ""
+
+#: preferences/generalPage.js:73
+msgid "Right"
+msgstr ""
+
+#: preferences/generalPage.js:121
+msgid "Appearance"
+msgstr ""
+
+#: preferences/generalPage.js:126
+msgid "Color"
+msgstr ""
+
+#: preferences/generalPage.js:127
+msgid "Choose the color of the borders (Default: White)"
+msgstr ""
+
+#: preferences/generalPage.js:150
+msgid "Adjust the color from Warm to Cold (Only if color is White)"
+msgstr ""
+
+#: preferences/generalPage.js:161
+msgid "Multi-Monitor"
+msgstr ""
+
+#: preferences/generalPage.js:162
+msgid "Show borders on all connected screens"
+msgstr ""
+
+#: preferences/profilesPage.js:33 preferences/profilesPage.js:48
+msgid "Cancel"
+msgstr ""
+
+#: preferences/profilesPage.js:34
+msgid "Save"
+msgstr ""
+
+#: preferences/profilesPage.js:49 preferences/profilesPage.js:247
+msgid "Delete"
+msgstr ""
+
+#: preferences/profilesPage.js:72 preferences/profilesPage.js:78
+msgid "Profiles"
+msgstr ""
+
+#: preferences/profilesPage.js:79
+msgid "Save the current settings as a named profile and switch between them"
+msgstr ""
+
+#: preferences/profilesPage.js:83
+msgid "Create a profile from the current settings"
+msgstr ""
+
+#: preferences/profilesPage.js:88
+msgid "New Profile"
+msgstr ""
+
+#: preferences/profilesPage.js:95
+msgid "Automatic Switching"
+msgstr ""
+
+#: preferences/profilesPage.js:96
+msgid "Apply profiles automatically based on the focused window"
+msgstr ""
+
+#: preferences/profilesPage.js:101
+msgid "Auto-switch profiles"
+msgstr ""
+
+#: preferences/profilesPage.js:102
+msgid "Watch the focused window and apply the first matching profile"
+msgstr ""
+
+#: preferences/profilesPage.js:108
+msgid "Default profile"
+msgstr ""
+
+#: preferences/profilesPage.js:109
+msgid "Applied when no window rule matches"
+msgstr ""
+
+#: preferences/profilesPage.js:140
+msgid "Active profile"
+msgstr ""
+
+#: preferences/profilesPage.js:147
+msgid "Apply"
+msgstr ""
+
+#: preferences/profilesPage.js:156
+msgid "Name"
+msgstr ""
+
+#: preferences/profilesPage.js:171
+msgid "Auto-activate"
+msgstr ""
+
+#: preferences/profilesPage.js:172
+msgid "Apply this profile when a window below is focused"
+msgstr ""
+
+#: preferences/profilesPage.js:182
+msgid "Application IDs (comma-separated, e.g. firefox.desktop)"
+msgstr ""
+
+#: preferences/profilesPage.js:197
+msgid "Window title regex (e.g. Meet)"
+msgstr ""
+
+#: preferences/profilesPage.js:216
+msgid "Priority"
+msgstr ""
+
+#: preferences/profilesPage.js:217
+msgid "Higher priority wins when several rules match"
+msgstr ""
+
+#: preferences/profilesPage.js:234
+msgid "Update from current"
+msgstr ""
+
+#: preferences/profilesPage.js:235
+msgid "Overwrite this profile with the current settings"
+msgstr ""
+
+#: preferences/profilesPage.js:242
+msgid "Duplicate"
+msgstr ""
+
+#: preferences/profilesPage.js:253
+msgid "Delete Profile?"
+msgstr ""
+
+#: preferences/profilesPage.js:254
+#, javascript-format
+msgid "\"%s\" will be removed permanently."
+msgstr ""
+
+#: preferences/profilesPage.js:270
+msgid "None"
+msgstr ""
+
+#: preferences/profilesPage.js:297
+msgid "No profiles yet"
+msgstr ""
+
+#: preferences/profilesPage.js:298
+msgid "Use the + button to save the current settings as a profile"
+msgstr ""
diff --git a/po/pt_BR.po b/po/pt_BR.po
new file mode 100644
index 0000000..a78f8f3
--- /dev/null
+++ b/po/pt_BR.po
@@ -0,0 +1,184 @@
+# Brazilian Portuguese translation for the Lighter GNOME Shell extension.
+# Copyright (C) 2026 Joao Ferrete
+# This file is distributed under the same license as the lighter package.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: lighter\n"
+"Report-Msgid-Bugs-To: https://github.com/joaoferrete/Lighter/issues\n"
+"PO-Revision-Date: 2026-07-13 11:30-0300\n"
+"Last-Translator: Joao Ferrete \n"
+"Language-Team: Brazilian Portuguese\n"
+"Language: pt_BR\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+
+msgid "Active"
+msgstr "Ativo"
+
+msgid "Profile"
+msgstr "Perfil"
+
+msgid "Auto-switch profile"
+msgstr "Trocar perfil automaticamente"
+
+msgid "Temperature"
+msgstr "Temperatura"
+
+msgid "Settings"
+msgstr "Configurações"
+
+msgid "General"
+msgstr "Geral"
+
+msgid "Control the main lighting parameters"
+msgstr "Controle os principais parâmetros de iluminação"
+
+msgid "Turn the screen borders on or off"
+msgstr "Liga ou desliga as bordas da tela"
+
+msgid "Maximize Brightness"
+msgstr "Maximizar brilho"
+
+msgid "Automatically set screen brightness to 100% when active"
+msgstr "Define automaticamente o brilho da tela em 100% quando ativo"
+
+msgid "Opacity"
+msgstr "Opacidade"
+
+msgid "Adjust the brightness of the light"
+msgstr "Ajuste a intensidade da luz"
+
+msgid "Thickness"
+msgstr "Espessura"
+
+msgid "Width of each border in pixels; 0 disables that side"
+msgstr "Largura de cada borda em pixels; 0 desativa aquele lado"
+
+msgid "Link sides"
+msgstr "Vincular lados"
+
+msgid "Changing one side updates all sides"
+msgstr "Alterar um lado atualiza todos os lados"
+
+msgid "Top"
+msgstr "Superior"
+
+msgid "Bottom"
+msgstr "Inferior"
+
+msgid "Left"
+msgstr "Esquerda"
+
+msgid "Right"
+msgstr "Direita"
+
+msgid "Appearance"
+msgstr "Aparência"
+
+msgid "Color"
+msgstr "Cor"
+
+msgid "Choose the color of the borders (Default: White)"
+msgstr "Escolha a cor das bordas (padrão: branco)"
+
+msgid "Adjust the color from Warm to Cold (Only if color is White)"
+msgstr "Ajuste a cor de quente para fria (somente se a cor for branca)"
+
+msgid "Multi-Monitor"
+msgstr "Múltiplos monitores"
+
+msgid "Show borders on all connected screens"
+msgstr "Mostrar bordas em todas as telas conectadas"
+
+msgid "Cancel"
+msgstr "Cancelar"
+
+msgid "Save"
+msgstr "Salvar"
+
+msgid "Delete"
+msgstr "Excluir"
+
+msgid "Profiles"
+msgstr "Perfis"
+
+msgid "Save the current settings as a named profile and switch between them"
+msgstr "Salve as configurações atuais como um perfil nomeado e alterne entre eles"
+
+msgid "Create a profile from the current settings"
+msgstr "Criar um perfil a partir das configurações atuais"
+
+msgid "New Profile"
+msgstr "Novo perfil"
+
+msgid "Automatic Switching"
+msgstr "Troca automática"
+
+msgid "Apply profiles automatically based on the focused window"
+msgstr "Aplica perfis automaticamente com base na janela em foco"
+
+msgid "Auto-switch profiles"
+msgstr "Trocar perfis automaticamente"
+
+msgid "Watch the focused window and apply the first matching profile"
+msgstr "Observa a janela em foco e aplica o primeiro perfil correspondente"
+
+msgid "Default profile"
+msgstr "Perfil padrão"
+
+msgid "Applied when no window rule matches"
+msgstr "Aplicado quando nenhuma regra de janela corresponde"
+
+msgid "Active profile"
+msgstr "Perfil ativo"
+
+msgid "Apply"
+msgstr "Aplicar"
+
+msgid "Name"
+msgstr "Nome"
+
+msgid "Auto-activate"
+msgstr "Ativação automática"
+
+msgid "Apply this profile when a window below is focused"
+msgstr "Aplica este perfil quando uma das janelas abaixo estiver em foco"
+
+msgid "Application IDs (comma-separated, e.g. firefox.desktop)"
+msgstr "IDs de aplicativos (separados por vírgula, ex.: firefox.desktop)"
+
+msgid "Window title regex (e.g. Meet)"
+msgstr "Regex do título da janela (ex.: Meet)"
+
+msgid "Priority"
+msgstr "Prioridade"
+
+msgid "Higher priority wins when several rules match"
+msgstr "A prioridade mais alta vence quando várias regras correspondem"
+
+msgid "Update from current"
+msgstr "Atualizar com as configurações atuais"
+
+msgid "Overwrite this profile with the current settings"
+msgstr "Sobrescreve este perfil com as configurações atuais"
+
+msgid "Duplicate"
+msgstr "Duplicar"
+
+msgid "Delete Profile?"
+msgstr "Excluir perfil?"
+
+msgid "\"%s\" will be removed permanently."
+msgstr "\"%s\" será removido permanentemente."
+
+msgid "None"
+msgstr "Nenhum"
+
+msgid "No profiles yet"
+msgstr "Nenhum perfil ainda"
+
+msgid "Use the + button to save the current settings as a profile"
+msgstr "Use o botão + para salvar as configurações atuais como um perfil"
diff --git a/preferences/generalPage.js b/preferences/generalPage.js
new file mode 100644
index 0000000..f0f2bb2
--- /dev/null
+++ b/preferences/generalPage.js
@@ -0,0 +1,168 @@
+import Adw from 'gi://Adw';
+import Gdk from 'gi://Gdk';
+import Gio from 'gi://Gio';
+import Gtk from 'gi://Gtk';
+import {gettext as _} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
+
+const SIDES = ['top', 'bottom', 'left', 'right'];
+
+/**
+ * Build the "General" preferences page (activation, thickness, appearance).
+ *
+ * @param {Gio.Settings} settings the extension settings
+ * @param {Adw.PreferencesWindow} window used to clean up signal handlers on close
+ * @returns {Adw.PreferencesPage} the page
+ */
+export function buildGeneralPage(settings, window) {
+ const page = new Adw.PreferencesPage({
+ title: _('General'),
+ icon_name: 'preferences-system-symbolic',
+ });
+
+ // Group: General
+ const generalGroup = new Adw.PreferencesGroup({
+ title: _('General'),
+ description: _('Control the main lighting parameters'),
+ });
+ page.add(generalGroup);
+
+ const enabledRow = new Adw.SwitchRow({
+ title: _('Active'),
+ subtitle: _('Turn the screen borders on or off'),
+ });
+ generalGroup.add(enabledRow);
+ settings.bind('enabled', enabledRow, 'active', Gio.SettingsBindFlags.DEFAULT);
+
+ const brightnessRow = new Adw.SwitchRow({
+ title: _('Maximize Brightness'),
+ subtitle: _('Automatically set screen brightness to 100% when active'),
+ });
+ generalGroup.add(brightnessRow);
+ settings.bind('maximize-brightness', brightnessRow, 'active', Gio.SettingsBindFlags.DEFAULT);
+
+ const opacityRow = new Adw.ActionRow({
+ title: _('Opacity'),
+ subtitle: _('Adjust the brightness of the light'),
+ });
+ const opacityScale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0.1, 1.0, 0.05);
+ opacityScale.set_draw_value(true);
+ opacityScale.set_hexpand(true);
+ opacityScale.set_valign(Gtk.Align.CENTER);
+ opacityRow.add_suffix(opacityScale);
+ generalGroup.add(opacityRow);
+ settings.bind('opacity', opacityScale.get_adjustment(), 'value', Gio.SettingsBindFlags.DEFAULT);
+
+ // Group: Thickness
+ const thicknessGroup = new Adw.PreferencesGroup({
+ title: _('Thickness'),
+ description: _('Width of each border in pixels; 0 disables that side'),
+ });
+ page.add(thicknessGroup);
+
+ const linkedRow = new Adw.SwitchRow({
+ title: _('Link sides'),
+ subtitle: _('Changing one side updates all sides'),
+ });
+ thicknessGroup.add(linkedRow);
+ settings.bind('thickness-linked', linkedRow, 'active', Gio.SettingsBindFlags.DEFAULT);
+
+ const sideTitles = {
+ top: _('Top'),
+ bottom: _('Bottom'),
+ left: _('Left'),
+ right: _('Right'),
+ };
+ for (const side of SIDES) {
+ const row = new Adw.SpinRow({
+ title: sideTitles[side],
+ adjustment: new Gtk.Adjustment({
+ lower: 0,
+ upper: 500,
+ step_increment: 5,
+ page_increment: 25,
+ }),
+ });
+ thicknessGroup.add(row);
+ settings.bind(`thickness-${side}`, row, 'value', Gio.SettingsBindFlags.DEFAULT);
+ }
+
+ // Propagate a side's value to the others while sides are linked. The guard
+ // avoids re-entrancy from our own writes.
+ let propagating = false;
+ const sideChangedIds = SIDES.map(side =>
+ settings.connect(`changed::thickness-${side}`, () => {
+ if (propagating || !settings.get_boolean('thickness-linked'))
+ return;
+ propagating = true;
+ const value = settings.get_int(`thickness-${side}`);
+ for (const other of SIDES) {
+ if (other !== side)
+ settings.set_int(`thickness-${other}`, value);
+ }
+ propagating = false;
+ }),
+ );
+ const linkedChangedId = settings.connect('changed::thickness-linked', () => {
+ if (!settings.get_boolean('thickness-linked'))
+ return;
+ propagating = true;
+ const value = settings.get_int('thickness-top');
+ for (const side of SIDES)
+ settings.set_int(`thickness-${side}`, value);
+ propagating = false;
+ });
+ window.connect('close-request', () => {
+ sideChangedIds.forEach(id => settings.disconnect(id));
+ settings.disconnect(linkedChangedId);
+ });
+
+ // Group: Appearance
+ const appearanceGroup = new Adw.PreferencesGroup({
+ title: _('Appearance'),
+ });
+ page.add(appearanceGroup);
+
+ const colorRow = new Adw.ActionRow({
+ title: _('Color'),
+ subtitle: _('Choose the color of the borders (Default: White)'),
+ });
+ const colorButton = new Gtk.ColorDialogButton({
+ dialog: new Gtk.ColorDialog({with_alpha: false}),
+ valign: Gtk.Align.CENTER,
+ });
+ colorRow.add_suffix(colorButton);
+ appearanceGroup.add(colorRow);
+
+ // Sync color manually because color buttons don't support GSettings binding
+ // with hex strings
+ const rgba = new Gdk.RGBA();
+ rgba.parse(settings.get_string('color'));
+ colorButton.set_rgba(rgba);
+ colorButton.connect('notify::rgba', () => {
+ const c = colorButton.get_rgba();
+ const toHex = v => Math.round(v * 255).toString(16).padStart(2, '0');
+ const hex = `#${toHex(c.red)}${toHex(c.green)}${toHex(c.blue)}`;
+ settings.set_string('color', hex);
+ });
+
+ const tempRow = new Adw.ActionRow({
+ title: _('Temperature'),
+ subtitle: _('Adjust the color from Warm to Cold (Only if color is White)'),
+ });
+ const tempScale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0, 100, 1);
+ tempScale.set_draw_value(true);
+ tempScale.set_hexpand(true);
+ tempScale.set_valign(Gtk.Align.CENTER);
+ tempRow.add_suffix(tempScale);
+ appearanceGroup.add(tempRow);
+ settings.bind('temperature', tempScale.get_adjustment(), 'value', Gio.SettingsBindFlags.DEFAULT);
+
+ const monitorRow = new Adw.SwitchRow({
+ title: _('Multi-Monitor'),
+ subtitle: _('Show borders on all connected screens'),
+ });
+ appearanceGroup.add(monitorRow);
+ settings.bind('multi-monitor', monitorRow, 'active', Gio.SettingsBindFlags.DEFAULT);
+
+ return page;
+}
diff --git a/preferences/profilesPage.js b/preferences/profilesPage.js
new file mode 100644
index 0000000..fda7323
--- /dev/null
+++ b/preferences/profilesPage.js
@@ -0,0 +1,330 @@
+import Adw from 'gi://Adw';
+import Gio from 'gi://Gio';
+import Gtk from 'gi://Gtk';
+import {gettext as _} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
+
+import {ProfileManager} from '../lib/profileManager.js';
+
+// Adw.AlertDialog needs libadwaita 1.5 (GNOME 46); fall back to the older
+// Adw.MessageDialog on GNOME 45.
+function createDialog(window, heading, body) {
+ if (Adw.AlertDialog) {
+ const dialog = new Adw.AlertDialog({heading, body});
+ dialog.show = () => dialog.present(window);
+ return dialog;
+ }
+ const dialog = new Adw.MessageDialog({
+ heading, body,
+ modal: true,
+ transient_for: window,
+ });
+ dialog.show = () => dialog.present();
+ return dialog;
+}
+
+function promptName(window, heading, initialText, onAccept) {
+ const dialog = createDialog(window, heading, '');
+ const entry = new Gtk.Entry({
+ text: initialText,
+ activates_default: true,
+ margin_top: 6,
+ });
+ dialog.set_extra_child(entry);
+ dialog.add_response('cancel', _('Cancel'));
+ dialog.add_response('accept', _('Save'));
+ dialog.set_response_appearance('accept', Adw.ResponseAppearance.SUGGESTED);
+ dialog.set_default_response('accept');
+ dialog.set_close_response('cancel');
+ dialog.connect('response', (d, response) => {
+ const name = entry.get_text().trim();
+ if (response === 'accept' && name !== '')
+ onAccept(name);
+ });
+ dialog.show();
+}
+
+function confirm(window, heading, body, onConfirm) {
+ const dialog = createDialog(window, heading, body);
+ dialog.add_response('cancel', _('Cancel'));
+ dialog.add_response('delete', _('Delete'));
+ dialog.set_response_appearance('delete', Adw.ResponseAppearance.DESTRUCTIVE);
+ dialog.set_default_response('cancel');
+ dialog.set_close_response('cancel');
+ dialog.connect('response', (d, response) => {
+ if (response === 'delete')
+ onConfirm();
+ });
+ dialog.show();
+}
+
+/**
+ * Build the "Profiles" preferences page: saved profiles list and automatic
+ * switching options.
+ *
+ * @param {Gio.Settings} settings the extension settings
+ * @param {Adw.PreferencesWindow} window used for dialogs and signal cleanup
+ * @returns {Adw.PreferencesPage} the page
+ */
+export function buildProfilesPage(settings, window) {
+ const manager = new ProfileManager(settings);
+
+ const page = new Adw.PreferencesPage({
+ title: _('Profiles'),
+ icon_name: 'view-list-symbolic',
+ });
+
+ // Group: profile list
+ const listGroup = new Adw.PreferencesGroup({
+ title: _('Profiles'),
+ description: _('Save the current settings as a named profile and switch between them'),
+ });
+ const addButton = new Gtk.Button({
+ icon_name: 'list-add-symbolic',
+ tooltip_text: _('Create a profile from the current settings'),
+ valign: Gtk.Align.CENTER,
+ css_classes: ['flat'],
+ });
+ addButton.connect('clicked', () => {
+ promptName(window, _('New Profile'), '', name => manager.createProfile(name));
+ });
+ listGroup.set_header_suffix(addButton);
+ page.add(listGroup);
+
+ // Group: automatic switching
+ const autoGroup = new Adw.PreferencesGroup({
+ title: _('Automatic Switching'),
+ description: _('Apply profiles automatically based on the focused window'),
+ });
+ page.add(autoGroup);
+
+ const autoRow = new Adw.SwitchRow({
+ title: _('Auto-switch profiles'),
+ subtitle: _('Watch the focused window and apply the first matching profile'),
+ });
+ autoGroup.add(autoRow);
+ settings.bind('auto-switch', autoRow, 'active', Gio.SettingsBindFlags.DEFAULT);
+
+ const defaultRow = new Adw.ComboRow({
+ title: _('Default profile'),
+ subtitle: _('Applied when no window rule matches'),
+ });
+ autoGroup.add(defaultRow);
+
+ // When > 0, 'changed::profiles' events were caused by edits on this page
+ // that already updated the UI in place, so a full rebuild is unnecessary
+ // (and would steal focus from the row being edited).
+ let squelch = 0;
+ const withSquelch = fn => {
+ squelch++;
+ try {
+ fn();
+ } finally {
+ squelch--;
+ }
+ };
+
+ let rows = [];
+ const activeIcons = new Map(); // profile id -> Gtk.Image
+
+ function updateActiveMarkers() {
+ const activeId = settings.get_string('active-profile');
+ for (const [id, icon] of activeIcons)
+ icon.visible = id === activeId;
+ }
+
+ function makeProfileRow(profile) {
+ const row = new Adw.ExpanderRow({title: profile.name});
+
+ const activeIcon = new Gtk.Image({
+ icon_name: 'object-select-symbolic',
+ tooltip_text: _('Active profile'),
+ visible: false,
+ });
+ activeIcons.set(profile.id, activeIcon);
+ row.add_suffix(activeIcon);
+
+ const applyButton = new Gtk.Button({
+ label: _('Apply'),
+ valign: Gtk.Align.CENTER,
+ css_classes: ['flat'],
+ });
+ applyButton.connect('clicked', () => manager.applyProfile(profile.id));
+ row.add_suffix(applyButton);
+
+ // Rename
+ const nameRow = new Adw.EntryRow({
+ title: _('Name'),
+ text: profile.name,
+ show_apply_button: true,
+ });
+ nameRow.connect('apply', () => {
+ const name = nameRow.get_text().trim();
+ if (name === '')
+ return;
+ withSquelch(() => manager.updateProfile(profile.id, {name}));
+ row.title = name;
+ });
+ row.add_row(nameRow);
+
+ // Match rules
+ const matchRow = new Adw.SwitchRow({
+ title: _('Auto-activate'),
+ subtitle: _('Apply this profile when a window below is focused'),
+ active: profile.match?.enabled ?? false,
+ });
+ matchRow.connect('notify::active', () => {
+ withSquelch(() => manager.updateProfile(profile.id,
+ {match: {enabled: matchRow.get_active()}}));
+ });
+ row.add_row(matchRow);
+
+ const appIdsRow = new Adw.EntryRow({
+ title: _('Application IDs (comma-separated, e.g. firefox.desktop)'),
+ text: (profile.match?.['app-ids'] ?? []).join(', '),
+ show_apply_button: true,
+ });
+ appIdsRow.connect('apply', () => {
+ const ids = appIdsRow.get_text()
+ .split(',')
+ .map(s => s.trim())
+ .filter(s => s !== '');
+ withSquelch(() => manager.updateProfile(profile.id,
+ {match: {'app-ids': ids}}));
+ });
+ row.add_row(appIdsRow);
+
+ const regexRow = new Adw.EntryRow({
+ title: _('Window title regex (e.g. Meet)'),
+ text: profile.match?.['title-regex'] ?? '',
+ show_apply_button: true,
+ });
+ regexRow.connect('changed', () => {
+ try {
+ new RegExp(regexRow.get_text());
+ regexRow.remove_css_class('error');
+ } catch {
+ regexRow.add_css_class('error');
+ }
+ });
+ regexRow.connect('apply', () => {
+ withSquelch(() => manager.updateProfile(profile.id,
+ {match: {'title-regex': regexRow.get_text().trim()}}));
+ });
+ row.add_row(regexRow);
+
+ const priorityRow = new Adw.SpinRow({
+ title: _('Priority'),
+ subtitle: _('Higher priority wins when several rules match'),
+ adjustment: new Gtk.Adjustment({
+ lower: 0,
+ upper: 100,
+ step_increment: 1,
+ }),
+ value: profile.match?.priority ?? 0,
+ });
+ priorityRow.connect('notify::value', () => {
+ withSquelch(() => manager.updateProfile(profile.id,
+ {match: {priority: priorityRow.get_value()}}));
+ });
+ row.add_row(priorityRow);
+
+ // Actions
+ const actionsRow = new Adw.ActionRow();
+ const updateButton = new Gtk.Button({
+ label: _('Update from current'),
+ tooltip_text: _('Overwrite this profile with the current settings'),
+ valign: Gtk.Align.CENTER,
+ });
+ updateButton.connect('clicked', () => {
+ withSquelch(() => manager.updateProfileFromCurrentSettings(profile.id));
+ });
+ const duplicateButton = new Gtk.Button({
+ label: _('Duplicate'),
+ valign: Gtk.Align.CENTER,
+ });
+ duplicateButton.connect('clicked', () => manager.duplicateProfile(profile.id));
+ const deleteButton = new Gtk.Button({
+ label: _('Delete'),
+ valign: Gtk.Align.CENTER,
+ css_classes: ['destructive-action'],
+ });
+ deleteButton.connect('clicked', () => {
+ confirm(window,
+ _('Delete Profile?'),
+ _('"%s" will be removed permanently.').replace('%s', profile.name),
+ () => manager.deleteProfile(profile.id));
+ });
+ actionsRow.add_suffix(updateButton);
+ actionsRow.add_suffix(duplicateButton);
+ actionsRow.add_suffix(deleteButton);
+ row.add_row(actionsRow);
+
+ return row;
+ }
+
+ // Default-profile combo: index 0 is "None"
+ let comboSyncing = false;
+ function rebuildCombo(profiles) {
+ comboSyncing = true;
+ const model = new Gtk.StringList();
+ model.append(_('None'));
+ for (const p of profiles)
+ model.append(p.name);
+ defaultRow.set_model(model);
+
+ const defaultId = settings.get_string('default-profile');
+ const index = profiles.findIndex(p => p.id === defaultId);
+ defaultRow.set_selected(index === -1 ? 0 : index + 1);
+ comboSyncing = false;
+ }
+ defaultRow.connect('notify::selected', () => {
+ if (comboSyncing)
+ return;
+ const profiles = manager.getProfiles();
+ const selected = defaultRow.get_selected();
+ const id = selected > 0 && profiles[selected - 1] ? profiles[selected - 1].id : '';
+ settings.set_string('default-profile', id);
+ });
+
+ function rebuild() {
+ rows.forEach(r => listGroup.remove(r));
+ rows = [];
+ activeIcons.clear();
+
+ const profiles = manager.getProfiles();
+ if (profiles.length === 0) {
+ const placeholder = new Adw.ActionRow({
+ title: _('No profiles yet'),
+ subtitle: _('Use the + button to save the current settings as a profile'),
+ });
+ listGroup.add(placeholder);
+ rows.push(placeholder);
+ } else {
+ for (const profile of profiles) {
+ const row = makeProfileRow(profile);
+ listGroup.add(row);
+ rows.push(row);
+ }
+ }
+
+ updateActiveMarkers();
+ rebuildCombo(profiles);
+ }
+
+ const profilesChangedId = settings.connect('changed::profiles', () => {
+ if (!squelch)
+ rebuild();
+ });
+ const activeChangedId = settings.connect('changed::active-profile',
+ updateActiveMarkers);
+ const defaultChangedId = settings.connect('changed::default-profile',
+ () => rebuildCombo(manager.getProfiles()));
+ window.connect('close-request', () => {
+ settings.disconnect(profilesChangedId);
+ settings.disconnect(activeChangedId);
+ settings.disconnect(defaultChangedId);
+ });
+
+ rebuild();
+ return page;
+}
diff --git a/prefs.js b/prefs.js
index b7163c6..9d3b947 100644
--- a/prefs.js
+++ b/prefs.js
@@ -1,116 +1,12 @@
-import GObject from 'gi://GObject';
-import Gtk from 'gi://Gtk';
-import Adw from 'gi://Adw';
-import Gio from 'gi://Gio';
-import Gdk from 'gi://Gdk';
-import {ExtensionPreferences, gettext as _} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
+import {ExtensionPreferences} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
+
+import {buildGeneralPage} from './preferences/generalPage.js';
+import {buildProfilesPage} from './preferences/profilesPage.js';
export default class LighterPreferences extends ExtensionPreferences {
fillPreferencesWindow(window) {
const settings = this.getSettings();
-
- // Page
- const page = new Adw.PreferencesPage({
- title: _('Settings'),
- icon_name: 'lightbulb-symbolic',
- });
- window.add(page);
-
- // Group: General
- const generalGroup = new Adw.PreferencesGroup({
- title: _('General'),
- description: _('Control the main lighting parameters'),
- });
- page.add(generalGroup);
-
- // Enabled Toggle
- const enabledRow = new Adw.SwitchRow({
- title: _('Active'),
- subtitle: _('Turn the screen borders on or off'),
- });
- generalGroup.add(enabledRow);
- settings.bind('enabled', enabledRow, 'active', Gio.SettingsBindFlags.DEFAULT);
-
- // Maximize Brightness
- const brightnessRow = new Adw.SwitchRow({
- title: _('Maximize Brightness'),
- subtitle: _('Automatically set screen brightness to 100% when active'),
- });
- generalGroup.add(brightnessRow);
- settings.bind('maximize-brightness', brightnessRow, 'active', Gio.SettingsBindFlags.DEFAULT);
-
- // Thickness Slider
- const thicknessRow = new Adw.ActionRow({
- title: _('Thickness'),
- subtitle: _('Adjust the width of the white borders'),
- });
- const thicknessScale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 5, 500, 5);
- thicknessScale.set_draw_value(true);
- thicknessScale.set_hexpand(true);
- thicknessScale.set_valign(Gtk.Align.CENTER);
- thicknessRow.add_suffix(thicknessScale);
- generalGroup.add(thicknessRow);
- settings.bind('thickness', thicknessScale.get_adjustment(), 'value', Gio.SettingsBindFlags.DEFAULT);
-
- // Opacity Slider
- const opacityRow = new Adw.ActionRow({
- title: _('Opacity'),
- subtitle: _('Adjust the brightness of the light'),
- });
- const opacityScale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0.1, 1.0, 0.05);
- opacityScale.set_draw_value(true);
- opacityScale.set_hexpand(true);
- opacityScale.set_valign(Gtk.Align.CENTER);
- opacityRow.add_suffix(opacityScale);
- generalGroup.add(opacityRow);
- settings.bind('opacity', opacityScale.get_adjustment(), 'value', Gio.SettingsBindFlags.DEFAULT);
-
- // Group: Appearance
- const appearanceGroup = new Adw.PreferencesGroup({
- title: _('Appearance'),
- });
- page.add(appearanceGroup);
-
- // Color Picker
- const colorRow = new Adw.ActionRow({
- title: _('Color'),
- subtitle: _('Choose the color of the borders (Default: White)'),
- });
- const colorButton = new Gtk.ColorButton();
- colorButton.set_valign(Gtk.Align.CENTER);
- colorRow.add_suffix(colorButton);
- appearanceGroup.add(colorRow);
-
- // Sync color manually because Gtk.ColorButton doesn't support GSettings binding well with hex strings
- const rgba = new Gdk.RGBA();
- rgba.parse(settings.get_string('color'));
- colorButton.set_rgba(rgba);
- colorButton.connect('color-set', () => {
- const c = colorButton.get_rgba();
- const toHex = (v) => Math.round(v * 255).toString(16).padStart(2, '0');
- const hex = `#${toHex(c.red)}${toHex(c.green)}${toHex(c.blue)}`;
- settings.set_string('color', hex);
- });
-
- // Temperature Slider
- const tempRow = new Adw.ActionRow({
- title: _('Temperature'),
- subtitle: _('Adjust the color from Warm to Cold (Only if color is White)'),
- });
- const tempScale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0, 100, 1);
- tempScale.set_draw_value(true);
- tempScale.set_hexpand(true);
- tempScale.set_valign(Gtk.Align.CENTER);
- tempRow.add_suffix(tempScale);
- appearanceGroup.add(tempRow);
- settings.bind('temperature', tempScale.get_adjustment(), 'value', Gio.SettingsBindFlags.DEFAULT);
-
- // Multi-Monitor Toggle
- const monitorRow = new Adw.SwitchRow({
- title: _('Multi-Monitor'),
- subtitle: _('Show borders on all connected screens'),
- });
- appearanceGroup.add(monitorRow);
- settings.bind('multi-monitor', monitorRow, 'active', Gio.SettingsBindFlags.DEFAULT);
+ window.add(buildGeneralPage(settings, window));
+ window.add(buildProfilesPage(settings, window));
}
}
diff --git a/schemas/org.gnome.shell.extensions.lighter.gschema.xml b/schemas/org.gnome.shell.extensions.lighter.gschema.xml
index 5c3a934..579aee5 100644
--- a/schemas/org.gnome.shell.extensions.lighter.gschema.xml
+++ b/schemas/org.gnome.shell.extensions.lighter.gschema.xml
@@ -7,9 +7,33 @@
50
- The thickness of the white borders in pixels.
+ Deprecated: single thickness for all sides. Migrated to the per-side thickness keys.
+
+ 50
+ Thickness of the top border in pixels (0 disables it).
+
+
+
+ 50
+ Thickness of the bottom border in pixels (0 disables it).
+
+
+
+ 50
+ Thickness of the left border in pixels (0 disables it).
+
+
+
+ 50
+ Thickness of the right border in pixels (0 disables it).
+
+
+
+ true
+ Whether changing one side's thickness updates all sides.
+
0.9
The opacity of the light bars.
@@ -34,5 +58,25 @@
['<Control><Alt>l']
Keyboard shortcut to open preferences.
+
+ '[]'
+ Saved profiles as a JSON document.
+
+
+ ''
+ ID of the last applied profile (empty for custom settings).
+
+
+ ''
+ Profile applied when auto-switching and no window rule matches.
+
+
+ false
+ Whether to switch profiles automatically based on the focused window.
+
+
+ 1
+ Internal settings format version, used for migrations.
+