diff --git a/config/dot/quickshell/scripts/panama-settings-docs b/config/dot/quickshell/scripts/panama-settings-docs
new file mode 100755
index 0000000..b4a6b34
--- /dev/null
+++ b/config/dot/quickshell/scripts/panama-settings-docs
@@ -0,0 +1,214 @@
+#!/usr/bin/env python3
+
+"""Generate the settings reference from PreferenceSchema.qml.
+
+Every other form of documentation in this repository has drifted at least once:
+routing that pointed at a page not containing the setting, contracts that pinned
+the bug they were meant to prevent, and a comment that failed to stop the person
+who read it making the exact mistake it warned about. Prose describing 137
+settings would drift the day after it was written.
+
+So this is generated, and a contract fails when the committed copy no longer
+matches what the schema says. The document cannot be wrong for longer than it
+takes to run the suite.
+
+Usage:
+ panama-settings-docs write docs/settings.md
+ panama-settings-docs --check exit 1 if the committed copy is stale
+ panama-settings-docs --stdout print without writing
+
+Parsing rather than importing: the schema is QML, there is no QML interpreter
+here, and a regex reader that FAILS LOUDLY when it stops recognising the file is
+better than a dependency on a shell that has to start a compositor.
+"""
+
+import argparse
+import pathlib
+import re
+import sys
+
+# scripts/ -> quickshell/ -> dot/ -> config/ -> repo root. resolve() first, so
+# this still works when invoked through the ~/.config/quickshell symlink.
+ROOT = pathlib.Path(__file__).resolve().parents[4]
+SCHEMA = ROOT / "config/dot/quickshell/config/PreferenceSchema.qml"
+SEARCH = ROOT / "config/dot/quickshell/services/SettingsSearch.qml"
+OUTPUT = ROOT / "docs/settings.md"
+
+# Page id -> the name a person sees in the sidebar.
+PAGE_TITLES = {
+ "home": "Home", "appearance": "Appearance", "displays": "Displays",
+ "connectivity": "Network & Devices", "home-phone": "Home & Phone",
+ "desktop": "Desktop & Dock", "sound": "Sound",
+ "notifications": "Notifications & Focus",
+ "screen-intelligence": "Screen Intelligence", "shortcuts": "Keyboard",
+ "mouse": "Mouse & Touchpad", "privacy": "Privacy & Security",
+ "region": "Region & Language", "accounts": "Online Accounts",
+ "accessibility": "Accessibility", "power": "Power & Lock",
+ "datetime": "Date & Time", "applications": "Applications",
+ "services": "System Health", "about": "About",
+}
+
+
+class SchemaError(RuntimeError):
+ """The schema stopped looking the way this reader expects."""
+
+
+def read_routes():
+ """group -> page id, from SettingsSearch."""
+ text = SEARCH.read_text()
+ block = re.search(r"groupPages:\s*\(\{(.*?)\}\)", text, re.S)
+ if not block:
+ raise SchemaError("could not find groupPages in SettingsSearch.qml")
+ routes = dict(re.findall(r'"([a-zA-Z]+)"\s*:\s*"([a-z-]+)"', block.group(1)))
+ if not routes:
+ raise SchemaError("groupPages matched but contained no routes")
+ return routes
+
+
+def read_entries():
+ """Every schema entry, as dicts, in declaration order."""
+ text = SCHEMA.read_text()
+
+ # Each entry begins at `key:` and ends at the closing brace of its block.
+ # Nested braces (options, hypr) are skipped by counting depth.
+ entries = []
+ for match in re.finditer(r'\{\s*\n\s*key:\s*"([^"]+)"', text):
+ name = match.group(1)
+ start = match.start()
+ depth = 0
+ end = None
+ for index in range(start, len(text)):
+ if text[index] == "{":
+ depth += 1
+ elif text[index] == "}":
+ depth -= 1
+ if depth == 0:
+ end = index
+ break
+ if end is None:
+ raise SchemaError(f"entry {name!r} is not brace-balanced")
+ body = text[start:end]
+
+ def scalar(field):
+ found = re.search(rf'\b{field}:\s*("([^"]*)"|[^,\n]+)', body)
+ if not found:
+ return None
+ return (found.group(2) if found.group(2) is not None
+ else found.group(1).strip())
+
+ entry = {
+ "key": name,
+ "type": scalar("type"),
+ "default": scalar("def"),
+ "group": scalar("group"),
+ "label": scalar("label"),
+ "detail": scalar("detail"),
+ "unit": scalar("unit"),
+ "min": scalar("min"),
+ "max": scalar("max"),
+ "internal": scalar("internal") == "true",
+ "option": scalar("option"),
+ "options": re.findall(r'\{\s*value:\s*("?[^",]+)"?,\s*label:\s*"([^"]+)"', body),
+ }
+ if not entry["type"] or not entry["group"]:
+ raise SchemaError(f"entry {name!r} is missing a type or group")
+ entries.append(entry)
+
+ if len(entries) < 50:
+ raise SchemaError(
+ f"only {len(entries)} entries parsed; the schema has far more, so "
+ "this reader no longer recognises the file"
+ )
+ return entries
+
+
+def render(entries, routes):
+ lines = [
+ "# Settings reference",
+ "",
+ "**Generated from `config/dot/quickshell/config/PreferenceSchema.qml`.**",
+ "Do not edit this file. Run `quickshell/scripts/panama-settings-docs`",
+ "after changing the schema; a contract fails when this copy is stale.",
+ "",
+ f"{len([e for e in entries if not e['internal']])} settings across "
+ f"{len({e['group'] for e in entries if not e['internal']})} groups. "
+ f"{len([e for e in entries if e['option']])} of them are applied to the "
+ "compositor and confirmed by reading the value back.",
+ "",
+ ]
+
+ by_group = {}
+ for entry in entries:
+ by_group.setdefault(entry["group"], []).append(entry)
+
+ for group in sorted(by_group):
+ visible = [e for e in by_group[group] if not e["internal"]]
+ if not visible:
+ continue
+ page = routes.get(group)
+ title = PAGE_TITLES.get(page, page or "—")
+ lines += [f"## {group}", "", f"Found on **{title}**.", ""]
+ lines += ["| Setting | Default | What it does |", "|---|---|---|"]
+ for entry in visible:
+ default = entry["default"] or "—"
+ if entry["unit"]:
+ default = f"{default} {entry['unit']}"
+ if entry["options"]:
+ choices = ", ".join(label for _value, label in entry["options"])
+ detail = f"{entry['detail'] or ''} Choices: {choices}."
+ else:
+ detail = entry["detail"] or ""
+ if entry["min"] is not None and entry["max"] is not None:
+ # Schema details are written without trailing punctuation, so
+ # one is added before appending a second sentence.
+ if detail and not detail.rstrip().endswith((".", "!", "?")):
+ detail = detail.rstrip() + "."
+ detail += f" Range {entry['min']}–{entry['max']}."
+ label = entry["label"] or entry["key"]
+ compositor = f" `{entry['option']}`" if entry["option"] else ""
+ lines.append(
+ f"| **{label}**
`{entry['key']}`{compositor} | {default} | "
+ f"{detail.strip()} |"
+ )
+ lines.append("")
+
+ return "\n".join(lines) + "\n"
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--check", action="store_true")
+ parser.add_argument("--stdout", action="store_true")
+ args = parser.parse_args()
+
+ try:
+ rendered = render(read_entries(), read_routes())
+ except SchemaError as error:
+ # Loudly, and without writing. A partial reference is worse than a stale
+ # one: stale is caught by --check, partial reads as complete.
+ print(f"panama-settings-docs: {error}", file=sys.stderr)
+ return 2
+
+ if args.stdout:
+ print(rendered, end="")
+ return 0
+
+ if args.check:
+ if not OUTPUT.exists():
+ print("panama-settings-docs: docs/settings.md has never been generated",
+ file=sys.stderr)
+ return 1
+ if OUTPUT.read_text() != rendered:
+ print("panama-settings-docs: docs/settings.md is stale; re-run this "
+ "script and commit the result", file=sys.stderr)
+ return 1
+ return 0
+
+ OUTPUT.parent.mkdir(parents=True, exist_ok=True)
+ OUTPUT.write_text(rendered)
+ print(f"wrote {OUTPUT.relative_to(ROOT)}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/docs/settings.md b/docs/settings.md
new file mode 100644
index 0000000..917defc
--- /dev/null
+++ b/docs/settings.md
@@ -0,0 +1,317 @@
+# Settings reference
+
+**Generated from `config/dot/quickshell/config/PreferenceSchema.qml`.**
+Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
+after changing the schema; a contract fails when this copy is stale.
+
+127 settings across 26 groups. 67 of them are applied to the compositor and confirmed by reading the value back.
+
+## accessibility
+
+Found on **Accessibility**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **Magnifier**
`magnifierFactor` `cursor:zoom_factor` | 1.0 | Magnifies the screen around the pointer. 1.0 is off. Range 1.0–5.0. |
+| **Magnifier follows in steps**
`magnifierRigid` `cursor:zoom_rigid` | false | Moves the magnified view in increments rather than gliding with the pointer |
+| **Dim inactive windows**
`dimInactive` `decoration:dim_inactive` | false | Darkens every window except the focused one, so the active window is unmistakable |
+| **Dim amount**
`dimStrength` `decoration:dim_strength` | 0.5 | How much darker unfocused windows are. Range 0.05–0.9. |
+| **Pointer size**
`cursorSize` | 24 px | Applies to the compositor and to applications. Range 16–64. |
+| **Text size**
`textScale` | 1.0 | Scales interface text everywhere; 1.00 is the design size. Range 0.75–2.0. |
+
+## appearance
+
+Found on **Appearance**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **Appearance**
`colorScheme` | dark | Light and dark share one identity, not two themes Choices: Dark, Light. |
+| **Accent color**
`accentName` | blue | Drives the focused window border, the bar hairline, and every active state Choices: Prism blue, Orchid, Teal, Green, Amber, Orange, Rose, Slate. |
+
+## capture
+
+Found on **Screen Intelligence**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **Screenshot folder**
`screenshotDir` | Pictures/Screenshots | Folder under your home directory for screenshots Choices: Pictures / Screenshots, Pictures, Desktop. |
+| **Recording folder**
`recordingDir` | Videos/Recordings | Folder under your home directory for screen recordings Choices: Videos / Recordings, Videos, Desktop. |
+| **Recording encoder**
`recorderArgs` | -c h264_vaapi -d /dev/dri/renderD128 | Hardware encoding keeps recording off the processor while gaming Choices: VAAPI H.264, VAAPI HEVC, CPU x264. |
+
+## clock
+
+Found on **Appearance**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **24-hour time**
`use24Hour` | false | Use 18:30 instead of 6:30 PM |
+| **Show seconds**
`showSeconds` | true | Keep a precise clock in the center of the bar |
+| **Show weekday**
`showWeekday` | true | Include the abbreviated weekday before the date |
+
+## display
+
+Found on **Displays**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **Game-aware HDR**
`autoHdr` `render:cm_auto_hdr` | true | Hand HDR to fullscreen games while the desktop stays SDR |
+| **Variable refresh rate**
`vrrPolicy` `misc:vrr` | 3 | Matches the display's refresh rate to what is on screen Choices: Off, Always on, Fullscreen only, Fullscreen games. |
+| **Direct scanout**
`directScanoutPolicy` `render:direct_scanout` | 2 | Lets fullscreen content bypass compositing Choices: Off, Always on, Automatic. |
+
+## dock
+
+Found on **Desktop & Dock**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **Automatically hide the Dock**
`dockAutohide` | true | Reveal it at the bottom edge when a workspace is occupied |
+| **Reveal delay**
`dockRevealDelayMs` | 0 ms | Zero reveals the Dock the instant the pointer reaches the edge. Range 0–1000. |
+| **Hide delay**
`dockHideDelayMs` | 250 ms | Prevents flicker when crossing between icons. Range 0–2000. |
+| **Pinned applications**
`dockPinned` | [ | Applications that stay in the Dock whether or not they are running |
+
+## edges
+
+Found on **Desktop & Dock**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **Resize by dragging the border**
`resizeOnBorder` `general:resize_on_border` | true | Drag a window's edge to resize it, instead of only with the keyboard |
+| **Border grab area**
`borderGrabArea` `general:extend_border_grab_area` | 15 px | How far outside the border still counts as grabbing it. Larger is easier to hit. Range 0–40. |
+| **Show the resize cursor**
`hoverIconOnBorder` `general:hover_icon_on_border` | true | Change the pointer when it is over a resizable border |
+| **Snap distance between windows**
`snapWindowGap` `general:snap:window_gap` | 10 px | How close two floating windows must be before they snap together. Range 0–60. |
+| **Snap distance to screen edges**
`snapMonitorGap` `general:snap:monitor_gap` | 10 px | How close a floating window must be to an edge before it snaps to it. Range 0–60. |
+| **Snapping respects gaps**
`snapRespectGaps` `general:snap:respect_gaps` | false | Snapped windows keep the configured gap instead of touching |
+
+## effects
+
+Found on **Appearance**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **Blur**
`blurEnabled` `decoration:blur:enabled` | true | Blur the desktop behind translucent surfaces |
+| **Blur radius**
`blurSize` `decoration:blur:size` | 8 | Larger is softer and costs more frame time. Range 1–20. |
+| **Blur passes**
`blurPasses` `decoration:blur:passes` | 3 | More passes look smoother and cost more frame time. Range 1–5. |
+| **Window shadows**
`shadowEnabled` `decoration:shadow:enabled` | true | Lift windows off the wallpaper with a soft shadow |
+| **Shadow size**
`shadowRange` `decoration:shadow:range` | 20 px | How far the shadow spreads from the window edge. Range 0–60. |
+| **Hard-edged shadow**
`shadowSharp` `decoration:shadow:sharp` | false | A crisp shadow instead of a soft falloff |
+| **Shadow falloff**
`shadowRenderPower` `decoration:shadow:render_power` | 3 | How sharply the shadow fades out. Higher is tighter to the window. Range 1–4. |
+| **Focus glow**
`glowEnabled` `decoration:glow:enabled` | true | A faint halo behind the focused window |
+| **Glow size**
`glowRange` `decoration:glow:range` | 8 px | Kept small deliberately: the gradient border is the signature. Range 0–30. |
+| **Animations**
`animationsEnabled` `animations:enabled` | true | Window, workspace, and panel motion |
+
+## focus
+
+Found on **Desktop & Dock**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **Focus session length**
`focusDurationMinutes` | 45 min | How long a focus session runs before it ends itself. Range 5–180. |
+
+## idle
+
+Found on **Power & Lock**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **Turn the screen off after**
`screenBlankMinutes` | 5 min | Blanks the display; nothing is locked yet. Range 0–120. |
+| **Lock the screen after**
`lockMinutes` | 10 min | Counted from when the session went idle, not from blanking. Range 0–240. |
+| **Suspend after**
`suspendMinutes` | 0 min | This is a desktop, so Panama ships with automatic suspend off. Range 0–480. |
+| **Lock before sleeping**
`lockOnSleep` | true | Requires your password when the machine wakes |
+
+## input
+
+Found on **Keyboard**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **Keyboard layout**
`keyboardLayout` `input:kb_layout` | us | XKB layout name, or a comma-separated list to switch between |
+| **Layout variant**
`keyboardVariant` `input:kb_variant` | — | XKB variant, such as dvorak or colemak. Empty for the standard layout |
+| **Keyboard options**
`keyboardOptions` `input:kb_options` | caps:escape_shifted_capslock | XKB options, such as compose:ralt to make right Alt a compose key |
+| **Num Lock on login**
`numlockByDefault` `input:numlock_by_default` | true | Turn Num Lock on when the session starts |
+| **Repeat delay**
`keyRepeatDelay` `input:repeat_delay` | 500 ms | How long a key is held before it starts repeating. Range 150–1000. |
+| **Repeat rate**
`keyRepeatRate` `input:repeat_rate` | 33 /s | How many characters a second a held key produces. Range 5–100. |
+
+## lockAppearance
+
+Found on **Appearance**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **Background**
`lockBackgroundMode` | screenshot | What appears behind the lock screen Choices: Blurred desktop, Current wallpaper, Solid color. |
+| **Background blur**
`lockBlurLevel` | 3 | Softens what is behind the password field. Range 0–5. |
+| **Show clock**
`lockShowClock` | true | Use the desktop's 12 or 24-hour format |
+| **Show date**
`lockShowDate` | true | Show the weekday and full date |
+| **Show user name**
`lockShowUser` | true | Identify the signed-in account |
+| **Hide password field until typing**
`lockFadeOnEmpty` | false | Keep the empty field out of the way |
+
+## master
+
+Found on **Desktop & Dock**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **Master area size**
`masterFactor` `master:mfact` | 0.55 | How much of the screen the master window takes. Range 0.1–0.9. |
+| **Master area position**
`masterOrientation` `master:orientation` | left | Which side of the screen the master window occupies Choices: Left, Right, Top, Bottom, Center. |
+| **New windows become**
`masterNewStatus` `master:new_status` | slave | Whether a new window takes the master area or joins the stack Choices: The master window, Part of the stack, Whatever the focused window is. |
+| **Add new windows at the top**
`masterNewOnTop` `master:new_on_top` | false | New stack windows go above the others rather than below |
+
+## multitasking
+
+Found on **Desktop & Dock**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **Tiling layout**
`windowLayout` `general:layout` | dwindle | Dwindle splits the focused window; master keeps one large window beside a stack Choices: Dwindle, Master and stack. |
+| **Keep split direction**
`preserveSplit` `dwindle:preserve_split` | true | New windows follow the split of the window they replace, instead of always halving the longer side |
+| **New windows open**
`forceSplit` `dwindle:force_split` | 0 | Where a new window lands relative to the one that was focused Choices: Where the pointer is, Always left or above, Always right or below. |
+| **Snap floating windows**
`windowSnapping` `general:snap:enabled` | true | Floating windows stick to screen edges and to each other as you drag them |
+| **Switch back and forth**
`workspaceBackAndForth` `binds:workspace_back_and_forth` | false | Selecting the workspace you are already on returns you to the previous one |
+| **Wrap around at the ends**
`allowWorkspaceCycles` `binds:allow_workspace_cycles` | false | Moving past the last workspace continues from the first |
+| **Let applications take focus**
`focusOnActivate` `misc:focus_on_activate` | false | An application asking for attention is switched to, rather than only highlighted |
+| **Pointer changes active display**
`mouseMoveFocusesMonitor` `misc:mouse_move_focuses_monitor` | true | Moving the pointer to another display makes it the active one |
+
+## nightLight
+
+Found on **Displays**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **Night Light**
`nightLightEnabled` | false | Shift the display warmer to reduce blue light |
+| **Schedule automatically**
`nightLightAutomatic` | false | Turn Night Light on and off at the scheduled hours |
+| **Color temperature**
`nightLightTemperature` | 3500 K | Lower is warmer. Range 2000–6500. |
+| **Turns on at**
`nightLightFrom` | 17.0 | Only used when Night Light follows a schedule. Range 0–23.5. |
+| **Turns off at**
`nightLightTo` | 10.0 | A time earlier than the start simply means the next morning. Range 0–23.5. |
+
+## notices
+
+Found on **Desktop & Dock**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **Hyprland wallpaper**
`hyprlandLogo` `misc:disable_hyprland_logo` | false | The stock background Hyprland draws when no wallpaper is set |
+| **Splash text**
`hyprlandSplash` `misc:disable_splash_rendering` | false | The line of text Hyprland renders over the stock background |
+| **Update announcements**
`hyprlandUpdateNews` `ecosystem:no_update_news` | false | The window Hyprland opens after an update to describe what changed |
+| **Donation reminders**
`hyprlandDonationNag` `ecosystem:no_donation_nag` | false | The prompt Hyprland shows twice a year asking for support |
+
+## notifications
+
+Found on **Notifications & Focus**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **Notification duration**
`notificationTimeoutMs` | 5000 ms | How long ordinary notification banners remain visible. Range 1000–30000. |
+| **Critical notification duration**
`notificationTimeoutCriticalMs` | 0 ms | Zero keeps critical notification banners visible until dismissed. Range 0–60000. |
+| **Notification history**
`notificationHistoryLimit` | 100 | Maximum notifications retained in the notification center. Range 10–500. |
+| **Visible banners**
`maxVisibleToasts` | 4 | Maximum notification banners shown at once. Range 1–8. |
+
+## pointer
+
+Found on **Mouse & Touchpad**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **Pointer focus**
`followMouse` `input:follow_mouse` | 1 | What moving the pointer does to which window is focused Choices: Click to focus, Focus follows pointer, Pointer detached, Pointer fully separate. |
+| **Pointer speed**
`pointerSensitivity` `input:sensitivity` | 0.0 | Zero is flat, unaccelerated response. Range -1.0–1.0. |
+| **Hide pointer after**
`cursorInactiveTimeout` `cursor:inactive_timeout` | 4 s | Seconds of stillness before the pointer fades out; 0 never hides it. Range 0–60. |
+| **Natural scrolling**
`naturalScroll` `input:natural_scroll` | false | Content follows the direction of your fingers, as on a phone |
+| **Acceleration**
`accelProfile` `input:accel_profile` | flat | Flat moves the pointer the same distance however fast you move Choices: Flat, Adaptive. |
+| **Scroll speed**
`scrollFactor` `input:scroll_factor` | 1.0 | Multiplies how far one notch of the wheel scrolls. Range 0.1–4.0. |
+| **Left-handed**
`leftHanded` `input:left_handed` | false | Swap the primary and secondary buttons |
+| **Middle-click paste**
`middleClickPaste` `misc:middle_click_paste` | true | Paste the primary selection in GTK and native Wayland applications |
+
+## themes
+
+Found on **Appearance**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **Pointer theme**
`cursorTheme` | oreo_blue_cursors | The pointer design used by applications and Hyprland |
+| **Application icons**
`iconTheme` | Adwaita | The icon set used by GTK applications |
+
+## titlebar
+
+Found on **Appearance**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **Button side**
`titlebarButtonSide` | right | Place application titlebar buttons on the left or right Choices: Left, Right. |
+| **Maximize button**
`titlebarMaximizeButton` | false | Show a maximize button in application titlebars that support it |
+| **Double-click titlebar**
`titlebarDoubleClick` | toggle-maximize | Choose what a double-click on an application titlebar does Choices: Toggle maximize, Do nothing. |
+
+## touchpad
+
+Found on **Mouse & Touchpad**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **Tap to click**
`touchpadTapToClick` `input:touchpad:tap-to-click` | true | A tap counts as a click without pressing down |
+| **Natural scrolling**
`touchpadNaturalScroll` `input:touchpad:natural_scroll` | true | Content follows the direction of your fingers |
+| **Disable while typing**
`touchpadDisableWhileTyping` `input:touchpad:disable_while_typing` | true | Ignore the touchpad briefly after a keystroke, so a palm cannot move the pointer |
+| **Scroll speed**
`touchpadScrollFactor` `input:touchpad:scroll_factor` | 1.0 | Multiplies how far a two-finger scroll travels. Range 0.1–4.0. |
+| **Drag lock**
`touchpadDragLock` `input:touchpad:drag_lock` | 0 | Keeps a tap-and-drag active when you lift a finger mid-drag Choices: Off, On, On, until you tap again. |
+| **Middle-click by pressing both buttons**
`touchpadMiddleButtonEmulation` `input:touchpad:middle_button_emulation` | false | Pressing left and right together acts as a middle click |
+
+## typography
+
+Found on **Appearance**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **Interface font**
`interfaceFont` | Adwaita Sans | Used for every piece of text in the shell |
+| **Icon font**
`iconFont` | VictorMono Nerd Font | Draws the shell's glyphs, so it must be a Nerd Font |
+| **Interface text size**
`interfaceFontSize` | 13 px | The base size the rest of the shell's type scales from. Range 10–18. |
+| **Application font**
`applicationFont` | Adwaita Sans | Used by menus, controls, and labels in applications |
+| **Application text size**
`applicationFontSize` | 11 pt | The base text size used by applications. Range 6–32. |
+| **Document font**
`documentFont` | Adwaita Sans | Used for document content when an application follows the system choice |
+| **Document text size**
`documentFontSize` | 12 pt | The default text size for document content. Range 6–32. |
+| **Monospace font**
`monospaceFont` | VictorMono Nerd Font | Used by terminals, editors, and code fields that follow the system choice |
+| **Monospace text size**
`monospaceFontSize` | 10 pt | The default text size for terminals and code. Range 6–32. |
+| **Font hinting**
`fontHinting` | slight | How strongly text aligns to the pixel grid Choices: None, Slight, Medium, Full. |
+| **Text smoothing**
`fontAntialiasing` | rgba | How application text softens its edges Choices: None, Grayscale, Subpixel. |
+
+## vitals
+
+Found on **Appearance**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **Processor**
`showCpu` | true | Show processor usage beside the workspace indicator |
+| **Memory**
`showMemory` | true | Show memory usage beside the workspace indicator |
+| **Graphics**
`showGpu` | true | Show graphics usage beside the workspace indicator |
+| **Vitals refresh**
`vitalsIntervalMs` | 2000 ms | How often processor, memory, and graphics usage update. Range 500–10000. |
+
+## wallpaper
+
+Found on **Appearance**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **Wallpaper**
`wallpaperPath` | — | Shown on every output |
+| **Wallpaper mode**
`wallpaperMode` | single | Use one image, rotate a collection, or choose per display Choices: Single, Slideshow, Per display. |
+| **Change background every**
`wallpaperIntervalMinutes` | 30 min | Time between slideshow images. Range 5–1440. |
+| **Shuffle**
`wallpaperShuffle` | true | Show every selected image before repeating |
+
+## weather
+
+Found on **Home**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **Temperature unit**
`temperatureUnit` | fahrenheit | Choose Fahrenheit or Celsius for the weather card Choices: Fahrenheit, Celsius. |
+| **Weather refresh**
`weatherRefreshMinutes` | 20 min | How often Panama updates the current conditions. Range 5–120. |
+
+## windows
+
+Found on **Appearance**.
+
+| Setting | Default | What it does |
+|---|---|---|
+| **Inner gaps**
`gapsIn` `general:gaps_in` | 5 px | Space between neighboring tiled windows. Range 0–40. |
+| **Outer gaps**
`gapsOut` `general:gaps_out` | 10 px | Space between the tiled area and the screen edge. Range 0–80. |
+| **Border width**
`borderSize` `general:border_size` | 2 px | Thickness of the gradient border on the focused window. Range 0–10. |
+| **Corner radius**
`windowRounding` `decoration:rounding` | 18 px | Matches the shell's popover radius so windows and panels agree. Range 0–40. |
+| **Unfocused window opacity**
`inactiveOpacity` `decoration:inactive_opacity` | 1.0 | Fade windows that do not have focus. Range 0.5–1.0. |
+| **Focused window opacity**
`activeOpacity` `decoration:active_opacity` | 1.0 | Fade even the focused window; 1.0 is fully opaque. Range 0.5–1.0. |
+| **Fullscreen opacity**
`fullscreenOpacity` `decoration:fullscreen_opacity` | 1.0 | Applied instead of the focused opacity when a window is fullscreen. Range 0.5–1.0. |
+| **Corner shape**
`roundingPower` `decoration:rounding_power` | 2.0 | 2 is a circular corner; higher values approach a squircle. Range 2.0–10.0. |
+
diff --git a/tests/quickshell/settings-docs-contract.sh b/tests/quickshell/settings-docs-contract.sh
new file mode 100755
index 0000000..8a7d57f
--- /dev/null
+++ b/tests/quickshell/settings-docs-contract.sh
@@ -0,0 +1,60 @@
+#!/usr/bin/env bash
+
+# The settings reference is generated, and must not be stale.
+#
+# Every other kind of documentation here has drifted at least once: routing that
+# pointed at a page not containing the setting, contracts that pinned the bug
+# they were meant to prevent, a comment that failed to stop the person who read
+# it from making the exact mistake it described. Prose describing 127 settings
+# would drift the day after it was written.
+#
+# So docs/settings.md is generated from the schema, and this fails the moment
+# the committed copy stops matching. That is the whole mechanism: the document
+# cannot be wrong for longer than it takes to run the suite.
+
+set -uo pipefail
+
+repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+generator="$repo_dir/config/dot/quickshell/scripts/panama-settings-docs"
+doc="$repo_dir/docs/settings.md"
+
+fail() {
+ printf 'settings docs contract: %s\n' "$1" >&2
+ exit 1
+}
+
+[[ -x "$generator" ]] || fail 'the generator is missing or not executable'
+
+"$generator" --check || fail 'docs/settings.md is stale -- run quickshell/scripts/panama-settings-docs and commit the result'
+
+# A generator that silently emitted nothing would also pass --check against an
+# equally empty file, so the output is checked for substance too.
+[[ -s "$doc" ]] || fail 'docs/settings.md is empty'
+
+settings="$(grep -c '^| \*\*' "$doc")"
+schema_keys="$(grep -c 'key: "' "$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml")"
+
+# Internal keys are deliberately omitted, so the document is smaller than the
+# schema -- but not by much. A large gap means the reader stopped recognising
+# entries and quietly documented a fraction of them.
+(( settings > schema_keys * 8 / 10 )) \
+ || fail "the reference documents only $settings of $schema_keys schema keys, which suggests the generator stopped parsing partway"
+
+# The compositor-backed settings are the ones worth naming precisely, since
+# that is the string someone would search the Hyprland docs for.
+grep -q 'cursor:zoom_factor' "$doc" \
+ || fail 'compositor option names are missing from the reference'
+
+# A stale copy must be detectable, not merely regenerable. Prove the check
+# actually compares content rather than always returning success.
+scratch="$(mktemp -d /tmp/panama-docs.XXXXXX)"
+trap 'rm -rf "$scratch"' EXIT
+cp "$doc" "$scratch/settings.md"
+printf '\n\n' >>"$doc"
+if "$generator" --check >/dev/null 2>&1; then
+ cp "$scratch/settings.md" "$doc"
+ fail '--check reported success on a modified file, so staleness would never be caught'
+fi
+cp "$scratch/settings.md" "$doc"
+
+printf 'settings docs contract: PASS (%d settings documented)\n' "$settings"