Skip to content

Looks

List, inspect and swap the desktop’s looks. A look is presentation only (colors, fonts, opacity, shape, cursor, the wallpaper): one choice themes the compositor, the bar and the wallpaper together. The format is documented; the five shipped looks are listed under Theming. No step here needs Nix: a plain binary install lists, previews, applies and authors looks with these commands alone, once the catalog has looks in it.

The catalog is the union of the source directories, in fixed order: your own looks, installed looks, home-manager looks, system looks, then the shipped ones. Shipped looks come from $SCOOT_LOOKS_DIR, then share/scoot/looks beside the binary (a release tarball lays them there), then /usr/local and /usr. A name that exists twice resolves to the earliest; list marks the later one shadowed. An empty catalog is never an error in itself: list prints no looks found, and every other command names the fix (set SCOOT_LOOKS_DIR to a looks/ directory, or install a handed-over tarball as below).

There are no per-command help pages: scoot look apply --help refuses. This page and scoot look --help list every command and flag.

Terminal window
scoot look list # the catalog, in search order
scoot look show moonrise # tokens, wallpaper, fonts, contrast
scoot look apply moonrise # render, validate, swap, reload
scoot look next # step through the catalog (wraps)
scoot look prev # step back through the catalog
scoot look random # a look other than the active one
scoot look pick # choose a look in fuzzel, apply it
scoot look apply moonrise --for 20 # preview 20 seconds, then revert
scoot look current # the active look and shadowed keys
scoot look wiring # the one-time setup still missing
scoot look validate ./my-look # check a directory without touching anything
scoot look build ./my-look --out /tmp/my-look # render without applying
scoot look sync # follow the Nix declared default (below)
scoot look new my-look --from moonrise # start a user look extending moonrise
scoot look watch ./my-look # validate and apply on every save
scoot look preview my-look --capture # screenshot the look into preview.png
scoot look auto --light dawn --dark moonrise --schedule 07:00/19:00 # switch by the clock
scoot look auto --light dawn --dark moonrise --sun --latitude 51.5074 --longitude -0.1278 # switch at sunrise and sunset
scoot look auto --light dawn --dark moonrise --follow-portal # follow the desktop's light/dark setting

next and prev step through the catalog in list order, wrapping at either end. random picks a random look other than the active one, and stays when the catalog holds one look. Super+Shift+T steps to the next look; it does nothing while the session is locked. Each step applies through the same transaction as apply, with the same reloads and rollback. Two quick presses can land on the same look: each press picks its target before the swap takes its lock. A look that refuses to apply (a refused fragment or wallpaper; a missing font falls back instead) is skipped, and the step lands on the next look that applies. The skipped looks are reported. A degraded step posts one desktop notification through notify-send (to whichever daemon serves org.freedesktop.Notifications), since the shortcut’s output goes nowhere. Clean steps stay silent.

apply NAME --for SECONDS previews a look and reverts after the delay. A newer apply before the timer fires cancels the revert, so a preview never undoes a newer choice. A preview over a pending preview chains the reverts: each timer restores the choice it replaced, and an older timer stands down once a newer choice lands. The timer is a detached child of the applying CLI, so closing the terminal does not stop it, and neither does a compositor restart (a restart drops only the IPC for_ms timer). If a swap is running when the delay ends, the revert waits for it and retries a bounded number of times. SECONDS is 1 or more. scoot look apply --keep keeps the preview (and cancels the timer); apply NAME --keep applies for good and cancels any pending preview; bare --keep with no pending preview refuses.

scoot look auto swaps between a light look and a dark look on its own. It is off until you turn it on, and there is no daemon: it installs a systemd user unit that runs scoot look apply for you and exits. It needs a systemd user manager. Pick one trigger:

  • --schedule swaps at two times of day.
  • --sun swaps at sunrise and sunset, computed for a place you give.
  • --follow-portal swaps when the desktop’s light/dark setting changes (a settings app, a quick toggle).
Terminal window
scoot look auto --light dawn --dark moonrise --schedule 07:00/19:00
scoot look auto --light dawn --dark moonrise --sun --latitude 51.5074 --longitude -0.1278
scoot look auto --light dawn --dark moonrise --follow-portal
scoot look auto # show what is installed
scoot look auto --off # remove everything it created

Setting up one trigger removes the others, so there is never more than one. Both looks must exist: a name that is not in the catalog your shell sees refuses when you run auto, not at the first swap. A look that is removed later makes that run fail; systemctl --user status scoot-look-auto.service and journalctl --user -u scoot-look-auto show the refusal.

--schedule LIGHT_FROM/DARK_FROM takes two times as HH:MM, 00:00 to 23:59, two digits each, and they must differ. dawn is applied from 07:00 until 19:00 and moonrise from 19:00 until 07:00; either window may cross midnight (22:00/06:00 is light overnight). auto applies the look that is due right now when you run it, so the screen matches the schedule at once, then the timer does the rest.

  • Time zone. Times are the system’s local time. The decision is made from the wall clock when the timer fires, so a clock change cannot leave the wrong look up past the next boundary. On the day a daylight-saving change skips an hour, a boundary inside that hour does not fire; the next boundary decides. When an hour repeats, the boundary fires once.
  • Suspend and shutdown. The timer is persistent: a boundary that passed while the machine was off or you were logged out runs at the next start. If both passed, the run applies the look due now, not the older boundary’s.
  • Your own choices. The schedule is a live choice like apply. A look you pick by hand stays until the next boundary, which applies the scheduled look again. A boundary does nothing when its look is already active, and it cancels a pending --for preview (keeping the scheduled look) instead of leaving its revert to undo the swap.

--sun --latitude N --longitude E applies --light while the Sun is up at that place and --dark after it sets. Both are required with --sun and are decimal degrees: latitude -90 to 90 (north positive), longitude -180 to 180 (east positive), written plainly (51.5074, -0.1278; no +, no exponent, no N/W). Write a negative longitude as --longitude=-0.1278 or as a separate word; both work. auto applies the look that is due right now when you run it, then the timers do the rest.

Terminal window
scoot look auto --light dawn --dark moonrise --sun --latitude 40.7128 --longitude=-74.006
scoot look auto # shows the place, the side due now and the next boundary
  • Where the place comes from. Only from these two flags, stored in the unit like the --schedule times are. Nothing reads your Nix nightlight.latitude/longitude, asks a location service or touches the network. Pass the same numbers to both if you want the night-light and the look to turn over together.
  • What counts as sunrise. The Sun’s upper edge crosses the horizon, with the usual allowance for atmospheric refraction: the convention almanacs print. The position is calculated on the machine; it agrees with the US Naval Observatory’s published times to within a minute. Hills and buildings on your real horizon are not modelled.
  • Time zone. None to give. The boundaries are computed in UTC and armed in UTC, so daylight-saving changes and a changed time zone do not move them.
  • Polar day and night. When the Sun neither rises nor sets within the next 36 hours, nothing is armed and the side stays as it is: --light through a polar day, --dark through a polar night. The daily run picks up the first sunrise or sunset when it comes.
  • How it works. A systemd calendar cannot say “sunset”, so two timers cooperate. scoot-look-auto.timer wakes the service at 00:05 local time every day and 30 seconds after the user manager starts (a login or a reboot). Each run of the service computes the next sunrise or sunset, writes a one-shot scoot-look-auto-next.timer for it, and applies the look that is due now. When that timer fires, the same run happens and arms the one after. The one-shot timer lives in $XDG_RUNTIME_DIR/systemd/user, which systemd clears at logout and reboot; the daily and login runs put it back.
  • Suspend and shutdown. The side is chosen from the Sun’s position when the service runs, never from which timer fired, so a boundary missed while the machine slept is made up by the next run and any number of catch-up runs agree. The daily timer is persistent, like --schedule’s.
  • Cost. A run with nothing to change reads the clock and the active look, asks the manager whether the one-shot timer is still waiting, and exits; the systemctl call is most of its time. It happens at most a few times a day. An unchanged one-shot timer is not rewritten, so the manager does not reload.
  • Your own choices. As for --schedule: a look you pick by hand stays until the next boundary or daily run, which applies the look due then. A run does nothing when its look is already active, and it cancels a pending --for preview instead of leaving its revert to undo the swap.

--follow-portal watches the setting that GTK 4, libadwaita and xdg-desktop-portal read as org.freedesktop.appearance color-scheme: the dconf key /org/gnome/desktop/interface/color-scheme. --light must name a light look and --dark a dark one; auto refuses a pair the other way round. When the setting asks for dark and the active look is a light one, the service applies the --dark look, and the same the other way. auto runs that check once when you set it up, so the look matches the setting at once.

  • It compares polarity, not names. A look you applied by hand stays while its polarity matches the setting, even when it is not one of the two. If you apply a look of the other polarity by hand, apply writes color-scheme to match, as it does everywhere else, and the setting follows the look. With scoot look toolkit off or apply --no-toolkit nothing writes the setting, so the next dconf write swaps the look back to match it: change the desktop setting instead.
  • Which values count. 'prefer-dark' is dark. 'prefer-light' and 'default' are light ('default' is what GNOME’s own settings write for Light). Unset, or any other text, is no signal and nothing happens.
  • When it runs. systemd starts the service when the dconf database file changes, and any program writing any dconf key changes it. Most runs change nothing: they read the key and the active look, find the polarity matching, and exit. That costs about 60 ms of wall time and 9 to 12 ms of CPU, mostly starting the process and the dconf command. An unset or unrecognised key exits before the look catalog is read. apply itself writes the database (the toolkit step), which starts the service once more; that run finds the polarity already matching and stops. After a swap the service reads the setting again before it exits, because systemd does not watch the file while the service runs. A flip in the instant between that last read and systemd watching the file again is not seen until the next dconf write.
  • The watched file. $XDG_CONFIG_HOME/dconf/user (~/.config/dconf/user), the default dconf profile. If ~/.config/dconf does not exist yet, the path unit still installs: systemd watches the nearest directory that exists and starts the service when the file appears. A different DCONF_PROFILE is not followed.
  • Not at login. The unit does not run when the user manager starts, so a change made while you were logged out is picked up at the next dconf write, not at login. Run scoot look auto with the same arguments to apply the current setting by hand.
  • dconf on the PATH. The service reads the key with the dconf command, and it needs to be on the user manager’s PATH. auto checks your shell’s PATH when you run it; a service that cannot find dconf fails with no dconf on PATH.
  • Start limits. The service lifts systemd’s default limit of five starts in ten seconds, so a settings slider that writes the database many times does not stop the path unit. The path unit’s own limit (200 starts in two seconds) still applies.
  • What the service sees. The service runs in the user manager’s environment (systemctl --user show-environment), not your shell’s. The state directory is resolved when you run auto and written into the unit, so it swaps the state your sessions read. PATH is the manager’s: dconf must be on it, and without dconf the swap leaves the toolkit keys (including color-scheme) alone, as it does anywhere else; a schedule also needs date. SCOOT_LOOKS_DIR is not carried over: auto notes it when it is set here, since the service would validate a different catalog than the one it checked.
  • Which logins it runs in. The unit is wanted by timers.target or paths.target, so it runs in any login of that user, not only in a scoot session, and the swap writes the dconf keys the toolkit step owns there too. Turn it off with --off if you also use another desktop.
  • Nix. programs.scoot.look is the declared default; auto swaps the live choice, and the declaration wins again only when you change it there (the sync rule). auto prints a note when a declaration exists.
  • Re-running with the same arguments changes nothing. With new ones it rewrites the unit files and restarts the trigger.
  • --json prints a document with enabled, trigger (schedule, sun or portal), the two looks, the unit files written and the one replaced, and apply_error when the swap on setup failed. For sun it adds latitude and longitude. When that swap does swap, apply prints its own document first.

What it creates in $XDG_CONFIG_HOME/systemd/user (~/.config/systemd/user; a relative $XDG_CONFIG_HOME is ignored, as systemd ignores it): the service scoot-look-auto.service and, for the trigger, either scoot-look-auto.timer (--schedule and --sun) or scoot-look-auto.path. --sun also keeps scoot-look-auto-next.timer in $XDG_RUNTIME_DIR/systemd/user; it has the same marker, and the same rule: a file of that name that is not ours is never overwritten or removed. Setting up another trigger and --off stop and delete it. Each starts with a # Managed by scoot look auto line. The trigger’s enable link goes in timers.target.wants or paths.target.wants. systemd keeps its own record of when the timer last ran, which --off leaves alone. --off disables the trigger, then deletes the files. A file with one of those names that does not carry the marker (a home-manager link into the store, a unit you wrote) is never overwritten or removed: auto refuses and names it, and --off leaves it and says so. The service runs one hidden scoot command that carries the two names (and the times for a schedule, the place for --sun), which passed the same checks as the command line, and nothing a look supplies. It starts the scoot found first on PATH that is the same file as the one you ran, so a profile link survives upgrades; otherwise the binary you ran. If that path disappears, bare scoot look auto warns, and running auto with the arguments again repairs the unit. --state DIR moves the state the unit swaps, as it does for every scoot look command.

Symptom: no systemd user manager answers (...): scoot look auto runs as a user timer or path unit and needs one. The session has no user manager (a container, or a login started without systemd). Nothing was written. Apply looks with scoot look apply, or run your own scheduler to call it.

Symptom: systemd cannot run an executable at a path containing ’ (/home/o'brien/.local/bin/scoot). systemd refuses ", ', \ and $ in the path of the program a unit runs. Nothing was written. Install or link scoot at a plain path, such as ~/.local/bin/scoot, and run scoot look auto from there. (Spaces, % and ; are fine, and the state directory may hold any of them.)

Symptom: .../scoot-look-auto.timer exists and was not written by scoot look auto`` (or .path, or .service). Another file holds the name, often a link home-manager made. Move it aside and run auto again.

Symptom: unknown look NAME (available: ...). --light or --dark names a look the catalog does not hold. Nothing was written; scoot look list shows the names.

Symptom: invalid --schedule time (want HH:MM, 00:00 to 23:59, two digits each): 7:5``. Write both times with two digits each, such as 07:05; 24:00 is not a time, use 00:00.

Symptom: invalid --latitude (want decimal degrees from -90 to 90, like 51.5074): 91``. The latitude is first and the longitude second; a latitude past 90 usually means they are swapped. Nothing was written. --longitude takes -180 to 180, plain decimals only (-0.1278, not 0.1278W).

Symptom: XDG_RUNTIME_DIR is not set to an absolute path: --sun keeps its next-boundary timer there. The shell you ran auto from is not part of a systemd user login (su, sudo, a container). Nothing was written. Run it from a normal login session.

Symptom: --sun is on, but the look changes at the wrong time. scoot look auto prints the next sunrise or sunset it calculated in UTC; compare with an almanac for your place. A latitude and longitude swapped, or a west longitude written without its minus sign, moves the times by hours. journalctl --user -u scoot-look-auto shows one sun: line per run with the side it wanted and the next boundary it armed; not armed: there says why a boundary was not scheduled.

Symptom: `moonrise` is a dark look: --follow-portal needs a light look there. The two names are the wrong way round, or the look’s polarity is not what you expect (scoot look list shows it). Nothing was written.

Symptom: --follow-portal is on, but changing the desktop setting does nothing. Check what the setting reads (dconf read /org/gnome/desktop/interface/color-scheme; nothing printed means unset), then journalctl --user -u scoot-look-auto: each run logs one follow-portal: line saying why it did or did not swap. no dconf on PATH means the user manager’s PATH lacks dconf. A desktop that keeps its appearance setting somewhere else (not dconf) is not followed.

Symptom: auto says on, but the look never changes. scoot look auto shows the trigger’s state (and the next run, for a timer). A trigger that is not active was stopped or failed; run scoot look auto with the same arguments to start it again. With --json, the immediate swap runs first and prints its own document (if it swapped), then the single auto document, which carries apply_error when the swap failed.

list shows the catalog above, one row per look. list --names prints one name per line, for scripting.

apply NAME renders the look, validates it, records the choice, repoints the state link, reloads each running app’s look layer in order (the compositor first, then every bar of the session), and signals the third-party daemons (mako, scootnotify, fcitx5, the OSD and night-light units). scootlaunch needs no reload (it reads launch.toml at every open). scoot look apply reloads the running scootnotify live (msg reload --look does the same by hand). Apps that are not running are skipped, never an error. With nothing running, only the link moves, and the next session starts on the new look. A shipped look names its Nerd families (DroidSansM Nerd Font Propo, FiraCode Nerd Font) plus DejaVu Sans. A named family that is not installed does not refuse the swap: the apply falls back through the look’s other families, then fontconfig’s generic sans-serif/monospace answer, with a note naming the substitution. It refuses only when no usable font file exists at all. validate falls back the same way, with the same note. On Nix the profile installs all three families and points fontconfig at them, so the fallback rarely fires there.

Two pickers drive the same command. The bar’s looks module shows the active look’s name and opens the catalog as a popup — one row per look, the active one marked with ●, a long catalog paged — where a row click runs apply for that look (modules). From the keyboard, scoot look pick chooses a look through fuzzel and applies it:

Terminal window
scoot look pick # choose a look in fuzzel, apply it
scoot look list --names # names in catalog order, for scripting

pick uses fuzzel, not the scootlaunch panel: the look picker has not moved to the panel yet. pick needs a live Wayland display plus scoot and fuzzel on PATH; list needs only scoot. No Nix, no running session either way. A cancel applies nothing and prints nothing ({"applied": null, "cancelled": true} under --json). A missing or stale display (an ssh session, a restarted compositor: no socket, or a plain file where the socket was) or a missing or broken fuzzel fails loudly instead of applying.

A swap reports three lists: what reloaded, what was skipped, and every shadowed key (a key in your own files that overrides a look token). A swap that changes nothing it was asked to still answers applied; check current when the screen disagrees with the reply.

On refusal nothing half-applies. An invalid look refuses before anything is touched. A refused reload rolls the swap back: the record and the link go back to the previous look, the reply names the refusal, and the reloads run again against the restored choice for every peer that may hold the new layer. That is a peer that answered ok, one that applied some fields and refused others, or one that was sent the request and never answered (a timeout, a connection closed after the write). A peer that refused outright, an old compositor or bar included, never accepted the new layer, so it is not re-run and nothing retries as a plain reload. If a rollback reload also refuses, the reply says ROLLED BACK WITH ERRORS with both reasons. Each app is on its old or its new look, never between. The exception is a first-ever apply with no previous look: there is nothing to reload back to, so whatever may hold the new look still shows it while no look is recorded, and the reply names exactly that.

Every command takes --json (the reply as JSON on stdout, notes on stderr); the shapes and the exit codes are under Exit codes and JSON.

Change When it shows
Compositor ring, background, corners, cursor Live, on apply
Bar colors, radius, opacity, fonts Live, on apply
Wallpaper image or color Live, on apply; the five shipped looks fade over 500 ms (transition = "fade" in the look’s [wallpaper]; "none" swaps at once)
Dark mode, GTK theme, icons, cursor, app font (color-scheme, gtk-theme, icon-theme, cursor-theme, cursor-size, font-name) Live, on apply (see below)
GTK gtk.css colors Next launch: GTK 3 text selection plus libadwaita accent and surfaces follow; plain GTK 4 keeps its stock accent; running apps keep their palette
Qt style variant and palette Next launch of each app; under the Adwaita style QWidget apps keep Adwaita’s fixed palette (see below)
Notification colors (mako, scootnotify) Live, on apply (mako: D-Bus reload, makoctl fallback; scootnotify: look-scoped reload, cards kept)
OSD colors (wob) Live, on apply (the unit restarts)
Input-method candidate theme (fcitx5) Live, on apply (D-Bus reload)
Night-light temperature Live, on apply (the unit restarts)
Launcher, picker and lock colors (fuzzel.ini, swaylock) Next launch of each app
Launcher panel and notification cards (launch.toml, notify.toml) Next open of the panel; live for running scootnotify on apply
Running terminals Keep their palette; new terminals use the look’s
Cursor size in already-running apps Next launch
Login screen (ReGreet CSS, backdrop, dark setting, sans) System build time, never live

A swap never applies pending edits to your own files: the reloads are scoped to the look layer, under a snapshot of your files from their last successful load. Half-written files wait for your own scoot msg reload (or scootbar msg reload).

Each swap also renders the third-party daemon configs from the look’s tokens (mako, wob.ini, fcitx5/theme.conf, nightlight beside scoot.toml in the rendered look) and signals each daemon. A daemon that is not installed or not running is a note, never an error; a private look state (headless or --private-look) signals none of them.

  • mako reads the fragment through an include= of the link’s file (ahead of your own keys, which win) and a D-Bus Reload restyles on-screen notifications without starting a stopped mako (makoctl reload only where no busctl exists). On Nix the generated config carries the include; without Nix add include=~/.local/state/scoot/look/mako yourself.
  • wob reads a complete wob.ini from -c and the unit restarts (it starts in milliseconds and holds no state). Without Nix run wob -c ~/.local/state/scoot/look/wob.ini.
  • fcitx5 reads the scoot-look classic UI theme and reloads it over D-Bus (ReloadAddonConfig "classicui"). Without Nix link ~/.local/share/fcitx5/themes/scoot-look/theme.conf at the fragment and keep Theme=scoot-look in classicui.conf.
  • The night-light unit reads the temperature from the fragment at start (only while the look’s own warmth is in effect; an explicit temperature or a disabled target uses your value instead) and restarts on swap. Without Nix read <state>/look/nightlight (temperature= in Kelvin) in your own warmer’s startup.

Your own settings win over the look, and a theme target turned off keeps the look away from that app entirely: mako and fcitx5 keys you set override the fragment by file order, an explicit night temperature beats the look’s, and the OSD hide timeout and the day temperature stay yours. The first apply on a Nix machine seeds the declared default look, so a fresh install’s daemons never start against a missing fragment.

The choice is per-session state: <state>/look.json (the record) plus a look link to one complete rendered look, under one lock. Renders live in the cache ($XDG_CACHE_HOME/scoot/rendered/<name>-<hash>/), pruned to the active, the previous and the four most recent. A watched look’s reused <name>-watch slot is never pruned: it holds the last good render until the next watched save. An unwritable state dir refuses loudly. The rendered look holds fuzzel.ini and swaylock beside the first-party fragments: layer them over your own configs and the next launch picks the look up, no Nix involved. It also holds greet.toml for the login screen, which nothing reads yet. On Nix the login screen takes the look’s ReGreet CSS, backdrop, dark setting and sans at system build time, and a live swap never reaches it.

Your own configs stay the base. Neither file is ever passed to its app as the whole config: fuzzel’s --config and swaylock’s -C each read that file instead of your own, so your font, terminal, lock image and other settings would be lost while the look is active. The look’s colors layer over your files instead. Fuzzel takes them as CLI flags (flags win over the file, so only the seven colors change). Swaylock reads the look’s lines first and yours after in one merged file (later lines win, so your keys win per key and the look fills the colors). A Nix install wires this for the launcher, the pickers, the chooser and the lock screen, falling back to the eval-time theme while no look is applied. Without Nix, wire the same two mergers yourself:

Terminal window
# Fuzzel: your config stays; the look's seven colors arrive as flags.
flags=$(awk '/^\[colors\]/{s=1;next} /^\[/{s=0} s&&/=/ {k=$0;sub(/=.*/,"",k);v=$0;sub(/[^=]*=/,"",v); if((k=="background"||k=="text"||k=="prompt"||k=="match"||k=="selection"||k=="selection-text"||k=="border") && v~/^[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]$/) printf " --"k"-color="v}' ~/.local/state/scoot/look/fuzzel.ini)
fuzzel $flags
# Swaylock: the look's lines first, your config after, as one file.
cat ~/.local/state/scoot/look/swaylock ~/.config/swaylock/config > "$XDG_RUNTIME_DIR/scoot-swaylock-config"
swaylock -C "$XDG_RUNTIME_DIR/scoot-swaylock-config"

(~/.local/state/scoot is the default state dir; --state and $SCOOT_LOOK_STATE move it, as above.)

  • --state DIR takes the state (default: $SCOOT_LOOK_STATE, then $XDG_STATE_HOME/scoot, then ~/.local/state/scoot).
  • --socket PATH takes the compositor socket (default: $SCOOT_SOCKET, then $XDG_RUNTIME_DIR/scoot.sock).
  • A headless session keeps a private copy of the choice: swaps inside it never touch the desktop. --tty and --nested sessions use the shared choice, so their swaps survive a restart.

apply renders the toolkit files once and writes the dconf keys, so a swap reaches GTK and libadwaita at once with no resident daemon. GTK 4 and libadwaita follow through the portal; GTK 3 follows the theme, icons, cursor and font through GSettings directly (color-scheme means nothing to GTK 3).

The dconf keys, all under org.gnome.desktop.interface: color-scheme (prefer-dark/prefer-light from the polarity), gtk-theme (Adwaita-dark/Adwaita), icon-theme, cursor-theme, cursor-size and font-name. The owned files, under ~/.config: the two gtk.css shims (one @import of the state link’s per-version gtk/gtk-3.0.css and gtk/gtk-4.0.css, so new GTK apps take the new palette with no rewrite per swap), the two settings.ini files, qt6ct/qt6ct.conf (through the link’s scheme), and the fontconfig default. The tool owns a key or file only while it holds the record: it writes when the key is unset or holds what the tool last wrote, adopts it on the first apply whatever it holds, and otherwise leaves it alone and names it. A file the tool did not write (hand-made, or linked into the Nix store by home-manager) is left alone and named. A box without dconf gets a note, not a refusal.

Qt limits: under the Adwaita style the look’s palette never reaches QWidget apps (adwaita-qt replaces it with its fixed one; a style that honors custom_palette paints the scheme), and an in-place scheme change fires no watcher in a running app (qt6ct watches its own directory for added and removed files only). New Qt processes read the new palette; running ones keep theirs.

  • scoot look apply --no-toolkit skips the step for one apply.
  • scoot look toolkit off persists the skip in look.json. When the tool wrote what is still live, off restores each key from before the tool’s first write and stops owning it; a key the tool never wrote, one someone else changed since (including a color-scheme you set with the desktop’s own light/dark toggle), one home-manager owns (see below), or one that cannot be read is left alone. Either way the record clears, so turning back on adopts fresh on the next apply. off leaves the written files in place; only the keys are restored. scoot look toolkit shows the state.
  • A private look state (a headless or --private-look session) never runs the step: its swaps must not move the real desktop.

apply renders the look’s [terminal] hues into the rendered look’s foot.ini: [main] selects the look’s polarity as foot’s initial theme, and both [colors-dark] and [colors-light] carry the same hues, with alpha from opacity.terminal. A look without [terminal] renders no fragment. New terminals follow the look once your foot.ini includes the fragment (one-time setup):

foot.ini
include=~/.local/state/scoot/look/foot.ini

Both palettes hold the look’s hues, so new terminals match whatever theme is in effect, even with a user’s own opposite initial-color-theme. Running terminals keep their palette until they restart: foot never re-reads its config, and its SIGUSR1/SIGUSR2 switch only toggles the two palettes loaded at foot start, so a swap sends no signal. validate and build check the fragment against foot’s own parser; build --out writes foot.ini beside the other fragments.

The themed fragment needs foot 1.26 or newer. Against an older foot (Debian 13 ships 1.21.0 as its newest) the apply renders the legacy [colors] fragment instead: one section with the same hues, which that era parses. A note names the refused key and the 1.26 floor, and everything else applies. A foot whose version does not parse renders the themed fragment first, and the same downgrade runs off its --check-config refusal. Only a fragment broken beyond the themed keys still refuses the look.

Each shipped look with an apps/ directory carries static leaves: apps/helix.toml (the Helix theme), apps/nvim.lua (the Neovim colorscheme), apps/btop.theme (the btop theme) and apps/starship.toml (the prompt). apply copies them into the rendered look verbatim (they are the author’s files, never generated from tokens). Then SIGUSR1 reaches your own hx processes (Helix re-reads its config and theme from disk) and SIGUSR2 your own btop (it re-reads its config and theme). Starship is a new process per prompt, so the next prompt draws the look with no signal. Neovim recolors itself live with no signal either: the colorscheme watches itself (and the state directory behind the look link) and re-sources on change, within about a second. A look without an apps/ file renders no leaf for it (radial-burst ships none; music-desk ships no btop theme).

A leaf must be a regular file, with no symlink anywhere below the look directory (the look directory itself may be a link, as home-manager makes it). apply and scoot-look build both refuse a symlinked leaf or a symlinked apps/ directory, whatever it points at, and name the link:

refused: ~/.local/share/scoot/user-looks/moonrise/apps/helix.toml is a symlink

Copy the file into the look instead of linking it.

New editors follow the look once each file is wired (one-time setup): ~/.config/helix/themes/scoot-look.toml points at ~/.local/state/scoot/look/apps/helix.toml with theme = "scoot-look" in config.toml; ~/.config/nvim/colors/scoot-look.lua points at ~/.local/state/scoot/look/apps/nvim.lua (select it once with :colorscheme scoot-look; running nvim then follows swaps live); ~/.config/btop/themes/scoot-look.theme points at ~/.local/state/scoot/look/apps/btop.theme with color_theme = "scoot-look" in btop.conf; STARSHIP_CONFIG or ~/.config/starship.toml points at ~/.local/state/scoot/look/apps/starship.toml. The reply names this wiring on every swap that carries the leaves.

  • scoot look apply --no-helix and --no-btop skip that app’s signal for one apply (the leaf still renders).
  • Installed looks (someone else’s) drop these leaves: their formats are never generated, so a hostile file could carry anything (starship custom commands run a shell, Lua runs in the editor). Neovim needs no --no-* flag: there is no signal to skip (the colorscheme watches itself). apply, build and validate take --trust to keep them, printing each kept leaf first so the reply shows what was trusted. Trust lasts one apply: a managed sync, cycle or revert re-renders untrusted, so the leaves vanish on the next sync.
  • build --out writes apps/ beside the other fragments; a rebuild for a look that no longer carries a leaf removes the stale file.

A swap renders every fragment but wires none of them into your own files: each app needs one manual step, once. scoot look wiring checks each step for the active look against the live files and prints the exact missing command or line, with real paths. Read-only: it writes nothing, starts nothing, signals nothing.

Terminal window
scoot look wiring # every step, with the exact fix
scoot look wiring --json # the same report as JSON

Each item reports one status: wired (nothing to do), missing (with the exact fix), manual (no file records the step, so the detail gives the command: wob, the night-light temperature, the launcher flags, the lock merger) or absent (the look carries no leaf for that app: nothing to do). The steps, per app:

  • foot: add the printed include=... line to foot.ini.
  • mako: add the printed include=... line to mako/config, then makoctl reload.
  • wob: run the printed wob -c ... command.
  • fcitx5: link the theme as printed and keep Theme=scoot-look in classicui.conf.
  • Helix, Neovim, btop, starship: point the theme files as printed (above).
  • fuzzel, swaylock: run the printed merger commands (below).
  • night-light: read the printed temperature= file in your own warmer’s startup.

Exit status is 0 once the report prints, even with missing items. With no look applied yet it refuses and names the fix (apply one first).

programs.scoot.looks lists the catalog (default: every shipped look; add paths to your own source look directories, or a pack of them (look packs), beside the names); each look renders at build time and installs as ~/.local/share/scoot/looks/<name> (sources) beside ~/.local/share/scoot/rendered/<name> (renders). programs.scoot.look is the declared default (ginger-night with the profile on, null for no layer), written to ~/.config/scoot/look-default. Each activation runs scoot look sync after the links and dconf land: with no state it starts on the declared default; when the default changed the declaration wins; when the live choice left the catalog it falls back and says so; otherwise it keeps the live choice and re-resolves it, reloading only when the render changed. A null default removes the layer, rolling back like any other swap. With no session running, only the link moves.

The toolkit step above runs on sync too, from the active look. Keys home-manager owns stay home-manager’s: the catalog records the org.gnome.desktop.interface names the merged dconf.settings still carry in look-nix-claims beside the declared default, and sync (and apply) leave those alone however the live value reads. On a NixOS-module-only machine the catalog installs system-wide, the default lands in /etc/scoot/look-default, and the session launcher runs scoot look sync at login.

A look pack is a directory whose subdirectories are source looks, each with a look.toml. List it in programs.scoot.looks and it expands to one catalog entry per look, in directory-name order. A flake shares a pack as its lookPacks.<name> output, a plain path:

flake.nix
# the sharing flake
outputs = { self, ... }: {
lookPacks.default = ./looks; # ./looks/dusk/look.toml, ./looks/dawn/look.toml
};
home.nix
# your configuration
programs.scoot.looks = [
"ginger-night"
inputs.friend.lookPacks.default
./my-looks/moonrise-green # a single look directory still works
];

scoot’s own flake exports its shipped looks as lookPacks.shipped.

nix flake check and nix flake show print unknown flake output 'lookPacks' for a flake that exports one. Nix has no schema for the name; the warning is expected and harmless.

A directory with a look.toml is one look; one without is a pack. Each look in a pack is checked like a single entry: the manifest must parse and carry a name of 1 to 32 characters from a-z, 0-9 and -. The manifest’s name, not the directory name, is the catalog name.

  • Plain files (a README.md) and directories starting with . are skipped.
  • A subdirectory without a look.toml, or a symlink, refuses the evaluation, so a misnamed manifest or a link cannot make a look vanish quietly.
  • A pack with no look refuses.
  • A name claimed by two different sources refuses when either comes from a pack: two looks in one pack, a pack look against a shipped name or against a path entry. The same directory listed beside the pack that holds it is not a collision. Among entries that are not from packs, the first listed still wins.

Looks from a pack are declared looks like any path entry: they install without scoot look install’s --trust review, so a pack is as trusted as the flake input that supplies it. Pin the input.

An entry is a path value (./looks, inputs.<name>.lookPacks.<pack>) or an absolute string, so "${inputs.friend}/looks" and inputs.friend + "/looks" work too. A string that points into a store path stays a build input of the catalog. A flake input is already in the store. A derivation output that is not built yet is built during evaluation, which slows evaluation. It fails where import from derivation is disabled (allow-import-from-derivation = false, for example on Hydra).

Symptom: programs.scoot.looks: look name `twin` is declared twice with different sources: /nix/store/...-source/dup/one and /nix/store/...-source/dup/two (a look pack never shadows or is shadowed; rename one look's `name`). Two looks claim one name. Rename one look’s name in its look.toml, or drop one of the entries. You cannot edit a look in someone else’s flake, so two third-party packs that share a name, or a third-party look named like a shipped one (ginger-night), cannot be combined: drop the entry you want least, or fork the pack and rename the look.

Symptom: programs.scoot.looks: look pack /nix/store/...-source: subdirectory `junk` is not a look (no look.toml). Remove the directory from the pack, or add its look.toml.

Symptom: programs.scoot.looks: look pack /nix/store/...-source: `link` is a symlink (a pack holds look directories, not links to them). Replace the link with the directory it points to.

Symptom: programs.scoot.looks: /nix/store/...-source is neither a look (no look.toml) nor a look pack (no subdirectory holds a look). The path is not a look directory and holds no looks. Check that the flake input and the lookPacks attribute name the directory you meant.

scoot look sync [--no-toolkit] [--json] is the managed end of the rule above: each home-manager activation runs it after the links and dconf land, and the session launcher runs it at login on a NixOS-module-only machine. It reads the declared default (~/.config/scoot/look-default, else /etc/scoot/look-default) and applies the four rules; a missing file is unmanaged and sync exits 0 leaving the live choice alone. The first managed sync after a choice apply recorded wins for the declaration: a record from before any sync carries no declared default, which counts as changed, so the declaration takes over. A refused sync fails the home-manager switch: the links and dconf already landed, so the failure is loud rather than a quietly stale look. The session launcher instead logs the refusal and starts on the recorded choice, so a bad declaration never strands a login.

--no-toolkit skips the dconf keys and toolkit files for that run; --json prints the reply as JSON (notes on stderr). Exit status is 0 synced (including “nothing to do”), 1 the sync refused, 2 the invocation was wrong.

look-nix-targets beside the default names the opted-out theme targets (cursor, fonts, one per line, written from the Nix desktop.theme.targets switches): the synced render leaves those pieces out: no cursor keys in the compositor layer, no bar font in the bar layer, no matching toolkit keys or files — so an explicit opt-out holds through the managed sync. A hand-written targets file works with no Nix involved; a manual apply still themes everything until the next sync re-resolves it.

Start from a catalog look, then watch the directory while you edit it. Your own looks live in ~/.local/share/scoot/user-looks/<name>/. No step here needs Nix.

Terminal window
scoot look new my-green --from moonrise # writes user-looks/my-green/look.toml
scoot look watch ./my-green --no-toolkit # validate and apply on every save
scoot look preview my-green --capture # screenshot the look into preview.png

new NAME --from BASE writes a source look with extends = "BASE": the new look inherits every token and overrides from there. BASE names a source look — shipped, declared or user-authored — never an installed one (applied but never extended) and never a rendered one. A look made with new is user-authored, so it can be the next new’s base. A name the catalog or the directory already holds refuses, and nothing is written.

watch DIR validates and applies the directory on every save, until interrupted. Saves are the file events editors produce (close_write on plain writes, moved_to on atomic renames), debounced, so one save applies once. Each save takes the look lock and swaps through the same transaction as apply: the watched directory wins over any same-named catalog entry, its extends base resolves through the catalog, and it renders into one reused cache slot (<cache>/<name>-watch/), so the cache never grows per save. An invalid save is refused loudly. The reply names the file and the reason, and the last good look stays: the record, the link and the slot are untouched. A plain apply cancels a pending timed preview before the loop starts. --no-toolkit skips the toolkit step per save; --origin sets the origin the directory validates as (like validate).

preview [NAME] reports the look’s preview.png (beside its look.toml) and whether it exists yet; with no name it reports the active look’s. --capture renders the image instead: it renders the look into a scratch cache, seeds a scratch state with it, starts a private headless session on that state, screenshots it, and writes the PNG (beside look.toml by default, --out FILE to move it). The scratch state, cache, config and home live under one temp root, removed when the capture ends: your real session, home and cache are never read or written. --width/--height size the session’s output (default 1600x1000); --timeout bounds the wait (default 60 seconds). Capture needs Linux: the headless session is the compositor, which only runs there.

Write a directory with a look.toml (see the format), then scoot look validate DIR and scoot look build DIR --out OUT. validate resolves, contrast-checks and runs the effectful checks: fonts through fontconfig (a missing named family falls back with a note, as in apply, and refuses only with no usable font file at all; a [fonts.files] path must exist), the wallpaper through scootbg check-image, the bar fragment through the installed bar’s own parser.

Hand a look over as a tarball, or install one someone handed you, from a file or from a git repository. No step here needs Nix.

Terminal window
scoot look export moonrise --out moonrise.tar # a tarball of the source look
scoot look install ./handed-over # a directory or a tarball
scoot look install https://example.org/me/my-look # a git repository
scoot look target add ./pack # review a pack of app targets
scoot look save my-tweak # your own files as a user look

export NAME writes a plain .tar of the source look (<name>.tar here unless --out FILE moves it): every regular file a look uses, under one <name>/ prefix (a symlink skips with a note). The tarball installs back unchanged.

install PATH takes a directory or a tarball into ~/.local/share/scoot/installed-looks/: regular files only (a symlink, a hard link, a device, a FIFO or a socket refuses the install), only the file kinds a look uses (look.toml, preview.png, image wallpapers, fonts/*.ttf|otf, the six apps/ leaves, LICENSE*, COPYING*, README.md), file count and total size capped, license required. An installed look may not take the name of a shipped, declared or user-authored look, and it can be applied but never extended. Its apps/ leaves drop unless --trust keeps them (printed first). A wallpaper given by URL is fetched, verified against its sha256 and cached now — the install refuses if that fails — so a later swap makes no network request.

install GIT-URL clones a repository and installs the look at its root. The checks, the --trust rule and the result are the same as for a directory: the repository is someone else’s data, and nothing installs without the review --trust prints.

Terminal window
scoot look install https://example.org/me/my-look
scoot look install ssh://git@example.org/me/my-look.git --trust

Scoot decides from the text of the argument alone whether it is a URL or a path, and never looks at the filesystem to decide:

  • A word starting with /, ./ or ../ is a path, whatever it contains. This is how to spell a local path that holds :// or :.
  • Otherwise, a word containing :// is a URL, always. Only https:// and ssh:// (lowercase) install; http://, git://, file://, ftp:// and the rest refuse, and are never reinterpreted as paths.
  • Otherwise, a word whose first : comes before its first / is a git remote shorthand (host:path, git@host:path, ext::command) and refuses. Use ssh://git@host/path.
  • Anything else is a path.

A URL is ASCII with no spaces or control characters, no query or fragment, a host name of letters, digits, dots and hyphens (not starting with - or .; no bracketed IPv6), an optional port, and a repository path. An https:// URL takes no user name. An ssh:// URL takes a bare user name (git@) and never a password. Scoot reads and stores no credentials. There is no way to name a branch or tag: the default branch’s latest commit installs.

How the repository is read:

  • git must be on PATH, version 2.32 or later (checked with git --version; older refuses, since the config isolation below needs GIT_CONFIG_GLOBAL). Without it the command stops with a message saying so; clone the repository yourself and install the directory instead.
  • The clone is git clone --bare --depth 1 --single-branch --no-tags --no-recurse-submodules into a private temporary directory (scoot-look-clone-* under $TMPDIR, mode 0700) that is removed when the command ends. Nothing is checked out. The look is read from git’s object store, so no hook, smudge or filter= driver (Git LFS included), .gitattributes conversion, symlink or submodule is ever created or run, and .git is not part of the look. Scoot never runs anything a look supplies.
  • The tree is checked before any file is read: a symlink, a submodule, a path no look uses, more than 64 files or a file over its size cap refuses the install, then the usual directory checks run.
  • git runs with an environment reduced to PATH, HOME, locale, proxy and CA-certificate variables and SSH_AUTH_SOCK. The system and global git config are off, so there is no insteadOf rewrite and no credential helper. Only the https and ssh transports are allowed, redirects are not followed, prompts are off, and ssh runs with BatchMode, a connect timeout, keepalives, and agent, X11 and port forwarding off, so a Host * ForwardAgent yes in your ssh config cannot hand your agent to the host a pasted URL names. The rest of your ssh config (keys, Host blocks, ProxyJump) applies. The clone is stopped after 120 seconds or when the temporary directory passes 256 MiB (checked every 10 ms and when git exits).
  • git and everything it starts (ssh, the remote helper) are stopped together at the deadline or the size cap. If scoot itself is killed, Ctrl-C included, a small sh watchdog stops them and removes the temporary directory a moment later. On a machine with no sh there is no watchdog, and a killed scoot leaves the directory behind.

The repository root is the look directory, so a stray file there refuses the install by name, exactly as it would in a directory. A .gitignore, .gitattributes, .gitmodules or .github/ in the repository counts. Remove it from the repository or install a clean copy as a tarball.

Symptom: cannot install from a git URL: no `git` on PATH. Install git, or clone the repository yourself and run scoot look install DIRECTORY. A directory clone also needs its .git directory removed, since a directory install refuses it.

Symptom: refused: `git@example.com` is a git remote shorthand, or refused: `http://` is not installable. Only https:// and ssh:// URLs install from git. Spell the first as ssh://git@host/path; use the https:// URL for the second.

Symptom: git failed ... Host key verification failed or Permission denied (publickey) on an ssh:// URL. The install never prompts. Connect once with ssh git@host so the host key is in known_hosts, and load the key into your agent (SSH_AUTH_SOCK).

Symptom: git failed ... terminal prompts disabled or Repository not found on a private https:// repository. https:// installs are anonymous and ignore your git credential helper. Use the ssh:// URL for a private repository.

Symptom: git failed ... The requested URL returned error: 301. A redirect (a renamed repository, a trailing slash) is not followed. Use the URL the host now serves.

Symptom: git 2.30 is too old or cannot tell which git this is. Install git 2.32 or later, or clone the repository yourself and install the directory.

Symptom: refused: .gitignore is not a file a look uses. The repository has files a look does not use at its root. Ask the author to remove them, or install a tarball made with scoot look export.

target add PATH adds a pack of app targets (templates that theme apps scoot has no renderer for) to your own look-targets/. It is not part of install: an installed look still cannot carry templates, outputs or reload commands. See Look targets for the review it prints and the checks it runs.

save NAME snapshots your own appearance and color values (the compositor’s [appearance] and [wallpaper], the bar’s [colors]) as a source look in user-looks/, extending the recorded live look (apply one first). The save is user-authored, so it can be extended.

To turn the fade off, set transition = "none" in the [wallpaper] of a look you author. A [wallpaper] section in your own scoot.toml replaces the look’s whole wallpaper, image included, so it is not a way to drop only the fade.

Exit status: 0 the command ran (help counts; wiring counts even with missing items), 1 the run failed (an unknown look, a refused value, a refused reload, toolkit with no applied look, bare --keep with no pending preview), 2 the invocation was wrong (an unknown command, flag or value; a flag-shaped word where a name belongs, since there are no per-command help pages).

Every command takes --json: the reply as JSON on stdout, notes on stderr. The shapes:

Command --json document
list {"looks": [{name, title, polarity, origin, source}], "shadowed": [{name, source}], "notes": [...]} (--names: a bare array of names)
current {"active": {name, rendered, generation, chosen_by, toolkit} or null, "link": ..., "consistent": ..., "shadowed": [...], "notes": [...]}
show the merged tokens (palette, terminal, fonts, opacity, shape, cursor, wallpaper, icons, night, extends) plus contrast, warnings and refusals
validate {"target": ..., "valid": ..., "errors": [...], "warnings": [...], "notes": [...]}
build {"source": ..., "name": ..., "out": ..., "warnings": [...], "notes": [...]}
apply, next, prev, random, sync {"applied": ..., "rendered": ..., "generation": ..., "reloaded": [...], "skipped": [...], "shadowed": [...], "invalid": [...], "preview": {revert_to, from_generation} or null, "notes": [...]} (a refusal prints {"target", "applied": false, "error", "rolled_back_to", "rollback_errors"} instead; sync adds "target" and "removed")
pick the apply document above, or {"applied": null, "cancelled": true} on Esc
toolkit {"toolkit": ..., "color_scheme": ..., "keys": {...}}
new {"created": ..., "name": ..., "extends": ..., "valid": ..., "errors": [...], "warnings": [...], "notes": [...]}
watch one document per save: {"applied": ..., "generation": ..., "rendered": ..., "reloaded": [...], "skipped": [...], "shadowed": [...], "invalid": [...], "notes": [...]} (a refused save prints {"applied": null, "error": ...})
preview {"name": ..., "preview": ..., "exists": ...} (with --capture: {"name", "preview", "width", "height", "bytes"})
install {"installed": ..., "dir": ..., "dropped": [...], "kept": [...], "fetched": ... or null, "notes": [...]}
target add {"added": ..., "targets": [{name, output, reload ({argv, timeout_secs} or null), template_file, template}], "notes": [...]} (added is false without --trust, which exits 1 after printing the review)
export {"exported": ..., "tarball": ..., "files": [...], "notes": [...]}
save {"saved": ..., "dir": ..., "extends": ..., "tokens": [...], "notes": [...]}
auto {"enabled": ..., "trigger": "schedule"|"sun"|"portal", "light": ..., "dark": ..., "active": ..., "exe": ..., "exe_exists": ..., "timer": {active, enabled, next}} plus light_from, dark_from and due (schedule), or latitude, longitude, due, next_change ({kind (sunrise|sunset), at_utc, unix} or null) and next_timer ({active, next} or null) (sun), or watch and color_scheme (portal); off: {"enabled": false}; installing prints the apply document when a swap was needed, then {"enabled": true, ..., "wrote": [...], "unit_dir": ..., "apply_error": null or the swap's error}; --off: {"enabled": false, "removed": [...], "next_timer_removed": ..., "left": [...], "manager_skipped": ...}
wiring {"active": {name, rendered, generation}, "items": [{app, status, detail}], "notes": [...]} (status is wired, missing, manual or absent)

Two flags are internal: the compositor’s own IPC child passes --from-compositor and --only-if-generation (a timed-preview revert passes the generation its preview wrote, so the revert stands down instead of undoing a newer choice). You never need either: apply without them is the whole interface.

Symptom: apply answers applied and nothing changes. A key in your own files shadows the look. scoot look current names every shadowing key; remove or change yours to let the look through.

Symptom: apply refuses with the compositor predates scoot look: update scoot and restart the session. The compositor is older than the look-scoped reload. Update scoot and restart the session; the swap rolled back and nothing retried as a plain reload.

Symptom: apply refuses with the running bar predates the look-scoped reload: restart it. The bar is older than the look-scoped reload: it answered the reload-look request with an ordinary unknown-request error, applying nothing. Restart the bar and re-apply; the swap never retries as a plain reload.