- Arch Linux dotfiles for Hyprland (Lua config) and Niri, deployed with GNU Stow. Arch is authoritative for packages, runtime and installation.
- The shell is
mantle/.config/mantle/, a Lua 5.4 config for the upstream Mantle engine. The engine ships themantlebinary, Lua API and capabilities; this repo holds only the Lua. NixConfig/is inactive. Ignore it unless asked; never use it as context or sync it with Arch.
- Never run
stowor start the shell (mantle,mantle -d) unless told to. - Saving a
.luareloads the running shell in place. A failed reload keeps the last scene and shows the error in the bar.
| Command | Use |
|---|---|
just |
Gate before done: lua (parse + format), types (LuaLS on the root .luarc.json), mantle. just fmt formats |
mantle check -c mantle/.config/mantle |
Run after every edit (just mantle). Evaluates the config with no Wayland and no subprocesses, then lays it out twice: every capability nil, then one sample push each (one-entry lists). Writes no state |
mantle log [-f] |
Running shell output. Debug from Lua with log.debug(...), log.info(...), log.warn(...): stamped with time and level, unlike a bare print() |
mantle set, toggle, call <name> |
Drive live state and action names like a keybind; changes the live UI |
luac5.4 -p file.lua |
Syntax check. Plain luac is Lua 5.5 |
hyprctl repl '<lua>' |
Evaluate hl.* in the running Hyprland without a reload |
shellcheck script.sh |
Lint Bash |
Upstream checkout at /mnt/Work/0Coding/1Rust/mantle. Read it; never edit it from here unless asked to.
| Question | Read |
|---|---|
| What a config can declare and call | docs/introduction.md, then docs/guide/, docs/nodes/, docs/surfaces/ |
| Capability fields and actions | lua-meta/mantle.lua (generated), docs/capabilities/<name>.md |
| Node and surface properties | lua-meta/nodes.lua, lua-meta/surfaces.lua |
| Lua change or engine gap | docs/roadmap.md. Flag a real engine gap instead of working around it |
| Terms (generation, named state, capability) | docs/glossary.md; engine-internal ones in CONTEXT.md |
mantle/.config/mantle/
shell.lua Entry: font chain, requires modules, returns the surface list
config/ Tokens: theme.lua (wallpaper Material roles or a preset), colour_presets.lua, icons.lua, dev_tools.lua
components/ Reusable widgets with no state of their own
lib/ Node-free domain logic and state; ui_state owns navigation, disclosure owns panel resets
modules/ bar/{indicators,panels}, global/, notification/, osd/, shell/panel_host.lua
shaders/ `.frag` sources compiled by the engine at runtime: wallpaper transitions and the launcher rail
templates/ App colour files `lib/app_colours.lua` fills from the wallpaper
| Topic | Rule |
|---|---|
| Signals | Pass the signal itself to keep a property live; :get() is a snapshot. Derive with :map, computed, delay, pulse |
| Hydration | Capabilities read nil until the first push. Every map handles nil |
| Actions | mantle.audio:set_volume(0.5) returns nothing. Observe state for the outcome |
| Keybinds | Navigation state lives in lib/ui_state.lua; domain state stays with its owning lib/ module; action(name, fn) backs mantle call. A rename also updates hypr/.config/hypr/config/keybinds.lua and niri/.config/niri/config.kdl |
| Transient UI | Bar panels share the always-shown bar surface, so reset_on_close never fires there: declare a disclosure with disclosure.state from lib/disclosure.lua, which resets on every panel open. A modal resets its state in ui_state.on_modal_close and lists its scrolls in modal { reset_on_close } |
| Persistence | One persistent_table in lib/store.lua (~/.local/state/mantle/state.json). Add keys to its defaults |
| Processes | process.run dies with the generation, process.detach outlives the shell, session_process survives reloads |
| Compositor | Per-compositor commands go in lib/compositor.lua; behavior reads mantle.workspaces:get().compositor |
| Theme | config/theme.lua tokens and config/icons.lua glyphs. Never hardcode colours, sizes or spacing |
| App colours | lib/app_colours.lua writes templates/ and config/wallpaper_colours.lua (gitignored, reloads the shell) from the first screen's wallpaper. The wallpaper picker sets the source (colour_source: the wallpaper, or a config/colour_presets.lua scheme on Catppuccin's slot names), the wallpaper style (colour_style), dark or light (colour_scheme, also mantle call colours.mode) and the accent: per wallpaper among Score's candidates (colour_accents), per preset by name (colour_preset_accents). Never edit a generated file; add a role to theme.roles or a template. Once per host, outside the repo: btop's color_theme to mantle, Zed's theme light and dark to Mantle, Vesktop's enabledThemes to mantle.css (while it is closed), Zen's chrome/userChrome.css on GTK system colours plus zen.theme.use-system-colors, Thunderbird's theme to System |
| Panels | Four bands, top down. panel_header first, its subtitle one live state line, trailing only a badge (count), panel_action_icon (verb) or toggle (the subject's power). Then one row of controls: panel_toggle_card switches or accent action_buttons; a radio anywhere is one segmented bar, never a row of tiles; every primary in a panel is that tinted glass, solid belongs to a modal's confirm, danger to the one action that ends something running. Then a flat body: section_header + panel_rows; "more" is always panel_row { expanded } in place, never a view swap; a panel_card only wraps a composite control (row + slider, status + meter, a table). Last, text buttons that conclude what the body showed. A modal is the same panel with a theme.font.xl masthead (on_close included), lg padding, md spacing and its one scroll area on an outlined card; a search modal's search bar is its masthead |
| Shaders | A new shaders/wallpaper_<name>.frag shows in the picker as <name>; other .frag files stay out of it. Add a lib/wallpaper.lua row only for non-zero uniforms |
Lazy means efficient, not careless. The best code is the code never written.
- Output. Everything an agent writes (code, chat, comments, docs, commit messages) is to the point. No walls of text: tables first, bullets second, short prose last.
- Diffs. Add the fewest lines possible and remove as many as possible. Delete dead, redundant and duplicated code around the change. The smallest diff in the wrong place is a second bug.
- Root cause. Fix the cause, not the symptom. Grep every caller and fix the shared function once.
- Boring. No new abstractions, dependencies, boilerplate or files unless required. Between similar-sized approaches, take the edge-case-correct one.
- Ceilings. Mark a deliberate simplification (global lock, O(n²) scan, naive heuristic) with a
ponytail:comment naming its ceiling and upgrade path. - Checks. Non-trivial logic gets one runnable check; trivial one-liners get none.
- Full effort. Understanding the problem, trust-boundary validation, data loss, security, accessibility, real-hardware calibration and explicit requests are never lazy.
Before writing code, trace the real flow end to end, then stop at the first rung that holds:
- Does it need to exist? (YAGNI)
- Does the codebase already have it? Reuse it.
- Does the Lua API, a capability, the platform or an installed tool cover it? Use it.
- Can it be one line?
- Only then, the minimum code that works.
| Path | Holds |
|---|---|
mantle/.config/mantle/demo/ |
Effects demo run as its own instance (mantle -c), sharing config/, components/ and lib/ through symlinks. Not required by shell.lua, but its edits still reload the main shell |
bin/.local/bin/arch-install.sh |
Full Arch install: Wolverine, Mentalist or a new host detected from its hardware |
home/.profile |
XDG dirs, NVIDIA env, Wayland toolkit config, PATH |
home/.stowrc |
Points stow at this repo and ~ |
.luarc.json |
One LuaLS config for the mantle, Hyprland, Neovim and mpv Lua |
fish/.config/fish/conf.d/various.fish |
Custom fish functions |
.local_secrets/ |
Gitignored secrets; .gitconfig symlinks here |
The default terminal resolves through xdg-terminal-exec.
- Record only non-obvious failures likely to recur: what fails, why, what to do instead. Name a version only when the behavior is version-bound.
- After edits, delete the stale comments, docs and entries here that the work exposed. Delete obsolete guidance rather than adding exceptions.
| Trap | Fix |
|---|---|
The VM has no io, debug or FFI; os has only time, date, clock, getenv |
Shell out with process.run |
require returns a second value, the path, so as the last item of a table constructor it adds a string entry |
Bind modules to locals first, or wrap the call: (require(...)) |
:map, computed, on_change, action and timer bodies have a 2.5 ms CPU budget (exceeded the 2.5ms CPU budget) |
Keep them cheap; build nodes once and let a map only pick between them |
A node keeps its properties until a signal it read is written. os.time(), os.date() with no time, or a local read in a map stays at its last answer |
Derive time from mantle.system; keep changing values in a state |
A signal nested in children, a list's source, a structural field (a panel's anchor) or a table a signal returns does not resolve; elsewhere in a property table it does |
Derive the whole value there |
A node rejects unknown fields (node.expanded = x) only when its subtree resolves, so mantle check misses ones under a closed panel |
Export signals from a lib/ module, never on a node; check mantle log after opening the panel |
visible = false keeps a frozen subtree |
Switch views through children |
clip defaults to "none": children, shadows and transforms paint past a box |
Set clip = "box" where a box must cut: a fill hiding its label copy, a slide-in, an animated width |
A row's align_h packs its children and also places the row in a stacking parent, so a fixed-width row centres itself |
Wrap it in a rect (or width = "fill") to centre only the children |
| Named state resets when its scalar seed changes | Keep the seed stable |
timer, action and on_change last one evaluation |
Expect them to re-register on every reload |
fonts is read once at startup |
Restart the shell after editing it |
mantle input takes surface-logical coordinates, and a surface anchored to every edge starts below the bar's exclusive zone |
Subtract the bar height (modal_host sits 42 px down on 1440p) |
UPower's PendingCharge also follows Discharging |
Only Charging to PendingCharge means charging stopped (modules/global/power_events.lua) |
| Trap | Fix |
|---|---|
Stow folds a target directory that does not exist yet (~/.local) into one symlink to the repo, so apps write into Dots |
Create it first; arch-install.sh runs mkdir -p ~/.local before stow |
Zed reads only the root .luarc.json; nested ones are ignored |
Keep all Lua settings in the root file |
LuaLS builtin os types reject valid mantle os.time calls |
Keep builtin os and debug disabled in .luarc.json |
A running LuaLS ignores runtime.* changes |
Restart the language server |
Hyprland 0.56 parses its socket as Lua, so hyprctl dispatch exit fails |
hyprctl dispatch 'hl.dsp.exit()' |
Hyprland 0.56.1 fades mapped Top surfaces when fullscreen starts, but not ones mapped after |
Handle both on Hyprland; Niri does neither |
Hyprland sends the pointer only to a layer with Exclusive keyboard interactivity, so other surfaces stop taking clicks |
OnDemand on Hyprland; it still takes focus when it maps |
A niri workspace switch hands an OnDemand layer's keyboard to a window |
Exclusive on niri (modules/global/modal_host.lua) |
niri msg action spawn passes an activation token that overrides open-focused false |
Spawn through env -u XDG_ACTIVATION_TOKEN, then focus explicitly |
niri --config x validate is rejected |
niri validate --config x |
systemd-run --scope rejects --pipe, and fixed-name scopes linger after exit |
Transient service with --pipe --collect |
Upstream qt6ct never reports a colour scheme, so Qt apps that check dark mode pick light; Kirigami apps (KDE Connect) read only a KDE .colors scheme |
qt6ct-kde with a .colors scheme; keep QT_QPA_PLATFORMTHEME=qt6ct |
qt6ct-kde's KDE icon engine drops -symbolic when the theme has the full-colour icon, so Telegram (org.telegram.desktop-symbolic) shows its colour tray icon and hides "Use monochrome icon" |
arch-install.sh links Tela's telegram*-panel.svg under those names in ~/.local/share/icons/Tela-circle-dracula |
GTK_THEME in the environment overrides every GTK theme and libadwaita colour |
Never set it; lib/app_colours.lua sets the theme through gsettings |
Running apps never re-read colours under the same name: KSharedConfig caches a .colors by path, GTK3 reloads its user gtk.css never and its theme only on a name change. GtkSourceView never rescans at all, so Text Editor recolours on its next launch |
Alternate Mantle-A/Mantle-B copies (KDE scheme in qt6ct.conf, GTK theme in gsettings) |
GTK4 re-reads ~/.config/gtk-4.0/gtk.css imports only when a portal preference changes; a color-scheme flip flashes every app light |
Colours in an import; flip org.gnome.desktop.a11y.interface reduced-motion for 50 ms |
A GTK3 theme without gtk-3.0/gtk-dark.css falls back to Adwaita in dark mode |
Ship gtk-dark.css |
Zen (Gecko) follows the GTK theme live only through system colours in userChrome.css, and pairs AccentColor/AccentColorText differently per mode; web content gets stand-ins, not GTK colours |
Accent as LinkText (adw-gtk3's accent_color; Zen 1.23.4b dropped -moz-nativehyperlinktext, so every declaration using it goes transparent), surfaces as -moz-dialog/Field/-moz-headerbar; leave userContent.css static |
Zen's folder icons and selected tab fill through light-dark(color-mix(var(--zen-primary-color))); with that on a system colour, Gecko drops them when the GTK theme and colour scheme flip together, until a restart |
Override --zen-folder-behind-bgcolor, -front-bgcolor, -stroke and --tab-background-color-selected (on zen-workspace) in userChrome.css without light-dark() |
Vencord watches quickCss.css as a file, so the watch dies at the first atomic write (file.write renames) |
Write a theme into the watched themes/ directory instead |