← All entries

Hyprland on the CHUWI MiniBook X (N150, 12 GB)

A complete Wayland desktop for a 10.5-inch convertible: Debian 13 "trixie", Hyprland 0.55.2, 216 DPI, and a long evening of things failing quietly.

Hyprland on the CHUWI MiniBook X (N150, 12 GB)

The code. Every config file described here lives in one repo, ready to clone: github.com/mattnovakemail/minibook-x-hyprland.

The machine

This writeup is specific to one laptop. Most of what follows is driven by its particular quirks — a rotated DSI panel, an extremely dense screen, a 6 W CPU, and firmware that blocks hibernation — so here it is in full:

Model CHUWI MiniBook X (BIOS DNN20 V2.50)
Form factor 10.5" convertible, 360° hinge, touchscreen
CPU Intel N150 (Alder Lake-N) — 4 cores / 4 threads, 700 MHz–3.6 GHz, 6 MiB L3, 6 W
RAM 12 GB LPDDR5-6400, soldered — 4 × 3 GB rows across two channels
GPU Intel Graphics (Alder Lake-N), 8086:46d4
Panel 1200×1920 DSI, mounted rotated → 1920×1200 landscape via transform 3
Density 141 × 226 mm → 10.5" diagonal → ~216 DPI
Touch Goodix capacitive touchscreen (GDIX1002) over i2c
Wi-Fi Intel CNVi 8086:54f0
Audio Alder Lake-N PCH HD Audio 8086:54c8
Storage 476 GB NVMe → LUKS → LVM (463 GB root, 11.7 GB swap)
Battery 3800 mAh @ 7.6 V nominal, reporting 100% health
Firmware Secure Boot enabled, kernel lockdown at integrity

Two details from that table cause more trouble than the rest combined.

The panel is 2.25× denser than a normal display. A conventional desktop monitor is around 96 DPI; this one is 216. Anything rendered 1:1 comes out roughly half the size it should be. That single number explains the unreadable Firefox, the 24-point terminal font, and an entire section of scaling work below.

The CPU is a 6 W part. The N150 is genuinely capable for its class, but it has no thermal headroom to waste. That drives the choice of hardware video encoding, the power tuning, and the decision to shell into a bigger machine for compilation rather than build locally.

A note on the RAM figure, since it looks inconsistent: dmidecode reports 4 × 3 GB = 12 GB installed, while /proc/meminfo shows MemTotal: 11986544 kB ≈ 11.4 GiB. The difference is firmware reservation — normal, and not a fault. It's a 12 GB machine.


The starting point

Debian 13 was installed — deliberately minimal. At the installer's software selection step only standard system utilities and SSH server were ticked; every desktop task was left unchecked. The disk was set up with the installer's guided encrypted LVM option, so root and swap both sit inside LUKS. Hyprland had been installed from backports, and that was about it.

The entire Hyprland config was thirteen lines. This is the starting state — the finished version is reproduced in full at the end of this post:

monitor = DSI-1, preferred, auto, 1, transform, 3

$mod = SUPER

exec-once = waybar
exec-once = mako

input {
	kb_layout = us

	touchpad {
		natural_scroll = true
		tap-to-click = true
	}
}

general {
	gaps_in = 3
	gaps_out = 5
	border_size = 2
}

decoration {
	rounding = 6
}

bind = $mod, Return, exec, foot

One keybinding. You could open a terminal. You could not close a window, move one, switch workspaces, take a screenshot, lock the screen, or quit the compositor. Waybar was listed in exec-once but had no config file at all.

The goal was a complete daily-driver desktop, tuned for battery life, on a machine intended mostly for SSH-ing into a beefier build box, taking screenshots, and light development.

What follows is everything that got built, and — more usefully — everything that broke along the way. The interesting through-line turned out not to be the configuration. It was this:

Almost every bug in this stack failed silently. Not one of them printed an error where anyone would look. A keybinding that did nothing. A stylesheet that vanished. Icons that rendered as blank space. A wallpaper daemon that logged one cryptic line and gave up. Precision that was invented out of thin air. The work wasn't writing config — it was noticing the absence of things.


Where it started, in numbers

Facts gathered rather than assumed, before changing anything:

  • CPU governor: powersave on the intel_pstate driver, EPP at balance_performance
  • Idle draw: 3.68 W (0.5 A × 7.36 V), projecting ~7.6 h from full
  • Sleep states offered: freeze mem — and, unusually for a recent Intel laptop, mem_sleep offering both [s2idle] and deep. Many Alder Lake-N machines only expose s2idle; this one still has working S3, which turns out to matter a lot
  • Nerd Fonts installed: zero
  • Display manager: none
  • Wi-Fi: a hand-started wpa_supplicant plus dhcpcd, no NetworkManager

Part 1 — The compositor, and a version that moved underneath us

Hyprland 0.55.2 is recent enough that a large amount of community documentation is simply wrong for it. Three concrete breakages surfaced within minutes of writing a normal-looking config.

Window rules changed shape entirely

The syntax everyone writes:

windowrule = float, class:^(pavucontrol)$

produces, in 0.55:

Config error: invalid field float: missing a value

The rule system was rebuilt around explicit key/value fields with a match: prefix for the selector. The working form is:

windowrule = float = true, match:class = ^(pavucontrol)$
windowrule = suppress_event = maximize, match:class = .*
windowrule = size = 60% 55%, match:class = ^(foot-float)$

I found this by probing the live compositor with hyprctl keyword windowrule until something returned ok, rather than guessing. The error messages were genuinely helpful here — float = true, class = ... complained about class specifically, which pointed straight at needing match:class.

togglesplit no longer exists

Invalid dispatcher, requested "togglesplit" does not exist

It moved into the layout message system:

bind = $mod, Y, layoutmsg, togglesplit

Similarly, dwindle:pseudotile is gone as a config option (the pseudo dispatcher still exists), and misc:vfr moved to debug:vfr.

Gestures were rewritten

The familiar gestures { workspace_swipe = true } block returns no such option. 0.51 replaced it with a top-level keyword:

gesture = 3, horizontal, workspace
gesture = 4, up, special, magic

The one that parses fine and does nothing

This is the first silent failure of the evening.

workspace = workspace = 1, monitor = DSI-1, persistent = true

hyprctl configerrors returns empty. The rule is accepted. The workspace is never created. I tried every syntax variant I could construct — match:id, bare 1,, with and without a monitor — and eventually spun up a nested Hyprland instance with a throwaway config to test in isolation. None of them work in 0.55.2.

Digging into the binary's strings revealed the internal function ensurePersistentWorkspacesPresent and its failure mode (couldn't resolve monitor for {}, skipping), which suggested the rule needs a monitor — but supplying one changed nothing.

So I deleted the dead configuration rather than leave four lines that look meaningful and aren't. Waybar's own persistent-workspaces setting displays workspaces 1–5 permanently regardless, which is the actual user-visible behaviour anyone wants from that feature.

Lesson: "it parses" and "it works" are unrelated claims. Verify the effect, not the syntax.

What got built

Roughly 80 keybindings, in themed groups:

Keys Function
Super+Q close window
Super+F / Super+Shift+F / Super+Ctrl+F fullscreen / maximize / fake-fullscreen
Super+hjkl + arrows move focus
Super+Shift+hjkl move the window
Super+Alt+hjkl resize (key-repeating via binde)
Super + left/right drag move / resize with the mouse (bindm)
Super+1..5 five workspaces
Super+Shift+1..5 / Super+Ctrl+1..5 send window there, following / staying
Super+Tab, Super+scroll, 3-finger swipe cycle workspaces
Super+S / Super+Alt+S scratchpad toggle / send to scratchpad

Plus hardware keys (volume, brightness, mic mute) via wpctl and brightnessctl, and a lid switch binding.

One small thing that mattered on a touchscreen device: the panel is rotated transform 3, but the touchscreen input had no matching transform, so taps landed in the wrong place. Wayland doesn't infer this:

input {
    touchdevice {
        transform = 3
    }
}

Part 2 — The bar, and a font that wasn't there

Waybar was in exec-once but had no config whatsoever, so it was running with compiled-in defaults. Building a real one was straightforward: workspaces, window title, wireplumber volume, backlight, network, battery, clock, tray, all in a Tokyo Night palette.

Then every icon rendered as blank space.

The cause is a naming trap. Debian's fonts-font-awesome package installs a font whose family name is literally FontAwesome — the legacy FA4 family — not Font Awesome 6 Free, which is what every modern config snippet lists. The CSS font stack silently matched nothing and fell through to a font with no glyphs at those codepoints.

Worse, the glyphs I'd chosen weren't all in FA4. U+F5DE (a brightness icon in newer sets) simply doesn't exist there. So I checked coverage directly before picking anything:

fc-list ":charset=f185" family    # → FontAwesome

and rebuilt the icon set from codepoints verified present. The final stack:

font-family: "JetBrains Mono", "FontAwesome", monospace;

The glyphs that vanished in transit

A subtler problem, and one that recurred all evening: three-byte private-use-area characters get stripped when written through certain tooling. Codepoints like U+E0B0 (the powerline separator) and U+E0A0 (the git branch symbol) arrived at the file as empty strings, while four-byte supplementary-plane glyphs like U+F0509 survived intact.

The result is config that looks correct and renders nothing:

[](fg:iris)      # ← there is supposed to be a glyph inside those brackets

The fix is to inject them programmatically rather than typing them:

LCAP = chr(0xe0b6)   #  rounded left cap
s = s.replace('[](fg:iris)', '[%s](fg:iris)' % LCAP)

From that point on, every glyph in this project was written with chr() and then audited by reading the file back and checking that the symbol fields were non-empty. That audit caught two more empty symbols later in the starship config that would otherwise have shipped as blank space.


Part 3 — Screenshots

A small script, ~/.config/scripts/screenshot, wrapping grim and slurp:

  • always copies to the clipboard and saves to ~/Pictures/Screenshots
  • region uses slurp -d; pressing Escape exits silently rather than erroring
  • window reads the focused window's geometry from hyprctl activewindow -j via jq
  • --edit opens the capture in swappy for cropping, arrows and redaction before saving

Bound to Super+Shift+S (region), Print (screen), Shift+Print (window), and Super+Ctrl+Shift+S (region with annotation).

Note the window-geometry line, which is the sort of thing that's fragile if you write it with grep and awk:

geom=$(hyprctl activewindow -j | jq -r '"\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')

Part 4 — Lock, idle, wallpaper, clipboard — and more version drift

Four packages: hyprlock, hypridle, hyprpaper, cliphist. Two of them had moved their config format, and both failed quietly.

hyprpaper 0.8.4 removed preload entirely

The universally documented form:

preload = ~/Pictures/wallpaper.png
wallpaper = DSI-1, ~/Pictures/wallpaper.png

produces no error, no warning, and exactly one line of log:

Monitor DSI-1 has no target: no wp will be created

No wallpaper appears. Searching the binary for strings showed preload was absent from it altogether, while monitor, path, fit_mode and order were present — implying a block-structured format. The working config:

splash = false

wallpaper {
	monitor = DSI-1
	path = /home/YOUR-USER/Pictures/wallpaper.png
	fit_mode = cover
}

Also: ~ is not expanded, so paths must be absolute.

hyprlock moved grace out of general

Config error: config option <general:grace> does not exist

In 0.9.5 it's a top-level key. I found this by testing candidate placements against a throwaway config using hyprlock --display no-such-display, which parses the file and then fails to connect — a config check that never locks your screen. That trick was worth its weight later.

There was no wallpaper at all

No image existed on the machine, and neither ImageMagick nor Python's PIL was installed. Rather than pull in a dependency, I generated a 1920×1200 Tokyo Night gradient by writing the PNG by hand — zlib-compressed scanlines, CRC'd chunks, ordered dithering to prevent banding on an 8-bit gradient:

def chunk(tag, data):
    return (struct.pack('>I', len(data)) + tag + data
            + struct.pack('>I', zlib.crc32(tag + data) & 0xffffffff))

png = (b'\x89PNG\r\n\x1a\n'
       + chunk(b'IHDR', struct.pack('>IIBBBBB', W, H, 8, 2, 0, 0, 0))
       + chunk(b'IDAT', zlib.compress(bytes(rows), 9))
       + chunk(b'IEND', b''))

118 KB, no dependencies, and it became the basis for the lock screen and greeter backgrounds too.

Verifying the lock screen safely

Before letting anything auto-lock the machine, two checks:

  1. /etc/pam.d/hyprlock exists and includes the login stack — if PAM were misconfigured, the first idle lock would strand the user permanently.
  2. A deliberate, timed test-lock to confirm it renders and releases.

Both passed. That second check is also where this post's biggest mistake originates, but that comes later.


Part 5 — The idle ladder incident

This is the most instructive failure of the evening, because nothing was broken. Everything worked exactly as configured. The configuration was just wrong for a human being.

The initial hypridle ladder:

idle action
4 min brightnessctl -s set 10%
8 min lock
10 min screen off
30 min systemctl suspend

I started it live and moved on without saying the timers were now armed.

Four minutes later, while reading my own summary on screen, the backlight dropped to 10%. On this panel that reads as a dead screen, not a dim one. The reasonable response to a laptop that appears to have died is to switch to a different TTY — which is exactly what happened: Ctrl+Alt+F2, log in, start a second Hyprland session.

The consequences compounded:

  1. Two live sessions. The abandoned tty1 session kept running, along with its waybar, mako, hyprpaper — and its hypridle.
  2. An abandoned session is permanently idle. Its 30-minute systemctl suspend timer was still counting, and systemctl suspend suspends the whole machine, not a session. It would have fired while the user was working in the new session.
  3. Two hypridle daemons were now driving global hardware — one backlight, one DPMS state, one suspend command — from two different idle clocks.

I also discovered I'd left two orphaned nested Hyprland instances running from the persistent-workspace experiment; timeout 12 had sent SIGTERM and they'd survived it, running for seventeen minutes.

The fixes

The immediate hazards were killed with about four minutes to spare before the next session's dim/lock cycle. Then the policy changed:

# 10 min -- dim, but stay clearly readable. Any input restores.
listener {
	timeout = 600
	on-timeout = brightnessctl -s set 30%
	on-resume = brightnessctl -r
}

# 20 min -- screen off
listener {
	timeout = 1200
	on-timeout = hyprctl dispatch dpms off
	on-resume = hyprctl dispatch dpms on
}

# No auto-lock and no auto-suspend. Lock manually with Super+Escape.

30% instead of 10% — dim, never dead-looking. Ten minutes instead of four. No auto-suspend at all.

And a structural fix so this class of problem can't recur:

exec-once = pgrep -x hypridle >/dev/null || hypridle    # singleton: global hardware

hypridle touches global hardware, so it must be a singleton across sessions. Waybar and hyprpaper are legitimately per-session; hypridle is not.

Lessons:

  • Never arm timers on someone's live machine without telling them the clock is running.
  • A dim that looks like failure is a failure, regardless of intent.
  • Daemons that control global hardware need singleton guards, because sessions can and do stack up.
  • timeout sending SIGTERM does not guarantee a process dies. Verify.

Part 6 — The binding that did nothing

Super+Shift+S produced no selector, no error, no notification. Nothing.

The binding was registered correctly — hyprctl binds showed mods=65 key=S -> exec screenshot region. The script existed and was executable. Running it by hand worked perfectly.

The environment Hyprland's exec inherits is not your login shell's:

PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games

No ~/.local/bin. The shell couldn't resolve screenshot, and the exec failed into the void — Hyprland doesn't surface a failed exec anywhere visible.

Every earlier test of mine had passed because I'd been exporting PATH="$HOME/.local/bin:$PATH" in my own shell before running things. I had never once tested the environment the binding runs in.

The fix, belt and braces:

env = PATH,/home/YOUR-USER/.config/scripts:/home/YOUR-USER/.local/bin:/usr/local/bin:/usr/bin:/bin:...
$scripts = /home/YOUR-USER/.config/scripts
bind = $mod SHIFT, S, exec, $scripts/screenshot region

Both the env line and absolute paths, so the bindings work even if the env line is ever lost. Verified by making Hyprland itself report its environment:

hyprctl dispatch exec "sh -c 'echo PATH=$PATH > /tmp/probe.txt'"

Lesson: test in the environment the code will actually run in, not the one that's convenient.


Part 7 — HiDPI: 216 DPI and the scale that fixes everything

Firefox launched with unreadably small UI. The instinct is to hunt for a Firefox font setting. That would have been wrong — the same problem afflicted every GTK app, every dialog, every file picker, because it isn't a font problem at all.

At 216 DPI with no scaling, everything renders at roughly half its intended physical size. That's also why the terminal font had been set to 24pt: at 216 DPI, 24pt lands at a normal-looking ~10.7pt physically.

The fix is compositor scaling:

monitor = DSI-1, preferred, auto, 1.5, transform, 3

Scale 1.5 was chosen deliberately over 1.25, 1.75 and 2.0 because 1920/1.5 = 1280 and 1200/1.5 = 800 are both exact integers. Fractional scales that don't divide evenly (1.75 gives 1097.14 × 685.71) force resampling and soften text. Effective density becomes ~144 DPI.

Then everything already tuned for the unscaled screen had to be rebalanced so it stayed the same physical size:

before after physical result
foot font 24pt @ 1.0 16pt @ 1.5 unchanged
waybar height 42px @ 1.0 28px @ 1.5 unchanged
waybar font 20px @ 1.0 13px @ 1.5 unchanged

Both running apps reported xwayland=false, so they scale natively rather than going through XWayland's blurry path.

The 25-second bar

After the reboot that followed, waybar took 25 seconds to appear. Its log:

[info] Unable to receive desktop appearance: Timeout was reached

Waybar asks the XDG desktop portal for the light/dark preference at startup and waits out a full D-Bus timeout when nobody answers.

Nobody answered because xdg-desktop-portal-gtk had failed to start, and it had failed because the systemd user manager outlives individual login sessions and was still advertising WAYLAND_DISPLAY=wayland-1 — the display of the session that got abandoned during the idle incident. The GTK portal started, couldn't reach a display that no longer existed, and exited 1.

exec-once = dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE HYPRLAND_INSTANCE_SIGNATURE
exec-once = systemctl --user restart xdg-desktop-portal-gtk.service

Startup went from 25 seconds to 1 second. The same fix repairs file-picker dialogs and screen sharing, which route through the identical portal and were broken in exactly the same way.


Part 8 — Battery

Baseline: 3.68 W idle, projecting ~7.6 hours from full. Decent for an N150 — but several cheap wins were sitting unclaimed.

Energy performance preference. intel_pstate was at balance_performance. A systemd oneshot pins it:

ExecStart=/bin/sh -c 'for f in /sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference; do echo power > "$f"; done'

Bluetooth was running with zero paired devices — a daemon and a radio powered for nothing. Disabled.

thermald wasn't installed. On a 6 W part, Intel's thermal daemon is worth having.

powertop was installed but nothing ran --auto-tune at boot. Now a oneshot unit does, and I verified afterwards that the touchpad, keyboards and touchscreen were all still enumerated — powertop's aggressive runtime PM has a reputation for breaking input devices.

Wi-Fi power saving was off; a unit enables it via iw.

Result: 3.68 W → 3.57 W, plus a much lower-power sleep state (below).


Part 9 — The network, and the worst mistake of the evening

The machine's Wi-Fi was managed by a hand-started wpa_supplicant plus a hand-started dhcpcd — no NetworkManager, no nmtui, no roaming, and the waybar network module's click action pointed at nmtui, which wasn't installed. Another silent no-op.

I decided to migrate to NetworkManager, and got two things badly wrong.

Mistake one: reading a permission error as an empty file

cat /etc/network/interfaces    # printed nothing

I concluded the file was empty and announced that the Wi-Fi config wasn't persistent and wouldn't survive a reboot. In fact the file is 363 bytes, mode 0600 — the cat had failed with permission denied, and I'd read the empty output as an empty file. The user's reboot disproved me directly.

Worse, that file contained the PSK. Reading it properly would have let me configure NetworkManager non-interactively from the start, without ever needing to ask for a password.

Mistake two: a teardown with no completion guarantee

The migration ran as a plain foreground command:

pkill -f 'wpa_supplicant ...'
pkill dhcpcd
systemctl enable --now NetworkManager    # ← never reached

The command was interrupted between the pkill and the enable. The old stack was dead; the new one had never started. The user came back from a shower to a machine with no networking, and after a suspend/resume cycle it still had none — because nothing was left running to restore it.

The fix: make it atomic and self-healing

The rewritten migration is a script that cannot leave a half-state:

  1. Read SSID and PSK from /etc/network/interfaces
  2. Write the NetworkManager profile first, before touching anything
  3. Comment the interface out of ifupdown's control
  4. Stop the old stack
  5. Start NetworkManager
  6. Poll for up to 60 s for genuine connectivity — an IP and a successful ping
  7. On failure, roll back automatically: restore the backup, restart ifup@, and report

It also logs to /var/log/net-switch.log so a failure is diagnosable rather than mysterious. It ran clean: SWITCH-OK, same IP, and it survived a suspend/resume intact.

Lessons:

  • A silent command failure and an empty result look identical. Check exit status, or run privileged reads privileged.
  • Any operation that dismantles working infrastructure before building its replacement must be atomic and must roll itself back. "I'll just run these four commands" is not a plan.

Part 10 — Sleep: measuring instead of guessing

The direct question was: what happens when I close the lid — will suspend break this machine?

Rather than opine, I wrote a test script that suspends via rtcwake with a hardware timer wake, so the machine returns on its own even if the resume path is completely broken, and logs everything before sleeping so a non-returning machine still leaves evidence.

First result: it works, but the test was worthless

rtcwake rc=0  asleep for 5s (asked for 20s)
battery before: 22%   battery after: 23%

Suspend and resume worked — clean resume, network intact, no hang. But the battery went up: the machine was on AC, so the drain figure measured nothing. And it slept for 3.3 seconds instead of 20, because charging generates a constant stream of EC events, any of which can wake an s2idle system.

Second result, on battery, and a lesson in fake precision

Ten minutes in each state:

mode 10 minutes asleep woke early?
s2idle one full 1% step consumed no — slept 602 s
deep (S3) below the detection floor no — slept 604 s

The script had confidently printed "5.98 %/hour". That number is fabricated precision, and I'd written the code that produced it.

This battery reports charge_now in 38000 µAh quanta — exactly 1%. Every reading in both tests is a multiple of it: 950000, 912000, 874000, 836000. A ten-minute test that consumes one step tells you the true rate is somewhere around 3–12 %/hour. It does not tell you 5.98.

The script now reports step counts and an explicit quantisation range:

drain: 38000 uAh over 602s = 1 reporting step(s)
       midpoint 227 mAh/h (6.0 %/h, ~17h from full)
       range    0.0-12.0 %/h  <- quantisation, not noise
       only 1 step(s) resolved; test 3-4x longer for a firm number

The comparison still stands on its own: same duration, same conditions, one state consumed a measurable step and the other didn't. deep wins unambiguously, even without a precise ratio.

Making it permanent, without touching the bootloader

I had earlier described switching to S3 as a kernel-commandline change requiring a GRUB edit and a reboot. That was wrong: /sys/power/mem_sleep is writable at runtime. So the whole thing is a sysfs write:

ExecStart=/bin/sh -c 'grep -qw deep /sys/power/mem_sleep && echo deep > /sys/power/mem_sleep'

Same effect as mem_sleep_default=deep, no bootloader risk, and systemctl disable mem-sleep-deep fully reverts it.

Hibernate: blocked, and I got this wrong first

I initially reported hibernate as "95% configured, needs testing not building", pointing at the correctly-sized 11.7 GB encrypted swap and the RESUME= entry already present in the initramfs.

Then I actually looked at /sys/power/state:

freeze mem

No disk. Hibernation is unavailable. The reason:

lockdown:   none [integrity] confidentiality
secureboot: SecureBoot enabled

Secure Boot puts the kernel in integrity lockdown, which disables hibernation outright — regardless of CONFIG_HIBERNATION=y, swap sizing, or resume configuration. The kernel refuses because it can't verify the resume image hasn't been tampered with.

Everything I'd said about the storage side was correct. I simply hadn't checked the one thing that actually gated it. With S3 now working, hibernate's advantage shrank enough that keeping Secure Boot is the better trade.


Part 11 — A graphical greeter

There was no display manager at all. Login was a text console, after which Hyprland had to be started by hand.

The chosen stack: greetd → Hyprland (minimal) → gtkgreet.

Running gtkgreet inside a tiny Hyprland instance rather than under cage is deliberate: this panel needs transform 3 and scale 1.5, and cage can't rotate an output. A greeter compositor config solves it in two lines:

monitor = DSI-1, preferred, auto, 1.5, transform, 3
exec-once = gtkgreet -l -b /usr/share/backgrounds/login.png -s /etc/greetd/gtkgreet.css -c start-hyprland; hyprctl dispatch exit

greetd is deliberately on VT 7, leaving tty1–6 as plain console logins — the escape hatch if the greeter ever fails.

Hardening before shipping something unverified

I could not fully verify the greeter before the user rebooted: their live Hyprland session held DRM master, so the greeter's compositor could never acquire the display while it was running.

Shipping something unverified means hardening the failure path. greetd ships Restart=always with RestartSec=1, so a broken greeter would respawn every second, seizing VT 7 each time and making it genuinely difficult to reach a console. A drop-in caps it:

[Unit]
StartLimitIntervalSec=60
StartLimitBurst=3

Three failures in sixty seconds and it gives up, leaving you on a TTY.

Two bugs found by looking

The greeter user got no groups. This:

usermod -aG video,input,render,seat _greetd

failed entirely because the seat group doesn't exist on this system — usermod aborts the whole call rather than adding the three valid groups. It had to be split into separate invocations. A greeter with no video group cannot open the DRM device.

The wallpaper was unreachable. A home directory is drwx------, so _greetd could never have read a background from the user's home. The image had to go to /usr/share/backgrounds/.

GTK CSS silently discarding the entire stylesheet

Styling gtkgreet, I squeezed the form inward with:

box#body {
    margin-left: 34%;
    margin-right: 34%;
}

The greeter rendered in default GTK — stock blue button, wrong fonts, nothing themed. Not partially styled. Entirely unstyled.

Theme parsing error: gtkgreet.css:23:19: Junk at end of value for margin-left
style loading failed: gtkgreet.css:23:19

GTK CSS does not support percentage margins, and a single invalid declaration causes GTK to discard the whole stylesheet. One bad line, and every rule in the file evaporates.

This is the same failure shape as the empty icons and the unresolvable PATH: the system does exactly nothing, and the nothing looks like a design choice. Every render after that checked for style loading failed explicitly.

The error that flashed at boot

The greeter came up on the first real reboot — with something flashing briefly in the top-right. That's Hyprland's error overlay, and the cause:

WARNING: Hyprland is being launched without start-hyprland. This is highly advised against.

greetd was invoking Hyprland directly rather than the start-hyprland watchdog wrapper. The fix passes the config through the wrapper:

command = "start-hyprland -- --config /etc/greetd/hyprland.conf"

I had that exact warning in my very first nested test log and dismissed the neighbouring line — Creating the Error Overlay! — as routine object construction. It wasn't routine. The overlay was being built because of that warning. A user glancing at their screen for one second found what I'd had in a log file and misread.


Part 12 — Typography and a prompt

The font

The machine had zero Nerd Fonts — only legacy FontAwesome and plain JetBrains Mono. Four styles of MesloLGS NF went into ~/.local/share/fonts (user-level, no root).

Rather than replace the typeface, foot uses a fallback chain:

font=MesloLGS NF:size=16, JetBrains Mono:size=16

foot falls through to later fonts for missing glyphs, so text renders in the primary face and only icons come from the fallback.

The prompt

Starship, two lines, self-contained "pills", Tokyo Night, rendering in 27 ms:

 󰊠  …/demo    master 󰋗1   󰎙 v20.19.2   󱎫 45s
→ npm test

The first version was a continuous powerline chain. It looked good in a git repo, and left two orphaned arrows dangling in a plain directory, because chained background colours assume every segment renders. Self-contained pills — each with its own rounded caps — degrade correctly in every case. Tested across plain directory, clean repo, dirty repo, and repo-with-node.

Two other decisions:

  • right_format doesn't work in bash at all (fish/zsh/nushell only). I'd written battery and clock into it before checking. They were dropped rather than faked with $fill — waybar already shows both, and at 24pt there are only ~50 columns in a half-tiled window.
  • Segments appear only when relevant: git pill only in a repo, language pill only in a project, duration only past 2 s, exit code only on failure.

Finding a glyph, properly

Asked for a ghost icon, I guessed six codepoints and rendered them at 30pt. They turned out to be a gift box, the Dropbox logo, an image placeholder, a keyboard, a fishhook and a lightning bolt. F02A1, which I'd tried, is one past the real ghost.

So I stopped guessing and fetched the authoritative mapping:

curl -fsSL https://raw.githubusercontent.com/ryanoasis/nerd-fonts/master/glyphnames.json

Eight glyphs match "ghost". Rendering them revealed two more traps: nf-dev-ghost is the Ghost blogging platform wordmark, and nf-fa-ghost (U+EEFE) isn't present in MesloLGS NF at all — it renders as a tofu box. That one would have shipped as an empty rectangle on the strength of its name.

The winner: nf-md-ghost, U+F02A0.


Part 13 — The launcher, and matching the lock screen to it

fuzzel

Super+D had no configuration. It now has a translucent #1a1b26 panel, a 2px iris border with 14px radius, MesloLGS NF, Adwaita app icons, and a magnifier prompt glyph.

Two settings matter beyond looks:

dpi-aware=no      # scale with the compositor's 1.5x, don't compute our own DPI
match-mode=fzf    # subsequence matching: "tgd" finds Telegram Desktop

The first render used selection=2f3549, which was almost indistinguishable from the 1a1b26 background — the highlighted row was effectively invisible. 3b4261 matches waybar's hover state and reads clearly. That's the kind of thing only a screenshot tells you.

hyprlock, and a measurement that overturned an assumption

Restyling the lock screen to match used hyprlock's shape element to draw the same panel.

The first attempt came out visibly undersized. Measuring the screenshot explained why: a panel declared 440 wide rendered 440 physical pixels on a 1920-pixel screen. hyprlock draws in physical pixels and ignores the compositor's 1.5 scale — unlike fuzzel, waybar, and every normal Wayland client. Every dimension in that config is therefore ~1.5× its equivalent elsewhere, and the file says so in a comment so the next person doesn't have to rediscover it.

Final panel: 780×430, chosen to match fuzzel's measured ~787 px width so the two read as one design.


Part 14 — Lockdead

The worst outage of the evening, and entirely self-inflicted.

To preview the restyled lock screen I ran, twice in quick succession:

setsid timeout -s TERM 15 hyprlock --grace 0 &

hyprlock's own log tells the whole story:

Locking session
onLockFinished called. Seems we got yeeten. Is another lockscreen running?
corrupted double-linked list (not small)

A second hyprlock started while the first still held the session lock. Wayland refused it, and it then hit heap corruption and aborted while the session was locked. When a session locker dies without releasing the lock, the compositor deliberately stays locked — a security property, not a bug. The screen was stuck with no locker. The user had to power-cycle.

Three fixes

1. Stop testing that way. SIGTERM-ing a session locker was never safe. Previews now happen inside a nested Hyprland instance, which has its own session lock and cannot touch the real one. For a true-scale preview, the real monitor can be dropped to scale 1.0 briefly so a fullscreen nested compositor is genuinely 1920×1200 — reversible with a single hyprctl reload.

2. Make lockdead recoverable.

misc {
    allow_session_lock_restore = true
}

A freshly launched hyprlock can now adopt an orphaned lock instead of requiring a reboot. Recovery is documented in the keybinding cheatsheet: Ctrl+Alt+F2, log in, hyprctl -i 0 dispatch exec hyprlock.

3. A near-miss worth recording. While cleaning up leftover nested instances I wrote pkill -x Hyprland, which would have killed the user's real session along with the strays. It was blocked before it ran. The correct approach — identifying the nested instance by PID from ps -eo pid,tty,args and killing exactly that — takes ten more seconds and cannot destroy anything.

Earlier in the same session I'd also written pkill -f 'start-hyprland -- --config', which matched its own command line and killed the shell executing it, silently skipping every subsequent step.

pkill with broad patterns is a loaded gun pointed at whatever happens to match, including yourself.


Part 15 — Applications

Thunderbird and Telegram Desktop, with one non-obvious correction.

Telegram is a Qt5 application, and Qt5 defaults to the xcb backend — it would have run through XWayland and looked soft at 1.5 scale, the same class of problem as the tiny Firefox. qtwayland5 was already installed; it just needed telling:

env = QT_QPA_PLATFORM,wayland;xcb

The ;xcb suffix is a deliberate fallback so any Qt app still starts if the Wayland plugin fails, rather than refusing to launch. Verified: xwayland=false on both apps.

They also both opened onto workspace 1, quartering the terminal workspace into 628×374 boxes. On a 1280×800 logical screen that's unusable, so applications got homes:

windowrule = workspace = 2, match:class = ^(firefox|firefox-esr)$
windowrule = workspace = 3, match:class = ^(thunderbird)$
windowrule = workspace = 4, match:class = ^(org\.telegram\.desktop)$

Part 16 — Screen recording

wf-recorder, with two decisions that matter more than usual on this hardware.

Hardware encoding. vainfo reports H.264 and HEVC EncSliceLP — low-power encode — on renderD128. Software-encoding 1920×1200 on a 6 W N150 would have destroyed both framerate and battery:

wf-recorder -c h264_vaapi -d /dev/dri/renderD128 --codec-param=qp=24 -m mp4

Signals, again. wf-recorder must be stopped with SIGINT, not SIGTERM or SIGKILL: it writes the MP4 moov atom on interrupt, and a harder kill leaves an unplayable file. Given how the evening had gone, this was verified rather than assumed — a test capture was parsed for its top-level boxes:

top-level boxes: ['ftyp', 'free', 'mdat', 'moov']
moov present (finalised): True

Recording is a toggle (Super+Alt+R screen, +Shift region, Super+Alt+A with system audio), a red REC pill appears in waybar while active and is hidden otherwise, and the file path lands in the clipboard on stop.

Audio defaults to off, and --audio captures the default sink's .monitor — system audio, what you hear — not the microphone. Mic capture is a separate explicit --mic flag, so nothing records you by accident.



Part 17 — The console, where none of the fonts exist

The prompt looked wrong outside Hyprland. Icons missing, colours flat. The instinct is to install the font on the console too. That instinct is wrong, and usefully so.

The Linux virtual console does not use fontconfig. It loads a PSF font directly into the video hardware, and the format is hard-capped at 512 glyphs. Nerd Font icons live in the Private Use Area — U+E000U+F8FF and the supplementary block at U+F0000+. They cannot be represented in a console font at all. There is no font you can install that fixes this, because the VT never consults the font system that would find it.

The console also has no truecolor. Hex values like #bb9af7 have nowhere to go; you get the 16 ANSI colours.

So the console gets a different prompt, chosen automatically:

if command -v starship >/dev/null 2>&1; then
    if [ "$TERM" = "linux" ]; then
        export STARSHIP_CONFIG="$HOME/.config/starship-tty.toml"
    fi
    eval "$(starship init bash)"
fi

starship-tty.toml carries the same information with pure ASCII symbols and named ANSI colours — git:main, ~1 ?1 for modified and untracked, node:v20.19.2, > for the character:

user@host .../system git:main ~1 ?1 node:v20.19.2 took 4s !NOTFOUND
>

Rather than squint at it, the check was programmatic — strip the escape sequences and assert that nothing above ASCII survives:

txt = re.sub(r'\x1b\[[0-9;]*m', '', out)
bad = [(c, hex(ord(c))) for c in txt if ord(c) > 127]
#  → NONE

The bigger console problem nobody mentioned

At 216 DPI, the default Fixed 8x16 console font is 5.3 points physically. Not small — unreadable.

That matters more than it sounds, because on this machine the TTY is the documented escape hatch: it is where you go when the greeter fails to start, and where you go to recover a dead lock screen. Twice in one evening it was the recovery path. An escape hatch you cannot read is not an escape hatch.

 8x16 glyph  →  5.3pt physical   unusable
16x32 glyph  → 10.7pt physical   comfortable

Debian ships Terminus at that size, though the filenames are a trap: Lat15-Terminus32x16.psf.gz is 16 wide by 32 tall — the name lists height first. Verified by reading the PSF2 header rather than trusting the ordering:

h = struct.unpack('<I', d[24:28])[0]   # 32
w = struct.unpack('<I', d[28:32])[0]   # 16
FONTFACE="TerminusBold"
FONTSIZE="16x32"

120 × 37 cells on the panel, at a size you can actually read.

And one last silent no-op, right on cue

Applying it:

sudo setupcon --force

Exit status 0. Nothing changed. The cached loader still read:

setfont '/usr/share/consolefonts/Lat15-Fixed16.psf.gz'

with timestamps untouched from that morning's install. --force does not regenerate the cache; setupcon --save --force does. The only way to catch it was to read the generated script and compare file timestamps — the command itself reported success either way.

What was deliberately left broken

The LUKS passphrase prompt at boot is still 8×16, because Debian's console-setup package ships no initramfs hook — the hooks directory has keymap and a pile of cryptroot scripts, but nothing for the font. Getting a console font into the initramfs means hand-writing a hook, and on a machine that boots from an encrypted root, a broken initramfs hook is an unbootable machine.

A larger password prompt is not worth that risk. Documented as a known limitation instead of quietly attempted.


Part 18 — Files: one need that was already met, one that wasn't

Two requirements that sound like the same thing and aren't: a working file picker for browser uploads, and something to browse files in.

The picker needed nothing at all

The instinct is to install a file manager so the browser has something to open. That is not how it works. Firefox and Chromium get their upload dialog from GTK, or from the XDG desktop portalorg.freedesktop.portal.FileChooser, implemented here by xdg-desktop-portal-gtk. No file manager is involved.

Rather than assume it worked, the exact call the browser makes was invoked directly over D-Bus:

gdbus call --session \
  --dest org.freedesktop.portal.Desktop \
  --object-path /org/freedesktop/portal/desktop \
  --method org.freedesktop.portal.FileChooser.OpenFile \
  "" "Upload a file" "{}"

A real dialog appeared — Wayland-native, dark-themed, with Recent / Home / Other Locations. Nothing to install.

Worth noting why it worked, because it very nearly didn't: this is the same portal stack that was silently broken earlier by a stale WAYLAND_DISPLAY in the systemd user environment. The dbus-update-activation-environment fix that took waybar's startup from 25 seconds to 1 also repaired file pickers and screen sharing, which route through the identical backend. One environment variable, three unrelated-looking symptoms.

The browser: pcmanfm, and two silent config traps

For actually browsing files with image previews, the candidates by installed footprint:

size direct deps
nnn 171 KB 5
thunar 1238 KB 20
pcmanfm 1588 KB 11
lf 5610 KB 0 (static Go)

Thunar is nominally smaller than pcmanfm but drags in twenty XFCE dependencies. pcmanfm won on total weight.

On previews specifically: no lightweight GTK file manager has a hover-preview pane. What pcmanfm does instead is better in practice — persistent thumbnails in thumbnail view, so every image in a directory shows its content at once rather than one at a time under the cursor.

Getting those thumbnails working took two corrections, both silent, both mine.

Keys in the wrong section are discarded without a word. I wrote thumbnail_local and thumbnail_max under [ui]. libfm expects them in [config]. The size key is thumbnail_size, not thumbnail_icon_size. First launch showed generic mimetype icons, no error, no warning, empty thumbnail cache. The only way I found the correct names was to read the file pcmanfm itself had rewritten, which spelled out the schema it actually uses.

The config file is autogenerated and overwritten on exit:

# Configuration file for the libfm version 1.4.0.
# Autogenerated file, don't edit, your changes will be overwritten.

My first edit was reverted the instant pcmanfm launched. It has to be written while pcmanfm is not running — which is a rule the file politely states and which is very easy to skim past.

One more default worth changing: thumbnail_max=2048 is a size cap in KB. Images larger than 2 MB silently get no thumbnail. Current screenshots are ~200 KB so it wasn't the blocker, but a photo or a full-resolution capture would have quietly lost its preview later. Set to 0 for no limit.

[config]
thumbnail_local=1
thumbnail_max=0

[ui]
thumbnail_size=160
show_thumbnail=1

Bound to Super+E, and registered as the inode/directory handler so "open containing folder" works from other applications.


Part 19 — Camera and microphone, and 60 dB of stacked gain

"Does the webcam work?" is a question with two very different answers: does the device enumerate, and does it produce usable output. Only the second one matters before a video call, and only the second one requires actually capturing something.

The camera enumerated, then proved itself

/dev/video0, /dev/video1, /dev/media0
uvcvideo loaded
Hy-Usb2.0-1*MIC: Hy-UXGA(8101)-   1600x1200 / 960x720 / 640x480

Enumeration is not evidence. The real test is a frame:

v4l2-ctl -d /dev/video0 --stream-mmap --stream-count=1 --stream-to=frame.raw

168 KB for a 1600×1200 capture is far too small for raw YUYV (which would be 3.8 MB), so it should be MJPEG — confirmed by the magic bytes:

d[:2] == b'\xff\xd8'   # → JPEG

A real, compressed, correctly-sized image. That's the camera working.

I deliberately did not open the file. Format, size and a successful capture prove the device works; the contents are a photograph of somebody's room. Verification does not require looking at it, and it was deleted along with the audio test.

The microphone "worked" and was completely unusable

The first recording captured audio on the first try. It was also garbage:

6.0s   peak 0.00000 dBFS   rms -1.3 dBFS
non-zero samples: 285689/285696  (100.0%)

A microphone in a quiet room does not produce an RMS of −1.3 dBFS. That is a saturated square wave — the input pegged against the rail for six solid seconds. "The mic works" and "the mic is usable on a call" were, again, unrelated claims.

The codec's mixer explained it immediately:

Capture            63/63  [100%]  [+30.00 dB]
Internal Mic Boost  3/3   [100%]  [+30.00 dB]

Sixty decibels of gain stacked on an internal microphone. Two independent controls, each at maximum, each contributing +30 dB. Nothing warns you; the device simply reports beautifully loud audio that happens to be clipped beyond recognition.

Dropping Internal Mic Boost to 0 dB and the PipeWire source to 40%:

peak -0.3 dBFS   rms -17.6 dBFS   clipped samples: 0

Persisted with alsactl store so it survives reboots.

The measurement that nearly went wrong

My second attempt still showed clipping, and the obvious move was to keep cutting gain until it stopped. That would have been wrong.

wpctl status showed a live stream:

Streams:
    66. Firefox
         68. output_FL  > ALC269VC Analog:playback_FL  [active]

Firefox was playing audio through the speakers, and the microphone was faithfully recording it. I was about to tune a microphone against the machine's own output. Muting the sink for the duration of the measurement — and unmuting afterwards — isolated the actual room:

wpctl set-mute @DEFAULT_AUDIO_SINK@ 1
#   ... record ...
wpctl set-mute @DEFAULT_AUDIO_SINK@ 0

The lesson generalises well beyond audio: before tuning against a measurement, check what else is feeding into it.

The device name that lies

The USB module identifies itself as Hy-Usb2.0-1*MIC, which reads unambiguously as "this is where the microphone is". It is not:

Port 005: Dev 002, If 0, Class=Video, Driver=uvcvideo
Port 005: Dev 002, If 1, Class=Video, Driver=uvcvideo

Two interfaces, both Video. No USB audio class interface at all, and /proc/asound/cards lists exactly one card — the Intel HDA codec. The microphone is the built-in analog input; the camera module has no microphone despite its name. Worth knowing before hunting for a device in a conferencing app's dropdown that does not exist.

For video calls specifically

Camera access in Firefox and Chromium goes straight through V4L2 — no portal involved, and the user needs to be in the video group (or hold the logind ACL). Screen sharing does go through the portal, which means xdg-desktop-portal-hyprland has to be alive — the same portal stack that serves file pickers, and the same one that was silently dead earlier over a stale WAYLAND_DISPLAY.

Final advice worth writing down: after setting levels by measurement, confirm them with the conferencing app's own audio test and its live level meter. A number that looks right for room noise still has to be right for a speaking voice, and that is not something a script can measure for you.


Part 20 — The small pieces that make it a daily driver

Not everything worth doing needs a section of its own. These are the pieces that turn a configured compositor into a machine you actually work on.

Remote development, on a laptop that suspends

This machine is not for compiling. It is for shelling into something that is. That changes which tools matter.

mosh is the important one, and the reason is specific to a laptop: an SSH session dies when the machine suspends or changes network. Close the lid, walk to a café, open it — the SSH connection is gone and whatever it was running went with it. mosh survives both suspend and roaming, reconnects silently, and keeps local echo responsive over bad links. Paired with tmux on the remote end, closing the lid mid-build costs nothing.

The SSH config is tuned for the same reality:

Host *
    ServerAliveInterval 20
    ServerAliveCountMax 3
    TCPKeepAlive no
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 10m
    AddKeysToAgent yes
    IdentityFile ~/.ssh/id_ed25519

ServerAliveInterval with a low count means dead connections fail fast instead of hanging for minutes after a resume. ControlMaster with ControlPersist 10m multiplexes subsequent connections over the first one, so the second and third ssh to the same host are instant — noticeable on a machine where you open a lot of short-lived sessions.

Default applications, so things open where you expect

A desktop without mime associations is a desktop where double-clicking does nothing. The set here is deliberately minimal — one lightweight tool per type:

[Default Applications]
inode/directory=pcmanfm.desktop
image/png=imv.desktop
image/jpeg=imv.desktop
application/pdf=org.pwmt.zathura.desktop
text/html=firefox-esr.desktop
x-scheme-handler/https=firefox-esr.desktop

imv is a Wayland-native image viewer, zathura a keyboard-driven PDF reader. Both are small, both start instantly, and neither pulls in a desktop environment. This matters more than it sounds on a screenshot-heavy workflow: the screenshot tool saves a file, and you want looking at it to be free.

Clipboard history

cliphist with two watchers — one for text, one for images:

exec-once = pgrep -f "type text --watch cliphist" >/dev/null || wl-paste --type text --watch cliphist store
exec-once = pgrep -f "type image --watch cliphist" >/dev/null || wl-paste --type image --watch cliphist store

Super+Shift+V pipes the history through fuzzel and copies the selection back. Image entries are stored too, which pairs neatly with a screenshot tool that always copies to the clipboard — the last several screenshots stay retrievable rather than being overwritten by the next Ctrl+C.

The pgrep guards are the same singleton pattern used for hypridle: these are per-user daemons, and a second login session should not start a second copy.

A cheatsheet, because eighty bindings is too many to remember

Super+/ opens a formatted reference in a floating terminal — apps, window management, workspaces, screenshots, recording, clipboard, and the recovery steps for a hung lock screen. It is a shell script with printf, which means it costs nothing and cannot itself break.

Everything in version control

All of it lives in a git repo in ~/.config, including copies of the files that live outside it — the four custom systemd units, the greeter config in /etc/greetd, ~/.bashrc and ~/.profile, and /etc/default/console-setup — each with a README noting the live path, since editing the copy does nothing.

Fifteen commits, each one explaining why rather than what. That matters more than usual here, because a surprising amount of this configuration looks arbitrary until you know which silent failure it exists to prevent.


MiniBook X quirks worth knowing

Things specific to this hardware that cost time, and that anyone else running Linux on an N150 MiniBook X will meet.

The panel is mounted rotated

The DSI panel is physically 1200×1920 portrait, mounted sideways. Everything must be rotated in software:

monitor = DSI-1, preferred, auto, 1.5, transform, 3

The kernel console needs telling separately — this machine already had fbcon=rotate:1 on the kernel command line, which is why the boot text and TTYs are the right way up. Without it, your escape hatch is sideways.

The touchscreen does not inherit the rotation. Wayland won't infer it, so taps land in the wrong place until you match the transform explicitly:

input {
    touchdevice {
        transform = 3
    }
}

Any greeter or lock screen also needs its own rotation, which is why the greeter here runs gtkgreet inside a minimal Hyprland rather than under cage — cage can't rotate an output.

Goodix touchscreen throws an i2c error on every resume

Goodix-TS i2c-GDIX1002:00: Error reading 1 bytes from 0x8047: -121

-121 is EREMOTEIO, and it fires on every wake from suspend. It looks alarming in dmesg --level=err and it is entirely cosmetic: the device never re-probes, stays registered on the same input node, and touch works normally afterwards. Verified by resuming and using it.

DSI link not ready on resume, and atomic-update warnings in normal use

i915 0000:00:02.0: [drm] *ERROR* DSI link not ready
i915 0000:00:02.0: [drm] *ERROR* Atomic update failure on pipe A ...

Both are logged at error level and neither indicates a real fault. Check the uptime stamps: the atomic-update messages appear mid-session, nowhere near a suspend, so they're routine scanline-timing warnings on a DSI panel. The link message appears at resume and the display recovers immediately.

The internal microphone ships with +60 dB of gain

Out of the box, the ALC269VC codec has both capture controls at maximum:

Capture            63/63  [+30.00 dB]
Internal Mic Boost  3/3   [+30.00 dB]

The result is audio clipped into a square wave — it records, and it is unusable on a call. Fix, then persist it:

amixer -c 0 sset "Internal Mic Boost" 0
wpctl set-volume @DEFAULT_AUDIO_SOURCE@ 0.40
sudo alsactl store

The camera module is named "MIC" and has no microphone

The webcam enumerates as Hy-Usb2.0-1*MIC, which reads like a combined camera-and-microphone device. lsusb -t shows two interfaces, both Class=Video, and /proc/asound/cards lists only the Intel HDA codec. There is no camera-side microphone. The camera itself is fine: MJPEG at 1600×1200, 960×720 and 640×480.

The firmware has broken ACPI tables

Every boot logs exactly six ACPI errors:

ACPI BIOS Error (bug): Failure creating named object [\_SB.PC00.RP09.PXSX._DSD], AE_ALREADY_EXISTS
ACPI BIOS Error (bug): Could not resolve symbol [\_SB.PC00.TXHC.RHUB.SS01], AE_NOT_FOUND
Bluetooth: hci0: No support for _PRR ACPI method

Six on this boot, six on the previous, six on the one before. They are CHUWI firmware bugs, they are harmless, and they will alarm you if you go looking at journalctl -p 3 for the first time after changing something. Count them across boots before blaming your own work.

The firmware refuses Secure Boot dbx updates

fwupd offers a Microsoft UEFI revocation-database update and then reports:

Update Error: System firmware cannot accept DBX updates

So there is effectively nothing installable via fwupd on this machine. Worth setting up anyway, but don't expect it to do anything.

Secure Boot blocks hibernation

lockdown:   none [integrity] confidentiality
secureboot: SecureBoot enabled

Secure Boot puts the kernel in integrity lockdown, and lockdown disables hibernation outright. /sys/power/state shows freeze mem with no disk, no matter how correctly your swap is sized or how carefully RESUME= is set in the initramfs. Your options are to disable Secure Boot in firmware or to do without hibernate.

But S3 deep sleep does work — and it's worth switching to

This is the good news, and it's unusual for a recent Intel laptop:

/sys/power/mem_sleep:  [s2idle] deep

deep is offered and functional. Measured over ten minutes each on battery, s2idle consumed a full 1% battery step while deep stayed below the measurement floor entirely. Since /sys/power/mem_sleep is writable at runtime, switching needs no bootloader edit at all:

ExecStart=/bin/sh -c 'grep -qw deep /sys/power/mem_sleep && echo deep > /sys/power/mem_sleep'

With hibernate unavailable, this is the single most valuable power change on the machine.

Battery reports charge in 1% quanta

charge_now on this battery only moves in 38000 µAh steps — exactly 1% of its 3800 mAh design capacity. Any drain measurement shorter than an hour resolves one or two steps and cannot support more than one significant figure. Do not let a script print you "5.98 %/hour" from a ten-minute sample; it is arithmetic, not measurement.

The N150 has hardware video encoding — use it

VAProfileH264Main:  VAEntrypointEncSliceLP
VAProfileHEVCMain:  VAEntrypointEncSliceLP

EncSliceLP is Intel's low-power encode path, available on /dev/dri/renderD128 once intel-media-va-driver is installed. On a 6 W part, software-encoding a 1920×1200 screen recording is not viable; VAAPI is close to free.


The machine itself: worth it

This post is a long list of things that broke, so it would be easy to come away with the wrong impression. Let me correct that, because the MiniBook X is a genuinely excellent little computer and most of the problems above were software drift, not hardware faults.

It is completely silent, and it stays cold. There are zero fans in this chassis — /sys/class/hwmon/*/fan1_input returns nothing to enumerate. Under a load average of ~1.0 with Firefox, Thunderbird and Telegram all running, the package temperature sits at 34 °C against an ambient of 27.8 °C. Six degrees over room temperature. You can work with it on your lap indefinitely, and there is no fan noise because there is no fan.

It is quick in the ways you actually notice. The N150 is a 6 W part, and on paper that sounds like a compromise. In practice: graphical.target is reached 4.3 seconds into userspace, the shell prompt renders in 27 ms, the status bar starts in 1 second, and applications launch without the pause you brace for on low-power hardware. LPDDR5-6400 across two channels is doing real work here — this is not the sluggish Atom experience the "N" prefix might suggest.

The battery genuinely lasts. Measured on battery with the full application stack running: 3.83 W, projecting 7.6 hours from full on a 3800 mAh cell. Idle at the desktop it drops to 3.57 W. And with S3 deep sleep enabled, a closed lid consumed less than the battery's own 1% measurement resolution across ten minutes — you can shut it and walk away without watching the charge evaporate.

The screen is the standout feature. 1920×1200 on a 10.5-inch panel works out to 216 PPI. For context:

display PPI
CHUWI MiniBook X 216
MacBook Air 13" 225
MacBook Pro 14" 254
typical 24" 1080p desktop monitor 92

It is within four percent of a MacBook Air's density, on a machine that costs a fraction as much. Text at 1.5× scale is genuinely crisp — the entire scaling section of this post exists precisely because the panel is too good to run unscaled. The 3:2 aspect ratio is also the right choice for a small screen: more vertical space for code and documents than a 16:9 panel of the same diagonal.

The build quality is not what the price suggests. Aluminium chassis, a 360° hinge with no flex, a keyboard that is genuinely usable at this size, and a touchscreen that works properly once you tell Wayland the panel is rotated. It feels like a considerably more expensive machine than it is.

So: should you buy one?

Yes — with clear eyes about what you are buying.

The issues catalogued in this post are worth reading before you commit, and they are almost all one-time setup costs rather than daily friction:

  • Secure Boot blocks hibernation. S3 deep sleep works, which covers most of the same ground, but you should know before you plan around hibernate.
  • The internal microphone ships with +60 dB of stacked gain and must be turned down before it is usable on a call. One command, once.
  • The camera module is misleadingly named and has no microphone in it.
  • The firmware logs six harmless ACPI errors on every boot and refuses dbx updates.
  • The panel is mounted rotated, so anything that draws to the screen — greeter, lock screen, console — needs to be told about it.

None of that is a defect in daily use. Every one of them is a thing you configure once and never think about again. What you get in return is a fanless, silent, genuinely portable machine with a near-Retina screen and seven-plus hours of real battery life, running a full Wayland desktop comfortably on 6 watts.

For SSH-ing into a build box, taking screenshots, writing, and light development — the exact workload this was set up for — it is hard to think of anything better at the price. It is a fantastic little machine and it is well worth what it costs.


What the finished system looks like

~/.config/                          15 commits, 39 tracked files
├── hypr/
│   ├── hyprland.conf               277 lines — compositor, 80+ binds
│   ├── hyprlock.conf               109 lines — lock screen
│   ├── hypridle.conf                28 lines — idle policy
│   └── hyprpaper.conf                     — wallpaper
├── waybar/{config.jsonc,style.css}  bar + Tokyo Night CSS
├── fuzzel/fuzzel.ini                launcher
├── starship.toml                   214 lines — prompt (Nerd Font, truecolor)
├── starship-tty.toml               ASCII/16-colour prompt for the VT
├── foot/foot.ini                    terminal
├── mako/config                      notifications
├── swappy/config                    screenshot annotation
├── pcmanfm/, libfm/                 file manager + thumbnail settings
├── gtk-3.0/, gtk-4.0/               GTK settings (no gsettings on this box)
├── scripts/
│   ├── screenshot                   grim/slurp/swappy → clipboard + file
│   ├── record                       wf-recorder toggle, VAAPI
│   ├── hypr-keys                    keybinding cheatsheet (Super+/)
│   ├── sleep-test.sh                rtcwake harness with honest drain math
│   └── net-switch.sh                atomic ifupdown→NM migration w/ rollback
├── systemd-system/                  copies of 4 custom units, for VCS
├── greetd-system/                   copies of the greeter config, for VCS
├── home-dotfiles/                   copies of ~/.bashrc, ~/.profile
└── etc-console-setup/               copy of /etc/default/console-setup

Custom systemd units: epp-power, powertop, wifi-powersave, mem-sleep-deep.

Measured outcomes: 3.68 W → 3.57 W idle; s2idle → deep (S3) sleep; waybar startup 25 s → 1 s; prompt renders in 27 ms; every application confirmed Wayland-native.


The pattern

Nineteen distinct problems were solved over the course of this build. Almost none of them announced themselves.

  • A binding that did nothing, because PATH lacked one directory
  • Icons that rendered as blank space, because a font family had a different name than every tutorial claims
  • Glyphs stripped to empty strings in transit, twice
  • A stylesheet entirely discarded over one unsupported unit
  • A wallpaper daemon that logged one line and gave up
  • A workspace rule that parses cleanly and does nothing
  • A menu item pointing at a program that isn't installed
  • A 25-second startup delay caused by a display name from a session that no longer existed
  • Fabricated precision from a quantised sensor
  • A permission error read as an empty file
  • A usermod that added no groups because one name was invalid
  • A pkill that killed the shell running it
  • A setupcon --force that exited 0 and regenerated nothing
  • Config keys placed in the wrong INI section, discarded without comment
  • A config file silently overwritten by the program that reads it
  • A microphone at +60 dB reporting healthy, fully-clipped audio
  • A USB device whose name advertises a microphone it does not have

The tools were not lying. They were saying nothing at all, and nothing is indistinguishable from success until you go looking.

Three habits did most of the work:

Verify the effect, not the syntax. hyprctl configerrors being empty means the parser was satisfied. It says nothing about whether the workspace exists, the wallpaper drew, or the binding fired. Every claim in this post that survived was one where the outcome was observed — a screenshot taken, a process listed, a file's byte structure parsed.

Test in the real environment. The screenshot binding worked every time I tested it, because I was testing in my shell and not in Hyprland's. The greeter's compositor could never be verified while a live session held DRM master. Where the real environment couldn't be reached, the honest move was to say so and harden the failure path instead of claiming success.

When you can see it, look at it. fuzzel, gtkgreet and hyprlock are all Wayland clients that can be screenshotted. Every visual decision here — the invisible selection colour, the crushed background gradient, the orphaned powerline arrows, the undersized lock panel, the fake ghost glyphs — was caught by rendering the thing and looking at it, not by reasoning about the config.

The single most expensive mistake, the lockdead reboot, came from breaking that last rule in the most direct way possible: I tested a lock screen by locking a screen someone was using.


What is still outstanding

In the spirit of the rest of this post, the things that are not done:

There is no off-machine backup. The configuration is in git — fifteen commits — but git remote returns nothing. It lives on exactly one encrypted NVMe. Every silent failure catalogued here was recoverable; a dead disk would not be. This is the highest-value remaining task and it takes about a minute.

No automatic security updates. unattended-upgrades is not installed, on a laptop that roams onto networks it does not control.

No on-screen keyboard, on a 360° convertible. Fold it into tablet mode and there is no way to type — including at the greeter and the lock screen, which would strand you. wvkbd is the Wayland answer, and it needs wiring into hyprlock and gtkgreet specifically, not just the desktop session.

The sleep drain figure is one significant figure at best. Ten-minute samples resolve a single 1% battery step. An hour-long test would give a real number; the comparison between s2idle and deep stands regardless, because both ran under identical conditions.

Secure Boot versus hibernation is an open decision, deliberately left to the owner. With S3 working, the case for disabling Secure Boot is much weaker than it was.

hyprlock looks fragile in this build. It aborted with corrupted double-linked list when a second instance was refused the session lock — and it aborted with the same message during a harmless config parse check with no display attached. It locks and unlocks reliably in normal use, and allow_session_lock_restore now makes a failure recoverable rather than terminal, but it is worth knowing.

The LUKS passphrase prompt is still 8×16. Deliberate: Debian ships no console-setup initramfs hook, and hand-writing one on an encrypted-root machine risks an unbootable system.

Two things were checked and needed nothing: fstrim.timer is enabled and active (correct, given discard on the LUKS volume), and the firmware has no installable updates.


Before you copy anything: what to substitute

Paths and device names in this post are from one machine. Four things need changing, and one of them is not optional.

/home/YOUR-USER appears in hyprland.conf and hyprpaper.conf. These must be absolute paths — Hyprland does not expand $HOME in env = or in $variable definitions, and hyprpaper 0.8.4 does not expand ~ in a wallpaper { path = } block either. That last one fails silently: you get one log line about the monitor having no target, and no wallpaper. Substitute your actual username rather than trying to make ~ work.

(hyprlock.conf is the exception — it does expand ~ in its background path.)

DSI-1 is this panel's output name. It is the same on any MiniBook X, but on other hardware run hyprctl monitors and use whatever Monitor <name> reports.

wlp0s20f3 is this machine's wireless interface, referenced in the wifi-powersave unit. Find yours with ip -br link.

card0 / /dev/dri/renderD128 are the ALSA card index and the DRM render node used in the microphone and screen-recording commands. Both are index 0 on a single-GPU, single-sound-card laptop, which this is; confirm with aplay -l and ls /dev/dri/.

Everything else — usernames in prompt examples, hostnames — is cosmetic.


Appendix — reproducing this from scratch

An ordered runbook. Everything here was done on this machine; the parts that predate the desktop build (the Debian install itself, backports, the console rotation) are included because without them the rest does not work.

1. The Debian install

Debian 13 "trixie", installed from the netinst image. At the software selection step, everything graphical is deselected:

[ ] Debian desktop environment
[ ] ... GNOME / KDE / Xfce / etc
[*] SSH server
[*] standard system utilities

That is the whole point — no display manager, no desktop, no half of GNOME arriving as a dependency. You get a text console and a network stack, and every graphical component that ends up on the machine is one you chose.

For partitioning: Guided — use entire disk and set up encrypted LVM. That produces the layout this machine runs:

nvme0n1
├─nvme0n1p1   976M  vfat         /boot/efi
├─nvme0n1p2   977M  ext4         /boot          (unencrypted; required for LUKS root)
└─nvme0n1p3   475G  crypto_LUKS
  └─nvme0n1p3_crypt  LVM2 volume group "host-vg"
    ├─host--vg-root    463.2G  ext4   /
    └─host--vg-swap_1   11.7G  swap   [SWAP]

/boot has to sit outside the encrypted volume so the bootloader can read a kernel and initramfs; everything else is inside. The resulting crypttab entry matters later:

nvme0n1p3_crypt UUID=... none luks,discard,x-initrd.attach

discard enables TRIM through the LUKS layer (pair it with fstrim.timer, which Debian enables by default), and x-initrd.attach unlocks the volume early — which is what would make hibernate-to-encrypted-swap possible, if Secure Boot were not blocking hibernation.

2. Console rotation, before anything else

This panel is mounted rotated. Until the kernel console is rotated, the text console — your only interface at this point, and your escape hatch forever after — is sideways.

sudo nano /etc/default/grub
GRUB_CMDLINE_LINUX_DEFAULT="quiet fbcon=rotate:1"
sudo update-grub

fbcon=rotate:1 rotates the framebuffer console 90° clockwise. It is independent of anything Wayland does later — Hyprland's transform = 3 handles the graphical session, and this handles TTYs, the GRUB menu's aftermath, and the boot messages.

Reboot and confirm the console is readable before continuing.

3. A readable console font

At 216 DPI the default Fixed 8x16 is 5.3 points. Fix it now, while the console is still the primary interface:

sudo nano /etc/default/console-setup
FONTFACE="TerminusBold"
FONTSIZE="16x32"
sudo setupcon --save --force     # --force alone silently does nothing

4. Enable backports

Hyprland is not in Debian stable:

echo 'deb http://deb.debian.org/debian trixie-backports main' \
    | sudo tee /etc/apt/sources.list.d/trixie-backports.list
sudo apt update

5. Install the compositor and the desktop

hyprland exists only in backports, so it would resolve there on its own. Pass -t trixie-backports anyway: the flag is what allows its dependencies to be satisfied from backports too. mesa, PipeWire, WirePlumber and libxkbcommon all exist in stable at older versions, and without the flag you get a new compositor on an old graphics stack:

sudo apt install -t trixie-backports hyprland xdg-desktop-portal-hyprland

Then everything else — see the install appendix below for the full command and which packages resolve to backports on their own.

6. Group membership

sudo usermod -aG video $USER      # camera and DRM access

Log out and back in for it to take effect. Note that usermod -aG a,b,c aborts entirely if any one group does not exist — issue them separately if unsure.

7. Fonts

mkdir -p ~/.local/share/fonts && cd ~/.local/share/fonts
base=https://github.com/romkatv/powerlevel10k-media/raw/master
for style in Regular Bold Italic "Bold%20Italic"; do
    curl -fsSL "$base/MesloLGS%20NF%20${style}.ttf" \
         -o "MesloLGS NF ${style//%20/ }.ttf"
done
fc-cache -f ~/.local/share/fonts

8. The four systemd units

None of these ship with anything; all are hand-written to /etc/systemd/system/, then sudo systemctl enable --now <name>.

# epp-power.service — intel_pstate energy preference
[Unit]
Description=Set intel_pstate energy_performance_preference to power
After=multi-user.target
[Service]
Type=oneshot
ExecStart=/bin/sh -c 'for f in /sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference; do echo power > "$f"; done'
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
# mem-sleep-deep.service — S3 instead of s2idle
[Service]
Type=oneshot
ExecStart=/bin/sh -c 'grep -qw deep /sys/power/mem_sleep && echo deep > /sys/power/mem_sleep'
RemainAfterExit=yes
# wifi-powersave.service
[Service]
Type=oneshot
ExecStart=/usr/sbin/iw dev wlp0s20f3 set power_save on
RemainAfterExit=yes
# powertop.service — runtime PM for PCI/SATA/audio
[Service]
Type=oneshot
ExecStart=/usr/sbin/powertop --auto-tune
RemainAfterExit=yes

Also sudo systemctl disable --now bluetooth if you have no paired devices, and sudo systemctl enable --now thermald.

9. The greeter

Four files, all under /etc/greetd/, plus a systemd drop-in:

  • config.tomlvt = 7, and command = "start-hyprland -- --config /etc/greetd/hyprland.conf". Launching Hyprland directly makes it raise a warning into its on-screen error overlay.
  • hyprland.conf — a minimal compositor carrying the same monitor = DSI-1, preferred, auto, 1.5, transform, 3, input:touchdevice:transform = 3, and env = GTK_THEME,Adwaita:dark, whose only exec-once runs gtkgreet and then exits.
  • gtkgreet.css — theming. Remember GTK CSS rejects percentage units and discards the entire stylesheet on one bad declaration.
  • environments — one line: start-hyprland.
sudo usermod -aG video _greetd
sudo usermod -aG input _greetd
sudo usermod -aG render _greetd

The background must live somewhere _greetd can read — /usr/share/backgrounds/, not your home directory, which is mode 0700.

And the safety net, at /etc/systemd/system/greetd.service.d/10-restart-limit.conf:

[Unit]
StartLimitIntervalSec=60
StartLimitBurst=3
[Service]
RestartSec=2

greetd defaults to Restart=always with a one-second delay; without this, a broken greeter respawns forever and seizes VT 7 each time, making it very hard to reach a console to fix it.

10. Networking

If the installer left you on ifupdown (a hand-rolled wpa_supplicant plus dhcpcd), migrating to NetworkManager gives you roaming, nmtui, and a working click target in the status bar. The PSK is already on disk in /etc/network/interfacesmode 0600, so read it with sudo; a plain cat fails with permission denied and prints nothing, which is easy to misread as an empty file.

Write the NetworkManager profile before tearing down the old stack, and have the migration roll itself back if connectivity does not return.

11. The microphone

Non-negotiable before any video call on this hardware:

amixer -c 0 sset "Internal Mic Boost" 0
wpctl set-volume @DEFAULT_AUDIO_SOURCE@ 0.40
sudo alsactl store

12. Verify, rather than assume

hyprctl configerrors                       # empty
cat /sys/power/mem_sleep                   # s2idle [deep]
cat /sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference   # power
systemctl --failed                         # 0 units
systemctl is-active greetd thermald powertop mem-sleep-deep
wpctl status                               # a sink and a source
v4l2-ctl -d /dev/video0 --stream-mmap --stream-count=1 --stream-to=/tmp/f.raw

Then reboot once and confirm the greeter comes up on its own. Everything in this post that broke, broke because something was assumed rather than checked.


Appendix — everything installed, and the backports situation

Why backports is involved at all

Debian 13 "trixie" is a stable release, and stable does not carry Hyprland or its satellite tools. They live in trixie-backports — packages built from testing/unstable and recompiled against stable's libraries.

On this machine backports was already enabled before the desktop build started, so nothing in this post required editing apt sources. For anyone reproducing it from a clean Debian 13, this is the setup:

echo 'deb http://deb.debian.org/debian trixie-backports main' \
    | sudo tee /etc/apt/sources.list.d/trixie-backports.list
sudo apt update

The full source list on this machine, for reference:

# /etc/apt/sources.list
deb http://deb.debian.org/debian/ trixie main non-free-firmware
deb http://security.debian.org/debian-security trixie-security main non-free-firmware
deb http://deb.debian.org/debian/ trixie-updates main non-free-firmware

# /etc/apt/sources.list.d/trixie-backports.list
deb http://deb.debian.org/debian trixie-backports main

The mechanic worth understanding: priority 100

Backports is marked NotAutomatic, which apt translates into priority 100 — below the default 500 of the normal archives:

100 http://deb.debian.org/debian trixie-backports/main amd64 Packages
    release o=Debian Backports,a=stable-backports,n=trixie-backports,...
500 http://deb.debian.org/debian trixie-updates/main amd64 Packages

That has two consequences, and they are easy to get backwards:

Enabling backports will not silently upgrade anything. Nothing already installed from stable gets pulled forward. fwupd on this machine exists in both archives and stayed on the stable version (2.0.20-1~deb13u1) precisely because 500 beats 100. This is the whole point of the design — you opt in per package, not wholesale.

But you do not always need -t. If a package exists only in backports, its version is the only candidate and apt installs it without argument. That is why apt install hyprlock worked here with no flags: there is no hyprlock in trixie main at all.

You need -t trixie-backports when the package exists in both and you want the newer one — and critically, that flag also lets apt satisfy dependencies from backports. That is how the graphics stack on this machine ended up modernised: installing Hyprland from backports dragged mesa 26.1.2, PipeWire 1.4.9, WirePlumber 0.5.12 and libxkbcommon 1.13.1 forward with it, all of which do exist in stable at older versions.

Forty-eight packages on this system now come from backports. The load-bearing ones:

package version why
hyprland 0.55.2+ds-1~bpo13+1 not in stable
hyprlock 0.9.5-1~bpo13+1 not in stable
hypridle 0.1.7-2~bpo13+1 not in stable
hyprpaper 0.8.4-1~bpo13+1 not in stable
xdg-desktop-portal-hyprland 1.3.12-1~bpo13+1 not in stable
telegram-desktop 5.7.2+ds-2~bpo13+1 not in stable
mesa-* 26.1.2-1~bpo13+1 pulled as a Hyprland dependency
pipewire, wireplumber 1.4.9 / 0.5.12 pulled as a dependency
libxkbcommon* 1.13.1-1~bpo13+1 pulled as a dependency

This is also, indirectly, the reason so much of this post is about version drift: running a compositor from backports on a stable base means the software is considerably newer than most of the documentation written about it.

The full install, in one command

Everything added during this build. Debian shipped hyprland, waybar, mako, foot, fuzzel, grim, slurp, wl-clipboard, brightnessctl, powertop, jq, git, mosh, tmux and rsync already:

sudo apt update && sudo apt install -y \
    hyprlock hypridle hyprpaper cliphist \
    greetd gtkgreet \
    network-manager thermald fwupd iw \
    imv zathura swappy pcmanfm \
    wf-recorder vainfo intel-media-va-driver \
    thunderbird telegram-desktop \
    starship \
    util-linux-extra v4l-utils alsa-utils libglib2.0-bin

Of those, four resolve to backports automatically because they have no stable version — hyprlock, hypridle, hyprpaper and telegram-desktop. The rest come from trixie main. No -t flag is needed for any of them.

The last line is diagnostics that earned their place during the build:

package provides used for
util-linux-extra rtcwake timed-wake suspend testing
v4l-utils v4l2-ctl proving the camera captures frames
alsa-utils amixer, alsactl finding and fixing +60 dB of mic gain
libglib2.0-bin gdbus invoking the FileChooser portal directly

If you would rather not install the diagnostics permanently, everything except alsa-utils can be removed afterwards — but alsactl is what persists the mixer settings across reboots, so that one should stay.

Fonts, which are not packaged

MesloLGS NF is not in Debian. Installed per-user, no root required:

mkdir -p ~/.local/share/fonts && cd ~/.local/share/fonts
base=https://github.com/romkatv/powerlevel10k-media/raw/master
for style in Regular Bold Italic "Bold%20Italic"; do
    curl -fsSL "$base/MesloLGS%20NF%20${style}.ttf" \
         -o "MesloLGS NF ${style//%20/ }.ttf"
done
fc-cache -f ~/.local/share/fonts

Debian's fonts-font-awesome is worth having as well, but note the trap from Part 2: it installs a family named FontAwesome, not Font Awesome 6 Free.


Appendix — the finished hyprland.conf

The thirteen lines at the top of this post grew into this. Reproduced in full, because most of the comments exist to record why a line is the way it is — which silent failure it prevents, or which 0.55 syntax change it survives. This file and every other one below are in the repo: github.com/mattnovakemail/minibook-x-hyprland.

###############################################################################
# MONITOR  --  1200x1920 DSI panel, rotated 270 deg -> 1920x1200 landscape
#
# The panel is 10.5" at 1920x1200 = ~216 DPI, i.e. 2.25x a normal 96 DPI
# display, which is why everything renders tiny at scale 1. Scale 1.5 gives
# 1280x800 logical (an exact integer division, so no blurry resampling) and
# ~144 DPI effective. Every Wayland client scales with this -- GTK apps,
# Firefox, dialogs, cursors -- not just text.
###############################################################################
monitor = DSI-1, preferred, auto, 1.5, transform, 3

# Hyprland does not inherit the login shell's PATH, so ~/.local/bin is absent
# and any bind calling a script there silently does nothing.
# Telegram is Qt5, which defaults to xcb -> XWayland -> soft at 1.5x scale.
# ";xcb" is a fallback so a Qt app still starts if the wayland plugin fails.
env = QT_QPA_PLATFORM,wayland;xcb
env = QT_WAYLAND_DISABLE_WINDOWDECORATION,1
env = QT_AUTO_SCREEN_SCALE_FACTOR,1

env = XCURSOR_SIZE,24
env = HYPRCURSOR_SIZE,24
env = PATH,/home/YOUR-USER/.config/scripts:/home/YOUR-USER/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games

$scripts  = /home/YOUR-USER/.config/scripts
$mod      = SUPER
$terminal = foot
$menu     = fuzzel
$browser  = firefox

###############################################################################
# AUTOSTART
###############################################################################
# The systemd/dbus user manager outlives a session and keeps the OLD
# WAYLAND_DISPLAY, which makes xdg-desktop-portal-gtk fail to start and costs
# waybar a 25s timeout on every launch. Hand it the live session's values first.
exec-once = dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE HYPRLAND_INSTANCE_SIGNATURE
exec-once = systemctl --user restart xdg-desktop-portal-gtk.service

exec-once = hyprpaper
exec-once = waybar
exec-once = mako
exec-once = pgrep -x hypridle >/dev/null || hypridle    # singleton: global hardware
exec-once = pgrep -f "type text --watch cliphist" >/dev/null || wl-paste --type text --watch cliphist store
exec-once = pgrep -f "type image --watch cliphist" >/dev/null || wl-paste --type image --watch cliphist store

###############################################################################
# INPUT
###############################################################################
input {
	kb_layout = us
	follow_mouse = 1
	sensitivity = 0

	touchpad {
		natural_scroll = true
		tap-to-click = true
		drag_lock = true
		disable_while_typing = true
		scroll_factor = 0.6
	}

	# Touchscreen must match the panel rotation, or taps land in the wrong spot.
	touchdevice {
		transform = 3
	}
}

# Three fingers sideways = change desktop; four fingers = scratchpad.
gesture = 3, horizontal, workspace
gesture = 4, up, special, magic

###############################################################################
# LOOK
###############################################################################
general {
	gaps_in = 3
	gaps_out = 5
	border_size = 2
	col.active_border = rgba(7aa2f7ff) rgba(bb9af7ff) 45deg
	col.inactive_border = rgba(2a2f41aa)
	resize_on_border = true
	layout = dwindle
}

decoration {
	rounding = 6

	blur {
		enabled = true
		size = 4
		passes = 2
	}

	shadow {
		enabled = true
		range = 12
		render_power = 3
		color = rgba(00000055)
	}
}

animations {
	enabled = true
	bezier = snap, 0.05, 0.9, 0.1, 1.05
	animation = windows, 1, 4, snap, popin 80%
	animation = border, 1, 8, default
	animation = fade, 1, 5, default
	animation = workspaces, 1, 4, snap, slide
}

dwindle {
	preserve_split = true
	smart_resizing = true
}

misc {
	# If the lock screen ever dies while holding the session lock, Hyprland
	# normally leaves the session locked with no locker ("lockdead") and the
	# only way out is a reboot. This lets a freshly launched hyprlock adopt the
	# orphaned lock instead. Costs nothing; saves a forced reboot.
	allow_session_lock_restore = true

	force_default_wallpaper = 0
	disable_hyprland_logo = true
	background_color = rgb(1a1b26)
	focus_on_activate = true
}

###############################################################################
# WINDOW RULES
###############################################################################
# Hyprland 0.55 rule syntax: <field> = <value>, match:<field> = <value>
windowrule = suppress_event = maximize, match:class = .*
windowrule = float = true, match:class = ^(pavucontrol|nm-connection-editor|blueman-manager)$
windowrule = float = true, match:title = ^(Open File|Save File|Choose Files|Open Folder)$
windowrule = float = true, match:class = ^(foot-float)$
windowrule = size = 60% 55%, match:class = ^(foot-float)$
windowrule = center = true, match:class = ^(foot-float)$
# Give the big apps their own desktops -- at 1280x800 logical, tiling four
# windows on one workspace leaves each ~628x374, which is unusable.
windowrule = workspace = 2, match:class = ^(firefox|firefox-esr)$
windowrule = workspace = 3, match:class = ^(thunderbird)$
windowrule = workspace = 4, match:class = ^(org\.telegram\.desktop)$

# Stop empty XWayland drag-shadow windows from stealing focus
windowrule = no_focus = true, match:class = ^$, match:title = ^$, match:xwayland = true, match:float = true, match:fullscreen = false, match:pin = false

###############################################################################
# KEYBINDS -- apps
###############################################################################
bind = $mod, Return, exec, $terminal
bind = $mod, D, exec, $menu
bind = $mod, B, exec, $browser
bind = $mod, E, exec, pcmanfm        # file manager
bind = $mod SHIFT, Return, exec, $terminal --app-id=foot-float

###############################################################################
# KEYBINDS -- window management
###############################################################################
bind = $mod, Q, killactive                          # close the focused window
bind = $mod, F, fullscreen, 0                       # true fullscreen
bind = $mod SHIFT, F, fullscreen, 1                 # maximize (keeps bar + gaps)
bind = $mod CTRL, F, fullscreenstate, -1, 2         # fake fullscreen (games)
bind = $mod, V, togglefloating
bind = $mod SHIFT, space, togglefloating
bind = $mod, P, pseudo
bind = $mod, Y, layoutmsg, togglesplit
bind = $mod, C, centerwindow
bind = $mod, T, pin                                 # keep floating window on top

# Move focus
bind = $mod, left,  movefocus, l
bind = $mod, right, movefocus, r
bind = $mod, up,    movefocus, u
bind = $mod, down,  movefocus, d
bind = $mod, H, movefocus, l
bind = $mod, K, movefocus, u
bind = $mod, L, movefocus, r
bind = $mod, J, movefocus, d

# Move the window itself
bind = $mod SHIFT, left,  movewindow, l
bind = $mod SHIFT, right, movewindow, r
bind = $mod SHIFT, up,    movewindow, u
bind = $mod SHIFT, down,  movewindow, d
bind = $mod SHIFT, H, movewindow, l
bind = $mod SHIFT, K, movewindow, u
bind = $mod SHIFT, L, movewindow, r
bind = $mod SHIFT, J, movewindow, d

# Resize (held down = repeats)
binde = $mod ALT, left,  resizeactive, -40 0
binde = $mod ALT, right, resizeactive, 40 0
binde = $mod ALT, up,    resizeactive, 0 -40
binde = $mod ALT, down,  resizeactive, 0 40
binde = $mod ALT, H, resizeactive, -40 0
binde = $mod ALT, K, resizeactive, 0 -40
binde = $mod ALT, L, resizeactive, 40 0
binde = $mod ALT, J, resizeactive, 0 40

# Mouse: SUPER + left-drag moves, SUPER + right-drag resizes
bindm = $mod, mouse:272, movewindow
bindm = $mod, mouse:273, resizewindow

###############################################################################
# KEYBINDS -- 5 desktops
###############################################################################
bind = $mod, 1, workspace, 1
bind = $mod, 2, workspace, 2
bind = $mod, 3, workspace, 3
bind = $mod, 4, workspace, 4
bind = $mod, 5, workspace, 5

# Send the window to a desktop and follow it
bind = $mod SHIFT, 1, movetoworkspace, 1
bind = $mod SHIFT, 2, movetoworkspace, 2
bind = $mod SHIFT, 3, movetoworkspace, 3
bind = $mod SHIFT, 4, movetoworkspace, 4
bind = $mod SHIFT, 5, movetoworkspace, 5

# Send the window to a desktop and stay put
bind = $mod CTRL, 1, movetoworkspacesilent, 1
bind = $mod CTRL, 2, movetoworkspacesilent, 2
bind = $mod CTRL, 3, movetoworkspacesilent, 3
bind = $mod CTRL, 4, movetoworkspacesilent, 4
bind = $mod CTRL, 5, movetoworkspacesilent, 5

# Cycle desktops
bind = $mod, Tab, workspace, e+1
bind = $mod SHIFT, Tab, workspace, e-1
bind = $mod CTRL, right, workspace, e+1
bind = $mod CTRL, left, workspace, e-1
bind = $mod, grave, workspace, previous
bind = $mod, mouse_down, workspace, e+1
bind = $mod, mouse_up, workspace, e-1

# Scratchpad
bind = $mod, S, togglespecialworkspace, magic
bind = $mod ALT, S, movetoworkspace, special:magic

###############################################################################
# KEYBINDS -- screenshots (region -> clipboard AND ~/Pictures/Screenshots)
###############################################################################
bind = $mod SHIFT, S, exec, $scripts/screenshot region
bind = $mod CTRL SHIFT, S, exec, $scripts/screenshot region --edit    # annotate before saving
bind = , Print, exec, $scripts/screenshot screen
bind = SHIFT, Print, exec, $scripts/screenshot window
bind = $mod, Print, exec, $scripts/screenshot window

###############################################################################
# KEYBINDS -- hardware keys
###############################################################################
bindel = , XF86AudioRaiseVolume, exec, wpctl set-volume -l 1.4 @DEFAULT_AUDIO_SINK@ 5%+
bindel = , XF86AudioLowerVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-
bindl  = , XF86AudioMute, exec, wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle
bindl  = , XF86AudioMicMute, exec, wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle
bindel = , XF86MonBrightnessUp, exec, brightnessctl -e4 -n2 set 5%+
bindel = , XF86MonBrightnessDown, exec, brightnessctl -e4 -n2 set 5%-

# Lid closed -> lock + screen off, lid open -> screen on
bindl = , switch:on:Lid Switch, exec, loginctl lock-session && hyprctl dispatch dpms off
bindl = , switch:off:Lid Switch, exec, hyprctl dispatch dpms on

###############################################################################
# KEYBINDS -- session
###############################################################################
bind = $mod, Escape, exec, loginctl lock-session      # lock the screen
bind = $mod SHIFT, V, exec, cliphist list | fuzzel --dmenu --width 70 | cliphist decode | wl-copy
bind = $mod CTRL SHIFT, V, exec, cliphist wipe && notify-send "Clipboard history cleared"
# Screen recording (toggle -- press again to stop and finalise the file)
bind = $mod ALT, R, exec, $scripts/record screen
bind = $mod ALT SHIFT, R, exec, $scripts/record region
bind = $mod ALT, A, exec, $scripts/record screen --audio

bind = $mod SHIFT, R, exec, hyprctl reload
bind = $mod SHIFT, Q, exit                          # quit Hyprland
bind = $mod SHIFT, E, exit
bind = $mod, slash, exec, $terminal --app-id=foot-float -e $scripts/hypr-keys

Appendix — every remaining config file

The full hyprland.conf is in the appendix above. These are the rest, verbatim from the running system, so the guide is self-contained. Remember to substitute /home/YOUR-USER where it appears.

Compositor

~/.config/hypr/hyprlock.conf

###############################################################################
# hyprlock -- lock screen
#
# Styled to match the fuzzel launcher (Super+D): a translucent #1a1b26 panel
# with a 2px iris border and 14px radius, MesloLGS NF throughout, purple
# accents.
#
# NOTE: hyprlock draws in PHYSICAL pixels and ignores the compositor's 1.5x
# scale -- measured, a 440px panel came out 440px wide on the 1920px panel.
# So every size here is ~1.5x what the same element would be in a scaled
# Wayland client like fuzzel or waybar.
###############################################################################

grace = 2                        # 2s to abort the lock by moving/typing

general {
	hide_cursor = true
	ignore_empty_input = true
}

auth {
	pam {
		enabled = true
	}
}

background {
	monitor =
	path = ~/Pictures/wallpaper.png
	blur_passes = 3
	blur_size = 8
	brightness = 0.7
	contrast = 0.9
	vibrancy = 0.17
}

# The panel, matching fuzzel's frame. Declared first so everything else
# renders on top of it.
shape {
	monitor =
	size = 780, 430
	color = rgba(1a1b26f2)
	rounding = 14
	border_size = 2
	border_color = rgba(bb9af7ff)
	position = 0, 0
	halign = center
	valign = center
}

# Clock
label {
	monitor =
	text = cmd[update:1000] date +"%H:%M"
	color = rgba(c0caf5ff)
	font_size = 92
	font_family = MesloLGS NF Bold
	position = 0, 108
	halign = center
	valign = center
}

# Date
label {
	monitor =
	text = cmd[update:60000] date +"%A, %d %B"
	color = rgba(bb9af7ff)
	font_size = 22
	font_family = MesloLGS NF
	position = 0, 34
	halign = center
	valign = center
}

# Who you are, and how much charge is left
label {
	monitor =
	text = cmd[update:30000] printf '%s  ·  %s%%' "$USER" "$(cat /sys/class/power_supply/BAT0/capacity)"
	color = rgba(565f89ff)
	font_size = 16
	font_family = MesloLGS NF
	position = 0, 2
	halign = center
	valign = center
}

# Password field -- fuzzel's input frame: iris border, 14px radius,
# translucent #1a1b26 fill
input-field {
	monitor =
	size = 580, 66
	outline_thickness = 2
	dots_size = 0.24
	dots_spacing = 0.32
	dots_center = true
	outer_color = rgba(bb9af7ff)
	inner_color = rgba(1a1b26f2)
	font_color = rgba(c0caf5ff)
	font_family = MesloLGS NF
	fade_on_empty = false
	placeholder_text = <span foreground="##565f89">type to unlock</span>
	fail_color = rgba(f7768eff)
	fail_text = <span foreground="##f7768e">$FAIL  ($ATTEMPTS)</span>
	check_color = rgba(7aa2f7ff)
	rounding = 14
	position = 0, -110
	halign = center
	valign = center
}

~/.config/hypr/hypridle.conf

###############################################################################
# hypridle -- idle management
#
# Deliberately gentle. A dim that looks like a dead screen, or a surprise
# lock, is worse than a slightly warmer battery.
###############################################################################
general {
	lock_cmd = pidof hyprlock || hyprlock          # never stack two lockers
	before_sleep_cmd = loginctl lock-session
	after_sleep_cmd = hyprctl dispatch dpms on
}

# 10 min -- dim, but stay clearly readable. Any input restores.
listener {
	timeout = 600
	on-timeout = brightnessctl -s set 30%
	on-resume = brightnessctl -r
}

# 20 min -- screen off
listener {
	timeout = 1200
	on-timeout = hyprctl dispatch dpms off
	on-resume = hyprctl dispatch dpms on
}

# No auto-lock and no auto-suspend. Lock manually with Super+Escape.
# Closing the lid still locks (see hyprland.conf).

~/.config/hypr/hyprpaper.conf

splash = false

wallpaper {
	monitor = DSI-1
	path = /home/YOUR-USER/Pictures/wallpaper.png
	fit_mode = cover
}

Bar, launcher, notifications

~/.config/waybar/config.jsonc

{
	"layer": "top",
	"position": "top",
	"height": 28,
	"spacing": 6,

	"modules-left":   ["hyprland/workspaces", "hyprland/submap"],
	"modules-center": ["hyprland/window"],
	"modules-right":  ["custom/recording", "tray", "wireplumber", "backlight", "network", "battery", "clock"],

	"hyprland/workspaces": {
		"format": "{icon}",
		"format-icons": {
			"1": "1", "2": "2", "3": "3", "4": "4", "5": "5",
			"special": ""
		},
		"persistent-workspaces": { "*": 5 },
		"on-click": "activate",
		"on-scroll-up": "hyprctl dispatch workspace e+1",
		"on-scroll-down": "hyprctl dispatch workspace e-1"
	},

	"hyprland/window": {
		"format": "{title}",
		"max-length": 60,
		"separate-outputs": true
	},

	"custom/recording": {
		"exec": "pgrep -x wf-recorder >/dev/null && echo '{\"text\":\"REC\",\"tooltip\":\"recording -- Super+Alt+R to stop\"}' || echo '{}'",
		"return-type": "json",
		"interval": 2,
		"format": "{}",
		"on-click": "/home/YOUR-USER/.config/scripts/record"
	},

	"tray": { "icon-size": 14, "spacing": 8 },

	"wireplumber": {
		"format": "{icon}  {volume}%",
		"format-muted": "  muted",
		"format-icons": ["", "", ""],
		"on-click": "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle",
		"on-scroll-up": "wpctl set-volume -l 1.4 @DEFAULT_AUDIO_SINK@ 5%+",
		"on-scroll-down": "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"
	},

	"backlight": {
		"device": "intel_backlight",
		"format": "  {percent}%",
		"on-scroll-up": "brightnessctl -e4 -n2 set 5%+",
		"on-scroll-down": "brightnessctl -e4 -n2 set 5%-"
	},

	"network": {
		"format-wifi": "  {signalStrength}%",
		"format-ethernet": "  {ipaddr}",
		"format-disconnected": "  off",
		"tooltip-format-wifi": "{essid}  ({signalStrength}%)\n{ipaddr}",
		"on-click": "foot -e nmtui"
	},

	"battery": {
		"states": { "warning": 25, "critical": 10 },
		"format": "{icon}  {capacity}%",
		"format-charging": "  {capacity}%",
		"format-plugged": "  {capacity}%",
		"format-icons": ["", "", "", "", ""],
		"tooltip-format": "{timeTo}  ({power:.1f} W)"
	},

	"clock": {
		"format": "{:%a %d %b  %H:%M}",
		"tooltip-format": "<tt><small>{calendar}</small></tt>",
		"calendar": {
			"mode": "month",
			"format": { "today": "<span color='#7aa2f7'><b>{}</b></span>" }
		}
	}
}

~/.config/waybar/style.css

* {
	font-family: "JetBrains Mono", "FontAwesome", monospace;
	font-size: 13px;
	min-height: 0;
	border: none;
	border-radius: 0;
}

window#waybar {
	background: rgba(26, 27, 38, 0.92);
	color: #c0caf5;
}

#workspaces button {
	padding: 0 11px;
	color: #565f89;
	background: transparent;
}

#workspaces button.active {
	color: #1a1b26;
	background: #7aa2f7;
	border-radius: 6px;
}

#workspaces button.urgent {
	color: #1a1b26;
	background: #f7768e;
	border-radius: 6px;
}

#workspaces button:hover {
	background: #2a2f41;
	color: #c0caf5;
	border-radius: 6px;
}

#window {
	color: #a9b1d6;
}

window#waybar.empty #window {
	background: transparent;
}

#clock,
#battery,
#backlight,
#network,
#wireplumber,
#tray {
	padding: 0 10px;
	color: #c0caf5;
}

#clock       { color: #7aa2f7; font-weight: bold; }
#backlight   { color: #e0af68; }
#wireplumber { color: #9ece6a; }
#network     { color: #7dcfff; }
#battery     { color: #9ece6a; }

#battery.charging { color: #73daca; }

#battery.warning:not(.charging) { color: #e0af68; }

#battery.critical:not(.charging) {
	color: #f7768e;
	animation: blink 1s steps(2, start) infinite;
}

@keyframes blink {
	to { color: #c0caf5; }
}

#custom-recording {
	color: #1a1b26;
	background: #f7768e;
	padding: 0 9px;
	margin: 4px 2px;
	border-radius: 6px;
	font-weight: bold;
	animation: blink 1.4s steps(2, start) infinite;
}

~/.config/fuzzel/fuzzel.ini

# fuzzel -- Super+D launcher, Tokyo Night to match waybar / hyprlock / starship
[main]
font=MesloLGS NF:size=13
# The compositor runs at 1.5x. dpi-aware=no makes fuzzel scale with the output
# scale factor rather than computing its own DPI, which is what we want here.
dpi-aware=no
icon-theme=Adwaita
icons-enabled=yes
terminal=foot -e
# fzf-style subsequence matching: "tgd" finds Telegram Desktop
match-mode=fzf
use-bold=yes
prompt="󰍉  "
placeholder=type to search
lines=9
width=44
horizontal-pad=26
vertical-pad=20
inner-pad=14
line-height=26
layer=overlay
exit-on-keyboard-focus-loss=yes

[colors]
background=1a1b26f2
text=a9b1d6ff
prompt=bb9af7ff
placeholder=565f89ff
input=c0caf5ff
match=7aa2f7ff
selection=3b4261ff
selection-text=c0caf5ff
selection-match=bb9af7ff
counter=565f89ff
border=bb9af7ff

[border]
width=2
radius=14

~/.config/mako/config

font=JetBrains Mono 10
background-color=#1a1b26f2
text-color=#c0caf5
border-color=#7aa2f7
border-size=2
border-radius=6
padding=10
margin=8
default-timeout=5000
anchor=top-right
max-visible=5

[urgency=critical]
border-color=#f7768e
default-timeout=0

Terminal and prompt

~/.config/foot/foot.ini

[main]
# MesloLGS NF is the typeface and supplies its own Nerd Font glyphs.
# JetBrains Mono stays on as a fallback for anything Meslo lacks.
font=MesloLGS NF:size=16, JetBrains Mono:size=16
pad=8x6

[colors]
alpha=1.0
background=1a1b26
foreground=c0caf5
regular0=15161e
regular1=f7768e
regular2=9ece6a
regular3=e0af68
regular4=7aa2f7
regular5=bb9af7
regular6=7dcfff
regular7=a9b1d6
bright0=414868
bright1=f7768e
bright2=9ece6a
bright3=e0af68
bright4=7aa2f7
bright5=bb9af7
bright6=7dcfff
bright7=c0caf5
selection-background=283457
selection-foreground=c0caf5

[cursor]
color=1a1b26 c0caf5

~/.config/starship.toml

# ~/.config/starship.toml -- Tokyo Night, powerline, two-line.
# Designed for a ~100 column terminal at a large font: the top line carries the
# context, the bottom line is always a clean full-width runway for the command.

add_newline = true
palette = "tokyonight"
command_timeout = 900

format = """
[](fg:abyss)\
$os\
$username\
[](bg:steel fg:abyss)\
$directory\
[](fg:steel)\
$git_branch$git_status\
$nodejs$python$rust$golang$lua$zig$java$docker_context\
$status$jobs\
$cmd_duration\
$line_break\
$character"""

[palettes.tokyonight]
iris   = "#bb9af7"
brand  = "#4f9bff"   # brand accent blue (unused while the pill is iris)
steel  = "#3b4261"
slate  = "#2f3549"
abyss  = "#24283b"
ink    = "#1a1b26"
snow   = "#c0caf5"
haze   = "#565f89"
azure  = "#7aa2f7"
mint   = "#9ece6a"
amber  = "#e0af68"
coral  = "#ff9e64"
rose   = "#f7768e"
sky    = "#7dcfff"
jade   = "#73daca"

###############################################################################
# Identity
###############################################################################
[os]
disabled = false
style = "bg:abyss fg:iris"
format = "[ $symbol ]($style)"

[os.symbols]
Debian = "󰊠"   # nf-md-ghost U+F02A0
#Debian = "󰔉"  # site accent glyph (nf-md-terrain U+F0509)
Ubuntu = "󰕈"
Arch = "󰣇"
Linux = "󰊠"
Macos = "󰀵"

[username]
show_always = false          # only speaks up when it matters: root, or over ssh
style_user = "bg:abyss fg:iris"
style_root = "bg:rose fg:ink bold"
format = "[$user ]($style)"

[hostname]
ssh_only = true
style = "bg:iris fg:ink"
format = "[@$hostname ]($style)"

###############################################################################
# Where you are
###############################################################################
[directory]
style = "bg:steel fg:snow"
format = "[ $path ]($style)[$read_only]($read_only_style)"
truncation_length = 3
truncation_symbol = "…/"
read_only = " 󰌾"
read_only_style = "bg:steel fg:rose"
home_symbol = "󰋜"

[directory.substitutions]
"Documents" = "󰈙"
"Downloads" = "󰉍"
"Pictures"  = "󰋩"
"Music"     = "󰝚"
"Videos"    = "󰕧"
".config"   = "󰒓"
"system"    = "󰒓"

###############################################################################
# Git -- the segment that earns its space
###############################################################################
[git_branch]
symbol = ""
style = "bg:slate fg:mint"
format = " [](fg:slate)[ $symbol $branch ]($style)"
truncation_length = 20
truncation_symbol = "…"

[git_status]
style = "bg:slate fg:amber"
format = "[$all_status$ahead_behind]($style)[](fg:slate)"
conflicted = "󰞇 ${count} "
ahead      = "󰁝${count} "
behind     = "󰁅${count} "
diverged   = "󰃻${ahead_count}󰁅${behind_count} "
up_to_date = ""
untracked  = "󰋗${count} "
stashed    = "󰏗${count} "
modified   = "󰏫${count} "
staged     = "󰐗${count} "
renamed    = "󰑕${count} "
deleted    = "󰍶${count} "

###############################################################################
# Toolchains -- each only appears in a project that actually uses it
###############################################################################
[nodejs]
symbol = "󰎙"
style = "bg:abyss fg:mint"
format = " [](fg:abyss)[$symbol $version ]($style)[](fg:abyss)"

[python]
symbol = "󰌠"
style = "bg:abyss fg:amber"
format = " [](fg:abyss)[$symbol $version ]($style)[](fg:abyss)"
pyenv_version_name = true

[rust]
symbol = "󱘗"
style = "bg:abyss fg:coral"
format = " [](fg:abyss)[$symbol $version ]($style)[](fg:abyss)"

[golang]
symbol = "󰟓"
style = "bg:abyss fg:sky"
format = " [](fg:abyss)[$symbol $version ]($style)[](fg:abyss)"

[lua]
symbol = "󰢱"
style = "bg:abyss fg:azure"
format = " [](fg:abyss)[$symbol $version ]($style)[](fg:abyss)"

[zig]
symbol = ""
style = "bg:abyss fg:coral"
format = " [](fg:abyss)[$symbol $version ]($style)[](fg:abyss)"

[java]
symbol = "󰬷"
style = "bg:abyss fg:rose"
format = " [](fg:abyss)[$symbol $version ]($style)[](fg:abyss)"

[docker_context]
symbol = "󰡨"
style = "bg:abyss fg:sky"
format = " [](fg:abyss)[$symbol $context ]($style)[](fg:abyss)"
only_with_files = true

###############################################################################
# Feedback
###############################################################################
[cmd_duration]
min_time = 2_000
style = "fg:haze italic"
format = " [󱎫 $duration]($style)"
show_milliseconds = false

[character]
success_symbol = "[󰁔](bold mint)"
error_symbol = "[󰁔](bold rose)"
vimcmd_symbol = "[󰁍](bold iris)"

###############################################################################
# Right side
###############################################################################
[battery]
disabled = true   # waybar shows this
format = "[$symbol$percentage]($style) "
full_symbol = "󰁹 "
charging_symbol = "󰂄 "
discharging_symbol = "󰁿 "
unknown_symbol = "󰂑 "
empty_symbol = "󰂎 "

[[battery.display]]
threshold = 15
style = "fg:rose bold"

[[battery.display]]
threshold = 40
style = "fg:amber"

[[battery.display]]
threshold = 100
style = "fg:haze"

[time]
disabled = true   # waybar shows this
time_format = "%H:%M"
style = "fg:haze"
format = "[󰥔 $time]($style)"

[status]
disabled = false
symbol = "󰅙 "
format = " [$symbol$common_meaning$signal_name$maybe_int]($style)"
style = "fg:rose bold"
map_symbol = false
pipestatus = true

[jobs]
symbol = "󰑮"
style = "fg:sky"
format = " [$symbol$number]($style)"
number_threshold = 1

~/.config/starship-tty.toml

# starship-tty.toml -- prompt for the Linux virtual console (TERM=linux)
#
# The kernel console loads PSF fonts, which hold at most 512 glyphs and cannot
# contain Nerd Font Private Use Area codepoints. It also has no truecolor: the
# VT gives you the 16 ANSI colours, so hex values like #bb9af7 are not usable.
#
# This config is therefore deliberately plain: pure ASCII symbols, named ANSI
# colours, no powerline separators, no icons. Same information, nothing that
# renders as a blank box.
#
# ~/.bashrc selects it automatically when TERM=linux; every other terminal gets
# the full ~/.config/starship.toml.

add_newline = true
command_timeout = 900

format = """
$username\
$hostname\
$directory\
$git_branch\
$git_status\
$nodejs$python$rust$golang$lua$zig$java\
$cmd_duration\
$status$jobs\
$line_break\
$character"""

[username]
show_always = true
style_user = "bold cyan"
style_root = "bold red"
format = "[$user]($style)"

[hostname]
ssh_only = false
style = "cyan"
format = "[@$hostname]($style) "

[directory]
style = "bold blue"
format = "[$path]($style)[$read_only]($read_only_style) "
truncation_length = 3
truncation_symbol = ".../"
read_only = " [ro]"
read_only_style = "red"

[git_branch]
symbol = ""
style = "bold green"
format = "[git:$branch]($style) "
truncation_length = 20
truncation_symbol = "..."

[git_status]
style = "yellow"
format = "([$all_status$ahead_behind]($style) )"
conflicted = "!${count} "
ahead = "+${count} "
behind = "-${count} "
diverged = "+${ahead_count}-${behind_count} "
up_to_date = ""
untracked = "?${count} "
stashed = "$${count} "
modified = "~${count} "
staged = "*${count} "
renamed = "r${count} "
deleted = "x${count} "

[nodejs]
symbol = ""
style = "green"
format = "[node:$version]($style) "

[python]
symbol = ""
style = "yellow"
format = "[py:$version]($style) "

[rust]
symbol = ""
style = "red"
format = "[rust:$version]($style) "

[golang]
symbol = ""
style = "cyan"
format = "[go:$version]($style) "

[lua]
symbol = ""
style = "blue"
format = "[lua:$version]($style) "

[zig]
symbol = ""
style = "yellow"
format = "[zig:$version]($style) "

[java]
symbol = ""
style = "red"
format = "[java:$version]($style) "

[cmd_duration]
min_time = 2_000
style = "bright-black"
format = "[took $duration]($style) "

[status]
disabled = false
symbol = "!"
format = "[$symbol$common_meaning$signal_name$maybe_int]($style) "
style = "bold red"
map_symbol = false
pipestatus = true

[jobs]
symbol = "&"
style = "cyan"
format = "[$symbol$number]($style) "
number_threshold = 1

[character]
success_symbol = "[>](bold green)"
error_symbol = "[>](bold red)"
vimcmd_symbol = "[<](bold purple)"

# Everything below is off on the console: either it needs glyphs, or it is
# noise on an 80-column VT.
[battery]
disabled = true

[time]
disabled = true

[docker_context]
disabled = true

[package]
disabled = true

Scripts (~/.config/scripts, all chmod +x)

~/.config/scripts/screenshot

#!/usr/bin/env bash
# screenshot [region|window|screen] [--edit]
#   always: copies to clipboard AND saves to ~/Pictures/Screenshots
#   --edit: opens swappy first to crop / annotate / redact before saving
set -euo pipefail

mode="${1:-region}"
edit="${2:-}"
dir="$HOME/Pictures/Screenshots"
mkdir -p "$dir"
file="$dir/$(date +%Y-%m-%d_%H-%M-%S).png"

notify() { command -v notify-send >/dev/null && notify-send -t 2500 "$@" || true; }

case "$mode" in
	region)
		geom=$(slurp -d) || exit 0          # Esc -> quiet exit
		grim -g "$geom" "$file"
		;;
	window)
		geom=$(hyprctl activewindow -j \
			| jq -r '"\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')
		grim -g "$geom" "$file"
		;;
	screen)
		grim "$file"
		;;
	*)
		echo "usage: screenshot [region|window|screen] [--edit]" >&2
		exit 1
		;;
esac

if [ "$edit" = "--edit" ]; then
	# swappy writes the edited result back over the same path on save
	swappy -f "$file" -o "$file"
fi

wl-copy --type image/png < "$file"
notify "Screenshot copied" "$(basename "$file")"

~/.config/scripts/record

#!/usr/bin/env bash
# record [region|screen] [--audio|--mic]   -- toggle screen recording
#
#   record            toggle: full screen, no audio
#   record region     toggle: pick an area with slurp
#   record screen --audio   full screen + system audio (what you hear)
#   record screen --mic     full screen + microphone
#
# Running it again while a recording is active STOPS it, whatever the args.
#
# Encoding is VAAPI h264 on the iGPU. On a 6W N150, software x264 at 1920x1200
# would peg the CPU and cook the battery; the hardware encoder is near-free.
set -uo pipefail

dir="$HOME/Videos/Recordings"
mkdir -p "$dir"

notify() { command -v notify-send >/dev/null && notify-send -t 2500 "$@" || true; }

# --- stop an in-flight recording -------------------------------------------
# wf-recorder MUST get SIGINT, not SIGTERM/SIGKILL: it finalises the MP4
# container (moov atom) on interrupt. Killed any harder, the file is unplayable.
if pgrep -x wf-recorder >/dev/null; then
	pkill -INT -x wf-recorder
	for _ in $(seq 1 40); do
		pgrep -x wf-recorder >/dev/null || break
		sleep 0.1
	done
	newest=$(ls -t "$dir"/*.mp4 2>/dev/null | head -1)
	if [ -n "$newest" ]; then
		size=$(du -h "$newest" | cut -f1)
		printf '%s' "$newest" | wl-copy 2>/dev/null || true
		notify "Recording saved" "$(basename "$newest")  ($size)  path copied"
	else
		notify "Recording stopped" "no file produced"
	fi
	exit 0
fi

# --- start a new recording --------------------------------------------------
mode="${1:-screen}"
audio="${2:-}"
file="$dir/$(date +%Y-%m-%d_%H-%M-%S).mp4"

args=(-f "$file" -c h264_vaapi -d /dev/dri/renderD128
      --codec-param=qp=24 -m mp4)

case "$mode" in
	region)
		geom=$(slurp -d) || exit 0          # Esc -> quiet exit
		args+=(-g "$geom")
		;;
	screen) ;;
	*)
		echo "usage: record [region|screen] [--audio|--mic]" >&2
		exit 1
		;;
esac

case "$audio" in
	--audio)
		# .monitor of the default sink = system audio, not the microphone
		sink=$(wpctl inspect @DEFAULT_AUDIO_SINK@ 2>/dev/null \
			| sed -n 's/.*node\.name = "\(.*\)".*/\1/p' | head -1)
		if [ -n "$sink" ]; then
			args+=(--audio="${sink}.monitor")
		else
			notify "Recording" "could not resolve system audio; recording silent"
		fi
		;;
	--mic)
		args+=(-a)                          # default input device
		;;
	"") ;;
	*)
		echo "unknown audio option: $audio" >&2
		exit 1
		;;
esac

wf-recorder "${args[@]}" >/tmp/wf-recorder.log 2>&1 &
sleep 1.2
if pgrep -x wf-recorder >/dev/null; then
	notify "Recording started" "$mode${audio:+ $audio}  ·  Super+Alt+R to stop"
else
	notify "Recording FAILED" "$(tail -2 /tmp/wf-recorder.log | tr '\n' ' ')"
	exit 1
fi

~/.config/scripts/hypr-keys

#!/usr/bin/env bash
# Pretty-print the Hyprland keybinds cheatsheet.
b=$'\e[1m'; d=$'\e[2m'; c=$'\e[36m'; r=$'\e[0m'
sec(){ printf "\n${b}${c}%s${r}\n" "$1"; }
k(){ printf "  ${b}%-24s${r} %s\n" "$1" "$2"; }

sec "APPS"
k "Super Return"        "terminal"
k "Super Shift Return"  "floating terminal"
k "Super D"             "app launcher"
k "Super B"             "browser"

sec "WINDOWS"
k "Super Q"             "close window"
k "Super F"             "fullscreen"
k "Super Shift F"       "maximize (keeps bar)"
k "Super Ctrl F"        "fake fullscreen"
k "Super V"             "toggle floating"
k "Super C"             "centre window"
k "Super T"             "pin on top"
k "Super P / Super Y"   "pseudotile / toggle split"
k "Super hjkl|arrows"   "move focus"
k "Super Shift hjkl"    "move window"
k "Super Alt hjkl"      "resize window"
k "Super LMB / RMB"     "drag to move / resize"

sec "DESKTOPS (5)"
k "Super 1-5"           "go to desktop"
k "Super Shift 1-5"     "move window there, follow"
k "Super Ctrl 1-5"      "move window there, stay"
k "Super Tab"           "next desktop"
k "Super Shift Tab"     "previous desktop"
k "Super \`"             "last desktop"
k "Super scroll"        "cycle desktops"
k "3 fingers sideways"  "cycle desktops"
k "Super S"             "scratchpad"
k "Super Alt S"         "send to scratchpad"

sec "SCREENSHOTS  (clipboard + ~/Pictures/Screenshots)"
k "Super Shift S"       "select a region"
k "Print"               "whole screen"
k "Shift Print"         "active window"

sec "SCREEN RECORDING  (~/Videos/Recordings, toggle)"
k "Super Alt R"         "record whole screen"
k "Super Alt Shift R"   "record a region"
k "Super Alt A"         "record screen + system audio"
k "  press again"       "stops and finalises the file"

sec "CLIPBOARD"
k "Super Shift V"       "clipboard history"
k "Super Ctrl Shift V"  "wipe clipboard history"

sec "IF THE LOCK SCREEN EVER HANGS"
k "Ctrl+Alt+F2"         "switch to a text console, log in"
k "  then run:"         "hyprctl -i 0 dispatch exec hyprlock"
k "  "                  "(allow_session_lock_restore lets it adopt the dead lock)"

sec "SESSION"
k "Super Escape"        "lock the screen"
k "Super Shift R"       "reload config"
k "Super Shift Q"       "quit Hyprland"
echo
read -rsn1 -p "  press any key to close "

Greeter (these live in /etc/greetd/, root-owned)

/etc/greetd/config.toml

[terminal]
# VT 7 keeps tty1-6 free as plain console logins -- the escape hatch if the
# greeter ever fails to come up.
vt = 7

[default_session]
# Launched via the start-hyprland watchdog, not Hyprland directly: launching
# Hyprland bare makes it raise "being launched without start-hyprland" in the
# top-right error overlay, which flashes on the login screen.
# gtkgreet needs a Wayland compositor to live in; Hyprland gives it the correct
# panel rotation and scale. The greeter config exits the compositor on login.
command = "start-hyprland -- --config /etc/greetd/hyprland.conf"
user = "_greetd"

/etc/greetd/hyprland.conf

# Minimal compositor for the login greeter only.
# It exists so gtkgreet inherits the same rotation and 1.5x scale as the real
# session -- a plain cage/gtkgreet greeter cannot rotate this DSI panel.

monitor = DSI-1, preferred, auto, 1.5, transform, 3

env = GTK_THEME,Adwaita:dark

exec-once = gtkgreet -l -b /usr/share/backgrounds/login.png -s /etc/greetd/gtkgreet.css -c start-hyprland; hyprctl dispatch exit

input {
    kb_layout = us
    touchdevice {
        transform = 3
    }
    touchpad {
        natural_scroll = true
        tap-to-click = true
    }
}

general {
    border_size = 0
    gaps_in = 0
    gaps_out = 0
}

decoration {
    rounding = 0
    blur {
        enabled = false
    }
    shadow {
        enabled = false
    }
}

animations {
    enabled = false
}

misc {
    force_default_wallpaper = 0
    disable_hyprland_logo = true
    background_color = rgb(1a1b26)
}

/etc/greetd/gtkgreet.css

/* Login screen styled to match hyprlock.
 *
 * gtkgreet builds its own widget tree (label beside entry, session combo,
 * "Log in" button), so this cannot reproduce hyprlock's exact vertical stack.
 * What it does match: the wallpaper treatment, palette, typography, the big
 * clock, and the blue-bordered input field -- so the two read as one design.
 *
 * Sizes are LOGICAL px; the greeter compositor runs at scale 1.5.
 */

window {
    background-color: #1a1b26;
}

/* No margins here: symmetric margins visibly shifted the whole block right of
 * centre, and matching hyprlock's centred layout matters more than a narrower
 * field. hyprlock floats its text straight on the wallpaper, so no card. */
box#body {
    background-color: transparent;
    border: none;
    box-shadow: none;
    padding: 0;
}

/* Big clock -- hyprlock's 96px physical -> 64 logical */
label#clock {
    color: #c0caf5;
    font-family: "JetBrains Mono ExtraBold", "JetBrains Mono", monospace;
    font-weight: 800;
    font-size: 64px;
    margin-bottom: 18px;
    text-shadow: 0 2px 14px rgba(0, 0, 0, 0.6);
}

/* "Username:" -- hyprlock's muted secondary text */
label {
    color: #565f89;
    font-family: "JetBrains Mono", monospace;
    font-size: 13px;
    margin-right: 10px;
    text-shadow: 0 1px 6px rgba(0, 0, 0, 0.5);
}

/* hyprlock's input-field: 2px #7aa2f7, #1a1b26e6 fill, 8px radius */
entry {
    background-color: rgba(26, 27, 38, 0.90);
    color: #c0caf5;
    caret-color: #c0caf5;
    border: 2px solid #7aa2f7;
    border-radius: 8px;
    padding: 7px 13px;
    font-family: "JetBrains Mono", monospace;
    font-size: 14px;
    min-height: 32px;
    box-shadow: 0 4px 18px rgba(0, 0, 0, 0.45);
}

entry:focus {
    border-color: #bb9af7;
}

/* hyprlock submits on Enter and shows no button, so keep this understated. */
button {
    background-image: none;
    background-color: rgba(36, 40, 59, 0.75);
    color: #a9b1d6;
    border: 1px solid #3b4261;
    border-radius: 8px;
    padding: 5px 16px;
    font-family: "JetBrains Mono", monospace;
    font-size: 12px;
    margin-top: 12px;
    box-shadow: none;
}

button:hover {
    background-color: #7aa2f7;
    color: #1a1b26;
    border-color: #7aa2f7;
}

/* Session picker: recede, it is not part of the lock-screen look */
#command-selector,
combobox {
    margin-top: 8px;
}

#command-selector entry,
combobox entry,
combobox button {
    background-color: rgba(36, 40, 59, 0.6);
    color: #565f89;
    border: 1px solid #2f3549;
    border-radius: 6px;
    font-size: 12px;
    min-height: 26px;
    box-shadow: none;
}

combobox button:hover {
    background-color: rgba(59, 66, 97, 0.8);
    color: #a9b1d6;
    border-color: #3b4261;
}

/* Auth failure -- hyprlock turns its field #f7768e */
#error_type,
label#error_type {
    color: #f7768e;
    font-weight: bold;
    font-size: 13px;
    text-shadow: 0 1px 6px rgba(0, 0, 0, 0.6);
}

/etc/greetd/environments

start-hyprland

Applications and desktop integration

~/.config/gtk-3.0/settings.ini (copy to gtk-4.0/ as well)

[Settings]
gtk-application-prefer-dark-theme=1
gtk-font-name=Cantarell 11
gtk-cursor-theme-name=Adwaita
gtk-cursor-theme-size=24
gtk-xft-antialias=1
gtk-xft-hinting=1
gtk-xft-hintstyle=hintslight
gtk-xft-rgba=rgb

~/.config/mimeapps.list

[Default Applications]
inode/directory=pcmanfm.desktop
image/png=imv.desktop
image/jpeg=imv.desktop
image/gif=imv.desktop
image/webp=imv.desktop
application/pdf=org.pwmt.zathura.desktop
text/html=firefox-esr.desktop
x-scheme-handler/http=firefox-esr.desktop
x-scheme-handler/https=firefox-esr.desktop

~/.config/libfm/libfm.conf (write while pcmanfm is NOT running)

# Configuration file for the libfm version 1.4.0.
# Autogenerated file, don't edit, your changes will be overwritten.

[config]
single_click=0
middle_click=0
use_trash=1
confirm_del=1
confirm_trash=1
advanced_mode=0
si_unit=0
force_startup_notify=1
date_iso_8601=0
backup_as_hidden=1
no_usb_trash=1
no_child_non_expandable=0
show_full_names=0
only_user_templates=0
template_run_app=0
template_type_once=0
auto_selection_delay=600
drop_default_action=auto
defer_content_test=0
quick_exec=0
show_internal_volumes=0
thumbnail_local=1
thumbnail_max=0
smart_desktop_autodrop=1

[ui]
big_icon_size=64
small_icon_size=24
pane_icon_size=24
thumbnail_size=160
show_thumbnail=1
shadow_hidden=0

[places]
places_home=1
places_desktop=1
places_root=0
places_computer=0
places_trash=1
places_applications=1
places_network=0
places_unmounted=1

~/.config/swappy/config

[Default]
save_dir=$HOME/Pictures/Screenshots
save_filename_format=swappy-%Y%m%d-%H%M%S.png
show_panel=true
line_size=5
text_size=20
text_font=JetBrains Mono
paint_mode=brush
early_exit=false
fill_shape=false

~/.ssh/config

Host *
    ServerAliveInterval 20
    ServerAliveCountMax 3
    TCPKeepAlive no
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 10m
    AddKeysToAgent yes
    IdentityFile ~/.ssh/id_ed25519
    HashKnownHosts no

Generate the key with ssh-keygen -t ed25519 -C "$(whoami)@$(hostname)", and chmod 600 ~/.ssh/config.

~/.bashrc — prompt selection

Appended to the end. The TERM=linux branch is what keeps the prompt readable on a virtual console, where Nerd Font glyphs cannot render:

if command -v starship >/dev/null 2>&1; then
    if [ "$TERM" = "linux" ]; then
        export STARSHIP_CONFIG="$HOME/.config/starship-tty.toml"
    fi
    eval "$(starship init bash)"
fi

Written on the machine it describes: CHUWI MiniBook X, Intel N150, 12 GB LPDDR5, Debian 13 trixie, Hyprland 0.55.2, 216 DPI at 1.5× scale, S3 deep sleep, 3.57 W idle.