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.
scoot look list # the catalog, in search orderscoot look show moonrise # tokens, wallpaper, fonts, contrastscoot look apply moonrise # render, validate, swap, reloadscoot look next # step through the catalog (wraps)scoot look prev # step back through the catalogscoot look random # a look other than the active onescoot look pick # choose a look in fuzzel, apply itscoot look apply moonrise --for 20 # preview 20 seconds, then revertscoot look current # the active look and shadowed keysscoot look wiring # the one-time setup still missingscoot look validate ./my-look # check a directory without touching anythingscoot look build ./my-look --out /tmp/my-look # render without applyingscoot look sync # follow the Nix declared default (below)scoot look new my-look --from moonrise # start a user look extending moonrisescoot look watch ./my-look # validate and apply on every savescoot look preview my-look --capture # screenshot the look into preview.pngscoot look auto --light dawn --dark moonrise --schedule 07:00/19:00 # switch by the clockscoot look auto --light dawn --dark moonrise --sun --latitude 51.5074 --longitude -0.1278 # switch at sunrise and sunsetscoot look auto --light dawn --dark moonrise --follow-portal # follow the desktop's light/dark settingCycle and preview
Section titled “Cycle and preview”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.), 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.
Switch automatically
Section titled “Switch automatically”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:
--scheduleswaps at two times of day.--sunswaps at sunrise and sunset, computed for a place you give.--follow-portalswaps when the desktop’s light/dark setting changes (a settings app, a quick toggle).
scoot look auto --light dawn --dark moonrise --schedule 07:00/19:00scoot look auto --light dawn --dark moonrise --sun --latitude 51.5074 --longitude -0.1278scoot look auto --light dawn --dark moonrise --follow-portalscoot look auto # show what is installedscoot look auto --off # remove everything it createdSetting 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.
and journalctl --user -u scoot-look-auto show the refusal.
On a schedule
Section titled “On a schedule”--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--forpreview (keeping the scheduled look) instead of leaving its revert to undo the swap.
At sunrise and sunset
Section titled “At sunrise and sunset”--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.,
-0.; no +, no exponent, no N/W). Write a negative longitude
as --longitude=-0. 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.
scoot look auto --light dawn --dark moonrise --sun --latitude 40.7128 --longitude=-74.006scoot 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
--scheduletimes are. Nothing reads your Nixnightlight./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:
--lightthrough a polar day,--darkthrough 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.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-shottimer scoot-look-auto-next.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 intimer $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
systemctlcall 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--forpreview instead of leaving its revert to undo the swap.
Following the desktop setting
Section titled “Following the desktop setting”--follow-portal watches the setting that GTK 4, libadwaita and
xdg-desktop-portal read as org.: 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,
applywritescolor-schemeto match, as it does everywhere else, and the setting follows the look. Withscoot look toolkit offorapply --no-toolkitnothing 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
dconfcommand. An unset or unrecognised key exits before the look catalog is read.applyitself 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(~/.), the default dconf profile. Ifconfig/dconf/user ~/.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 differentconfig/dconf DCONF_PROFILEis 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 autowith the same arguments to apply the current setting by hand. dconfon thePATH. The service reads the key with thedconfcommand, and it needs to be on the user manager’sPATH.autochecks your shell’sPATHwhen you run it; a service that cannot finddconffails withno 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.
Every trigger
Section titled “Every trigger”- 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 runautoand written into the unit, so it swaps the state your sessions read.PATHis the manager’s:dconfmust be on it, and withoutdconfthe swap leaves the toolkit keys (includingcolor-scheme) alone, as it does anywhere else; a schedule also needsdate.SCOOT_LOOKS_DIRis not carried over:autonotes 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.ortarget paths., 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 withtarget --offif you also use another desktop. - Nix.
programs.is the declared default;scoot. look autoswaps the live choice, and the declaration wins again only when you change it there (thesyncrule).autoprints 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.
--jsonprints a document withenabled,trigger(schedule,sunorportal), the two looks, the unit files written and the one replaced, andapply_errorwhen the swap on setup failed. Forsunit addslatitudeandlongitude. When that swap does swap,applyprints its own document first.
What it creates in $XDG_CONFIG_HOME/systemd/user (~/.;
a relative $XDG_CONFIG_HOME is ignored, as systemd ignores it): the
service scoot-look-auto. and, for the trigger, either
scoot-look-auto. (--schedule and --sun) or scoot-look-auto..
--sun also keeps scoot-look-auto-next. 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. or paths.. 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 withscoot look apply, or run your own scheduler to call it.
Symptom:
systemd cannot run an executable at a path containing’(/home/o'brien/.. systemd refuseslocal/bin/scoot) ",',\and$in the path of the program a unit runs. Nothing was written. Install or linkscootat a plain path, such as~/., and runlocal/bin/scoot scoot look autofrom there. (Spaces,%and;are fine, and the state directory may hold any of them.)
Symptom:
...scoot look auto`` (or/scoot-look-auto. timer exists and was not written by ., orpath .). Another file holds the name, often a link home-manager made. Move it aside and runservice autoagain.
Symptom:
unknown lookNAME(available: ....) --lightor--darknames a look the catalog does not hold. Nothing was written;scoot look listshows 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 as07:05;24:00is not a time, use00:00.
Symptom:
invalid --latitude (want decimal degrees from -90 to 90, like 51.91``. The latitude is first and the longitude second; a latitude past 90 usually means they are swapped. Nothing was written.5074): --longitudetakes-180to180, plain decimals only (-0., not1278 0.).1278W
Symptom:
XDG_RUNTIME_DIR is not set to an absolute path: --sun keeps its next-boundary timer there. The shell you ranautofrom is not part of a systemd user login (su,sudo, a container). Nothing was written. Run it from a normal login session.
Symptom:
--sunis on, but the look changes at the wrong time.scoot look autoprints 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-autoshows onesun: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 listshows it). Nothing was written.
Symptom:
--follow-portalis 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), thenjournalctl --user -u scoot-look-auto: each run logs onefollow-portal:line saying why it did or did not swap.no dconf on PATHmeans the user manager’sPATHlacksdconf. A desktop that keeps its appearance setting somewhere else (not dconf) is not followed.
Symptom:
autosays on, but the look never changes.scoot look autoshows the trigger’s state (and the next run, for a timer). A trigger that is notactivewas stopped or failed; runscoot look autowith the same arguments to start it again. With--json, the immediate swap runs first and prints its own document (if it swapped), then the singleautodocument, which carriesapply_errorwhen the swap failed.
Pick and apply
Section titled “Pick and apply”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. 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:
scoot look pick # choose a look in fuzzel, apply itscoot look list --names # names in catalog order, for scriptingpick 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.
What is live and what is next-launch
Section titled “What is live and what is next-launch”| 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. 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., swaylock) |
Next launch of each app |
Launcher panel and notification cards (launch., notify.) |
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).
Third-party daemons
Section titled “Third-party daemons”Each swap also renders the third-party daemon configs from the look’s
tokens (mako, wob., fcitx5/theme., nightlight beside
scoot. 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-BusReloadrestyles on-screen notifications without starting a stopped mako (makoctl reloadonly where nobusctlexists). On Nix the generated config carries the include; without Nix addinclude=~/.yourself.local/state/scoot/look/mako - wob reads a complete
wob.fromini -cand the unit restarts (it starts in milliseconds and holds no state). Without Nix runwob -c ~/..local/state/scoot/look/wob. ini - fcitx5 reads the
scoot-lookclassic UI theme and reloads it over D-Bus (ReloadAddonConfig "classicui"). Without Nix link~/.at the fragment and keeplocal/share/fcitx5/themes/scoot-look/theme. conf Theme=scoot-lookinclassicui..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.
Files and flags
Section titled “Files and flags”The choice is per-session state: <state>/look. (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. 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. 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:
# 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"(~/. is the default state dir; --state and
$SCOOT_LOOK_STATE move it, as above.)
--state DIRtakes the state (default:$SCOOT_LOOK_STATE, then$XDG_STATE_HOME/scoot, then~/.).local/state/scoot --socket PATHtakes 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.
--ttyand--nestedsessions use the shared choice, so their swaps survive a restart.
Dark mode and the toolkit step
Section titled “Dark mode and the toolkit step”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.: 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 ~/.: the two gtk.
shims (one @import of the state link’s per-version gtk/gtk-3.
and gtk/gtk-4., so new GTK apps take the new palette with no
rewrite per swap), the two settings.
files, qt6ct/qt6ct. (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-toolkitskips the step for one apply.scoot look toolkit offpersists the skip inlook.. When the tool wrote what is still live,json offrestores 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 acolor-schemeyou 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 backonadopts fresh on the next apply.offleaves the written files in place; only the keys are restored.scoot look toolkitshows the state.- A private look state (a headless or
--private-looksession) never runs the step: its swaps must not move the real desktop.
Terminal (foot)
Section titled “Terminal (foot)”apply renders the look’s [terminal] hues into the rendered look’s
foot.: [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.. A look without
[terminal] renders no fragment. New terminals follow the look once
your foot. includes the fragment (one-time setup):
include=~/.local/state/scoot/look/foot.iniBoth 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. 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.
Helix, Neovim, btop and starship
Section titled “Helix, Neovim, btop and starship”Each shipped look with an apps/ directory carries static leaves:
apps/helix. (the Helix theme), apps/nvim. (the Neovim
colorscheme), apps/btop. (the btop theme) and
apps/starship. (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 symlinkCopy the file into the look instead of linking it.
New editors follow the look once each file is wired (one-time setup):
~/. points at
~/. with theme = "scoot-look" in config.;
~/. points at
~/. (select it once with
:colorscheme scoot-look; running nvim then follows swaps live);
~/. points at
~/. with color_theme = "scoot-look" in btop.; STARSHIP_CONFIG or
~/. points at
~/.. The reply names this
wiring on every swap that carries the leaves.
scoot look apply --no-helixand--no-btopskip 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,buildandvalidatetake--trustto 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 --outwritesapps/beside the other fragments; a rebuild for a look that no longer carries a leaf removes the stale file.
One-time setup (no Nix)
Section titled “One-time setup (no Nix)”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.
scoot look wiring # every step, with the exact fixscoot look wiring --json # the same report as JSONEach 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 tofoot..ini - mako: add the printed
include=...line tomako/config, thenmakoctl reload. - wob: run the printed
wob -c ...command. - fcitx5: link the theme as printed and keep
Theme=scoot-lookinclassicui..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).
The Nix catalog
Section titled “The Nix catalog”programs. 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
~/. (sources) beside
~/. (renders).
programs. is the declared default (ginger-night with the
profile on, null for no layer), written to
~/.. 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. names the merged dconf.
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.
Look packs
Section titled “Look packs”A look pack is a directory whose subdirectories are source looks, each
with a look.. List it in programs. and it expands to
one catalog entry per look, in directory-name order. A flake shares a
pack as its lookPacks. output, a plain path:
# the sharing flakeoutputs = { self, ... }: { lookPacks.default = ./looks; # ./looks/dusk/look.toml, ./looks/dawn/look.toml};# your configurationprograms.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..
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. 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.) and directories starting withmd .are skipped. - A subdirectory without a
look., or a symlink, refuses the evaluation, so a misnamed manifest or a link cannot make a look vanish quietly.toml - 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 (., inputs.)
or an absolute string, so "${inputs. and
inputs. 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.. Two looks claim one name. Rename one look’sscoot. 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`) namein itslook., 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 (toml ginger-night), cannot be combined: drop the entry you want least, or fork the pack and rename the look.
Symptom:
programs.. Remove the directory from the pack, or add itsscoot. looks: look pack /nix/store/... -source: subdirectory `junk` is not a look (no look. toml) look..toml
Symptom:
programs.. Replace the link with the directory it points to.scoot. looks: look pack /nix/store/... -source: `link` is a symlink (a pack holds look directories, not links to them)
Symptom:
programs.. The path is not a look directory and holds no looks. Check that the flake input and thescoot. looks: /nix/store/... -source is neither a look (no look. toml) nor a look pack (no subdirectory holds a look) lookPacksattribute name the directory you meant.
scoot look sync
Section titled “scoot look sync”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
(~/., 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. 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.
Authoring
Section titled “Authoring”Start from a catalog look, then watch the directory while you edit it.
Your own looks live in ~/.. No step
here needs Nix.
scoot look new my-green --from moonrise # writes user-looks/my-green/look.tomlscoot look watch ./my-green --no-toolkit # validate and apply on every savescoot look preview my-green --capture # screenshot the look into preview.pngnew 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. (beside its
look.) 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. 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. (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. path must exist), the wallpaper
through scootbg check-image, the bar fragment through the installed
bar’s own parser.
Sharing
Section titled “Sharing”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.
scoot look export moonrise --out moonrise.tar # a tarball of the source lookscoot look install ./handed-over # a directory or a tarballscoot look install https://example.org/me/my-look # a git repositoryscoot look target add ./pack # review a pack of app targetsscoot look save my-tweak # your own files as a user lookexport NAME writes a plain . of the source look (<name>.
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
~/.: 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., preview., image wallpapers, fonts/*.,
the six apps/ leaves, LICENSE*, COPYING*, README.), 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 from git
Section titled “Install from git”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.
scoot look install https://example.org/me/my-lookscoot look install ssh://git@example.org/me/my-look.git --trustScoot 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. Onlyhttps://andssh://(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. Usessh://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:
gitmust be onPATH, version 2.32 or later (checked withgit --version; older refuses, since the config isolation below needsGIT_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-submodulesinto 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 orfilter=driver (Git LFS included),.conversion, symlink or submodule is ever created or run, andgitattributes .is not part of the look. Scoot never runs anything a look supplies.git - 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 andSSH_AUTH_SOCK. The system and global git config are off, so there is noinsteadOfrewrite and no credential helper. Only thehttpsandsshtransports are allowed, redirects are not followed, prompts are off, and ssh runs withBatchMode, a connect timeout, keepalives, and agent, X11 and port forwarding off, so aHost * ForwardAgent yesin your ssh config cannot hand your agent to the host a pasted URL names. The rest of your ssh config (keys,Hostblocks,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 smallshwatchdog stops them and removes the temporary directory a moment later. On a machine with noshthere 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 .,
., . or . 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 runscoot look install DIRECTORY. A directory clone also needs its.directory removed, since a directory install refuses it.git
Symptom:
refused: `git@example., orcom` is a git remote shorthand refused: `http://` is not installable. Onlyhttps://andssh://URLs install from git. Spell the first asssh://git@host/path; use thehttps://URL for the second.
Symptom:
git failed ... Host key verification failedorPermission denied (publickey)on anssh://URL. The install never prompts. Connect once withssh git@hostso the host key is inknown_hosts, and load the key into your agent (SSH_AUTH_SOCK).
Symptom:
git failed ... terminal prompts disabledorRepository not foundon a privatehttps://repository.https://installs are anonymous and ignore your git credential helper. Use thessh://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.or30 is too old cannot tell which git this is. Install git 2.32 or later, or clone the repository yourself and install the directory.
Symptom:
refused: .. The repository has files a look does not use at its root. Ask the author to remove them, or install a tarball made withgitignore is not a file a look uses 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.
replaces the look’s whole wallpaper, image included, so it is not a way
to drop only the fade.
Exit codes and JSON
Section titled “Exit codes and JSON”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": ... |
show |
the merged tokens (palette, terminal, fonts, opacity, shape, cursor, wallpaper, icons, night, extends) plus contrast, warnings and refusals |
validate |
{"target": ... |
build |
{"source": ... |
apply, next, prev, random, sync |
{"applied": ...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": ... |
new |
{"created": ... |
watch |
one document per save: {"applied": ... (a refused save prints {"applied": null, "error": ...) |
preview |
{"name": ... (with --capture: {"name", "preview", "width", "height", "bytes"}) |
install |
{"installed": ... |
target add |
{"added": ...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": ... |
save |
{"saved": ... |
auto |
{"enabled": ...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, ...; --off: {"enabled": false, "removed": [... |
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:
applyanswers applied and nothing changes. A key in your own files shadows the look.scoot look currentnames every shadowing key; remove or change yours to let the look through.
Symptom:
applyrefuses withthe 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:
applyrefuses withthe running bar predates the look-scoped reload: restart it. The bar is older than the look-scoped reload: it answered thereload-lookrequest with an ordinary unknown-request error, applying nothing. Restart the bar and re-apply; the swap never retries as a plain reload.
