← KD100

Implement profile system: overlay configs, directory loading, hot reload

069aea9f6fbfd5b304955bc8143abe470477b650 · Claude · Feb 15, 2026 23:17

Major rewrite of the profile system implementing all planned features:

- Overlay semantics: profiles override only keys, wheel, and descriptions
  on top of default.cfg. Leader, OSD, and hardware settings are preserved.
  Uses config_merge() to build a merged config on profile switch.

- Directory-based profiles (apps.profiles.d/): each app gets its own .cfg
  file (e.g., krita.cfg, blender.cfg, gimp.cfg). Files are self-contained
  with name, pattern, priority, button overrides, and descriptions.

- Hot reload via inotify: the profiles directory is monitored for file
  changes at runtime. Modified files trigger "Refreshing configuration for
  profile <file>". New files are loaded, deleted files are removed.

- Consistency checking: validates duplicate names, duplicate patterns at
  same priority, button bounds (0-18), type validation (0-2), and missing
  function warnings on every profile load.

- Visual feedback: OSD shows "Profile: <name>" notification on switch.

- Sticky profiles: switching to a window without a profile keeps the
  current profile active (unless a default catch-all exists).

- Backward compatible: profiles_dir takes precedence over profiles_file.
  Falls back to monolithic profiles.cfg if directory loading fails.

https://claude.ai/code/session_01BE1wUvwm1CH6oy9w5AFzSu

+1156 −199 · 10 files changed

  • apps.profiles.d/blender.cfg +77 −0
  • apps.profiles.d/gimp.cfg +52 −0
  • apps.profiles.d/krita.cfg +45 −0
  • default.cfg +9 −2
  • docs/PROFILES_DESIGN.md +30 −24
  • src/config.c +14 −0
  • src/config.h +2 −1
  • src/device.c +27 −9
  • src/profiles.c +866 −136
  • src/profiles.h +34 −27
diff --git a/apps.profiles.d/blender.cfg b/apps.profiles.d/blender.cfg
new file mode 100644
index 0000000..d341cd9
--- /dev/null
+++ b/apps.profiles.d/blender.cfg
@@ -0,0 +1,77 @@
+// Blender profile for KD100
+// Remaps buttons to Blender-specific shortcuts.
+// Only parameters that differ from default.cfg need to be specified.
+
+name: Blender
+pattern: *blender*
+priority: 10
+
+// Button overrides for Blender
+Button 0
+type: 0
+function: g
+
+Button 2
+type: 0
+function: s
+
+Button 3
+type: 0
+function: r
+
+Button 4
+type: 0
+function: shift+a
+
+Button 5
+type: 0
+function: shift+d
+
+Button 6
+type: 0
+function: ctrl+j
+
+Button 7
+type: 0
+function: x
+
+Button 11
+type: 0
+function: ctrl+i
+
+Button 14
+type: 0
+function: KP_Decimal
+
+Button 15
+type: 0
+function: KP_5
+
+// Descriptions
+description_0: Grab (G)
+description_1: -
+description_2: Scale (S)
+description_3: Rotate (R)
+description_4: Add Object
+description_5: Duplicate
+description_6: Join
+description_7: Delete (X)
+description_8: Deselect
+description_9: Delete
+description_10: Undo
+description_11: Invert Select
+description_12: Pan
+description_13: Zoom
+description_14: Focus (Numpad .)
+description_15: Ortho Toggle
+description_16: SHIFT
+description_17: Redo
+description_18: Wheel Fn
+
+// Wheel: zoom in/out in Blender
+Wheel
+function: KP_Add
+Wheel
+function: KP_Subtract
+
+wheel_description_0: Viewport Zoom
diff --git a/apps.profiles.d/gimp.cfg b/apps.profiles.d/gimp.cfg
new file mode 100644
index 0000000..e2297ad
--- /dev/null
+++ b/apps.profiles.d/gimp.cfg
@@ -0,0 +1,52 @@
+// GIMP profile for KD100
+// Remaps buttons to GIMP-specific shortcuts.
+
+name: GIMP
+pattern: *gimp*
+priority: 10
+
+// Button overrides for GIMP
+Button 0
+type: 0
+function: p
+
+Button 1
+type: 0
+function: o
+
+Button 3
+type: 0
+function: f
+
+Button 4
+type: 0
+function: ctrl+shift+n
+
+Button 6
+type: 0
+function: ctrl+shift+m
+
+Button 14
+type: 0
+function: shift+ctrl+e
+
+// Descriptions
+description_0: Paintbrush (P)
+description_1: Color Pick (O)
+description_2: Transform (T)
+description_3: Free Select (F)
+description_4: New Layer
+description_5: Duplicate Layer
+description_6: Flatten Image
+description_7: Eraser (E)
+description_8: Select None
+description_9: Delete
+description_10: Undo
+description_11: Invert Selection
+description_12: Pan
+description_13: Zoom
+description_14: Export As
+description_15: Mirror
+description_16: SHIFT
+description_17: Redo
+description_18: Wheel Fn
diff --git a/apps.profiles.d/krita.cfg b/apps.profiles.d/krita.cfg
new file mode 100644
index 0000000..f7f7d79
--- /dev/null
+++ b/apps.profiles.d/krita.cfg
@@ -0,0 +1,45 @@
+// Krita profile for KD100
+// Only parameters that differ from default.cfg need to be specified.
+// Unspecified parameters inherit from default.cfg (overlay semantics).
+
+name: Krita
+pattern: *krita*
+priority: 10
+
+// Button overrides (only specify buttons that change from default)
+// Button 4: Krita's "New Paint Layer" instead of default "Insert"
+Button 4
+type: 0
+function: Insert
+
+// Descriptions for Krita-specific labels
+description_0: Brush (B)
+description_1: Color Pick (P)
+description_2: Transform (T)
+description_3: Lasso (L)
+description_4: New Paint Layer
+description_5: Duplicate Layer
+description_6: Merge Down
+description_7: Eraser (E)
+description_8: Deselect
+description_9: Delete
+description_10: Undo
+description_11: Invert Selection
+description_12: Pan
+description_13: Zoom
+description_14: Rotate Canvas
+description_15: Mirror View
+description_16: SHIFT
+description_17: Redo
+description_18: Wheel Fn
+
+// Leader descriptions
+leader_description_0: Shift+Brush
+leader_description_2: Shift+Transform
+leader_description_7: Shift+Eraser
+
+// Wheel descriptions
+wheel_description_0: Brush Size
+wheel_description_1: Opacity
+wheel_description_2: Flow
+wheel_description_3: Rotation
diff --git a/default.cfg b/default.cfg
index 52e0cb8..06a070b 100644
--- a/default.cfg
+++ b/default.cfg
@@ -100,12 +100,19 @@ osd_font_size: 20
 //        *blender*     - Matches any Blender window
 //
 //      Profile Settings:
-//        profiles_file:          Path to profiles configuration file
+//        profiles_dir:           Path to apps.profiles.d/ directory (preferred)
+//                                Each .cfg file in this directory defines one profile.
+//                                Hot reload: files are monitored for changes at runtime.
+//        profiles_file:          Path to monolithic profiles.cfg (backward compatible)
+//                                If both profiles_dir and profiles_file are set,
+//                                profiles_dir takes precedence.
 //        profile_auto_switch:    true/false - Automatically switch profiles
 //        profile_check_interval: milliseconds - How often to check active window
 //
-//      See profiles.cfg for profile definitions.
+//      See docs/PROFILES_DESIGN.md for full documentation.
+//      See apps.profiles.d/ for per-app profile examples.
 //
+profiles_dir: apps.profiles.d
 profiles_file: profiles.cfg
 profile_auto_switch: true
 profile_check_interval: 500
diff --git a/docs/PROFILES_DESIGN.md b/docs/PROFILES_DESIGN.md
index b901032..b1d0cf1 100644
--- a/docs/PROFILES_DESIGN.md
+++ b/docs/PROFILES_DESIGN.md
@@ -306,31 +306,37 @@ loop iteration, same as the profile check interval.
 
 ---
 
-## 3. Migration Path
-
-### Phase 1: Overlay semantics
-- Modify `profile_manager_get_config()` to return a merged config (default
-  overlaid with profile-specific overrides) instead of a full replacement.
-- Keep `profiles.cfg` format working during transition.
-
-### Phase 2: Directory-based profiles
-- Add `profiles_dir:` config option pointing to `apps.profiles.d/`.
-- Implement per-file loading with the same overlay semantics.
-- Keep `profiles_file:` working for backward compatibility.
-
-### Phase 3: Visual feedback
-- Add `osd_record_action(osd, -1, "Profile: Krita")` on profile switch.
-- Add profile name display to OSD expanded view header.
-
-### Phase 4: Hot reload
-- Add `inotify` monitoring for the profiles directory.
-- Integrate into the main event loop.
-- Add consistency re-checking on reload.
-
-### Phase 5: Deprecate monolithic format
-- Log a deprecation warning when `profiles_file:` is used.
-- Provide a migration script (`profiles-migrate.sh`) that splits
+## 3. Implementation Status
+
+All phases are implemented as of v1.8.0:
+
+- **Overlay semantics:** `config_merge()` in `profiles.c` builds a merged
+  config (default overlaid with profile overrides). Only keys, wheel, and
+  descriptions are overlaid. Leader, OSD, wheel_mode, and hardware settings
+  are copied from the base config unchanged.
+
+- **Directory-based profiles:** `profile_manager_load_dir()` scans
+  `apps.profiles.d/` for `.cfg` files. Each file is parsed for `name:`,
+  `pattern:`, `priority:`, button/wheel overrides, and descriptions.
+  The `profiles_dir:` config option points to the directory.
+
+- **Visual feedback:** `apply_profile_to_osd()` calls
+  `osd_record_action(osd, -1, "Profile: Krita")` on profile switch.
+
+- **Hot reload:** `profile_manager_watch_start()` sets up non-blocking
+  `inotify` on the profiles directory. `profile_manager_check_reload()`
+  is called from `profile_manager_update()` on each event loop iteration.
+  File modifications, creations, and deletions are all handled.
+
+- **Backward compatibility:** If `profiles_dir:` is set, it takes precedence.
+  If it fails or is not set, `profiles_file:` is used as fallback. Both
+  formats continue to work.
+
+### Future work
+- Add a migration script (`profiles-migrate.sh`) that splits a monolithic
   `profiles.cfg` into individual files in `apps.profiles.d/`.
+- Add debouncing for inotify events (text editors often write via temp files).
+- Add profile name display to OSD expanded view header area.
 
 ---
 
diff --git a/src/config.c b/src/config.c
index 96a72d9..c7eb145 100644
--- a/src/config.c
+++ b/src/config.c
@@ -142,6 +142,7 @@ config_t* config_create(void) {
 
     // Initialize profile settings
     config->profile.profiles_file = NULL;
+    config->profile.profiles_dir = NULL;
     config->profile.auto_switch = 1;  // Enabled by default
     config->profile.check_interval_ms = 500;
 
@@ -189,6 +190,9 @@ void config_destroy(config_t* config) {
     if (config->profile.profiles_file != NULL) {
         free(config->profile.profiles_file);
     }
+    if (config->profile.profiles_dir != NULL) {
+        free(config->profile.profiles_dir);
+    }
 
     // Free key descriptions
     for (int i = 0; i < 19; i++) {
@@ -422,6 +426,15 @@ int config_load(config_t* config, const char* filename, int debug) {
             continue;
         }
 
+        if (strncasecmp(line, "profiles_dir:", 13) == 0) {
+            char* value = line + 13;
+            while (*value == ' ') value++;
+            if (config->profile.profiles_dir) free(config->profile.profiles_dir);
+            config->profile.profiles_dir = strdup(value);
+            if (debug) printf("Config: profiles_dir = %s\n", config->profile.profiles_dir);
+            continue;
+        }
+
         if (strncasecmp(line, "profile_auto_switch:", 20) == 0) {
             char* value = line + 20;
             while (*value == ' ') value++;
@@ -703,6 +716,7 @@ void config_print(const config_t* config, int debug) {
 
     printf("\n=== Profile Configuration ===\n");
     printf("Profiles file: %s\n", config->profile.profiles_file ? config->profile.profiles_file : "(none)");
+    printf("Profiles dir:  %s\n", config->profile.profiles_dir ? config->profile.profiles_dir : "(none)");
     printf("Auto switch: %s\n", config->profile.auto_switch ? "yes" : "no");
     printf("Check interval: %d ms\n", config->profile.check_interval_ms);
 
diff --git a/src/config.h b/src/config.h
index 68a281a..b95ae6c 100644
--- a/src/config.h
+++ b/src/config.h
@@ -38,7 +38,8 @@ typedef struct {
 
 // Profile configuration (for referencing profile files)
 typedef struct {
-    char* profiles_file;      // Path to profiles configuration file
+    char* profiles_file;      // Path to monolithic profiles.cfg (backward compatible)
+    char* profiles_dir;       // Path to apps.profiles.d/ directory (preferred, takes precedence)
     int auto_switch;          // Enable automatic profile switching
     int check_interval_ms;    // How often to check active window (default 500ms)
 } profile_config_t;
diff --git a/src/device.c b/src/device.c
index f7bfd0a..69f84a3 100644
--- a/src/device.c
+++ b/src/device.c
@@ -105,8 +105,8 @@ void device_run(libusb_context* ctx, config_t* config, int debug, int accept, in
         }
     }
 
-    // Initialize profile manager if profiles file is configured
-    if (config->profile.profiles_file) {
+    // Initialize profile manager if profiles are configured (directory or file)
+    if (config->profile.profiles_dir || config->profile.profiles_file) {
         profile_manager = profile_manager_create(config);
         if (profile_manager != NULL) {
             profile_manager_set_debug(profile_manager, debug);
@@ -114,14 +114,32 @@ void device_run(libusb_context* ctx, config_t* config, int debug, int accept, in
             // Initialize with shared display if OSD is enabled
             void* shared_display = osd ? osd->display : NULL;
             if (profile_manager_init(profile_manager, shared_display, osd) == 0) {
-                // Load profiles from file
-                if (profile_manager_load(profile_manager, config->profile.profiles_file) == 0) {
-                    printf("Profiles: Loaded from %s\n", config->profile.profiles_file);
-                    if (debug) {
-                        profile_manager_print(profile_manager);
+                int profiles_loaded = 0;
+
+                // Prefer profiles_dir (apps.profiles.d/) over monolithic profiles_file
+                if (config->profile.profiles_dir) {
+                    if (profile_manager_load_dir(profile_manager, config->profile.profiles_dir) == 0) {
+                        profiles_loaded = 1;
+                        // Start hot reload watcher on the directory
+                        profile_manager_watch_start(profile_manager, config->profile.profiles_dir);
+                    } else if (config->profile.profiles_file) {
+                        printf("Profiles: Directory load failed, falling back to %s\n",
+                               config->profile.profiles_file);
                     }
-                } else {
-                    printf("Profiles: Failed to load from %s\n", config->profile.profiles_file);
+                }
+
+                // Fall back to monolithic profiles.cfg if directory didn't work
+                if (!profiles_loaded && config->profile.profiles_file) {
+                    if (profile_manager_load(profile_manager, config->profile.profiles_file) == 0) {
+                        printf("Profiles: Loaded from %s\n", config->profile.profiles_file);
+                        profiles_loaded = 1;
+                    } else {
+                        printf("Profiles: Failed to load from %s\n", config->profile.profiles_file);
+                    }
+                }
+
+                if (profiles_loaded && debug) {
+                    profile_manager_print(profile_manager);
                 }
             } else {
                 printf("Profiles: Failed to initialize manager\n");
diff --git a/src/profiles.c b/src/profiles.c
index 8ca79b0..d9fdacb 100644
--- a/src/profiles.c
+++ b/src/profiles.c
@@ -2,16 +2,25 @@
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
+#include <strings.h>
 #include <pwd.h>
 #include <unistd.h>
+#include <dirent.h>
+#include <sys/stat.h>
+#include <sys/inotify.h>
+#include <errno.h>
+
+// ============================================================================
+// Helper functions
+// ============================================================================
 
 // Helper: free profile contents
 static void free_profile(profile_t* profile) {
     if (profile->name) { free(profile->name); profile->name = NULL; }
     if (profile->window_pattern) { free(profile->window_pattern); profile->window_pattern = NULL; }
-    if (profile->config_file) { free(profile->config_file); profile->config_file = NULL; }
+    if (profile->source_file) { free(profile->source_file); profile->source_file = NULL; }
 
-    // Free config if we own it (not the default)
+    // Free config if we own it
     if (profile->config) {
         config_destroy(profile->config);
         profile->config = NULL;
@@ -23,22 +32,341 @@ static void free_profile(profile_t* profile) {
             free(profile->key_descriptions[i]);
             profile->key_descriptions[i] = NULL;
         }
+        if (profile->leader_descriptions[i]) {
+            free(profile->leader_descriptions[i]);
+            profile->leader_descriptions[i] = NULL;
+        }
+    }
+    for (int i = 0; i < 32; i++) {
+        if (profile->wheel_descriptions[i]) {
+            free(profile->wheel_descriptions[i]);
+            profile->wheel_descriptions[i] = NULL;
+        }
+    }
+}
+
+// Helper: sanitize a description string (max 64 chars, printable ASCII)
+static char* sanitize_desc(const char* input) {
+    if (input == NULL) return NULL;
+    while (*input == ' ' || *input == '\t') input++;
+    size_t len = strlen(input);
+    if (len > MAX_DESCRIPTION_LEN) len = MAX_DESCRIPTION_LEN;
+    char* result = malloc(len + 1);
+    if (!result) return NULL;
+    size_t j = 0;
+    for (size_t i = 0; i < len && input[i]; i++) {
+        if (input[i] >= 0x20 && input[i] <= 0x7E)
+            result[j++] = input[i];
+    }
+    result[j] = '\0';
+    while (j > 0 && (result[j-1] == ' ' || result[j-1] == '\t'))
+        result[--j] = '\0';
+    if (j == 0) { free(result); return NULL; }
+    return result;
+}
+
+// Helper: strip inline comments and trailing whitespace
+static void strip_comment(char* str) {
+    if (!str) return;
+    char* c = strstr(str, "//");
+    if (c) *c = '\0';
+    size_t len = strlen(str);
+    while (len > 0 && (str[len-1] == ' ' || str[len-1] == '\t' || str[len-1] == '\n' || str[len-1] == '\r'))
+        str[--len] = '\0';
+}
+
+// Helper: build a merged config (default overlaid with profile-specific overrides)
+// Only overlays: button events, wheel events, key/leader/wheel descriptions
+// Does NOT overlay: leader config, OSD, wheel_mode, enable_uclogic, profile settings
+static config_t* config_merge(const config_t* base, const config_t* overlay) {
+    if (base == NULL) return NULL;
+
+    config_t* merged = config_create();
+    if (merged == NULL) return NULL;
+
+    // Copy base settings that profiles should NOT change
+    merged->enable_uclogic = base->enable_uclogic;
+    merged->wheel_click_timeout_ms = base->wheel_click_timeout_ms;
+    merged->wheel_mode = base->wheel_mode;
+    merged->osd = base->osd;
+    merged->profile = base->profile;
+    // Null out the pointers we copied so they don't get double-freed
+    merged->profile.profiles_file = base->profile.profiles_file ? strdup(base->profile.profiles_file) : NULL;
+    merged->profile.profiles_dir = base->profile.profiles_dir ? strdup(base->profile.profiles_dir) : NULL;
+
+    // Copy leader config from base (leader is NOT per-profile)
+    merged->leader.leader_button = base->leader.leader_button;
+    merged->leader.leader_active = base->leader.leader_active;
+    merged->leader.last_button = base->leader.last_button;
+    merged->leader.leader_press_time = base->leader.leader_press_time;
+    merged->leader.leader_function = base->leader.leader_function ? strdup(base->leader.leader_function) : NULL;
+    merged->leader.timeout_ms = base->leader.timeout_ms;
+    merged->leader.mode = base->leader.mode;
+    merged->leader.toggle_state = base->leader.toggle_state;
+
+    // Copy button events from base
+    if (base->totalButtons > 0) {
+        event* ev = realloc(merged->events, base->totalButtons * sizeof(event));
+        if (ev) {
+            merged->events = ev;
+            merged->totalButtons = base->totalButtons;
+            for (int i = 0; i < base->totalButtons; i++) {
+                merged->events[i].type = base->events[i].type;
+                merged->events[i].function = base->events[i].function ? strdup(base->events[i].function) : NULL;
+                merged->events[i].leader_eligible = base->events[i].leader_eligible;
+            }
+        }
+    }
+
+    // Copy wheel events from base
+    if (base->totalWheels > 0) {
+        wheel* wh = realloc(merged->wheelEvents, base->totalWheels * sizeof(wheel));
+        if (wh) {
+            merged->wheelEvents = wh;
+            merged->totalWheels = base->totalWheels;
+            for (int i = 0; i < base->totalWheels; i++) {
+                merged->wheelEvents[i].right = base->wheelEvents[i].right ? strdup(base->wheelEvents[i].right) : NULL;
+                merged->wheelEvents[i].left = base->wheelEvents[i].left ? strdup(base->wheelEvents[i].left) : NULL;
+                merged->wheelEvents[i].description = base->wheelEvents[i].description ? strdup(base->wheelEvents[i].description) : NULL;
+            }
+        }
+    }
+
+    // Copy descriptions from base
+    for (int i = 0; i < 19; i++) {
+        merged->key_descriptions[i] = base->key_descriptions[i] ? strdup(base->key_descriptions[i]) : NULL;
+        merged->leader_descriptions[i] = base->leader_descriptions[i] ? strdup(base->leader_descriptions[i]) : NULL;
+    }
+
+    // Now overlay profile-specific values (if overlay is provided)
+    if (overlay == NULL) return merged;
+
+    // Overlay button events (only buttons that the overlay defines)
+    for (int i = 0; i < overlay->totalButtons; i++) {
+        if (overlay->events[i].function != NULL) {
+            // Ensure merged has enough button slots
+            if (i >= merged->totalButtons) {
+                event* ev = realloc(merged->events, (i + 1) * sizeof(event));
+                if (ev) {
+                    merged->events = ev;
+                    for (int j = merged->totalButtons; j <= i; j++) {
+                        merged->events[j].function = NULL;
+                        merged->events[j].type = 0;
+                        merged->events[j].leader_eligible = -1;
+                    }
+                    merged->totalButtons = i + 1;
+                }
+            }
+            if (i < merged->totalButtons) {
+                if (merged->events[i].function) free(merged->events[i].function);
+                merged->events[i].function = strdup(overlay->events[i].function);
+                merged->events[i].type = overlay->events[i].type;
+                if (overlay->events[i].leader_eligible != -1) {
+                    merged->events[i].leader_eligible = overlay->events[i].leader_eligible;
+                }
+            }
+        }
+    }
+
+    // Overlay wheel events
+    for (int i = 0; i < overlay->totalWheels; i++) {
+        if (overlay->wheelEvents[i].right || overlay->wheelEvents[i].left) {
+            if (i >= merged->totalWheels) {
+                wheel* wh = realloc(merged->wheelEvents, (i + 1) * sizeof(wheel));
+                if (wh) {
+                    merged->wheelEvents = wh;
+                    for (int j = merged->totalWheels; j <= i; j++) {
+                        merged->wheelEvents[j].right = NULL;
+                        merged->wheelEvents[j].left = NULL;
+                        merged->wheelEvents[j].description = NULL;
+                    }
+                    merged->totalWheels = i + 1;
+                }
+            }
+            if (i < merged->totalWheels) {
+                if (overlay->wheelEvents[i].right) {
+                    if (merged->wheelEvents[i].right) free(merged->wheelEvents[i].right);
+                    merged->wheelEvents[i].right = strdup(overlay->wheelEvents[i].right);
+                }
+                if (overlay->wheelEvents[i].left) {
+                    if (merged->wheelEvents[i].left) free(merged->wheelEvents[i].left);
+                    merged->wheelEvents[i].left = strdup(overlay->wheelEvents[i].left);
+                }
+                if (overlay->wheelEvents[i].description) {
+                    if (merged->wheelEvents[i].description) free(merged->wheelEvents[i].description);
+                    merged->wheelEvents[i].description = strdup(overlay->wheelEvents[i].description);
+                }
+            }
+        }
+    }
+
+    // Overlay descriptions
+    for (int i = 0; i < 19; i++) {
+        if (overlay->key_descriptions[i]) {
+            if (merged->key_descriptions[i]) free(merged->key_descriptions[i]);
+            merged->key_descriptions[i] = strdup(overlay->key_descriptions[i]);
+        }
+        if (overlay->leader_descriptions[i]) {
+            if (merged->leader_descriptions[i]) free(merged->leader_descriptions[i]);
+            merged->leader_descriptions[i] = strdup(overlay->leader_descriptions[i]);
+        }
+    }
+
+    return merged;
+}
+
+// Helper: apply profile switch to OSD (update descriptions and show notification)
+static void apply_profile_to_osd(profile_manager_t* manager, profile_t* profile) {
+    if (manager->osd == NULL) return;
+
+    osd_clear_descriptions(manager->osd);
+
+    // Get the merged config descriptions, or fall back to profile descriptions
+    config_t* active_cfg = profile_manager_get_config(manager);
+
+    // Set key descriptions: prefer merged config, then profile, then default
+    for (int i = 0; i < 19; i++) {
+        const char* desc = NULL;
+        if (active_cfg && active_cfg->key_descriptions[i]) {
+            desc = active_cfg->key_descriptions[i];
+        } else if (profile && profile->key_descriptions[i]) {
+            desc = profile->key_descriptions[i];
+        } else if (manager->default_config && manager->default_config->key_descriptions[i]) {
+            desc = manager->default_config->key_descriptions[i];
+        }
+        if (desc) {
+            osd_set_key_description(manager->osd, i, desc);
+        }
+    }
+
+    // Set leader descriptions
+    for (int i = 0; i < 19; i++) {
+        const char* desc = NULL;
+        if (active_cfg && active_cfg->leader_descriptions[i]) {
+            desc = active_cfg->leader_descriptions[i];
+        } else if (profile && profile->leader_descriptions[i]) {
+            desc = profile->leader_descriptions[i];
+        } else if (manager->default_config && manager->default_config->leader_descriptions[i]) {
+            desc = manager->default_config->leader_descriptions[i];
+        }
+        if (desc) {
+            osd_set_leader_description(manager->osd, i, desc);
+        }
+    }
+
+    // Set wheel descriptions
+    for (int i = 0; i < 32; i++) {
+        const char* desc = NULL;
+        if (profile && profile->wheel_descriptions[i]) {
+            desc = profile->wheel_descriptions[i];
+        } else if (active_cfg && i < active_cfg->totalWheels &&
+                   active_cfg->wheelEvents[i].description) {
+            desc = active_cfg->wheelEvents[i].description;
+        }
+        if (desc) {
+            osd_set_wheel_description(manager->osd, i, desc);
+        }
+    }
+
+    // Visual feedback: show "Profile: <name>" in OSD
+    if (profile && profile->name) {
+        char msg[128];
+        snprintf(msg, sizeof(msg), "Profile: %s", profile->name);
+        osd_record_action(manager->osd, -1, msg);
+    }
+}
+
+// ============================================================================
+// Consistency checking
+// ============================================================================
+
+// Validate a profile's data for consistency
+static int validate_profile(const char* filename, const char* name,
+                            const char* pattern, int priority,
+                            profile_manager_t* manager) {
+    int errors = 0;
+
+    if (name == NULL || strlen(name) == 0) {
+        fprintf(stderr, "Error: %s: profile has no name\n", filename);
+        errors++;
+    }
+
+    if (pattern == NULL || strlen(pattern) == 0) {
+        fprintf(stderr, "Error: %s: profile '%s' has no pattern\n",
+                filename, name ? name : "(unnamed)");
+        errors++;
+    }
+
+    // Check for duplicate names
+    if (name && manager) {
+        for (int i = 0; i < manager->profile_count; i++) {
+            if (manager->profiles[i].name && strcmp(manager->profiles[i].name, name) == 0) {
+                fprintf(stderr, "Error: %s: duplicate profile name '%s' (already defined in %s)\n",
+                        filename, name,
+                        manager->profiles[i].source_file ? manager->profiles[i].source_file : "unknown");
+                errors++;
+            }
+        }
+    }
+
+    // Check for duplicate patterns at same priority
+    if (pattern && manager) {
+        for (int i = 0; i < manager->profile_count; i++) {
+            if (manager->profiles[i].window_pattern &&
+                strcasecmp(manager->profiles[i].window_pattern, pattern) == 0 &&
+                manager->profiles[i].priority == priority) {
+                fprintf(stderr, "Warning: %s: profile '%s' has same pattern '%s' at priority %d as '%s'\n",
+                        filename, name ? name : "(unnamed)", pattern, priority,
+                        manager->profiles[i].name);
+            }
+        }
+    }
+
+    return errors;
+}
+
+// Validate a config loaded from a profile file
+static void validate_config(const char* filename, const config_t* cfg) {
+    if (cfg == NULL) return;
+
+    for (int i = 0; i < cfg->totalButtons; i++) {
+        if (i > 18) {
+            fprintf(stderr, "Error: %s: Button %d is out of range (valid: 0-18)\n", filename, i);
+            continue;
+        }
+        if (cfg->events[i].type < 0 || cfg->events[i].type > 2) {
+            fprintf(stderr, "Error: %s: Button %d has invalid type %d (valid: 0, 1, 2)\n",
+                    filename, i, cfg->events[i].type);
+        }
+        if (cfg->events[i].type >= 0 && cfg->events[i].function == NULL) {
+            // Only warn if the button was explicitly defined (has non-default type)
+            if (cfg->events[i].type != 0) {
+                fprintf(stderr, "Warning: %s: Button %d has type %d but no function defined\n",
+                        filename, i, cfg->events[i].type);
+            }
+        }
     }
 }
 
-// Create profile manager
+// ============================================================================
+// Profile manager lifecycle
+// ============================================================================
+
 profile_manager_t* profile_manager_create(config_t* default_config) {
     profile_manager_t* manager = calloc(1, sizeof(profile_manager_t));
     if (manager == NULL) return NULL;
 
     manager->default_config = default_config;
+    manager->merged_config = NULL;
     manager->profile_count = 0;
     manager->active_profile_index = -1;
     manager->debug = 0;
     manager->window_tracker = NULL;
     manager->osd = NULL;
+    manager->inotify_fd = -1;
+    manager->inotify_wd = -1;
+    manager->profiles_dir[0] = '\0';
 
-    // Initialize all profiles
     for (int i = 0; i < MAX_PROFILES; i++) {
         memset(&manager->profiles[i], 0, sizeof(profile_t));
     }
@@ -46,16 +374,19 @@ profile_manager_t* profile_manager_create(config_t* default_config) {
     return manager;
 }
 
-// Destroy profile manager
 void profile_manager_destroy(profile_manager_t* manager) {
     if (manager == NULL) return;
 
-    // Free all profiles
     for (int i = 0; i < manager->profile_count; i++) {
         free_profile(&manager->profiles[i]);
     }
 
-    // Free window tracker
+    if (manager->merged_config) {
+        config_destroy(manager->merged_config);
+    }
+
+    profile_manager_watch_stop(manager);
+
     if (manager->window_tracker) {
         window_tracker_destroy(manager->window_tracker);
     }
@@ -63,13 +394,11 @@ void profile_manager_destroy(profile_manager_t* manager) {
     free(manager);
 }
 
-// Initialize with X11 display
 int profile_manager_init(profile_manager_t* manager, void* display, osd_state_t* osd) {
     if (manager == NULL) return -1;
 
     manager->osd = osd;
 
-    // Create window tracker
     manager->window_tracker = window_tracker_create();
     if (manager->window_tracker == NULL) {
         fprintf(stderr, "Profile manager: Failed to create window tracker\n");
@@ -85,16 +414,19 @@ int profile_manager_init(profile_manager_t* manager, void* display, osd_state_t*
     return 0;
 }
 
-// Add a profile
+// ============================================================================
+// Profile management
+// ============================================================================
+
 int profile_add(profile_manager_t* manager, const char* name, const char* window_pattern,
-                const char* config_file, int priority) {
+                const char* source_file, int priority) {
     if (manager == NULL || name == NULL || window_pattern == NULL) return -1;
     if (manager->profile_count >= MAX_PROFILES) return -1;
 
     // Check for duplicate name
     for (int i = 0; i < manager->profile_count; i++) {
         if (manager->profiles[i].name && strcmp(manager->profiles[i].name, name) == 0) {
-            return -1;  // Already exists
+            return -1;
         }
     }
 
@@ -102,61 +434,44 @@ int profile_add(profile_manager_t* manager, const char* name, const char* window
 
     profile->name = strdup(name);
     profile->window_pattern = strdup(window_pattern);
-    profile->config_file = config_file ? strdup(config_file) : NULL;
+    profile->source_file = source_file ? strdup(source_file) : NULL;
     profile->priority = priority;
     profile->config = NULL;
     profile->is_default = 0;
 
-    // Initialize descriptions to NULL
     for (int i = 0; i < 19; i++) {
         profile->key_descriptions[i] = NULL;
+        profile->leader_descriptions[i] = NULL;
     }
-
-    // Load config if specified
-    if (config_file) {
-        profile->config = config_create();
-        if (profile->config) {
-            if (config_load(profile->config, config_file, manager->debug) < 0) {
-                if (manager->debug) {
-                    printf("Profile '%s': Failed to load config '%s', using default\n",
-                           name, config_file);
-                }
-                config_destroy(profile->config);
-                profile->config = NULL;
-            }
-        }
+    for (int i = 0; i < 32; i++) {
+        profile->wheel_descriptions[i] = NULL;
     }
 
     manager->profile_count++;
 
     if (manager->debug) {
-        printf("Profile added: '%s' (pattern: '%s', priority: %d)\n",
-               name, window_pattern, priority);
+        printf("Profile added: '%s' (pattern: '%s', priority: %d, source: %s)\n",
+               name, window_pattern, priority,
+               source_file ? source_file : "(inline)");
     }
 
     return 0;
 }
 
-// Remove a profile
 int profile_remove(profile_manager_t* manager, const char* name) {
     if (manager == NULL || name == NULL) return -1;
 
     for (int i = 0; i < manager->profile_count; i++) {
         if (manager->profiles[i].name && strcmp(manager->profiles[i].name, name) == 0) {
-            // Free this profile
             free_profile(&manager->profiles[i]);
 
-            // Shift remaining profiles
             for (int j = i; j < manager->profile_count - 1; j++) {
                 manager->profiles[j] = manager->profiles[j + 1];
             }
 
-            // Clear last slot
             memset(&manager->profiles[manager->profile_count - 1], 0, sizeof(profile_t));
-
             manager->profile_count--;
 
-            // Adjust active index if needed
             if (manager->active_profile_index == i) {
                 manager->active_profile_index = -1;
             } else if (manager->active_profile_index > i) {
@@ -167,29 +482,24 @@ int profile_remove(profile_manager_t* manager, const char* name) {
         }
     }
 
-    return -1;  // Not found
+    return -1;
 }
 
-// Get profile by name
 profile_t* profile_get(profile_manager_t* manager, const char* name) {
     if (manager == NULL || name == NULL) return NULL;
-
     for (int i = 0; i < manager->profile_count; i++) {
         if (manager->profiles[i].name && strcmp(manager->profiles[i].name, name) == 0) {
             return &manager->profiles[i];
         }
     }
-
     return NULL;
 }
 
-// Get profile by index
 profile_t* profile_get_by_index(profile_manager_t* manager, int index) {
     if (manager == NULL || index < 0 || index >= manager->profile_count) return NULL;
     return &manager->profiles[index];
 }
 
-// Set key description for a profile
 int profile_set_description(profile_manager_t* manager, const char* profile_name,
                             int button_index, const char* description) {
     if (manager == NULL || profile_name == NULL) return -1;
@@ -198,15 +508,13 @@ int profile_set_description(profile_manager_t* manager, const char* profile_name
     profile_t* profile = profile_get(manager, profile_name);
     if (profile == NULL) return -1;
 
-    if (profile->key_descriptions[button_index]) {
+    if (profile->key_descriptions[button_index])
         free(profile->key_descriptions[button_index]);
-    }
     profile->key_descriptions[button_index] = description ? strdup(description) : NULL;
 
     return 0;
 }
 
-// Get key description for a profile
 const char* profile_get_description(profile_manager_t* manager, const char* profile_name,
                                      int button_index) {
     if (manager == NULL || profile_name == NULL) return NULL;
@@ -218,16 +526,13 @@ const char* profile_get_description(profile_manager_t* manager, const char* prof
     return profile->key_descriptions[button_index];
 }
 
-// Set default profile
 int profile_set_default(profile_manager_t* manager, const char* name) {
     if (manager == NULL || name == NULL) return -1;
 
-    // Clear existing default
     for (int i = 0; i < manager->profile_count; i++) {
         manager->profiles[i].is_default = 0;
     }
 
-    // Set new default
     profile_t* profile = profile_get(manager, name);
     if (profile) {
         profile->is_default = 1;
@@ -237,24 +542,24 @@ int profile_set_default(profile_manager_t* manager, const char* name) {
     return -1;
 }
 
-// Update profile manager - check active window and switch if needed
-//
-// Profile matching behavior:
-//   1. If window matches a profile pattern, switch to highest-priority match
-//   2. If no pattern matches but a default profile exists, switch to default
-//   3. If no pattern matches and no default profile exists, keep current
-//      profile active ("sticky" behavior -- see docs/PROFILES_DESIGN.md)
-//
-// NOTE: Currently no visual feedback is given on profile switch (debug only).
-// Planned: OSD notification "Profile: <name>" on switch.
+// ============================================================================
+// Profile switching
+// ============================================================================
+
+// Update profile manager - check active window, check inotify, switch if needed
 int profile_manager_update(profile_manager_t* manager) {
     if (manager == NULL || manager->window_tracker == NULL) return -1;
 
+    // Check for hot reload events (non-blocking)
+    if (manager->inotify_fd >= 0) {
+        profile_manager_check_reload(manager);
+    }
+
     // Update window tracker
     int window_changed = window_tracker_update(manager->window_tracker);
 
     if (window_changed <= 0 && manager->active_profile_index >= 0) {
-        return 0;  // Same window, same profile
+        return 0;
     }
 
     const window_info_t* window = window_tracker_get_current(manager->window_tracker);
@@ -287,64 +592,64 @@ int profile_manager_update(profile_manager_t* manager) {
     }
 
     // Use default profile if no match; if no default either, keep current
-    // profile active (sticky behavior -- do not reset to -1)
+    // profile active (sticky behavior)
     if (best_index < 0) {
         best_index = default_index;
     }
     if (best_index < 0) {
-        // No match and no default profile: keep current profile (sticky)
         return 0;
     }
 
-    // Check if profile changed
     if (best_index == manager->active_profile_index) {
-        return 0;  // Same profile
+        return 0;
     }
 
     // Switch to new profile
+    int old_index = manager->active_profile_index;
     manager->active_profile_index = best_index;
 
-    if (manager->debug) {
-        if (best_index >= 0) {
-            printf("Profile switched: '%s'\n", manager->profiles[best_index].name);
-        } else {
-            printf("Profile switched: (default config)\n");
-        }
+    // Build merged config (default overlaid with profile overrides)
+    if (manager->merged_config) {
+        config_destroy(manager->merged_config);
+        manager->merged_config = NULL;
     }
 
-    // Update OSD with new profile's descriptions
-    if (manager->osd && best_index >= 0) {
-        profile_t* profile = &manager->profiles[best_index];
-        osd_clear_descriptions(manager->osd);
-        for (int i = 0; i < 19; i++) {
-            if (profile->key_descriptions[i]) {
-                osd_set_key_description(manager->osd, i, profile->key_descriptions[i]);
-            }
+    profile_t* profile = &manager->profiles[best_index];
+    manager->merged_config = config_merge(manager->default_config, profile->config);
+
+    if (manager->debug) {
+        printf("Profile switched: '%s'", profile->name);
+        if (old_index >= 0) {
+            printf(" (was '%s')", manager->profiles[old_index].name);
         }
+        printf("\n");
+    } else {
+        // Always print profile switch (not just in debug mode)
+        printf("Profile: %s\n", profile->name);
     }
 
-    return 1;  // Profile changed
+    // Update OSD with new profile's descriptions and show notification
+    apply_profile_to_osd(manager, profile);
+
+    return 1;
 }
 
-// Get active profile
 profile_t* profile_manager_get_active(profile_manager_t* manager) {
     if (manager == NULL || manager->active_profile_index < 0) return NULL;
     return &manager->profiles[manager->active_profile_index];
 }
 
-// Get active config
 config_t* profile_manager_get_config(profile_manager_t* manager) {
     if (manager == NULL) return NULL;
 
-    profile_t* active = profile_manager_get_active(manager);
-    if (active && active->config) {
-        return active->config;
+    // Return merged config if available
+    if (manager->merged_config) {
+        return manager->merged_config;
     }
 
     return manager->default_config;
 }
 
-// Manual profile switch by name
 int profile_manager_switch(profile_manager_t* manager, const char* name) {
     if (manager == NULL || name == NULL) return -1;
 
@@ -357,32 +662,34 @@ int profile_manager_switch(profile_manager_t* manager, const char* name) {
     return -1;
 }
 
-// Manual profile switch by index
 int profile_manager_switch_by_index(profile_manager_t* manager, int index) {
     if (manager == NULL || index < 0 || index >= manager->profile_count) return -1;
 
     manager->active_profile_index = index;
 
-    // Update OSD descriptions
-    if (manager->osd) {
-        profile_t* profile = &manager->profiles[index];
-        osd_clear_descriptions(manager->osd);
-        for (int i = 0; i < 19; i++) {
-            if (profile->key_descriptions[i]) {
-                osd_set_key_description(manager->osd, i, profile->key_descriptions[i]);
-            }
-        }
+    // Rebuild merged config
+    if (manager->merged_config) {
+        config_destroy(manager->merged_config);
+        manager->merged_config = NULL;
     }
 
+    profile_t* profile = &manager->profiles[index];
+    manager->merged_config = config_merge(manager->default_config, profile->config);
+
+    // Update OSD
+    apply_profile_to_osd(manager, profile);
+
     return 0;
 }
 
-// Set debug level
+// ============================================================================
+// Debug
+// ============================================================================
+
 void profile_manager_set_debug(profile_manager_t* manager, int level) {
     if (manager) manager->debug = level;
 }
 
-// Print profile information
 void profile_manager_print(const profile_manager_t* manager) {
     if (manager == NULL) return;
 
@@ -392,16 +699,21 @@ void profile_manager_print(const profile_manager_t* manager) {
     if (manager->active_profile_index >= 0) {
         printf(" ('%s')", manager->profiles[manager->active_profile_index].name);
     }
-    printf("\n\n");
+    printf("\n");
+    printf("Hot reload: %s\n", manager->inotify_fd >= 0 ? "active" : "inactive");
+    if (manager->profiles_dir[0]) {
+        printf("Profiles dir: %s\n", manager->profiles_dir);
+    }
+    printf("\n");
 
     for (int i = 0; i < manager->profile_count; i++) {
         const profile_t* p = &manager->profiles[i];
         printf("Profile %d: '%s'%s\n", i, p->name, p->is_default ? " [DEFAULT]" : "");
         printf("  Pattern:  '%s'\n", p->window_pattern);
         printf("  Priority: %d\n", p->priority);
-        printf("  Config:   %s\n", p->config_file ? p->config_file : "(default)");
+        printf("  Source:   %s\n", p->source_file ? p->source_file : "(inline)");
+        printf("  Config:   %s\n", p->config ? "overlay loaded" : "(default only)");
 
-        // Print non-null descriptions
         int has_desc = 0;
         for (int j = 0; j < 19; j++) {
             if (p->key_descriptions[j]) {
@@ -416,21 +728,18 @@ void profile_manager_print(const profile_manager_t* manager) {
     }
 }
 
-// Load profiles from config file
+// ============================================================================
+// Load from monolithic profiles.cfg (backward compatible)
+// ============================================================================
+
 int profile_manager_load(profile_manager_t* manager, const char* filename) {
     if (manager == NULL || filename == NULL) return -1;
 
     FILE* f = fopen(filename, "r");
     if (f == NULL) {
-        // Try home directory
         char* home = getpwuid(getuid())->pw_dir;
-        char* config_dir = "/.config/KD100/";
-        char temp[strlen(home) + strlen(config_dir) + strlen(filename) + 1];
-
-        strcpy(temp, home);
-        strcat(temp, config_dir);
-        strcat(temp, filename);
-
+        char temp[MAX_PROFILE_PATH];
+        snprintf(temp, sizeof(temp), "%s/.config/KD100/%s", home, filename);
         f = fopen(temp, "r");
         if (f == NULL) {
             if (manager->debug) {
@@ -449,36 +758,57 @@ int profile_manager_load(profile_manager_t* manager, const char* filename) {
     char* current_descriptions[19] = {NULL};
 
     while (fgets(line, sizeof(line), f) != NULL) {
-        // Remove newline
         line[strcspn(line, "\n")] = 0;
 
-        // Skip comments and empty lines
         if (strstr(line, "//") == line || strlen(line) == 0) continue;
 
-        // Skip leading whitespace
         char* ptr = line;
         while (*ptr == ' ' || *ptr == '\t') ptr++;
 
-        // Parse profile name
         if (strncasecmp(ptr, "profile:", 8) == 0) {
-            // Save previous profile if exists
+            // Save previous profile
             if (current_profile && current_pattern) {
-                if (profile_add(manager, current_profile, current_pattern,
-                               current_config, current_priority) == 0) {
-                    if (current_is_default) {
-                        profile_set_default(manager, current_profile);
+                if (validate_profile(filename, current_profile, current_pattern,
+                                     current_priority, manager) == 0) {
+                    if (profile_add(manager, current_profile, current_pattern,
+                                   current_config, current_priority) == 0) {
+                        if (current_is_default) {
+                            profile_set_default(manager, current_profile);
+                        }
+                        // Load overlay config if specified
+                        if (current_config) {
+                            profile_t* p = profile_get(manager, current_profile);
+                            if (p) {
+                                p->config = config_create();
+                                if (p->config) {
+                                    if (config_load(p->config, current_config, manager->debug) < 0) {
+                                        if (manager->debug) {
+                                            printf("Profile '%s': Failed to load config '%s'\n",
+                                                   current_profile, current_config);
+                                        }
+                                        config_destroy(p->config);
+                                        p->config = NULL;
+                                    } else {
+                                        validate_config(current_config, p->config);
+                                    }
+                                }
+                            }
+                        }
+                        for (int i = 0; i < 19; i++) {
+                            if (current_descriptions[i]) {
+                                profile_set_description(manager, current_profile, i, current_descriptions[i]);
+                                free(current_descriptions[i]);
+                                current_descriptions[i] = NULL;
+                            }
+                        }
                     }
+                } else {
                     for (int i = 0; i < 19; i++) {
-                        if (current_descriptions[i]) {
-                            profile_set_description(manager, current_profile, i, current_descriptions[i]);
-                            free(current_descriptions[i]);
-                            current_descriptions[i] = NULL;
-                        }
+                        if (current_descriptions[i]) { free(current_descriptions[i]); current_descriptions[i] = NULL; }
                     }
                 }
             }
 
-            // Start new profile
             if (current_profile) free(current_profile);
             if (current_pattern) free(current_pattern);
             if (current_config) free(current_config);
@@ -493,7 +823,6 @@ int profile_manager_load(profile_manager_t* manager, const char* filename) {
             continue;
         }
 
-        // Parse pattern
         if (strncasecmp(ptr, "pattern:", 8) == 0 && current_profile) {
             char* value = ptr + 8;
             while (*value == ' ') value++;
@@ -502,7 +831,6 @@ int profile_manager_load(profile_manager_t* manager, const char* filename) {
             continue;
         }
 
-        // Parse config file
         if (strncasecmp(ptr, "config:", 7) == 0 && current_profile) {
             char* value = ptr + 7;
             while (*value == ' ') value++;
@@ -511,7 +839,6 @@ int profile_manager_load(profile_manager_t* manager, const char* filename) {
             continue;
         }
 
-        // Parse priority
         if (strncasecmp(ptr, "priority:", 9) == 0 && current_profile) {
             char* value = ptr + 9;
             while (*value == ' ') value++;
@@ -519,7 +846,6 @@ int profile_manager_load(profile_manager_t* manager, const char* filename) {
             continue;
         }
 
-        // Parse default flag
         if (strncasecmp(ptr, "default:", 8) == 0 && current_profile) {
             char* value = ptr + 8;
             while (*value == ' ') value++;
@@ -527,7 +853,6 @@ int profile_manager_load(profile_manager_t* manager, const char* filename) {
             continue;
         }
 
-        // Parse button descriptions (description_0, description_1, etc.)
         if (strncasecmp(ptr, "description_", 12) == 0 && current_profile) {
             char* num_str = ptr + 12;
             char* colon = strchr(num_str, ':');
@@ -539,6 +864,9 @@ int profile_manager_load(profile_manager_t* manager, const char* filename) {
                     while (*value == ' ') value++;
                     if (current_descriptions[btn]) free(current_descriptions[btn]);
                     current_descriptions[btn] = strdup(value);
+                } else {
+                    fprintf(stderr, "Error: %s: description_%d is out of range (valid: 0-18)\n",
+                            filename, btn);
                 }
             }
             continue;
@@ -547,16 +875,37 @@ int profile_manager_load(profile_manager_t* manager, const char* filename) {
 
     // Save last profile
     if (current_profile && current_pattern) {
-        if (profile_add(manager, current_profile, current_pattern,
-                       current_config, current_priority) == 0) {
-            if (current_is_default) {
-                profile_set_default(manager, current_profile);
+        if (validate_profile(filename, current_profile, current_pattern,
+                             current_priority, manager) == 0) {
+            if (profile_add(manager, current_profile, current_pattern,
+                           current_config, current_priority) == 0) {
+                if (current_is_default) {
+                    profile_set_default(manager, current_profile);
+                }
+                if (current_config) {
+                    profile_t* p = profile_get(manager, current_profile);
+                    if (p) {
+                        p->config = config_create();
+                        if (p->config) {
+                            if (config_load(p->config, current_config, manager->debug) < 0) {
+                                config_destroy(p->config);
+                                p->config = NULL;
+                            } else {
+                                validate_config(current_config, p->config);
+                            }
+                        }
+                    }
+                }
+                for (int i = 0; i < 19; i++) {
+                    if (current_descriptions[i]) {
+                        profile_set_description(manager, current_profile, i, current_descriptions[i]);
+                        free(current_descriptions[i]);
+                    }
+                }
             }
+        } else {
             for (int i = 0; i < 19; i++) {
-                if (current_descriptions[i]) {
-                    profile_set_description(manager, current_profile, i, current_descriptions[i]);
-                    free(current_descriptions[i]);
-                }
+                if (current_descriptions[i]) free(current_descriptions[i]);
             }
         }
     }
@@ -568,3 +917,384 @@ int profile_manager_load(profile_manager_t* manager, const char* filename) {
     fclose(f);
     return 0;
 }
+
+// ============================================================================
+// Load from apps.profiles.d/ directory
+// ============================================================================
+
+// Load a single profile from a .cfg file in apps.profiles.d/
+// File format:
+//   name: Krita
+//   pattern: krita*
+//   priority: 10
+//   default: false
+//   Button 0
+//   type: 0
+//   function: b
+//   description_0: Brush (B)
+//   leader_description_0: Shift+Brush (B)
+//   wheel_description_0: Brush Size
+//   Wheel
+//   function: bracketright
+//   ...
+static int load_profile_file(profile_manager_t* manager, const char* filepath) {
+    FILE* f = fopen(filepath, "r");
+    if (f == NULL) {
+        fprintf(stderr, "Error: Cannot open profile file: %s\n", filepath);
+        return -1;
+    }
+
+    // Extract filename for logging
+    const char* basename = strrchr(filepath, '/');
+    basename = basename ? basename + 1 : filepath;
+
+    char line[512];
+    char* name = NULL;
+    char* pattern = NULL;
+    int priority = 0;
+    int is_default = 0;
+    char* descriptions[19] = {NULL};
+    char* leader_descs[19] = {NULL};
+    char* wheel_descs[32] = {NULL};
+
+    // First pass: extract profile metadata (name, pattern, priority, descriptions)
+    // and also load the config (buttons, wheel, etc.) using config_load
+    while (fgets(line, sizeof(line), f) != NULL) {
+        line[strcspn(line, "\n")] = 0;
+        if (strstr(line, "//") == line || strlen(line) == 0) continue;
+
+        char* ptr = line;
+        while (*ptr == ' ' || *ptr == '\t') ptr++;
+
+        if (strncasecmp(ptr, "name:", 5) == 0) {
+            char* value = ptr + 5;
+            while (*value == ' ') value++;
+            strip_comment(value);
+            if (name) free(name);
+            name = strdup(value);
+            continue;
+        }
+
+        if (strncasecmp(ptr, "pattern:", 8) == 0) {
+            char* value = ptr + 8;
+            while (*value == ' ') value++;
+            strip_comment(value);
+            if (pattern) free(pattern);
+            pattern = strdup(value);
+            continue;
+        }
+
+        if (strncasecmp(ptr, "priority:", 9) == 0) {
+            char* value = ptr + 9;
+            while (*value == ' ') value++;
+            priority = atoi(value);
+            continue;
+        }
+
+        if (strncasecmp(ptr, "default:", 8) == 0) {
+            char* value = ptr + 8;
+            while (*value == ' ') value++;
+            is_default = (strcasecmp(value, "true") == 0 || strcmp(value, "1") == 0);
+            continue;
+        }
+
+        if (strncasecmp(ptr, "description_", 12) == 0) {
+            char* num_str = ptr + 12;
+            char* colon = strchr(num_str, ':');
+            if (colon) {
+                *colon = '\0';
+                int btn = atoi(num_str);
+                if (btn >= 0 && btn <= 18) {
+                    char* value = colon + 1;
+                    while (*value == ' ') value++;
+                    if (descriptions[btn]) free(descriptions[btn]);
+                    descriptions[btn] = sanitize_desc(value);
+                } else {
+                    fprintf(stderr, "Error: %s: description_%d is out of range (valid: 0-18)\n",
+                            basename, btn);
+                }
+            }
+            continue;
+        }
+
+        if (strncasecmp(ptr, "leader_description_", 19) == 0) {
+            char* num_str = ptr + 19;
+            char* colon = strchr(num_str, ':');
+            if (colon) {
+                *colon = '\0';
+                int btn = atoi(num_str);
+                if (btn >= 0 && btn <= 18) {
+                    char* value = colon + 1;
+                    while (*value == ' ') value++;
+                    if (leader_descs[btn]) free(leader_descs[btn]);
+                    leader_descs[btn] = sanitize_desc(value);
+                }
+            }
+            continue;
+        }
+
+        if (strncasecmp(ptr, "wheel_description_", 18) == 0) {
+            char* num_str = ptr + 18;
+            char* colon = strchr(num_str, ':');
+            if (colon) {
+                *colon = '\0';
+                int idx = atoi(num_str);
+                if (idx >= 0 && idx < 32) {
+                    char* value = colon + 1;
+                    while (*value == ' ') value++;
+                    if (wheel_descs[idx]) free(wheel_descs[idx]);
+                    wheel_descs[idx] = sanitize_desc(value);
+                }
+            }
+            continue;
+        }
+    }
+
+    fclose(f);
+
+    // Validate
+    if (validate_profile(basename, name, pattern, priority, manager) != 0) {
+        if (name) free(name);
+        if (pattern) free(pattern);
+        for (int i = 0; i < 19; i++) { if (descriptions[i]) free(descriptions[i]); if (leader_descs[i]) free(leader_descs[i]); }
+        for (int i = 0; i < 32; i++) { if (wheel_descs[i]) free(wheel_descs[i]); }
+        return -1;
+    }
+
+    // Add profile
+    if (profile_add(manager, name, pattern, filepath, priority) != 0) {
+        fprintf(stderr, "Error: %s: failed to add profile '%s'\n", basename, name);
+        if (name) free(name);
+        if (pattern) free(pattern);
+        for (int i = 0; i < 19; i++) { if (descriptions[i]) free(descriptions[i]); if (leader_descs[i]) free(leader_descs[i]); }
+        for (int i = 0; i < 32; i++) { if (wheel_descs[i]) free(wheel_descs[i]); }
+        return -1;
+    }
+
+    if (is_default) {
+        profile_set_default(manager, name);
+    }
+
+    profile_t* p = profile_get(manager, name);
+    if (p == NULL) {
+        if (name) free(name);
+        if (pattern) free(pattern);
+        for (int i = 0; i < 19; i++) { if (descriptions[i]) free(descriptions[i]); if (leader_descs[i]) free(leader_descs[i]); }
+        for (int i = 0; i < 32; i++) { if (wheel_descs[i]) free(wheel_descs[i]); }
+        return -1;
+    }
+
+    // Load overlay config from the same file (reuses config_load which parses
+    // Button/Wheel/function lines)
+    p->config = config_create();
+    if (p->config) {
+        if (config_load(p->config, filepath, manager->debug) < 0) {
+            // No button/wheel overrides in this file - that's fine
+            config_destroy(p->config);
+            p->config = NULL;
+        } else {
+            validate_config(basename, p->config);
+        }
+    }
+
+    // Copy descriptions to profile
+    for (int i = 0; i < 19; i++) {
+        p->key_descriptions[i] = descriptions[i];   // Transfer ownership
+        p->leader_descriptions[i] = leader_descs[i];
+    }
+    for (int i = 0; i < 32; i++) {
+        p->wheel_descriptions[i] = wheel_descs[i];
+    }
+
+    if (name) free(name);
+    if (pattern) free(pattern);
+
+    return 0;
+}
+
+int profile_manager_load_dir(profile_manager_t* manager, const char* dirpath) {
+    if (manager == NULL || dirpath == NULL) return -1;
+
+    // Resolve the directory path
+    char resolved[MAX_PROFILE_PATH];
+    DIR* dir = opendir(dirpath);
+
+    if (dir == NULL) {
+        // Try relative to ~/.config/KD100/
+        char* home = getpwuid(getuid())->pw_dir;
+        snprintf(resolved, sizeof(resolved), "%s/.config/KD100/%s", home, dirpath);
+        dir = opendir(resolved);
+        if (dir == NULL) {
+            fprintf(stderr, "Profiles: Cannot open directory: %s\n", dirpath);
+            return -1;
+        }
+    } else {
+        strncpy(resolved, dirpath, sizeof(resolved) - 1);
+        resolved[sizeof(resolved) - 1] = '\0';
+    }
+
+    // Store resolved path for inotify
+    strncpy(manager->profiles_dir, resolved, MAX_PROFILE_PATH - 1);
+    manager->profiles_dir[MAX_PROFILE_PATH - 1] = '\0';
+
+    int loaded = 0;
+    int failed = 0;
+    struct dirent* entry;
+
+    while ((entry = readdir(dir)) != NULL) {
+        // Only process .cfg files
+        size_t namelen = strlen(entry->d_name);
+        if (namelen < 5) continue;
+        if (strcasecmp(entry->d_name + namelen - 4, ".cfg") != 0) continue;
+
+        // Build full path
+        char filepath[MAX_PROFILE_PATH];
+        snprintf(filepath, sizeof(filepath), "%s/%s", resolved, entry->d_name);
+
+        // Check it's a regular file
+        struct stat st;
+        if (stat(filepath, &st) != 0 || !S_ISREG(st.st_mode)) continue;
+
+        if (load_profile_file(manager, filepath) == 0) {
+            loaded++;
+        } else {
+            failed++;
+        }
+    }
+
+    closedir(dir);
+
+    printf("Profiles: Loaded %d profile(s) from %s", loaded, resolved);
+    if (failed > 0) {
+        printf(" (%d failed)", failed);
+    }
+    printf("\n");
+
+    return loaded > 0 ? 0 : -1;
+}
+
+// ============================================================================
+// Hot reload via inotify
+// ============================================================================
+
+int profile_manager_watch_start(profile_manager_t* manager, const char* dirpath) {
+    if (manager == NULL || dirpath == NULL) return -1;
+
+    // Use stored resolved path if available
+    const char* watch_path = manager->profiles_dir[0] ? manager->profiles_dir : dirpath;
+
+    manager->inotify_fd = inotify_init1(IN_NONBLOCK);
+    if (manager->inotify_fd < 0) {
+        fprintf(stderr, "Profiles: Failed to initialize inotify: %s\n", strerror(errno));
+        return -1;
+    }
+
+    manager->inotify_wd = inotify_add_watch(manager->inotify_fd, watch_path,
+                                              IN_CLOSE_WRITE | IN_CREATE | IN_DELETE |
+                                              IN_MOVED_TO | IN_MOVED_FROM);
+    if (manager->inotify_wd < 0) {
+        fprintf(stderr, "Profiles: Failed to watch directory %s: %s\n",
+                watch_path, strerror(errno));
+        close(manager->inotify_fd);
+        manager->inotify_fd = -1;
+        return -1;
+    }
+
+    printf("Profiles: Hot reload active on %s\n", watch_path);
+    return 0;
+}
+
+void profile_manager_watch_stop(profile_manager_t* manager) {
+    if (manager == NULL) return;
+
+    if (manager->inotify_wd >= 0 && manager->inotify_fd >= 0) {
+        inotify_rm_watch(manager->inotify_fd, manager->inotify_wd);
+        manager->inotify_wd = -1;
+    }
+    if (manager->inotify_fd >= 0) {
+        close(manager->inotify_fd);
+        manager->inotify_fd = -1;
+    }
+}
+
+int profile_manager_check_reload(profile_manager_t* manager) {
+    if (manager == NULL || manager->inotify_fd < 0) return -1;
+
+    char buf[4096] __attribute__((aligned(__alignof__(struct inotify_event))));
+    int reloaded = 0;
+
+    ssize_t len = read(manager->inotify_fd, buf, sizeof(buf));
+    if (len <= 0) return 0;  // No events (EAGAIN for non-blocking)
+
+    char* ptr = buf;
+    while (ptr < buf + len) {
+        struct inotify_event* ev = (struct inotify_event*)ptr;
+
+        if (ev->len > 0 && ev->name[0] != '.') {
+            // Only process .cfg files
+            size_t namelen = strlen(ev->name);
+            if (namelen >= 5 && strcasecmp(ev->name + namelen - 4, ".cfg") == 0) {
+                char filepath[MAX_PROFILE_PATH];
+                snprintf(filepath, sizeof(filepath), "%s/%s", manager->profiles_dir, ev->name);
+
+                if (ev->mask & (IN_CLOSE_WRITE | IN_MOVED_TO | IN_CREATE)) {
+                    // File created or modified: reload
+                    printf("Refreshing configuration for profile %s\n", ev->name);
+
+                    // Find existing profile from this file
+                    int found = -1;
+                    for (int i = 0; i < manager->profile_count; i++) {
+                        if (manager->profiles[i].source_file &&
+                            strcmp(manager->profiles[i].source_file, filepath) == 0) {
+                            found = i;
+                            break;
+                        }
+                    }
+
+                    if (found >= 0) {
+                        // Remove old profile
+                        int was_active = (found == manager->active_profile_index);
+                        char* old_name = manager->profiles[found].name ?
+                                         strdup(manager->profiles[found].name) : NULL;
+                        profile_remove(manager, manager->profiles[found].name);
+
+                        // Reload from file
+                        if (load_profile_file(manager, filepath) == 0) {
+                            reloaded++;
+                            if (was_active) {
+                                // Re-activate the profile by name if it still exists
+                                if (old_name) {
+                                    profile_manager_switch(manager, old_name);
+                                }
+                            }
+                        } else {
+                            fprintf(stderr, "Error: Failed to reload %s (profile removed)\n", ev->name);
+                        }
+                        if (old_name) free(old_name);
+                    } else {
+                        // New file: load as new profile
+                        printf("Loading new profile from %s\n", ev->name);
+                        if (load_profile_file(manager, filepath) == 0) {
+                            reloaded++;
+                        }
+                    }
+                } else if (ev->mask & (IN_DELETE | IN_MOVED_FROM)) {
+                    // File deleted: remove profile
+                    printf("Removing profile from deleted file %s\n", ev->name);
+                    for (int i = 0; i < manager->profile_count; i++) {
+                        if (manager->profiles[i].source_file &&
+                            strcmp(manager->profiles[i].source_file, filepath) == 0) {
+                            profile_remove(manager, manager->profiles[i].name);
+                            reloaded++;
+                            break;
+                        }
+                    }
+                }
+            }
+        }
+
+        ptr += sizeof(struct inotify_event) + ev->len;
+    }
+
+    return reloaded;
+}
diff --git a/src/profiles.h b/src/profiles.h
index 4f159dd..df96e6f 100644
--- a/src/profiles.h
+++ b/src/profiles.h
@@ -8,24 +8,18 @@
 // Maximum profiles supported
 #define MAX_PROFILES 32
 
+// Maximum path length for profile directory/files
+#define MAX_PROFILE_PATH 512
+
 // ============================================================================
 // PROFILE SYSTEM ARCHITECTURE
 // ============================================================================
 //
 // See docs/PROFILES_DESIGN.md for the full design rationale.
 //
-// Current behavior (v1.7.1):
-//   - Profiles loaded from a single profiles.cfg file
-//   - Each profile can optionally reference a full config_file, which does a
-//     FULL config replacement (keys, wheel, leader, OSD -- everything)
-//   - Without a config_file, a profile can only change description labels
-//   - No hot reload: configs loaded once at startup
-//   - No visual feedback on profile switch (debug log only)
-//   - When no profile matches, falls back to default profile (pattern: *)
-//     or to default_config if no default profile exists
-//
-// Planned behavior (see PROFILES_DESIGN.md):
+// Features:
 //   - Per-app config files in apps.profiles.d/ directory (one .cfg per app)
+//   - Backward-compatible: also supports monolithic profiles.cfg
 //   - Overlay semantics: profile overrides ONLY explicitly set parameters
 //     (keys, wheel functions, descriptions) on top of default.cfg
 //   - Leader, OSD, wheel_mode, and hardware settings are NOT per-profile
@@ -42,13 +36,13 @@ typedef struct {
     char* name;                    // Profile name (shown in OSD on switch)
     char* window_pattern;          // Window title/class pattern (e.g., "krita*", "*photoshop*")
     int priority;                  // Higher priority matched first (default: 0)
-    char* config_file;             // Config file for this profile (NULL = use main config)
-                                   // NOTE: currently does full config replacement; planned
-                                   // change is overlay-only (keys, wheel, descriptions)
-    config_t* config;              // Loaded configuration (or NULL to use default)
+    char* source_file;             // Source .cfg file this profile was loaded from
+    config_t* config;              // Overlay configuration (merged with default on switch)
     char* key_descriptions[19];    // Per-button descriptions for this profile
+    char* leader_descriptions[19]; // Per-button leader descriptions for this profile
+    char* wheel_descriptions[32];  // Per-wheel-function descriptions
     int is_default;                // Is this the default/fallback profile?
-                                   // NOTE: if no default profile is defined, switching to
+                                   // If no default profile is defined, switching to
                                    // an unmatched window keeps the current profile active
 } profile_t;
 
@@ -59,9 +53,15 @@ typedef struct {
     int active_profile_index;      // Currently active profile (-1 = none/default)
     config_t* default_config;      // Default configuration (fallback when no profile matches
                                    // and no default profile is defined)
+    config_t* merged_config;       // Merged config (default overlaid with active profile)
     window_tracker_t* window_tracker;
     osd_state_t* osd;              // Reference to OSD for updating descriptions
     int debug;                     // Debug output level
+
+    // Hot reload via inotify
+    int inotify_fd;                // inotify file descriptor (-1 if not active)
+    int inotify_wd;                // inotify watch descriptor
+    char profiles_dir[MAX_PROFILE_PATH]; // Watched directory path
 } profile_manager_t;
 
 // Lifecycle functions
@@ -73,7 +73,7 @@ int profile_manager_init(profile_manager_t* manager, void* display, osd_state_t*
 
 // Profile management
 int profile_add(profile_manager_t* manager, const char* name, const char* window_pattern,
-                const char* config_file, int priority);
+                const char* source_file, int priority);
 int profile_remove(profile_manager_t* manager, const char* name);
 profile_t* profile_get(profile_manager_t* manager, const char* name);
 profile_t* profile_get_by_index(profile_manager_t* manager, int index);
@@ -88,13 +88,14 @@ const char* profile_get_description(profile_manager_t* manager, const char* prof
 int profile_set_default(profile_manager_t* manager, const char* name);
 
 // Update function - check active window and switch profile if needed
+// Also checks inotify for hot reload events
 // Returns 1 if profile changed, 0 if same, -1 on error
 int profile_manager_update(profile_manager_t* manager);
 
 // Get current active profile
 profile_t* profile_manager_get_active(profile_manager_t* manager);
 
-// Get current active config (either from active profile or default)
+// Get current active config (merged overlay of default + active profile)
 config_t* profile_manager_get_config(profile_manager_t* manager);
 
 // Manual profile switching
@@ -105,15 +106,21 @@ int profile_manager_switch_by_index(profile_manager_t* manager, int index);
 void profile_manager_set_debug(profile_manager_t* manager, int level);
 void profile_manager_print(const profile_manager_t* manager);
 
-// Load profiles from config file
-// Format in config:
-//   profile: <name>
-//   pattern: <window_pattern>
-//   config: <config_file>  (optional)
-//   priority: <number>     (optional, default 0)
-//   description_0: <desc>  (optional)
-//   description_1: <desc>
-//   ...
+// Load profiles from monolithic config file (backward compatible)
 int profile_manager_load(profile_manager_t* manager, const char* filename);
 
+// Load profiles from apps.profiles.d/ directory (one .cfg per app)
+// Each file contains: name, pattern, priority, button overrides, descriptions
+int profile_manager_load_dir(profile_manager_t* manager, const char* dirpath);
+
+// Start inotify watcher on the profiles directory for hot reload
+int profile_manager_watch_start(profile_manager_t* manager, const char* dirpath);
+
+// Stop inotify watcher
+void profile_manager_watch_stop(profile_manager_t* manager);
+
+// Check for inotify events and reload changed profiles (non-blocking)
+// Returns number of profiles reloaded, 0 if none, -1 on error
+int profile_manager_check_reload(profile_manager_t* manager);
+
 #endif // PROFILES_H