diff --git a/.gitignore b/.gitignore
index 3cb9f9a..145b5f4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,8 @@ node_modules
.venv
.idea/
+/resources/demos/output/
+
.yarn/*
.pnp.*
!.yarn/cache
diff --git a/README.md b/README.md
index 54da72e..0595461 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,40 @@ If you use a tiling extension, switching the style trigger from **Maximized wind
## Screenshot

+## Demos
+
+**Proximity blend:** the panel blends into the alternate style as a window approaches, then back as it moves away.
+
+
+
+**Touch detection, 0 px:** an unmaximized window touches the panel to activate the alternate style.
+
+
+
+**Workspace transition:** swipe between workspaces with different panel styles for a smooth transition.
+
+
+
+**Maximized mode:** maximize and restore a window to switch panel styles.
+
+
+
+**Tiling, 0 px:** snap a window to the left half to activate the alternate style, then restore and move it away.
+
+
+
+The clips use the included [wallpaper](resources/demos/wallpaper.jpg), a black-to-transparent normal style, and a gold-to-teal alternate style. See the [demo guide](resources/demos/README.md) to reproduce them.
+
# Compatibility
@@ -127,9 +161,9 @@ The project includes several scripts to help with development:
- [ ] Add box shadow settings
- [ ] Fix an issue where "Desktop icons" extension creates a weird window that messes up the dynamic toolbar colour effect.
-- [ ] Add a demo video
-- [ ] Figure out reliable testing methods or at least unit tests
-- [ ] Make it work with something like tiling when the window occupies the whole top horizontally (to test with Tiling Assistant)
+- [x] Add demo videos
+- [x] Figure out reliable testing methods or at least unit tests - the [demo recording scripts](resources/demos/README.md) provide reproducible scenarios for visual testing.
+- [x] Make it work with something like tiling when the window occupies the whole top horizontally - proximity mode detects tiled windows near or touching the panel, resolving the styling issue without relying on maximized-window detection.
# Credits
This extension is a fork of [the Original Gradient Top Bar extension](https://extensions.gnome.org/extension/1264/gradient-top-bar/) by [Julien/jpec](https://peclu.net/).
diff --git a/package.json b/package.json
index 72c1137..f535d3e 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,9 @@
"author": "Petar Vasilev ",
"type": "module",
"scripts": {
+ "demo": "bash resources/demos/scripts/record.sh",
+ "demo:check": "bash resources/demos/scripts/check.sh",
+ "demo:export": "bash resources/demos/scripts/export.sh",
"nested-wayland": "dbus-run-session -- gnome-shell --devkit --wayland",
"pref-debug": "journalctl -f -o cat /usr/bin/gjs",
"open-prefs": "gnome-extensions prefs gradienttopbar@pshow.org",
diff --git a/resources/demos/README.md b/resources/demos/README.md
new file mode 100644
index 0000000..a62894f
--- /dev/null
+++ b/resources/demos/README.md
@@ -0,0 +1,70 @@
+# Demo recordings
+
+The recorder in `scripts/` reproduces the extension demos in a disposable GNOME Shell 51 Wayland devkit session. It uses `wallpaper.jpg`, a vertical black-to-transparent normal panel, and a horizontal gold-to-teal alternate panel.
+
+Final MP4s, the wallpaper, and scripts belong in Git. Generated masters and logs live under `output/`, which is ignored. Extension packaging includes only `src/`, so none of these demo assets enter the extension ZIP.
+
+## Requirements
+
+Recording needs GNOME Shell 51 with Mutter devkit, GJS, D-Bus tools, `glib-compile-schemas`, working PipeWire screencasting, and GStreamer's `x264enc` and `matroskamux` plugins. Run from an existing Wayland desktop. The preferences recorder uses the GNOME Shell libraries installed under `/usr/lib/gnome-shell`.
+
+Exporting needs FFmpeg with `libx264`. Validation also needs Node.js, Yarn, `gst-launch-1.0`, and `ffprobe`. No additional Node or Python packages are needed for recording.
+
+## Record
+
+From the project root:
+
+```sh
+yarn demo all
+```
+
+`scripts/record.sh` records the six scenarios sequentially and stops on the first failure. Omitting the scenario also records all six. Each run creates `output//masters/` and `output//logs/`. To choose the run directory:
+
+```sh
+yarn demo all resources/demos/output/my-run
+```
+
+Run one scenario by name. The optional second argument is a run directory:
+
+```sh
+yarn demo proximity
+yarn demo tiling resources/demos/output/tiling-run
+yarn demo preferences resources/demos/output/preferences-run
+```
+
+| Scenario | Approximate length | What it records |
+| --- | --- | --- |
+| `proximity` | 6 seconds | A window approaches the panel, blends into the alternate style, then moves away. |
+| `touch` | 4 seconds | With a 0 px distance, an unmaximized window touches the panel and moves away. |
+| `workspace` | 6 seconds | A native workspace swipe moves between alternate and normal panel styles, then back. |
+| `maximized` | 4 seconds | A window maximizes and restores. |
+| `tiling` | 4–5 seconds | With proximity at 0 px, a floating window snaps to the left half, touches the panel and activates the alternate style, then restores. |
+| `preferences` | 5 seconds | The actual Behaviour page switches to proximity, sets the distance to 0 and 100 px, and enables the gradual transition. |
+
+The recorder checks the private Shell PID, D-Bus bus, extension path, and Wayland display before controlling the session. Settings use private XDG directories, and cleanup closes the private Shell. The printed `/tmp/gradienttopbar-demo.*` profile is retained for troubleshooting; session and Shell logs are also saved in the run's `logs/` folder.
+
+The tiling demo uses GNOME's native Super+Left shortcut, sent through a virtual keyboard on the private Shell's seat. After untiling, it moves the window away from the panel so the normal style returns. It checks the snapped and restored window geometry and stops if either operation fails. No third-party tiling extension is needed. Watch the recorded [tiling clip](tiling.mp4).
+
+Capture uses the built-in GNOME recorder at native 1024×640 resolution, requesting 30 fps. H.264 encoding uses zero quantization, 4:4:4 color, the ultrafast preset, and two encoder threads. Encoding is lossless after conversion to YUV color. The original recording is copied into `masters/` without resizing or re-encoding.
+
+The preferences scenario is included, but its clip is still pending: the previous run stopped before capture because the private monitor reported 480×480 instead of 1024×640. The monitor check remains enforced.
+
+## Export for the README
+
+Export the available masters without starting a desktop session:
+
+```sh
+yarn demo:export resources/demos/output/my-run resources/demos/output/my-run/review
+```
+
+The exporter creates native-resolution H.264 MP4s at 30 fps with CRF 14 and 4:2:0 color for browser playback. The main README embeds each MP4 with a video element and a direct link for renderers that do not support it. Encoding uses two threads and a lower scheduling priority.
+
+Review the exports, then replace the corresponding MP4 files directly under `resources/demos/`. If the export directory is omitted, that is the default destination. Existing exports and masters are never overwritten by the scripts. Only scenarios with a master are exported.
+
+## Validate without recording
+
+```sh
+yarn demo:check
+```
+
+This checks Bash syntax and JavaScript lint, verifies the lossless encoder with synthetic frames, checks MP4 export metadata and output files, and verifies that the exporter refuses to overwrite an existing file. It does not launch GNOME or capture the desktop.
diff --git a/resources/demos/maximized.mp4 b/resources/demos/maximized.mp4
new file mode 100644
index 0000000..a94d2ac
Binary files /dev/null and b/resources/demos/maximized.mp4 differ
diff --git a/resources/demos/proximity.mp4 b/resources/demos/proximity.mp4
new file mode 100644
index 0000000..163527b
Binary files /dev/null and b/resources/demos/proximity.mp4 differ
diff --git a/resources/demos/scripts/check.sh b/resources/demos/scripts/check.sh
new file mode 100755
index 0000000..8fd6b58
--- /dev/null
+++ b/resources/demos/scripts/check.sh
@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+# Syntax, lint, and a synthetic encoder check: never start GNOME or capture the desktop.
+set -euo pipefail
+script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
+for script in "$script_dir/"*.sh; do
+ bash -n "$script"
+done
+for script in "$script_dir/"*.js; do
+ node --check "$script"
+done
+cd -- "$script_dir/../../.."
+yarn exec eslint resources/demos/scripts/*.js
+
+# Exercise the recorder's actual pipeline and compare its decoded pixels with the input.
+pipeline=$(node --input-type=module - "$script_dir/record.js" <<'JS'
+import { readFileSync } from 'node:fs';
+const source = readFileSync(process.argv[2], 'utf8');
+const match = source.match(/'pipeline': new GLib\.Variant\('s', '([^']+)'\)/);
+if (!match) throw new Error('Recording pipeline not found');
+process.stdout.write(match[1]);
+JS
+)
+read -r -a pipeline_elements <<<"$pipeline"
+test_dir=$(mktemp -d /tmp/gradienttopbar-quality.XXXXXX)
+trap 'rm -rf -- "$test_dir"' EXIT
+nice -n 10 timeout 20s gst-launch-1.0 -q \
+ videotestsrc num-buffers=8 pattern=snow \
+ ! video/x-raw,format=Y444,width=320,height=200,framerate=30/1 ! tee name=t \
+ t. ! queue ! filesink location="$test_dir/source.yuv" \
+ t. ! queue ! "${pipeline_elements[@]}" ! filesink location="$test_dir/lossless.mkv"
+nice -n 10 ffmpeg -hide_banner -loglevel error -n -threads 2 -i "$test_dir/lossless.mkv" \
+ -pix_fmt yuv444p -f rawvideo "$test_dir/decoded.yuv"
+cmp -- "$test_dir/source.yuv" "$test_dir/decoded.yuv"
+
+mkdir -- "$test_dir/masters"
+# The new tiling scenario must export through the same pipeline as the other demos.
+mv -- "$test_dir/lossless.mkv" "$test_dir/masters/tiling.mkv"
+bash "$script_dir/export.sh" "$test_dir" "$test_dir/exports"
+ffprobe -v error -select_streams v:0 \
+ -show_entries stream=codec_name,pix_fmt,width,height,nb_frames,r_frame_rate -of json \
+ "$test_dir/exports/tiling.mp4" >"$test_dir/export.json"
+node --input-type=module - "$test_dir/export.json" "$test_dir/exports" <<'JS'
+import assert from 'node:assert/strict';
+import { readFileSync, readdirSync } from 'node:fs';
+const [video] = JSON.parse(readFileSync(process.argv[2], 'utf8')).streams;
+assert.deepEqual(video, {
+ codec_name: 'h264', width: 320, height: 200, pix_fmt: 'yuv420p',
+ r_frame_rate: '30/1', nb_frames: '8'
+});
+assert.deepEqual(readdirSync(process.argv[3]), ['tiling.mp4'], 'Export must produce only an MP4');
+JS
+cp -- "$test_dir/exports/tiling.mp4" "$test_dir/unchanged.mp4"
+if bash "$script_dir/export.sh" "$test_dir" "$test_dir/exports" >"$test_dir/overwrite.log" 2>&1; then
+ printf 'Exporter unexpectedly accepted an existing output.\n' >&2; exit 1;
+fi
+cmp -- "$test_dir/unchanged.mp4" "$test_dir/exports/tiling.mp4"
+printf 'Demo syntax, lint, lossless encoder, and MP4 export checks passed. No desktop sessions or captures started.\n'
diff --git a/resources/demos/scripts/export.sh b/resources/demos/scripts/export.sh
new file mode 100644
index 0000000..7044058
--- /dev/null
+++ b/resources/demos/scripts/export.sh
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+# Export available lossless masters as MP4s for the README.
+set -euo pipefail
+
+script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
+[[ $# -ge 1 && $# -le 2 ]] || { printf 'Usage: %s RUN_DIRECTORY [export-directory]\n' "$0" >&2; exit 1; }
+run_dir=$(realpath -e -- "$1")
+output_dir=$(realpath -m -- "${2:-$script_dir/..}")
+command -v ffmpeg >/dev/null || { printf 'Missing command: ffmpeg\n' >&2; exit 1; }
+shopt -s nullglob
+masters=("$run_dir/masters/"*.mkv)
+[[ ${#masters[@]} -gt 0 ]] || { printf 'No masters found in %s/masters\n' "$run_dir" >&2; exit 1; }
+
+for master in "${masters[@]}"; do
+ name=$(basename -- "$master" .mkv)
+ case $name in
+ proximity|touch|workspace|maximized|tiling|preferences) ;;
+ *) printf 'Unknown demo master: %s\n' "$master" >&2; exit 1 ;;
+ esac
+ [[ ! -e $output_dir/$name.mp4 && ! -L $output_dir/$name.mp4 ]] || {
+ printf 'Refusing to overwrite %s/%s.mp4\n' "$output_dir" "$name" >&2; exit 1;
+ }
+done
+mkdir -p -- "$output_dir"
+staging_dir=$(mktemp -d "$run_dir/export.XXXXXX")
+trap 'rm -rf -- "$staging_dir"' EXIT
+for master in "${masters[@]}"; do
+ name=$(basename -- "$master" .mkv)
+ nice -n 10 ffmpeg -hide_banner -loglevel error -n -threads 2 -i "$master" \
+ -map 0:v:0 -vf fps=30 -c:v libx264 -preset slow -crf 14 -pix_fmt yuv420p \
+ -threads 2 -an -map_metadata -1 -movflags +faststart "$staging_dir/$name.mp4"
+ cp --update=none-fail -- "$staging_dir/$name.mp4" "$output_dir/$name.mp4"
+ printf 'Exported %s/%s.mp4\n' "$output_dir" "$name"
+done
diff --git a/resources/demos/scripts/record.js b/resources/demos/scripts/record.js
new file mode 100644
index 0000000..f7cac85
--- /dev/null
+++ b/resources/demos/scripts/record.js
@@ -0,0 +1,306 @@
+/* eslint-disable no-await-in-loop -- Animation steps and UI readiness checks run sequentially. */
+import Gio from 'gi://Gio';
+import GLib from 'gi://GLib';
+import Gtk from 'gi://Gtk?version=4.0';
+
+const [expectedPid, profile, scenario] = ARGV;
+const scenarios = ['proximity', 'touch', 'workspace', 'maximized', 'tiling', 'preferences'];
+const desktopWidth = 1024;
+const desktopHeight = 640;
+// A half-screen tile needs a floating window narrower than half the desktop.
+const demoWindowWidth = scenario === 'tiling' ? 440 : 620;
+const demoWindowX = Math.round((desktopWidth - demoWindowWidth) / 2);
+if (ARGV.length !== 3 || !/^\d+$/.test(expectedPid ?? '') ||
+ !profile?.startsWith('/tmp/gradienttopbar-demo.') || !scenarios.includes(scenario) ||
+ GLib.getenv('XDG_CONFIG_HOME') !== `${profile}/config` ||
+ GLib.getenv('XDG_DATA_HOME') !== `${profile}/data` ||
+ !GLib.getenv('DBUS_SESSION_BUS_ADDRESS') ||
+ GLib.getenv('DBUS_SESSION_BUS_ADDRESS') === GLib.getenv('demo_parent_bus')) {
+ printerr('Run this recorder through a named scenario script');
+ imports.system.exit(1);
+}
+const bus = Gio.DBus.session;
+const loop = new GLib.MainLoop(null, false);
+let demoWindow;
+let recording = false;
+let recordingFilename;
+let gestureActive = false;
+let shellModuleLoaded = false;
+let exitCode = 0;
+
+function call(name, path, iface, method, args, signature) {
+ return new Promise((resolve, reject) => {
+ bus.call(name, path, iface, method, args,
+ new GLib.VariantType(signature), Gio.DBusCallFlags.NONE, 15000, null,
+ (connection, result) => {
+ try {
+ resolve(connection.call_finish(result).deep_unpack());
+ } catch (error) {
+ reject(error);
+ }
+ });
+ });
+}
+
+function pause(milliseconds) {
+ return new Promise(resolve => GLib.timeout_add(GLib.PRIORITY_DEFAULT, milliseconds, () => {
+ resolve();
+ return GLib.SOURCE_REMOVE;
+ }));
+}
+
+async function evaluate(code) {
+ const [ok, result] = await call('org.gnome.Shell', '/org/gnome/Shell',
+ 'org.gnome.Shell', 'Eval', new GLib.Variant('(s)', [code]), '(bs)');
+ if (!ok)
+ throw new Error(`Private Shell Eval failed: ${result}`);
+ return result ? JSON.parse(result) : null;
+}
+
+// Eval is only the transport: all Shell operations live in a normal JS module.
+function shellCall(action, ...args) {
+ return evaluate(`global.demoActions[${JSON.stringify(action)}](...${JSON.stringify(args)})`);
+}
+
+async function stopRecording() {
+ const [ok] = await call('org.gnome.Shell.Screencast', '/org/gnome/Shell/Screencast',
+ 'org.gnome.Shell.Screencast', 'StopScreencast', null, '(b)');
+ recording = false;
+ if (!ok)
+ throw new Error('Private screencast did not stop successfully');
+}
+
+async function startRecording() {
+ const [ok, filename] = await call('org.gnome.Shell.Screencast', '/org/gnome/Shell/Screencast',
+ 'org.gnome.Shell.Screencast', 'ScreencastArea',
+ new GLib.Variant('(iiiisa{sv})', [0, 0, desktopWidth, desktopHeight, `${profile}/${scenario}`, {
+ 'draw-cursor': new GLib.Variant('b', false),
+ 'framerate': new GLib.Variant('i', 30),
+ 'pipeline': new GLib.Variant('s', 'videoconvert ! video/x-raw,format=Y444 ! x264enc pass=quant quantizer=0 speed-preset=ultrafast tune=zerolatency threads=2 ! matroskamux')
+ }]), '(bs)');
+ if (!ok)
+ throw new Error('Private screencast failed to start');
+ recording = true;
+ recordingFilename = filename;
+ print(`Recording ${filename}`);
+}
+
+async function preparePrivateSession() {
+ const [pid] = await call('org.freedesktop.DBus', '/org/freedesktop/DBus',
+ 'org.freedesktop.DBus', 'GetConnectionUnixProcessID',
+ new GLib.Variant('(s)', ['org.gnome.Shell']), '(u)');
+ if (pid !== Number(expectedPid))
+ throw new Error(`Refusing to control Shell PID ${pid}; expected private PID ${expectedPid}`);
+
+ await pause(2500);
+ const moduleUri = Gio.File.new_for_uri(import.meta.url).get_parent().get_child('shell-actions.js').get_uri();
+ await evaluate(`import(${JSON.stringify(moduleUri)}).then(module => { global.demoActions = module; return true; })`);
+ shellModuleLoaded = true;
+ const { extension, monitor, display } = await shellCall('getSessionInfo');
+ if (extension.state !== 1 || extension.path !== `${profile}/extensions-data/gnome-shell/extensions/gradienttopbar@pshow.org`)
+ throw new Error(`Private extension is not active: ${JSON.stringify(extension)}`);
+ if (monitor.width !== desktopWidth || monitor.height !== desktopHeight)
+ throw new Error(`Expected a ${desktopWidth}×${desktopHeight} private monitor: ${JSON.stringify(monitor)}`);
+ if (!display || display === GLib.getenv('demo_host_display'))
+ throw new Error('A separate private Wayland display is required');
+ GLib.setenv('WAYLAND_DISPLAY', display, true);
+ print(`Controlling private Shell PID ${pid} on ${display}`);
+
+ Gtk.init();
+}
+
+async function placeWindow(title, x, y, width, height) {
+ for (let attempt = 0; attempt < 50; attempt++) {
+ const found = await shellCall('placeWindow', title, x, y, width, height);
+ if (found) {
+ await pause(500);
+ return;
+ }
+ await pause(100);
+ }
+ throw new Error(`Window missing from private Shell: ${title}`);
+}
+
+async function showDemoWindow() {
+ const labels = {
+ proximity: 'Proximity blend\n\nWatch the panel change as this window approaches.',
+ touch: 'Touch detection · 0 px\n\nTouch the panel to apply the alternate style.',
+ workspace: 'Workspace transition\n\nSwipe between different panel styles.',
+ maximized: 'Maximized mode\n\nMaximize and restore to switch panel styles.',
+ tiling: 'Tiling · proximity mode · 0 px\n\nSnap left to change the panel style.'
+ };
+ demoWindow = new Gtk.Window({ title: 'Gradient Top Bar Demo', default_width: demoWindowWidth, default_height: 360 });
+ demoWindow.set_child(new Gtk.Label({
+ label: labels[scenario],
+ justify: Gtk.Justification.CENTER, margin_top: 40, margin_bottom: 40,
+ margin_start: 40, margin_end: 40
+ }));
+ demoWindow.present();
+ const panelHeight = await shellCall('getPanelHeight');
+ let gap = 130;
+ if (scenario === 'touch')
+ gap = 45;
+ if (scenario === 'workspace')
+ gap = 0;
+ await placeWindow('Gradient Top Bar Demo', demoWindowX, panelHeight + gap, demoWindowWidth, 360);
+ await shellCall('useFirstWorkspace');
+ if (scenario === 'tiling')
+ await shellCall('activateWindow');
+ await pause(500);
+}
+
+async function toggleLeftTiling() {
+ await shellCall('toggleLeftTiling');
+ await pause(550);
+}
+
+async function recordTiling() {
+ await toggleLeftTiling();
+ await shellCall('checkLeftTiling');
+ await pause(1200);
+ // Untiling restores the size but keeps GNOME's tile origin; move back away from the panel.
+ await toggleLeftTiling();
+ await shellCall('moveWindow', demoWindowX, 130);
+ await pause(300);
+ await shellCall('checkRestoredWindow', demoWindowX, demoWindowWidth);
+ await pause(1100);
+}
+
+async function moveWindow(startGap, endGap, steps, interval) {
+ for (let step = 0; step <= steps; step++) {
+ const gap = Math.round(startGap + (endGap - startGap) * step / steps);
+ await shellCall('moveWindow', demoWindowX, gap);
+ await pause(interval);
+ }
+}
+
+async function swipeWorkspace(targetIndex) {
+ gestureActive = true;
+ const { start, end } = await shellCall('beginWorkspaceSwipe', targetIndex, desktopWidth / 2, desktopHeight / 2);
+ const steps = 55;
+ for (let step = 0; step < steps; step++) {
+ await shellCall('updateWorkspaceSwipe', (end - start) / steps);
+ await pause(25);
+ }
+ await pause(150);
+ await shellCall('endWorkspaceSwipe');
+ await pause(500);
+ const index = await shellCall('getWorkspaceIndex');
+ if (index !== targetIndex)
+ throw new Error(`Workspace gesture ended on ${index}, expected ${targetIndex}`);
+ gestureActive = false;
+}
+
+function findRow(widget, title) {
+ if (widget.get_title?.() === title)
+ return widget;
+ for (let child = widget.get_first_child(); child; child = child.get_next_sibling()) {
+ const found = findRow(child, title);
+ if (found)
+ return found;
+ }
+ return null;
+}
+
+async function showPreferences() {
+ Gio.Resource.load('/usr/share/gnome-shell/org.gnome.Shell.Extensions.src.gresource')._register();
+ imports.package.initFormat();
+ const Adw = (await import('gi://Adw?version=1')).default;
+ Adw.init();
+ const { extensionManager } = await import('resource:///org/gnome/Shell/Extensions/js/extensionsService.js');
+ const { ExtensionPrefsDialog } = await import('resource:///org/gnome/Shell/Extensions/js/extensionPrefsDialog.js');
+ const [serialized] = await call('org.gnome.Shell', '/org/gnome/Shell', 'org.gnome.Shell.Extensions',
+ 'GetExtensionInfo', new GLib.Variant('(s)', ['gradienttopbar@pshow.org']), '(a{sv})');
+ const extension = extensionManager.createExtensionObject(serialized);
+ demoWindow = new ExtensionPrefsDialog(extension);
+ await new Promise(resolve => demoWindow.connect('loaded', resolve));
+ demoWindow.visible_page_name = 'Behavior';
+ if (demoWindow.visible_page_name !== 'Behavior')
+ throw new Error('The actual Behaviour preferences page did not load');
+ demoWindow.set_default_size(880, 560);
+ demoWindow.present();
+ await placeWindow(extension.metadata.name, 72, 50, 880, 560);
+ const trigger = findRow(demoWindow, 'Style trigger');
+ const distance = findRow(demoWindow, 'Proximity distance');
+ const transition = findRow(demoWindow, 'Transition proximity style');
+ if (!trigger || !distance || !transition)
+ throw new Error('The actual preferences controls were not found');
+ return { trigger, distance, transition };
+}
+
+async function recordDemo() {
+ await preparePrivateSession();
+ let controls;
+ if (scenario === 'preferences')
+ controls = await showPreferences();
+ else
+ await showDemoWindow();
+ await startRecording();
+ await pause(650);
+ switch (scenario) {
+ case 'proximity':
+ await moveWindow(130, 0, 50, 35);
+ await pause(500);
+ await moveWindow(0, 130, 50, 35);
+ await pause(500);
+ break;
+ case 'touch':
+ await moveWindow(45, 0, 30, 25);
+ await pause(650);
+ await moveWindow(0, 45, 30, 25);
+ await pause(650);
+ break;
+ case 'workspace':
+ await swipeWorkspace(1);
+ await pause(300);
+ await swipeWorkspace(0);
+ await pause(300);
+ break;
+ case 'maximized':
+ await shellCall('maximizeWindow');
+ await pause(1500);
+ await shellCall('restoreWindow');
+ await pause(1300);
+ break;
+ case 'tiling':
+ await recordTiling();
+ break;
+ case 'preferences':
+ controls.trigger.set_selected(1);
+ await pause(1100);
+ controls.distance.set_value(0);
+ await pause(1000);
+ controls.distance.set_value(100);
+ await pause(1100);
+ controls.transition.set_active(true);
+ await pause(1100);
+ break;
+ }
+ await stopRecording();
+ GLib.file_set_contents(`${profile}/recording.path`, recordingFilename);
+ print(`Demo complete: ${scenario}`);
+}
+
+recordDemo().catch(async error => {
+ printerr(`${error.message}\n${error.stack ?? ''}`);
+ exitCode = 1;
+ if (recording) {
+ try {
+ await stopRecording();
+ } catch (stopError) {
+ printerr(`Cleanup: ${stopError}`);
+ }
+ }
+}).finally(async () => {
+ if (shellModuleLoaded) {
+ try {
+ await shellCall('cleanup', gestureActive);
+ } catch (error) {
+ printerr(`Shell cleanup: ${error}`);
+ }
+ }
+ demoWindow?.close();
+ loop.quit();
+});
+loop.run();
+imports.system.exit(exitCode);
diff --git a/resources/demos/scripts/record.sh b/resources/demos/scripts/record.sh
new file mode 100755
index 0000000..6bed614
--- /dev/null
+++ b/resources/demos/scripts/record.sh
@@ -0,0 +1,129 @@
+#!/usr/bin/env bash
+# Record one demo or all demos in disposable GNOME Wayland devkit sessions.
+# Usage: ./record.sh [SCENARIO|all] [run-directory] (defaults to all).
+set -euo pipefail
+
+script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
+demo_dir=$(cd -- "$script_dir/.." && pwd)
+extension_schema=org.gnome.shell.extensions.org.pshow.gradienttopbar
+
+if [[ ${1:-} == --session ]]; then
+ [[ $# == 3 ]] || exit 1
+ profile=$2
+ scenario=$3
+ [[ $profile == /tmp/gradienttopbar-demo.* && -d $profile &&
+ ${XDG_CONFIG_HOME:-} == "$profile/config" && ${XDG_DATA_HOME:-} == "$profile/data" &&
+ -n ${DBUS_SESSION_BUS_ADDRESS:-} && ${DBUS_SESSION_BUS_ADDRESS:-} != "${demo_parent_bus:-}" ]] || {
+ printf 'Refusing to configure settings outside the private demo session.\n' >&2; exit 1;
+ }
+ wallpaper_uri=$(gjs -c 'print(imports.gi.Gio.File.new_for_path(ARGV[0]).get_uri())' "$demo_dir/wallpaper.jpg")
+
+ # All settings below live on the private session bus and in its private XDG directories.
+ gsettings set org.gnome.shell allow-extension-installation false
+ gsettings set org.gnome.shell enabled-extensions "['gradienttopbar@pshow.org']"
+ gsettings set org.gnome.shell welcome-dialog-last-shown-version "$(gnome-shell --version | awk '{print $3}')"
+ gsettings set org.gnome.desktop.screensaver lock-enabled false
+ gsettings set org.gnome.desktop.session idle-delay 0
+ gsettings set org.gnome.mutter dynamic-workspaces false
+ gsettings set org.gnome.desktop.wm.preferences num-workspaces 2
+ gsettings set org.gnome.desktop.background picture-uri "$wallpaper_uri"
+ gsettings set org.gnome.desktop.background picture-uri-dark "$wallpaper_uri"
+ gsettings set org.gnome.desktop.background picture-options zoom
+ style_trigger=proximity
+ proximity_distance=100
+ proximity_transition=false
+ case $scenario in
+ proximity) proximity_transition=true ;;
+ touch|workspace|tiling) proximity_distance=0 ;;
+ maximized|preferences) style_trigger=maximized ;;
+ *) printf 'Unknown scenario: %s\n' "$scenario" >&2; exit 1 ;;
+ esac
+ if [[ $scenario == tiling ]]; then
+ gsettings set org.gnome.mutter.keybindings toggle-tiled-left "['Left']"
+ fi
+ gsettings set "$extension_schema" style-trigger "$style_trigger"
+ gsettings set "$extension_schema" proximity-distance "$proximity_distance"
+ gsettings set "$extension_schema" proximity-transition "$proximity_transition"
+ gsettings set "$extension_schema" maximized-behavior apply-style
+ gsettings set "$extension_schema" gradient-direction vertical
+ gsettings set "$extension_schema" colors "['rgba(0, 0, 0, 1)', 'rgba(0, 0, 0, 0)']"
+ gsettings set "$extension_schema" maximized-gradient-direction horizontal
+ gsettings set "$extension_schema" maximized-colors "['rgba(157, 115, 53, 1)', 'rgba(61, 105, 114, 1)']"
+
+ gnome-shell --devkit --devkit-args='--monitor-size=1024x640' --wayland --no-x11 --unsafe-mode --force-animations >"$profile/shell.log" 2>&1 &
+ shell_pid=$!
+ cleanup() {
+ kill -TERM "$shell_pid" 2>/dev/null || true
+ wait "$shell_pid" 2>/dev/null || true
+ }
+ trap cleanup EXIT
+ trap 'exit 130' INT TERM HUP
+ gdbus wait --session --timeout 25 org.gnome.Shell
+ GDK_BACKEND=wayland \
+ GI_TYPELIB_PATH="/usr/lib/gnome-shell/girepository-1.0${GI_TYPELIB_PATH:+:$GI_TYPELIB_PATH}" \
+ LD_LIBRARY_PATH="/usr/lib/gnome-shell${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \
+ gjs -m "$script_dir/record.js" "$shell_pid" "$profile" "$scenario"
+ exit
+fi
+
+[[ $# -le 2 ]] || { printf 'Usage: %s [SCENARIO|all] [run-directory]\n' "$0" >&2; exit 1; }
+scenario=${1:-all}
+if [[ $scenario == all ]]; then
+ run_dir=$(realpath -m -- "${2:-$demo_dir/output/$(date +%Y%m%d-%H%M%S)}")
+ scenarios=(proximity touch workspace maximized tiling preferences)
+
+ # Check every destination before starting the first private session.
+ for scenario in "${scenarios[@]}"; do
+ [[ ! -e $run_dir/masters/$scenario.mkv && ! -L $run_dir/masters/$scenario.mkv ]] || {
+ printf 'Refusing to overwrite %s/masters/%s.mkv\n' "$run_dir" "$scenario" >&2; exit 1;
+ }
+ done
+ mkdir -p -- "$run_dir/masters" "$run_dir/logs"
+ printf 'Saving %s lossless demo masters in %s\n' "${#scenarios[@]}" "$run_dir"
+ for scenario in "${scenarios[@]}"; do
+ bash "$script_dir/record.sh" "$scenario" "$run_dir" || exit 1
+ done 2>&1 | tee "$run_dir/logs/recording.log"
+ printf 'All %s demos saved in %s\n' "${#scenarios[@]}" "$run_dir"
+ exit
+fi
+case $scenario in
+ proximity|touch|workspace|maximized|tiling|preferences) ;;
+ *) printf 'Unknown scenario: %s\n' "$scenario" >&2; exit 1 ;;
+esac
+[[ -n ${WAYLAND_DISPLAY:-} && -n ${XDG_RUNTIME_DIR:-} ]] || {
+ printf 'Run from an existing Wayland desktop session.\n' >&2; exit 1;
+}
+for command in gnome-shell gjs gsettings gdbus dbus-run-session glib-compile-schemas gst-inspect-1.0 timeout; do
+ command -v "$command" >/dev/null || { printf 'Missing command: %s\n' "$command" >&2; exit 1; }
+done
+for plugin in x264enc matroskamux; do
+ gst-inspect-1.0 "$plugin" >/dev/null || { printf 'Missing GStreamer plugin: %s\n' "$plugin" >&2; exit 1; }
+done
+run_dir=$(realpath -m -- "${2:-$demo_dir/output/$(date +%Y%m%d-%H%M%S)}")
+output="$run_dir/masters/$scenario.mkv"
+log_dir="$run_dir/logs"
+[[ ! -e $output && ! -L $output ]] || {
+ printf 'Refusing to overwrite %s\n' "$output" >&2; exit 1;
+}
+[[ -f $demo_dir/wallpaper.jpg ]] || { printf 'Missing wallpaper.jpg\n' >&2; exit 1; }
+mkdir -p -- "$run_dir/masters" "$log_dir"
+
+profile=$(mktemp -d /tmp/gradienttopbar-demo.XXXXXX)
+printf 'Private profile and logs: %s\n' "$profile"
+trap 'if [[ -f $profile/shell.log ]]; then cp -- "$profile/shell.log" "$log_dir/$scenario-shell.log"; fi' EXIT
+export XDG_CONFIG_HOME="$profile/config" XDG_DATA_HOME="$profile/data"
+export XDG_CACHE_HOME="$profile/cache" XDG_STATE_HOME="$profile/state"
+export XDG_DATA_DIRS="$profile/extensions-data:/usr/local/share:/usr/share"
+export GTK_A11Y=none GSETTINGS_BACKEND=dconf GIO_USE_VFS=local LC_ALL=C.UTF-8
+export demo_parent_bus=${DBUS_SESSION_BUS_ADDRESS:-}
+export demo_host_display=$WAYLAND_DISPLAY
+extension_dir="$profile/extensions-data/gnome-shell/extensions/gradienttopbar@pshow.org"
+mkdir -p "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_CACHE_HOME" "$XDG_STATE_HOME" "$extension_dir"
+cp -a -- "$script_dir/../../../src/." "$extension_dir/"
+export GSETTINGS_SCHEMA_DIR="$extension_dir/schemas"
+glib-compile-schemas --strict "$GSETTINGS_SCHEMA_DIR"
+# The temporary system-data path lets the extension load with online updates disabled.
+nice -n 10 timeout --signal=TERM --kill-after=5s 75s dbus-run-session -- bash "$script_dir/record.sh" --session "$profile" "$scenario" 2>&1 | tee "$log_dir/$scenario.log"
+recording=$(cat "$profile/recording.path")
+cp --update=none-fail -- "$recording" "$output"
+printf 'Saved %s demo: %s\n' "$scenario" "$output"
diff --git a/resources/demos/scripts/shell-actions.js b/resources/demos/scripts/shell-actions.js
new file mode 100644
index 0000000..ed9d2d2
--- /dev/null
+++ b/resources/demos/scripts/shell-actions.js
@@ -0,0 +1,124 @@
+// Loaded inside the verified private GNOME Shell, never in the recorder's GTK process.
+import Clutter from 'gi://Clutter';
+import GLib from 'gi://GLib';
+
+import * as Main from 'resource:///org/gnome/shell/ui/main.js';
+
+let demoWindow;
+let demoKeyboard;
+
+export function getSessionInfo() {
+ Main.overview.hide();
+ const extension = Main.extensionManager.lookup('gradienttopbar@pshow.org');
+ const monitor = Main.layoutManager.primaryMonitor;
+ return {
+ extension: { state: extension?.state, path: extension?.path },
+ monitor: { width: monitor.width, height: monitor.height },
+ display: GLib.getenv('WAYLAND_DISPLAY')
+ };
+}
+
+export function getPanelHeight() {
+ return Math.round(Main.panel.height);
+}
+
+export function placeWindow(title, x, y, width, height) {
+ demoWindow = global.get_window_actors().map(actor => actor.meta_window)
+ .find(window => window.get_title() === title);
+ if (!demoWindow)
+ return false;
+ demoWindow.move_resize_frame(false, x, y, width, height);
+ return true;
+}
+
+export function useFirstWorkspace() {
+ demoWindow.change_workspace_by_index(0, false);
+ global.workspace_manager.get_workspace_by_index(0).activate(0);
+}
+
+export function activateWindow() {
+ demoWindow.activate(global.get_current_time());
+}
+
+export function moveWindow(x, gap) {
+ demoWindow.move_frame(false, x, getPanelHeight() + gap);
+}
+
+export function toggleLeftTiling() {
+ // Send GNOME's native snap shortcut to the private Shell's own seat.
+ if (global.display.get_focus_window() !== demoWindow)
+ throw new Error('The private demo window must have focus before tiling');
+ demoKeyboard ??= global.stage.get_context().get_backend().get_default_seat()
+ .create_virtual_device(Clutter.InputDeviceType.KEYBOARD_DEVICE);
+ const time = GLib.get_monotonic_time();
+ demoKeyboard.notify_keyval(time, Clutter.KEY_Super_L, Clutter.KeyState.PRESSED);
+ try {
+ demoKeyboard.notify_keyval(time + 1000, Clutter.KEY_Left, Clutter.KeyState.PRESSED);
+ } finally {
+ demoKeyboard.notify_keyval(time + 2000, Clutter.KEY_Left, Clutter.KeyState.RELEASED);
+ demoKeyboard.notify_keyval(time + 3000, Clutter.KEY_Super_L, Clutter.KeyState.RELEASED);
+ }
+}
+
+export function checkLeftTiling() {
+ const frame = demoWindow.get_frame_rect();
+ const workArea = demoWindow.get_work_area_for_monitor(demoWindow.get_monitor());
+ const close = (actual, expected) => Math.abs(actual - expected) <= 2;
+ if (!close(frame.x, workArea.x) || !close(frame.y, workArea.y) ||
+ !close(frame.width, workArea.width / 2) || !close(frame.height, workArea.height) ||
+ frame.y > Main.layoutManager.primaryMonitor.y + Main.panel.height ||
+ demoWindow.is_maximized())
+ throw new Error('The private window did not snap to the left half and touch the panel');
+}
+
+export function checkRestoredWindow(x, width) {
+ const frame = demoWindow.get_frame_rect();
+ if (Math.abs(frame.x - x) > 2 || Math.abs(frame.width - width) > 2 ||
+ frame.y <= Main.layoutManager.primaryMonitor.y + Main.panel.height)
+ throw new Error('The private tiled window did not restore away from the panel');
+}
+
+export function beginWorkspaceSwipe(targetIndex, x, y) {
+ // Use GNOME 51's native gesture path so the extension receives swipe progress.
+ const animation = Main.wm._workspaceAnimation;
+ const gesture = animation?._swipeTracker?._touchpadGesture;
+ if (!gesture)
+ throw new Error('Native workspace gesture is unavailable');
+ gesture.emit('begin', Math.floor(GLib.get_monotonic_time() / 1000) >>> 0, x, y);
+ const group = animation._switchData?.baseMonitorGroup;
+ if (!group)
+ throw new Error('Private workspace gesture did not begin');
+ return {
+ start: group.progress,
+ end: group.getWorkspaceProgress(global.workspace_manager.get_workspace_by_index(targetIndex))
+ };
+}
+
+export function updateWorkspaceSwipe(delta) {
+ Main.wm._workspaceAnimation._swipeTracker._touchpadGesture.emit('update',
+ Math.floor(GLib.get_monotonic_time() / 1000) >>> 0, delta, 1.0);
+}
+
+export function endWorkspaceSwipe() {
+ Main.wm._workspaceAnimation._swipeTracker._touchpadGesture.emit('end',
+ Math.floor(GLib.get_monotonic_time() / 1000) >>> 0, 1.0);
+}
+
+export function getWorkspaceIndex() {
+ return global.workspace_manager.get_active_workspace_index();
+}
+
+export function maximizeWindow() {
+ demoWindow.maximize(3);
+}
+
+export function restoreWindow() {
+ demoWindow.unmaximize(3);
+}
+
+export function cleanup(interruptGesture) {
+ demoKeyboard = null;
+ if (interruptGesture)
+ Main.wm._workspaceAnimation?._swipeTracker?._interrupt();
+ demoWindow = null;
+}
diff --git a/resources/demos/tiling.mp4 b/resources/demos/tiling.mp4
new file mode 100644
index 0000000..2c054d4
Binary files /dev/null and b/resources/demos/tiling.mp4 differ
diff --git a/resources/demos/touch.mp4 b/resources/demos/touch.mp4
new file mode 100644
index 0000000..f95b349
Binary files /dev/null and b/resources/demos/touch.mp4 differ
diff --git a/resources/demos/wallpaper.jpg b/resources/demos/wallpaper.jpg
new file mode 100644
index 0000000..efd5c9f
Binary files /dev/null and b/resources/demos/wallpaper.jpg differ
diff --git a/resources/demos/workspace.mp4 b/resources/demos/workspace.mp4
new file mode 100644
index 0000000..ee4871a
Binary files /dev/null and b/resources/demos/workspace.mp4 differ