pragma Singleton

// ─────────────────────────────────────────────────────────────────────────────
// The schema is the single source of truth for every user-changeable setting.
//
// One entry describes a setting completely: its name, type, default, valid
// range, which group it belongs to, and how to label it. Persistence,
// validation, reset, and (from Stage 3) the settings UI itself are all derived
// from these entries rather than restated.
//
// Adding a setting means adding one entry here. It does not mean editing a
// property alias, a JSON adapter, a change handler, and a reset function --
// which is what it used to mean, and why the settings app stalled at sixteen
// knobs while forty comparable values stayed hardcoded one file away.
//
// Entry fields
//   key      unique identifier; also the JSON key on disk
//   type     "bool" | "int" | "real" | "string" | "enum" | "json"
//   def      shipped default, used when the file is absent or a value is invalid
//   min/max  inclusive bounds for int and real; values outside are clamped
//   step     UI increment for int and real
//   options  for "enum": [{ value, label }], the only accepted values
//   group    grouping id, used to build settings pages
//   label    short UI name
//   detail   one line explaining what changing it does
//   internal true for state the shell keeps but the user never edits directly
//   pattern  for "string": a regular expression the value must match in full
//
// "json" holds a structured value -- a list or an object -- that the schema
// stores and resets but does not validate field by field. It exists so that
// settings like the dock's pinned applications live in the same file, and are
// covered by the same reset, as everything else rather than growing a fourth
// preference store. The service that owns such a value is responsible for
// validating it; see services/Dock-related consumers.
//   hypr     present when the setting maps onto an Hyprland option:
//              path    the hl.config table path, e.g. ["decoration","blur","size"]
//              option  the getoption path used to read the value back
//              readAs  which field getoption returns it in -- "int", "bool",
//                      "float", "str", or "css" (gaps, returned as a box)
//
// Anything with a `hypr` block is applied live by services/SystemSettings.qml
// and read at startup by config/dot/hypr/prefs.lua, using the same key. The Lua
// keeps the shipped value as its fallback, so the config still works with no
// settings file at all.
// ─────────────────────────────────────────────────────────────────────────────

import Quickshell
import QtQuick

Singleton {
    id: root

    readonly property var entries: [
        // ── Clock ───────────────────────────────────────────────────────────
        {
            key: "use24Hour", type: "bool", def: false, group: "clock",
            label: "24-hour time",
            detail: "Use 18:30 instead of 6:30 PM"
        },
        {
            key: "showSeconds", type: "bool", def: true, group: "clock",
            label: "Show seconds",
            detail: "Keep a precise clock in the center of the bar"
        },
        {
            key: "showWeekday", type: "bool", def: true, group: "clock",
            label: "Show weekday",
            detail: "Include the abbreviated weekday before the date"
        },

        // ── Vitals ──────────────────────────────────────────────────────────
        {
            key: "showCpu", type: "bool", def: true, group: "vitals",
            label: "Processor",
            detail: "Show processor usage beside the workspace indicator"
        },
        {
            key: "showMemory", type: "bool", def: true, group: "vitals",
            label: "Memory",
            detail: "Show memory usage beside the workspace indicator"
        },
        {
            key: "showGpu", type: "bool", def: true, group: "vitals",
            label: "Graphics",
            detail: "Show graphics usage beside the workspace indicator"
        },

        // ── Dock ────────────────────────────────────────────────────────────
        {
            key: "dockAutohide", type: "bool", def: true, group: "dock",
            label: "Automatically hide the Dock",
            detail: "Reveal it at the bottom edge when a workspace is occupied"
        },
        {
            key: "dockRevealDelayMs", type: "int", def: 0, min: 0, max: 1000, step: 25,
            unit: "ms",
            group: "dock",
            label: "Reveal delay",
            detail: "Zero reveals the Dock the instant the pointer reaches the edge"
        },
        {
            key: "dockHideDelayMs", type: "int", def: 250, min: 0, max: 2000, step: 25,
            unit: "ms",
            group: "dock",
            label: "Hide delay",
            detail: "Prevents flicker when crossing between icons"
        },

        // ── Focus ───────────────────────────────────────────────────────────
        {
            key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
            unit: "min",
            group: "focus",
            label: "Focus session length",
            detail: "How long a focus session runs before it ends itself"
        },

        // ── Display policy ──────────────────────────────────────────────────
        // These three are written to the compositor and verified by read-back.
        // See services/SystemSettings.qml for why the exit code cannot be
        // trusted for either hyprctl keyword or hyprctl eval.
        {
            key: "autoHdr", type: "bool", def: true, group: "display",
            label: "Game-aware HDR",
            detail: "Hand HDR to fullscreen games while the desktop stays SDR",
            hypr: { path: ["render", "cm_auto_hdr"], option: "render:cm_auto_hdr", readAs: "int" }
        },
        {
            key: "vrrPolicy", type: "enum", def: 3, group: "display",
            label: "Variable refresh rate",
            detail: "Matches the display's refresh rate to what is on screen",
            // All four the compositor publishes, rather than the two that were
            // here. Always-on VRR is a legitimate choice on a panel that
            // handles it well, and it was simply unreachable -- as was
            // fullscreen-only, which is what someone wanting VRR for video
            // rather than games wants.
            options: [
                { value: 0, label: "Off",
                  detail: "The display runs at a fixed refresh rate" },
                { value: 1, label: "Always on",
                  detail: "Best on panels that handle low refresh rates without flicker" },
                { value: 2, label: "Fullscreen only",
                  detail: "Any fullscreen window, including video" },
                { value: 3, label: "Fullscreen games",
                  detail: "Only fullscreen games, which is the safest default" }
            ],
            hypr: { path: ["misc", "vrr"], option: "misc:vrr", readAs: "int" }
        },
        {
            key: "directScanoutPolicy", type: "enum", def: 2, group: "display",
            label: "Direct scanout",
            detail: "Lets fullscreen content bypass compositing",
            options: [
                { value: 0, label: "Off",
                  detail: "Everything goes through the compositor" },
                { value: 1, label: "Always on",
                  detail: "Forced rather than decided per surface; can drop frames on some drivers" },
                { value: 2, label: "Automatic",
                  detail: "The compositor decides per surface, which is the safe default" }
            ],
            hypr: { path: ["render", "direct_scanout"], option: "render:direct_scanout", readAs: "int" }
        },

        // ── Window appearance ───────────────────────────────────────────────
        // These adjust the parameters of the Prism identity -- how much space,
        // how soft, how much motion -- rather than replacing it. Shipped values
        // are duplicated as the fallbacks in config/dot/hypr/looks.lua so that
        // the Hyprland config still stands on its own.
        {
            key: "gapsIn", type: "int", def: 5, min: 0, max: 40, step: 1,
            unit: "px",
            group: "windows",
            label: "Inner gaps",
            detail: "Space between neighbouring tiled windows",
            hypr: { path: ["general", "gaps_in"], option: "general:gaps_in", readAs: "css" }
        },
        {
            key: "gapsOut", type: "int", def: 10, min: 0, max: 80, step: 1,
            unit: "px",
            group: "windows",
            label: "Outer gaps",
            detail: "Space between the tiled area and the screen edge",
            hypr: { path: ["general", "gaps_out"], option: "general:gaps_out", readAs: "css" }
        },
        {
            key: "borderSize", type: "int", def: 2, min: 0, max: 10, step: 1,
            unit: "px",
            group: "windows",
            label: "Border width",
            detail: "Thickness of the gradient border on the focused window",
            hypr: { path: ["general", "border_size"], option: "general:border_size", readAs: "int" }
        },
        {
            key: "windowRounding", type: "int", def: 18, min: 0, max: 40, step: 1,
            unit: "px",
            group: "windows",
            label: "Corner radius",
            detail: "Matches the shell's popover radius so windows and panels agree",
            hypr: { path: ["decoration", "rounding"], option: "decoration:rounding", readAs: "int" }
        },
        {
            key: "inactiveOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05,
            group: "windows",
            label: "Unfocused window opacity",
            detail: "Fade windows that do not have focus",
            hypr: { path: ["decoration", "inactive_opacity"], option: "decoration:inactive_opacity", readAs: "float" }
        },

        {
            key: "activeOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05,
            group: "windows",
            label: "Focused window opacity",
            detail: "Fade even the focused window; 1.0 is fully opaque",
            hypr: { path: ["decoration", "active_opacity"], option: "decoration:active_opacity", readAs: "float" }
        },
        {
            key: "fullscreenOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05,
            group: "windows",
            label: "Fullscreen opacity",
            detail: "Applied instead of the focused opacity when a window is fullscreen",
            hypr: { path: ["decoration", "fullscreen_opacity"], option: "decoration:fullscreen_opacity", readAs: "float" }
        },
        {
            key: "roundingPower", type: "real", def: 2.0, min: 2.0, max: 10.0, step: 0.5,
            group: "windows",
            label: "Corner shape",
            detail: "2 is a circular corner; higher values approach a squircle",
            hypr: { path: ["decoration", "rounding_power"], option: "decoration:rounding_power", readAs: "float" }
        },

        // ── Window edges ────────────────────────────────────────────────────
        // How the pointer interacts with a window's border, and how windows
        // behave near each other. All shipped by looks.lua with no way to
        // change any of it.
        {
            key: "resizeOnBorder", type: "bool", def: true, group: "edges",
            label: "Resize by dragging the border",
            detail: "Drag a window's edge to resize it, instead of only with the keyboard",
            hypr: { path: ["general", "resize_on_border"], option: "general:resize_on_border", readAs: "bool" }
        },
        {
            key: "borderGrabArea", type: "int", def: 15, min: 0, max: 40, step: 1,
            unit: "px",
            group: "edges",
            label: "Border grab area",
            detail: "How far outside the border still counts as grabbing it. Larger is easier to hit",
            hypr: { path: ["general", "extend_border_grab_area"], option: "general:extend_border_grab_area", readAs: "int" }
        },
        {
            key: "hoverIconOnBorder", type: "bool", def: true, group: "edges",
            label: "Show the resize cursor",
            detail: "Change the pointer when it is over a resizable border",
            hypr: { path: ["general", "hover_icon_on_border"], option: "general:hover_icon_on_border", readAs: "bool" }
        },
        {
            key: "snapWindowGap", type: "int", def: 10, min: 0, max: 60, step: 1,
            unit: "px",
            group: "edges",
            label: "Snap distance between windows",
            detail: "How close two floating windows must be before they snap together",
            hypr: { path: ["general", "snap", "window_gap"], option: "general:snap:window_gap", readAs: "int" }
        },
        {
            key: "snapMonitorGap", type: "int", def: 10, min: 0, max: 60, step: 1,
            unit: "px",
            group: "edges",
            label: "Snap distance to screen edges",
            detail: "How close a floating window must be to an edge before it snaps to it",
            hypr: { path: ["general", "snap", "monitor_gap"], option: "general:snap:monitor_gap", readAs: "int" }
        },
        {
            key: "snapRespectGaps", type: "bool", def: false, group: "edges",
            label: "Snapping respects gaps",
            detail: "Snapped windows keep the configured gap instead of touching",
            hypr: { path: ["general", "snap", "respect_gaps"], option: "general:snap:respect_gaps", readAs: "bool" }
        },

        // ── Master layout ───────────────────────────────────────────────────
        // Only meaningful when the tiling layout is Master and stack. Offering
        // that layout with none of its options was an omission: it is the one
        // layout whose whole behaviour is in these settings.
        {
            key: "masterFactor", type: "real", def: 0.55, min: 0.1, max: 0.9, step: 0.05,
            group: "master",
            label: "Master area size",
            detail: "How much of the screen the master window takes",
            hypr: { path: ["master", "mfact"], option: "master:mfact", readAs: "float" }
        },
        {
            key: "masterOrientation", type: "enum", def: "left", group: "master",
            label: "Master area position",
            detail: "Which side of the screen the master window occupies",
            options: [
                { value: "left", label: "Left" },
                { value: "right", label: "Right" },
                { value: "top", label: "Top" },
                { value: "bottom", label: "Bottom" },
                { value: "center", label: "Centre" }
            ],
            hypr: { path: ["master", "orientation"], option: "master:orientation", readAs: "str" }
        },
        {
            key: "masterNewStatus", type: "enum", def: "slave", group: "master",
            label: "New windows become",
            detail: "Whether a new window takes the master area or joins the stack",
            options: [
                { value: "master", label: "The master window" },
                { value: "slave", label: "Part of the stack" },
                { value: "inherit", label: "Whatever the focused window is" }
            ],
            hypr: { path: ["master", "new_status"], option: "master:new_status", readAs: "str" }
        },
        {
            key: "masterNewOnTop", type: "bool", def: false, group: "master",
            label: "Add new windows at the top",
            detail: "New stack windows go above the others rather than below",
            hypr: { path: ["master", "new_on_top"], option: "master:new_on_top", readAs: "bool" }
        },

        // ── Hyprland's own notices ──────────────────────────────────────────
        // Panama turns all four off on the user's behalf. That is a defensible
        // default and was not a decision anyone could reverse without editing
        // looks.lua, which is precisely the kind of thing this app exists to
        // stop.
        {
            key: "hyprlandLogo", type: "bool", def: false, group: "notices",
            label: "Hyprland wallpaper",
            detail: "The stock background Hyprland draws when no wallpaper is set",
            hypr: { path: ["misc", "disable_hyprland_logo"], option: "misc:disable_hyprland_logo", readAs: "bool", invert: true }
        },
        {
            key: "hyprlandSplash", type: "bool", def: false, group: "notices",
            label: "Splash text",
            detail: "The line of text Hyprland renders over the stock background",
            hypr: { path: ["misc", "disable_splash_rendering"], option: "misc:disable_splash_rendering", readAs: "bool", invert: true }
        },
        {
            key: "hyprlandUpdateNews", type: "bool", def: false, group: "notices",
            label: "Update announcements",
            detail: "The window Hyprland opens after an update to describe what changed",
            hypr: { path: ["ecosystem", "no_update_news"], option: "ecosystem:no_update_news", readAs: "bool", invert: true }
        },
        {
            key: "hyprlandDonationNag", type: "bool", def: false, group: "notices",
            label: "Donation reminders",
            detail: "The prompt Hyprland shows twice a year asking for support",
            hypr: { path: ["ecosystem", "no_donation_nag"], option: "ecosystem:no_donation_nag", readAs: "bool", invert: true }
        },

        // ── Effects ─────────────────────────────────────────────────────────
        {
            key: "blurEnabled", type: "bool", def: true, group: "effects",
            label: "Blur",
            detail: "Blur the desktop behind translucent surfaces",
            hypr: { path: ["decoration", "blur", "enabled"], option: "decoration:blur:enabled", readAs: "bool" }
        },
        {
            key: "blurSize", type: "int", def: 8, min: 1, max: 20, step: 1,
            group: "effects",
            label: "Blur radius",
            detail: "Larger is softer and costs more frame time",
            hypr: { path: ["decoration", "blur", "size"], option: "decoration:blur:size", readAs: "int" }
        },
        {
            key: "blurPasses", type: "int", def: 3, min: 1, max: 5, step: 1,
            group: "effects",
            label: "Blur passes",
            detail: "More passes look smoother and cost more frame time",
            hypr: { path: ["decoration", "blur", "passes"], option: "decoration:blur:passes", readAs: "int" }
        },
        {
            key: "shadowEnabled", type: "bool", def: true, group: "effects",
            label: "Window shadows",
            detail: "Lift windows off the wallpaper with a soft shadow",
            hypr: { path: ["decoration", "shadow", "enabled"], option: "decoration:shadow:enabled", readAs: "bool" }
        },
        {
            key: "shadowRange", type: "int", def: 20, min: 0, max: 60, step: 1,
            unit: "px",
            group: "effects",
            label: "Shadow size",
            detail: "How far the shadow spreads from the window edge",
            hypr: { path: ["decoration", "shadow", "range"], option: "decoration:shadow:range", readAs: "int" }
        },
        {
            key: "shadowSharp", type: "bool", def: false, group: "effects",
            label: "Hard-edged shadow",
            detail: "A crisp shadow instead of a soft falloff",
            hypr: { path: ["decoration", "shadow", "sharp"], option: "decoration:shadow:sharp", readAs: "bool" }
        },
        {
            key: "shadowRenderPower", type: "int", def: 3, min: 1, max: 4, step: 1,
            group: "effects",
            label: "Shadow falloff",
            detail: "How sharply the shadow fades out. Higher is tighter to the window",
            hypr: { path: ["decoration", "shadow", "render_power"], option: "decoration:shadow:render_power", readAs: "int" }
        },
        {
            key: "glowEnabled", type: "bool", def: true, group: "effects",
            label: "Focus glow",
            detail: "A faint halo behind the focused window",
            hypr: { path: ["decoration", "glow", "enabled"], option: "decoration:glow:enabled", readAs: "bool" }
        },
        {
            key: "glowRange", type: "int", def: 8, min: 0, max: 30, step: 1,
            unit: "px",
            group: "effects",
            label: "Glow size",
            detail: "Kept small deliberately: the gradient border is the signature",
            hypr: { path: ["decoration", "glow", "range"], option: "decoration:glow:range", readAs: "int" }
        },
        {
            key: "animationsEnabled", type: "bool", def: true, group: "effects",
            label: "Animations",
            detail: "Window, workspace, and panel motion",
            hypr: { path: ["animations", "enabled"], option: "animations:enabled", readAs: "bool" }
        },

        // ── Input ───────────────────────────────────────────────────────────
        {
            key: "keyboardLayout", type: "string", def: "us", group: "input",
            // Reaches an hl.config string, so it is constrained to the shape of
            // an XKB layout list and nothing else.
            pattern: "^[a-z]{2,8}(,[a-z]{2,8})*$",
            label: "Keyboard layout",
            detail: "XKB layout name, or a comma-separated list to switch between",
            hypr: { path: ["input", "kb_layout"], option: "input:kb_layout", readAs: "str" }
        },
        {
            key: "keyboardVariant", type: "string", def: "", group: "input",
            // Same shape as the layout list and for the same reason: this is
            // serialised into an hl.config string.
            pattern: "^$|^[a-z0-9_]{1,24}(,[a-z0-9_]{1,24})*$",
            label: "Layout variant",
            detail: "XKB variant, such as dvorak or colemak. Empty for the standard layout",
            hypr: { path: ["input", "kb_variant"], option: "input:kb_variant", readAs: "str" }
        },
        {
            key: "keyboardOptions", type: "string", def: "caps:escape_shifted_capslock", group: "input",
            // XKB option names are colon-separated pairs in a comma-separated
            // list, e.g. "compose:ralt,caps:escape".
            pattern: "^$|^[a-z0-9_]+:[a-z0-9_]+(,[a-z0-9_]+:[a-z0-9_]+)*$",
            label: "Keyboard options",
            detail: "XKB options, such as compose:ralt to make right Alt a compose key",
            hypr: { path: ["input", "kb_options"], option: "input:kb_options", readAs: "str" }
        },
        {
            key: "numlockByDefault", type: "bool", def: true, group: "input",
            label: "Num Lock on login",
            detail: "Turn Num Lock on when the session starts",
            hypr: { path: ["input", "numlock_by_default"], option: "input:numlock_by_default", readAs: "bool" }
        },
        {
            key: "keyRepeatDelay", type: "int", def: 500, min: 150, max: 1000, step: 25,
            unit: "ms",
            group: "input",
            label: "Repeat delay",
            detail: "How long a key is held before it starts repeating",
            hypr: { path: ["input", "repeat_delay"], option: "input:repeat_delay", readAs: "int" }
        },
        {
            key: "keyRepeatRate", type: "int", def: 33, min: 5, max: 100, step: 1,
            unit: "/s",
            group: "input",
            label: "Repeat rate",
            detail: "How many characters a second a held key produces",
            hypr: { path: ["input", "repeat_rate"], option: "input:repeat_rate", readAs: "int" }
        },
        {
            key: "followMouse", type: "enum", def: 1, group: "pointer",
            label: "Pointer focus",
            detail: "What moving the pointer does to which window is focused",
            // These labels were wrong, and wrong in the worst way: value 1 was
            // shown as "Click to focus" while Hyprland's 1 means the opposite.
            // The compositor publishes the authoritative mapping itself --
            // `hyprctl descriptions` gives
            //   map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}]
            // -- so a desktop labelled "Click to focus" was in fact following
            // the pointer, and the way to actually get click-to-focus was to
            // choose "Never". Value 3 was missing entirely.
            //
            // enum-hypr-map-contract now pins every mapped enum against that
            // published map, so this cannot drift again.
            options: [
                { value: 0, label: "Click to focus",
                  detail: "Moving the pointer never changes focus" },
                { value: 1, label: "Focus follows pointer",
                  detail: "The window under the pointer takes focus as you move" },
                { value: 2, label: "Pointer detached",
                  detail: "The pointer highlights windows on its own; clicking moves keyboard focus" },
                { value: 3, label: "Pointer fully separate",
                  detail: "Clicking does not move keyboard focus at all" }
            ],
            hypr: { path: ["input", "follow_mouse"], option: "input:follow_mouse", readAs: "int" }
        },
        {
            key: "pointerSensitivity", type: "real", def: 0.0, min: -1.0, max: 1.0, step: 0.05,
            group: "pointer",
            label: "Pointer speed",
            detail: "Zero is flat, unaccelerated response",
            hypr: { path: ["input", "sensitivity"], option: "input:sensitivity", readAs: "float" }
        },
        {
            key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
            unit: "s",
            group: "pointer",
            label: "Hide pointer after",
            detail: "Seconds of stillness before the pointer fades out; 0 never hides it",
            // Reported as a float even though it is only ever set to whole
            // seconds. `readAs` describes what getoption answers with, not what
            // the setting means -- getting this wrong makes every write to it
            // look rejected.
            hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" }
        },

        // ── Pointer ─────────────────────────────────────────────────────────
        //
        // Every `readAs` below was read back off the running compositor rather
        // than guessed. Two are not what they look like: touchpad drag lock is
        // an int with three states, not a switch, and scroll factors are floats
        // even at their default of exactly 1.
        {
            key: "naturalScroll", type: "bool", def: false, group: "pointer",
            label: "Natural scrolling",
            detail: "Content follows the direction of your fingers, as on a phone",
            hypr: { path: ["input", "natural_scroll"], option: "input:natural_scroll", readAs: "bool" }
        },
        {
            key: "accelProfile", type: "enum", def: "flat", group: "pointer",
            label: "Acceleration",
            detail: "Flat moves the pointer the same distance however fast you move",
            options: [
                { value: "flat", label: "Flat" },
                { value: "adaptive", label: "Adaptive" }
            ],
            hypr: { path: ["input", "accel_profile"], option: "input:accel_profile", readAs: "str" }
        },
        {
            key: "scrollFactor", type: "real", def: 1.0, min: 0.1, max: 4.0, step: 0.1,
            group: "pointer",
            label: "Scroll speed",
            detail: "Multiplies how far one notch of the wheel scrolls",
            hypr: { path: ["input", "scroll_factor"], option: "input:scroll_factor", readAs: "float" }
        },
        {
            key: "leftHanded", type: "bool", def: false, group: "pointer",
            label: "Left-handed",
            detail: "Swap the primary and secondary buttons",
            hypr: { path: ["input", "left_handed"], option: "input:left_handed", readAs: "bool" }
        },
        {
            key: "middleClickPaste", type: "bool", def: true, group: "pointer",
            label: "Middle-click paste",
            detail: "Paste the primary selection in GTK and native Wayland applications",
            hypr: { path: ["misc", "middle_click_paste"], option: "misc:middle_click_paste", readAs: "bool" }
        },

        // ── Touchpad ────────────────────────────────────────────────────────
        //
        // Shown only on machines that have one. These are separate from the
        // pointer settings above because libinput keeps them separate: a mouse
        // and a touchpad on the same machine can scroll in opposite directions,
        // and usually should.
        {
            key: "touchpadTapToClick", type: "bool", def: true, group: "touchpad",
            label: "Tap to click",
            detail: "A tap counts as a click without pressing down",
            // The Lua config key and the hyprctl option name genuinely differ
            // here: hl.config wants input.touchpad.tap_to_click, getoption
            // answers to input:touchpad:tap-to-click. Using either spelling for
            // both fails -- a hyphen is not a Lua identifier, and the
            // underscored name is not a known option to getoption. This is what
            // the two separate fields are for.
            hypr: { path: ["input", "touchpad", "tap_to_click"], option: "input:touchpad:tap-to-click", readAs: "bool" }
        },
        {
            key: "touchpadNaturalScroll", type: "bool", def: true, group: "touchpad",
            label: "Natural scrolling",
            detail: "Content follows the direction of your fingers",
            hypr: { path: ["input", "touchpad", "natural_scroll"], option: "input:touchpad:natural_scroll", readAs: "bool" }
        },
        {
            key: "touchpadDisableWhileTyping", type: "bool", def: true, group: "touchpad",
            label: "Disable while typing",
            detail: "Ignore the touchpad briefly after a keystroke, so a palm cannot move the pointer",
            hypr: { path: ["input", "touchpad", "disable_while_typing"], option: "input:touchpad:disable_while_typing", readAs: "bool" }
        },
        {
            key: "touchpadScrollFactor", type: "real", def: 1.0, min: 0.1, max: 4.0, step: 0.1,
            group: "touchpad",
            label: "Scroll speed",
            detail: "Multiplies how far a two-finger scroll travels",
            hypr: { path: ["input", "touchpad", "scroll_factor"], option: "input:touchpad:scroll_factor", readAs: "float" }
        },
        {
            key: "touchpadDragLock", type: "enum", def: 0, group: "touchpad",
            label: "Drag lock",
            detail: "Keeps a tap-and-drag active when you lift a finger mid-drag",
            // An int with three states rather than a switch, which is why this
            // is an enum: reported as `int` by getoption, not `bool`.
            options: [
                { value: 0, label: "Off" },
                { value: 1, label: "On" },
                { value: 2, label: "On, until you tap again" }
            ],
            hypr: { path: ["input", "touchpad", "drag_lock"], option: "input:touchpad:drag_lock", readAs: "int" }
        },
        {
            key: "touchpadMiddleButtonEmulation", type: "bool", def: false, group: "touchpad",
            label: "Middle-click by pressing both buttons",
            detail: "Pressing left and right together acts as a middle click",
            hypr: { path: ["input", "touchpad", "middle_button_emulation"], option: "input:touchpad:middle_button_emulation", readAs: "bool" }
        },

        // ── Multitasking ────────────────────────────────────────────────────
        //
        // GNOME's Multitasking panel, in Hyprland's terms. The Desktop page
        // described the first two of these as read-only facts ("Layout:
        // Tiling"), which was never true -- they are ordinary settings that
        // happened not to have controls.
        //
        // Defaults here are Panama's shipped values from hypr/looks.lua, not
        // Hyprland's own, so restoring defaults returns the desktop to how it
        // ships rather than to how Hyprland would behave with no config.
        {
            key: "windowLayout", type: "enum", def: "dwindle", group: "multitasking",
            label: "Tiling layout",
            detail: "Dwindle splits the focused window; master keeps one large window beside a stack",
            options: [
                { value: "dwindle", label: "Dwindle" },
                { value: "master", label: "Master and stack" }
            ],
            hypr: { path: ["general", "layout"], option: "general:layout", readAs: "str" }
        },
        {
            key: "preserveSplit", type: "bool", def: true, group: "multitasking",
            label: "Keep split direction",
            detail: "New windows follow the split of the window they replace, instead of always halving the longer side",
            hypr: { path: ["dwindle", "preserve_split"], option: "dwindle:preserve_split", readAs: "bool" }
        },
        {
            key: "forceSplit", type: "enum", def: 0, group: "multitasking",
            label: "New windows open",
            detail: "Where a new window lands relative to the one that was focused",
            options: [
                { value: 0, label: "Where the pointer is" },
                { value: 1, label: "Always left or above" },
                { value: 2, label: "Always right or below" }
            ],
            hypr: { path: ["dwindle", "force_split"], option: "dwindle:force_split", readAs: "int" }
        },
        {
            key: "windowSnapping", type: "bool", def: true, group: "multitasking",
            label: "Snap floating windows",
            detail: "Floating windows stick to screen edges and to each other as you drag them",
            hypr: { path: ["general", "snap", "enabled"], option: "general:snap:enabled", readAs: "bool" }
        },
        {
            key: "workspaceBackAndForth", type: "bool", def: false, group: "multitasking",
            label: "Switch back and forth",
            detail: "Selecting the workspace you are already on returns you to the previous one",
            hypr: { path: ["binds", "workspace_back_and_forth"], option: "binds:workspace_back_and_forth", readAs: "bool" }
        },
        {
            key: "allowWorkspaceCycles", type: "bool", def: false, group: "multitasking",
            label: "Wrap around at the ends",
            detail: "Moving past the last workspace continues from the first",
            hypr: { path: ["binds", "allow_workspace_cycles"], option: "binds:allow_workspace_cycles", readAs: "bool" }
        },
        {
            key: "focusOnActivate", type: "bool", def: false, group: "multitasking",
            label: "Let applications take focus",
            detail: "An application asking for attention is switched to, rather than only highlighted",
            hypr: { path: ["misc", "focus_on_activate"], option: "misc:focus_on_activate", readAs: "bool" }
        },
        {
            key: "mouseMoveFocusesMonitor", type: "bool", def: true, group: "multitasking",
            label: "Pointer changes active display",
            detail: "Moving the pointer to another display makes it the active one",
            hypr: { path: ["misc", "mouse_move_focuses_monitor"], option: "misc:mouse_move_focuses_monitor", readAs: "bool" }
        },

        // ── Accessibility ───────────────────────────────────────────────────
        //
        // Only what Hyprland can actually deliver. GNOME's sticky keys, slow
        // keys, bounce keys and mouse keys are AccessX, an X11 server feature:
        // XKB under Wayland has no accessx option group at all (verified
        // against evdev.lst), and Hyprland does not implement it. The
        // compositor will happily STORE "accessx:enable" as a keyboard option
        // and nothing will ever act on it, which is exactly the kind of switch
        // this app refuses to ship.
        {
            key: "magnifierFactor", type: "real", def: 1.0, min: 1.0, max: 5.0, step: 0.1,
            group: "accessibility",
            label: "Magnifier",
            detail: "Magnifies the screen around the pointer. 1.0 is off",
            hypr: { path: ["cursor", "zoom_factor"], option: "cursor:zoom_factor", readAs: "float" }
        },
        {
            key: "magnifierRigid", type: "bool", def: false, group: "accessibility",
            label: "Magnifier follows in steps",
            detail: "Moves the magnified view in increments rather than gliding with the pointer",
            hypr: { path: ["cursor", "zoom_rigid"], option: "cursor:zoom_rigid", readAs: "bool" }
        },
        {
            key: "dimInactive", type: "bool", def: false, group: "accessibility",
            label: "Dim inactive windows",
            detail: "Darkens every window except the focused one, so the active window is unmistakable",
            hypr: { path: ["decoration", "dim_inactive"], option: "decoration:dim_inactive", readAs: "bool" }
        },
        {
            key: "dimStrength", type: "real", def: 0.5, min: 0.05, max: 0.9, step: 0.05,
            group: "accessibility",
            label: "Dim amount",
            detail: "How much darker unfocused windows are",
            hypr: { path: ["decoration", "dim_strength"], option: "decoration:dim_strength", readAs: "float" }
        },

        // ── Night light ─────────────────────────────────────────────────────
        {
            key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
            label: "Night Light",
            detail: "Shift the display warmer to reduce blue light"
        },
        {
            key: "nightLightAutomatic", type: "bool", def: false, group: "nightLight",
            label: "Schedule automatically",
            detail: "Turn Night Light on and off at the scheduled hours"
        },
        {
            key: "nightLightTemperature", type: "int", def: 3500, min: 2000, max: 6500, step: 100,
            unit: "K",
            group: "nightLight",
            label: "Color temperature",
            detail: "Lower is warmer"
        },

        // ── Desktop background ──────────────────────────────────────────────
        // Applied through hyprpaper's IPC. Not an hl.config option, so it has
        // no `hypr` block; services/Wallpaper.qml owns applying it.
        {
            key: "wallpaperPath", type: "string", def: "", group: "wallpaper",
            // Reaches hyprpaper as the "<output>,<path>" argument form, so a
            // comma would split it into a different request. Absolute paths
            // only, no commas, no newlines.
            pattern: "^(|/[^,\\n]+)$",
            label: "Wallpaper",
            detail: "Shown on every output"
        },
        {
            key: "wallpaperMode", type: "enum", def: "single", group: "wallpaper",
            label: "Wallpaper mode", detail: "Use one image, rotate a collection, or choose per display",
            options: [
                { value: "single", label: "Single" },
                { value: "slideshow", label: "Slideshow" },
                { value: "per-monitor", label: "Per display" }
            ]
        },
        {
            key: "wallpaperSlideshowPaths", type: "json", def: ([]), group: "wallpaper", internal: true,
            label: "Slideshow collection", detail: "Backgrounds selected for rotation"
        },
        {
            key: "wallpaperIntervalMinutes", type: "int", def: 30, min: 5, max: 1440, step: 5,
            unit: "min", group: "wallpaper", label: "Change background every",
            detail: "Time between slideshow images"
        },
        {
            key: "wallpaperShuffle", type: "bool", def: true, group: "wallpaper",
            label: "Shuffle", detail: "Show every selected image before repeating"
        },
        {
            key: "wallpaperPerMonitor", type: "json", def: ({}), group: "wallpaper", internal: true,
            label: "Per-display backgrounds", detail: "Background assigned to each connected display"
        },

        // ── Lock-screen appearance ─────────────────────────────────────────
        // scripts/panama-lock validates these again before generating a state
        // config. The tracked hyprlock.conf remains the safe fallback.
        {
            key: "lockBackgroundMode", type: "enum", def: "screenshot", group: "lockAppearance",
            label: "Background", detail: "What appears behind the lock screen",
            options: [
                { value: "screenshot", label: "Blurred desktop" },
                { value: "wallpaper", label: "Current wallpaper" },
                { value: "solid", label: "Solid color" }
            ]
        },
        {
            key: "lockBlurLevel", type: "int", def: 3, min: 0, max: 5, step: 1,
            group: "lockAppearance", label: "Background blur",
            detail: "Softens what is behind the password field"
        },
        {
            key: "lockShowClock", type: "bool", def: true, group: "lockAppearance",
            label: "Show clock", detail: "Use the desktop's 12 or 24-hour format"
        },
        {
            key: "lockShowDate", type: "bool", def: true, group: "lockAppearance",
            label: "Show date", detail: "Show the weekday and full date"
        },
        {
            key: "lockShowUser", type: "bool", def: true, group: "lockAppearance",
            label: "Show user name", detail: "Identify the signed-in account"
        },
        {
            key: "lockFadeOnEmpty", type: "bool", def: false, group: "lockAppearance",
            label: "Hide password field until typing", detail: "Keep the empty field out of the way"
        },

        // ── Idle, lock, and sleep ───────────────────────────────────────────
        // Written into a generated hypridle config; see scripts/panama-idle.
        // Zero means never for all three.
        {
            key: "screenBlankMinutes", type: "int", def: 5, min: 0, max: 120, step: 1,
            unit: "min", group: "idle",
            label: "Turn the screen off after",
            detail: "Blanks the display; nothing is locked yet"
        },
        {
            key: "lockMinutes", type: "int", def: 10, min: 0, max: 240, step: 1,
            unit: "min", group: "idle",
            label: "Lock the screen after",
            detail: "Counted from when the session went idle, not from blanking"
        },
        {
            key: "suspendMinutes", type: "int", def: 0, min: 0, max: 480, step: 5,
            unit: "min", group: "idle",
            label: "Suspend after",
            detail: "This is a desktop, so Panama ships with automatic suspend off"
        },
        {
            key: "lockOnSleep", type: "bool", def: true, group: "idle",
            label: "Lock before sleeping",
            detail: "Requires your password when the machine wakes"
        },

        // ── Night light schedule ────────────────────────────────────────────
        // Hours as decimals, so 17.5 is half past five. Wrapping past midnight
        // is normal here and is what the shipped values do: on at 17:00, off at
        // 10:00 the following morning.
        {
            key: "nightLightFrom", type: "real", def: 17.0, min: 0, max: 23.5, step: 0.5,
            group: "nightLight",
            label: "Turns on at",
            detail: "Only used when Night Light follows a schedule"
        },
        {
            key: "nightLightTo", type: "real", def: 10.0, min: 0, max: 23.5, step: 0.5,
            group: "nightLight",
            label: "Turns off at",
            detail: "A time earlier than the start simply means the next morning"
        },

        // ── Colour scheme ───────────────────────────────────────────────────
        // Light is Tokyo Night Day, the official light variant, rather than a
        // palette invented to merely not be dark. Both share the same hues at
        // different lightness, which is what keeps the Prism identity intact
        // across the switch.
        //
        // services/ColorScheme.qml pushes the choice to GTK and to the
        // compositor's border colours, because an application toolbar or a
        // window border still wearing the other scheme is more jarring than
        // either scheme on its own.
        {
            key: "colorScheme", type: "enum", def: "dark", group: "appearance",
            label: "Appearance",
            detail: "Light and dark share one identity, not two themes",
            options: [
                { value: "dark", label: "Dark" },
                { value: "light", label: "Light" }
            ]
        },

        // ── Application themes ─────────────────────────────────────────────
        // ColorScheme owns GTK's light/dark theme. These are the two theme
        // choices GNOME applications expose independently of that palette:
        // their icons and pointer. DesktopStyle only accepts names found in
        // the read-only XDG catalog before storing them.
        {
            key: "cursorTheme", type: "string", def: "oreo_blue_cursors", group: "themes",
            pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
            label: "Pointer theme",
            detail: "The pointer design used by applications and Hyprland"
        },
        {
            key: "iconTheme", type: "string", def: "Adwaita", group: "themes",
            pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
            label: "Application icons",
            detail: "The icon set used by GTK applications"
        },

        // ── Typography ──────────────────────────────────────────────────────
        // The single largest thing in this desktop that used to be changeable
        // only by editing Theme.qml.
        //
        // interfaceFont is every piece of text a person reads. iconFont is used
        // ONLY to draw glyphs -- workspace pills, the status cluster, search
        // icons -- so it must carry Nerd Font glyphs; a plain monospace family
        // there replaces every icon in the shell with tofu, which is why the
        // picker offers them as a separate, filtered list.
        {
            key: "interfaceFont", type: "string", def: "Adwaita Sans", group: "typography",
            // A family name, matched by fontconfig. Constrained because it also
            // reaches hl.config as a string in hypr/looks.lua.
            pattern: "^[A-Za-z0-9 ._-]{1,64}$",
            label: "Interface font",
            detail: "Used for every piece of text in the shell"
        },
        {
            key: "iconFont", type: "string", def: "VictorMono Nerd Font", group: "typography",
            pattern: "^[A-Za-z0-9 ._-]{1,64}$",
            label: "Icon font",
            detail: "Draws the shell's glyphs, so it must be a Nerd Font"
        },
        {
            key: "interfaceFontSize", type: "int", def: 13, min: 10, max: 18, step: 1,
            unit: "px", group: "typography",
            label: "Interface text size",
            detail: "The base size the rest of the shell's type scales from"
        },
        {
            key: "applicationFont", type: "string", def: "Adwaita Sans", group: "typography",
            pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
            label: "Application font",
            detail: "Used by menus, controls, and labels in applications"
        },
        {
            key: "applicationFontSize", type: "int", def: 11, min: 6, max: 32, step: 1,
            unit: "pt", group: "typography",
            label: "Application text size",
            detail: "The base text size used by applications"
        },
        {
            key: "documentFont", type: "string", def: "Adwaita Sans", group: "typography",
            pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
            label: "Document font",
            detail: "Used for document content when an application follows the system choice"
        },
        {
            key: "documentFontSize", type: "int", def: 12, min: 6, max: 32, step: 1,
            unit: "pt", group: "typography",
            label: "Document text size",
            detail: "The default text size for document content"
        },
        {
            key: "monospaceFont", type: "string", def: "VictorMono Nerd Font", group: "typography",
            pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
            label: "Monospace font",
            detail: "Used by terminals, editors, and code fields that follow the system choice"
        },
        {
            key: "monospaceFontSize", type: "int", def: 10, min: 6, max: 32, step: 1,
            unit: "pt", group: "typography",
            label: "Monospace text size",
            detail: "The default text size for terminals and code"
        },
        {
            key: "fontHinting", type: "enum", def: "slight", group: "typography",
            label: "Font hinting",
            detail: "How strongly text aligns to the pixel grid",
            options: [
                { value: "none", label: "None" },
                { value: "slight", label: "Slight" },
                { value: "medium", label: "Medium" },
                { value: "full", label: "Full" }
            ]
        },
        {
            key: "fontAntialiasing", type: "enum", def: "rgba", group: "typography",
            label: "Text smoothing",
            detail: "How application text softens its edges",
            options: [
                { value: "none", label: "None" },
                { value: "grayscale", label: "Grayscale" },
                { value: "rgba", label: "Subpixel" }
            ]
        },

        // ── Application titlebars ──────────────────────────────────────────
        // These affect applications that honour GNOME's window preferences.
        // Hyprland itself has no server-side titlebar buttons, so minimize is
        // deliberately absent rather than presented as a switch that lies.
        {
            key: "titlebarButtonSide", type: "enum", def: "right", group: "titlebar",
            label: "Button side",
            detail: "Place application titlebar buttons on the left or right",
            options: [
                { value: "left", label: "Left" },
                { value: "right", label: "Right" }
            ]
        },
        {
            key: "titlebarMaximizeButton", type: "bool", def: false, group: "titlebar",
            label: "Maximize button",
            detail: "Show a maximize button in application titlebars that support it"
        },
        {
            key: "titlebarDoubleClick", type: "enum", def: "toggle-maximize", group: "titlebar",
            label: "Double-click titlebar",
            detail: "Choose what a double-click on an application titlebar does",
            options: [
                { value: "toggle-maximize", label: "Toggle maximize" },
                { value: "none", label: "Do nothing" }
            ]
        },

        // ── Accessibility ───────────────────────────────────────────────────
        // Backed by gsettings so GTK applications agree with the shell, and
        // pushed to the compositor as well where it has its own notion.
        {
            key: "cursorSize", type: "int", def: 24, min: 16, max: 64, step: 4,
            unit: "px", group: "accessibility",
            label: "Pointer size",
            detail: "Applies to the compositor and to applications"
        },
        {
            key: "textScale", type: "real", def: 1.0, min: 0.75, max: 2.0, step: 0.05,
            group: "accessibility",
            label: "Text size",
            detail: "Scales interface text everywhere; 1.00 is the design size"
        },

        // ── Weather ─────────────────────────────────────────────────────────
        {
            key: "temperatureUnit", type: "enum", def: "fahrenheit", group: "weather",
            label: "Temperature unit",
            detail: "Choose Fahrenheit or Celsius for the weather card",
            options: [
                { value: "fahrenheit", label: "Fahrenheit" },
                { value: "celsius", label: "Celsius" }
            ]
        },
        {
            key: "weatherRefreshMinutes", type: "int", def: 20, min: 5, max: 120, step: 5,
            unit: "min", group: "weather",
            label: "Weather refresh",
            detail: "How often Panama updates the current conditions"
        },

        // ── Which GPU the vitals readout tracks ─────────────────────────────
        // A sysfs path rather than a card number, because the number is neither
        // stable across machines nor meaningful. Constrained to the one shape
        // that can be read for utilisation; VitalsWidget hides itself when the
        // path is unreadable, so a stale value degrades to no readout rather
        // than a wrong one.
        {
            key: "gpuBusyPath", type: "string",
            def: "/sys/class/drm/card1/device/gpu_busy_percent",
            group: "vitals", internal: true,
            pattern: "^/sys/class/drm/card[0-9]+/device/gpu_busy_percent$",
            label: "Graphics device",
            detail: "Which GPU the graphics readout in the bar measures"
        },

        // ── Weather location ────────────────────────────────────────────────
        // Coordinates rather than a place name, because that is what Open-Meteo
        // takes and it needs no API key. weatherLocation is only the label shown
        // in the UI; it is never sent anywhere, so it can say whatever makes the
        // reading recognisable.
        {
            key: "weatherLatitude", type: "real", def: 27.7375, min: -90, max: 90, step: 0.0001,
            group: "weather", internal: true,
            label: "Latitude",
            detail: "Set by choosing a location"
        },
        {
            key: "weatherLongitude", type: "real", def: -82.6861, min: -180, max: 180, step: 0.0001,
            group: "weather", internal: true,
            label: "Longitude",
            detail: "Set by choosing a location"
        },
        {
            key: "weatherLocation", type: "string", def: "Local weather", group: "weather",
            internal: true,
            // Display only -- never sent to the weather service.
            pattern: "^[^\\n]{1,64}$",
            label: "Weather location",
            detail: "The place the weather reading is for"
        },

        // ── Vitals refresh ──────────────────────────────────────────────────
        {
            key: "vitalsIntervalMs", type: "int", def: 2000, min: 500, max: 10000, step: 500,
            unit: "ms", group: "vitals",
            label: "Vitals refresh",
            detail: "How often processor, memory, and graphics usage update"
        },

        // ── Notifications ───────────────────────────────────────────────────
        {
            key: "notificationTimeoutMs", type: "int", def: 5000, min: 1000, max: 30000, step: 500,
            unit: "ms", group: "notifications",
            label: "Notification duration",
            detail: "How long ordinary notification banners remain visible"
        },
        {
            key: "notificationTimeoutCriticalMs", type: "int", def: 0, min: 0, max: 60000, step: 1000,
            unit: "ms", group: "notifications",
            label: "Critical notification duration",
            detail: "Zero keeps critical notification banners visible until dismissed"
        },
        {
            key: "notificationHistoryLimit", type: "int", def: 100, min: 10, max: 500, step: 10,
            group: "notifications",
            label: "Notification history",
            detail: "Maximum notifications retained in the notification center"
        },
        {
            key: "maxVisibleToasts", type: "int", def: 4, min: 1, max: 8, step: 1,
            group: "notifications",
            label: "Visible banners",
            detail: "Maximum notification banners shown at once"
        },

        // ── Capture ─────────────────────────────────────────────────────────
        // Directories and encoder arguments are enums rather than free text:
        // both are handed to a recorder process, and an arbitrary string there
        // is a much larger surface than a settings page needs to expose.
        {
            key: "screenshotDir", type: "enum", def: "Pictures/Screenshots", group: "capture",
            label: "Screenshot folder",
            detail: "Folder under your home directory for screenshots",
            options: [
                { value: "Pictures/Screenshots", label: "Pictures / Screenshots" },
                { value: "Pictures", label: "Pictures" },
                { value: "Desktop", label: "Desktop" }
            ]
        },
        {
            key: "recordingDir", type: "enum", def: "Videos/Recordings", group: "capture",
            label: "Recording folder",
            detail: "Folder under your home directory for screen recordings",
            options: [
                { value: "Videos/Recordings", label: "Videos / Recordings" },
                { value: "Videos", label: "Videos" },
                { value: "Desktop", label: "Desktop" }
            ]
        },
        {
            key: "recorderArgs", type: "enum", def: "-c h264_vaapi -d /dev/dri/renderD128",
            group: "capture",
            label: "Recording encoder",
            detail: "Hardware encoding keeps recording off the processor while gaming",
            options: [
                { value: "-c h264_vaapi -d /dev/dri/renderD128", label: "VAAPI H.264" },
                { value: "-c hevc_vaapi -d /dev/dri/renderD128", label: "VAAPI HEVC" },
                { value: "-c libx264", label: "CPU x264" }
            ]
        },

        // ── Dock contents ───────────────────────────────────────────────────
        // A "json" value: the ordered list of desktop entry ids pinned to the
        // dock. Kept in the shared store so that reordering the dock is covered
        // by Restore defaults like everything else, rather than living in its
        // own file. The shipped order is the GNOME dash it replaced, with one
        // deliberate substitution: Panama Settings takes the first slot rather
        // than GNOME Settings. Panama now covers what GNOME Settings did for
        // this desktop and delegates the remainder to it by name, so pinning
        // the thing it delegates TO put the fallback in front of the real one.
        // GNOME Settings stays installed and searchable in the launcher.
        {
            key: "dockPinned", type: "json", group: "dock",
            label: "Pinned applications",
            detail: "Applications that stay in the Dock whether or not they are running",
            def: [
                "panama-settings", "kitty", "org.gnome.Nautilus",
                "com.bitwarden.desktop", "org.gnome.Software", "helium",
                "org.mozilla.thunderbird_esr", "com.slack.Slack",
                "app.bluebubbles.BlueBubbles", "rustdesk",
                "io.podman_desktop.PodmanDesktop", "claude-desktop",
                "codex-desktop", "md.obsidian.Obsidian",
                "com.obsproject.Studio", "steam"
            ]
        },

        // ── Keyboard shortcut overrides ─────────────────────────────────────
        // { "<bind description>": "<chord>" }. Only the chord is stored: the
        // action always comes from hypr/keybinds.lua, so an override can move a
        // shortcut but can never make one do something else. The Lua validates
        // each chord and falls back to the shipped one, so a hand-edited file
        // cannot cost you a keymap.
        //
        // Edited through the Input & Shortcuts page rather than as a row, hence
        // internal.
        {
            key: "keybindOverrides", type: "json", def: ({}), group: "input",
            internal: true,
            label: "Keyboard shortcut overrides",
            detail: "Shortcuts you have moved from their shipped chord"
        },

        // ── Display configuration ───────────────────────────────────────────
        // { "<output>": { mode, scale, transform, x, y, primary } }, applied by
        // hypr/monitors.lua on top of the shipped values. Colour management and
        // bit depth are deliberately not here: those carry a documented
        // screencopy tradeoff that a settings page cannot explain at the moment
        // you would be changing it.
        {
            key: "displays", type: "json", def: ({}), group: "display",
            internal: true,
            label: "Display configuration",
            detail: "Resolution, scale, rotation, position, and primary display"
        },

        // ── Per-application notification rules ──────────────────────────────
        // { "<appId>": { enabled, showOnLockScreen, showContentOnLockScreen } }
        //
        // Absent means "no rule", which is not the same as a rule that allows
        // everything: a new application must be able to notify without needing
        // an entry written for it first. services/Notifs.qml treats a missing
        // entry as permissive and Do Not Disturb remains an override on top,
        // rather than being duplicated per application.
        {
            key: "notificationAppRules", type: "json", def: ({}), group: "notifications",
            internal: true,
            label: "Application notification rules",
            detail: "Per-application notification and lock-screen visibility preferences"
        },

        // ── Internal ────────────────────────────────────────────────────────
        {
            key: "lastPage", type: "string", def: "home", group: "internal",
            internal: true,
            label: "Last settings page",
            detail: "Restores the page Settings was left on"
        }
    ]

    // key -> entry, built once. Every lookup below goes through this rather than
    // scanning `entries`, since get/set are called from bindings.
    readonly property var byKey: {
        const index = {};
        for (const entry of root.entries)
            index[entry.key] = entry;
        return index;
    }

    readonly property var userKeys: root.entries.filter(entry => !entry.internal).map(entry => entry.key)

    function spec(key: string): var {
        return root.byKey[key] ?? null;
    }

    function has(key: string): bool {
        return root.byKey[key] !== undefined;
    }

    function defaultFor(key: string): var {
        const entry = root.byKey[key];
        return entry ? entry.def : undefined;
    }

    function defaults(): var {
        const out = {};
        for (const entry of root.entries)
            out[entry.key] = entry.def;
        return out;
    }

    function inGroup(group: string): var {
        return root.entries.filter(entry => entry.group === group && !entry.internal);
    }

    // Entries the compositor owns, used to build one hl.config{} payload.
    function hyprEntries(): var {
        return root.entries.filter(entry => entry.hypr !== undefined);
    }

    // Returns the value coerced into the entry's type and range, or `undefined`
    // if it cannot be represented at all. Out-of-range numbers are clamped
    // rather than rejected: a stale file with a since-narrowed bound should
    // still yield a usable desktop.
    function coerce(key: string, value: var): var {
        const entry = root.byKey[key];
        if (!entry || value === undefined || value === null)
            return undefined;

        switch (entry.type) {
        case "bool":
            if (typeof value === "boolean") return value;
            if (value === "true") return true;
            if (value === "false") return false;
            return undefined;

        case "int":
        case "real": {
            const numeric = Number(value);
            if (!isFinite(numeric)) return undefined;
            const rounded = entry.type === "int" ? Math.round(numeric) : numeric;
            const lower = entry.min !== undefined ? Math.max(rounded, entry.min) : rounded;
            return entry.max !== undefined ? Math.min(lower, entry.max) : lower;
        }

        case "enum":
            return entry.options.some(option => option.value === value) ? value : undefined;

        case "json":
            // Accepted as-is. Anything JSON.parse produced is representable,
            // and per-field meaning belongs to the owning service rather than
            // here. A scalar is rejected so a corrupt file falls back to the
            // default instead of handing a list-shaped consumer a number.
            return (typeof value === "object") ? value : undefined;

        case "string": {
            const text = typeof value === "string" ? value : String(value);
            // A constrained string is rejected rather than sanitised. Several
            // of these are serialised into an hl.config payload, and quietly
            // stripping characters would turn a typo into a different setting
            // instead of an error the user can see.
            if (entry.pattern && !new RegExp(entry.pattern).test(text))
                return undefined;
            return text;
        }
        }
        return undefined;
    }
}
