homebridge-plugin-utils 1.27.0 → 1.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -809,6 +809,9 @@ export class webUiFeatureOptions {
809
809
  // Ensure the DOM is ready before we render our UI. We wait for Bootstrap styles to be applied before proceeding.
810
810
  await this.#waitForBootstrap();
811
811
 
812
+ // Initialize theme sync before injecting styles so CSS variables are defined and current.
813
+ await this.#setupThemeAutoUpdate();
814
+
812
815
  // Add our custom styles for hover effects, dark mode support, and modern layouts. These enhance the visual experience and ensure consistency with the
813
816
  // Homebridge UI theme.
814
817
  this.#injectCustomStyles();
@@ -1061,198 +1064,6 @@ export class webUiFeatureOptions {
1061
1064
  }
1062
1065
  }
1063
1066
 
1064
- /**
1065
- * Inject custom styles for hover effects, dark mode support, and modern layouts.
1066
- *
1067
- * These styles enhance the visual experience and ensure our UI integrates well with both light and dark modes. We use media queries to automatically adapt
1068
- * to the user's system preferences. The styles include support for flexbox layouts, responsive design, and theme-aware coloring.
1069
- *
1070
- * @private
1071
- */
1072
- #injectCustomStyles() {
1073
-
1074
- // Ensure we do not inject duplicate styles when re-entering this view. We make this idempotent for stability across navigations.
1075
- if(document.getElementById("feature-options-styles")) {
1076
-
1077
- return;
1078
- }
1079
-
1080
- // Extract our theme color from .btn-primary to ensure consistency with the Homebridge theme.
1081
- const probeBtn = document.createElement("button");
1082
-
1083
- probeBtn.className = "btn btn-primary";
1084
- probeBtn.style.display = "none";
1085
- document.body.appendChild(probeBtn);
1086
-
1087
- this.#themeColor.background = getComputedStyle(probeBtn).backgroundColor;
1088
- this.#themeColor.text = getComputedStyle(probeBtn).color;
1089
-
1090
- document.body.removeChild(probeBtn);
1091
-
1092
- // Quick utility to help us convert RGB values to RGBA for use in CSS.
1093
- const rgba = (rgb, alpha) => {
1094
-
1095
- const match = rgb.match(/\d+/g);
1096
-
1097
- if(!match || (match.length < 3)) {
1098
-
1099
- return rgb;
1100
- }
1101
-
1102
- return "rgba(" + match[0] + ", " + match[1] + ", " + match[2] + ", " + alpha + ")";
1103
- };
1104
-
1105
- const styles = [
1106
-
1107
- /* eslint-disable @stylistic/max-len */
1108
- // Remove margin collapse and enable clean layout flow.
1109
- "html, body { margin: 0; padding: 0; }",
1110
-
1111
- // Compensate for misbehavior in Homebridge Config UI X when switching to or from dark mode.
1112
- "body { background-color: #fff !important; }",
1113
-
1114
- // Page root uses a column layout with full width.
1115
- "#pageFeatureOptions { display: flex !important; flex-direction: column; width: 100%; }",
1116
-
1117
- // Sidebar + content layout is horizontal (row).
1118
- ".feature-main-content { display: flex !important; flex-direction: row !important; width: 100%; }",
1119
-
1120
- // Sidebar layout and appearance.
1121
- "#sidebar { display: block; width: 200px; min-width: 200px; max-width: 200px; background-color: var(--bs-gray-100); position: relative; }",
1122
-
1123
- // Remove internal scrolling from sidebar content.
1124
- "#sidebar .sidebar-content { padding: 0rem; overflow: unset; }",
1125
-
1126
- // Sidebar containers.
1127
- "#controllersContainer { padding: 0; margin-bottom: 0; }",
1128
- "#devicesContainer { padding: 0; margin-top: 0; padding-top: 0 !important; }",
1129
-
1130
- // Feature content (right-hand pane).
1131
- ".feature-content { display: flex !important; flex-direction: column !important; flex: 1 1 auto; min-width: 0; }",
1132
- ".category-border { border: 1px solid " + this.#themeColor.background + " !important; box-shadow: 0 0 0 1px " + rgba(this.#themeColor.background, 0.1) + "; }",
1133
-
1134
- // Ensure the table itself uses separate borders when we have rounded tbody elements. This is necessary for border-radius to work properly.
1135
- "table[data-category] { border-collapse: separate !important; border-spacing: 0; }",
1136
-
1137
- // How we define row visibility for feature options. We need this complexity because we hide or make visible rows depending on what the user has chosen to expose.
1138
- //
1139
- // "table[data-category] tbody tr.fo-visible,"
1140
- // "table[data-category] tbody tr:not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none']){}",
1141
-
1142
- // Create the outer border of the table on the left and right sides.
1143
- "table[data-category] tbody tr:not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none']) td:first-child{",
1144
- " border-left:1px solid " + this.#themeColor.background + ";",
1145
- "}",
1146
- "table[data-category] tbody tr:not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none']) td:last-child{",
1147
- " border-right:1px solid " + this.#themeColor.background + ";",
1148
- "}",
1149
-
1150
- // Provide the top border on the first visible row.
1151
- "table[data-category] tbody tr:nth-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td{",
1152
- " border-top:1px solid " + this.#themeColor.background + ";",
1153
- "}",
1154
-
1155
- // Provide the bottom border on the last visible row.
1156
- "table[data-category] tbody tr:nth-last-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td{",
1157
- " border-bottom:1px solid " + this.#themeColor.background + ";",
1158
- "}",
1159
-
1160
- // Create rounded corners at the top and bottom rows.
1161
- "table[data-category] tbody tr:nth-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td:first-child{",
1162
- " border-top-left-radius:.5rem;",
1163
- "}",
1164
- "table[data-category] tbody tr:nth-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td:last-child{",
1165
- " border-top-right-radius:.5rem;",
1166
- "}",
1167
- "table[data-category] tbody tr:nth-last-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td:first-child{",
1168
- " border-bottom-left-radius:.5rem;",
1169
- "}",
1170
- "table[data-category] tbody tr:nth-last-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td:last-child{",
1171
- " border-bottom-right-radius:.5rem;",
1172
- "}",
1173
-
1174
- // Main options area - remove scroll behavior, just layout styling.
1175
- ".options-content { padding: 1rem; margin: 0; }",
1176
-
1177
- // Info header styling.
1178
- "#headerInfo { flex-shrink: 0; padding: 0.5rem !important; margin-bottom: 0.5rem !important; }",
1179
-
1180
- // Device stats grid layout.
1181
- ".device-stats-grid { display: flex; justify-content: space-between; gap: 0.75rem; margin-bottom: 0.5rem; padding: 0 0.75rem; flex-wrap: nowrap; overflow: hidden; }",
1182
- ".device-stats-grid .stat-item:first-child { flex: 0 0 25% }",
1183
- ".device-stats-grid .stat-item:not(:first-child) { flex-grow: 1; min-width: 0; }",
1184
-
1185
- ".stat-item { display: flex; flex-direction: column; gap: 0.125rem; }",
1186
- ".stat-label { font-weight: 600; color: var(--bs-gray-600); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }",
1187
- ".stat-value { font-size: 0.875rem; color: var(--bs-body-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }",
1188
-
1189
- // Responsive hiding for our device stats grid.
1190
- "@media (max-width: 700px) { .device-stats-grid .stat-item:nth-last-of-type(1) { display: none !important; } }",
1191
- "@media (max-width: 500px) { .device-stats-grid .stat-item:nth-last-of-type(2) { display: none !important; } }",
1192
- "@media (max-width: 300px) { .device-stats-grid .stat-item:nth-last-of-type(3) { display: none !important; } }",
1193
-
1194
- // Responsive hiding for feature option status information.
1195
- "@media (max-width: 400px) { #statusInfo { display: none !important; } }",
1196
-
1197
- // Navigation styles.
1198
- ".nav-link { border-radius: 0.375rem; transition: all 0.2s; position: relative; padding: 0.25rem 0.75rem !important; line-height: 1.2; font-size: 0.8125rem; }",
1199
- ".nav-link:hover { background-color: " + rgba(this.#themeColor.background, 0.1) + "; color: " + this.#themeColor.background + " !important; }",
1200
- ".nav-link.active { background-color: " + this.#themeColor.background + "; color: " + this.#themeColor.text + " !important; }",
1201
- ".nav-header { border-bottom: 1px solid rgba(0, 0, 0, 0.1); margin-bottom: 0.125rem; padding: 0.25rem 0.75rem !important; font-size: 0.75rem !important; line-height: 1.2; }",
1202
- "#devicesContainer .nav-header { font-weight: 600; margin-top: 0 !important; padding-top: 0.5rem !important; }",
1203
- "#controllersContainer .nav-header { font-weight: 600; margin-top: 0 !important; padding-top: 0.5rem !important; }",
1204
-
1205
- // Search bar.
1206
- ".search-toolbar { border-radius: 0.5rem; padding: 0 0 0.5rem 0; }",
1207
- ".search-input-wrapper { min-width: 0; }",
1208
- ".filter-pills { display: flex; gap: 0.5rem; flex-wrap: wrap; }",
1209
-
1210
- // Grouped option visual indicator.
1211
- ".grouped-option { background-color: " + rgba(this.#themeColor.background, 0.08) + "; }",
1212
- ".grouped-option td:nth-child(2) label { padding-left: 20px; position: relative; }",
1213
- ".grouped-option td:nth-child(2) label::before { content: \"\\21B3\"; position: absolute; left: 4px; color: #666; }",
1214
-
1215
- // Dark mode refinements.
1216
- "@media (prefers-color-scheme: dark) {",
1217
-
1218
- // Compensate for misbehavior in Homebridge Config UI X when switching to or from dark mode.
1219
- " body { background-color: #242424 !important; }",
1220
- " #sidebar { background-color: #1A1A1A !important; }",
1221
- " .nav-header { border-bottom-color: rgba(255, 255, 255, 0.1); }",
1222
- " .text-body { color: #999 !important; }",
1223
- " .text-muted { color: #999 !important; }",
1224
- " .device-stats-grid { background-color: #1A1A1A; border-color: #444; }",
1225
- " .stat-label { color: #999; }",
1226
- " .stat-value { color: #999; }",
1227
- " #search .form-control { background-color: #1A1A1A; border-color: #444; color: #F8F9FA; }",
1228
- " #search .form-control:focus { background-color: #1A1A1A; border-color: #666; color: #F8F9FA; box-shadow: 0 0 0 0.2rem rgba(255, 160, 0, 0.25); }",
1229
- " #search .form-control::placeholder { color: #999; }",
1230
- " #statusInfo .text-muted { color: #B8B8B8 !important; }",
1231
- "}",
1232
-
1233
- // Table hover styling.
1234
- ".table-hover tbody tr { transition: background-color 0.15s; }",
1235
- ".table-hover tbody tr:hover { background-color: rgba(0, 0, 0, 0.03); }",
1236
- "@media (prefers-color-scheme: dark) { .table-hover tbody tr:hover { background-color: rgba(255, 255, 255, 0.20); } }",
1237
-
1238
- // Utility styles.
1239
- ".btn-xs { font-size: 0.75rem !important; padding: 0.125rem 0.5rem !important; line-height: 1.5; touch-action: manipulation; }",
1240
- ".cursor-pointer { cursor: pointer; }",
1241
- ".user-select-none { user-select: none; -webkit-user-select: none; }",
1242
-
1243
- // Use CSS for the category header hover emphasis to avoid JS event handlers for simple hover effects.
1244
- "table[data-category] thead th[role='button']:hover { color: " + this.#themeColor.background + " !important; }",
1245
-
1246
- // Respect reduced motion settings for accessibility.
1247
- "@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }"
1248
- /* eslint-enable @stylistic/max-len */
1249
- ];
1250
-
1251
- const styleElement = this.#createElement("style", { id: "feature-options-styles" }, [styles.join("\n")]);
1252
-
1253
- document.head.appendChild(styleElement);
1254
- }
1255
-
1256
1067
  /**
1257
1068
  * Show the device list taking the controller context into account.
1258
1069
  *
@@ -3109,10 +2920,11 @@ export class webUiFeatureOptions {
3109
2920
 
3110
2921
  row.style.display = this.#rowMatchesFilter(row, filterType) ? "" : "none";
3111
2922
  }
3112
- } else {
3113
2923
 
3114
- row.style.display = this.#rowMatchesFilter(row, filterType) ? "" : "none";
2924
+ continue;
3115
2925
  }
2926
+
2927
+ row.style.display = this.#rowMatchesFilter(row, filterType) ? "" : "none";
3116
2928
  }
3117
2929
 
3118
2930
  // Update counts and visibility. We need to update various UI elements to reflect the filtered view.
@@ -3145,11 +2957,13 @@ export class webUiFeatureOptions {
3145
2957
  switch(filterType) {
3146
2958
 
3147
2959
  case "modified":
2960
+
3148
2961
  // Modified options have the text-info class to indicate they differ from defaults.
3149
2962
  return row.querySelector("label")?.classList.contains("text-info") ?? false;
3150
2963
 
3151
2964
  case "all":
3152
2965
  default:
2966
+
3153
2967
  return true;
3154
2968
  }
3155
2969
  }
@@ -3403,6 +3217,282 @@ export class webUiFeatureOptions {
3403
3217
  }
3404
3218
  }
3405
3219
 
3220
+ /**
3221
+ * Inject custom styles for hover effects, dark mode support, and modern layouts.
3222
+ *
3223
+ * These styles enhance the visual experience and ensure our UI integrates well with both light and dark modes. We use media queries to automatically adapt
3224
+ * to the user's system preferences. The styles include support for flexbox layouts, responsive design, and theme-aware coloring.
3225
+ *
3226
+ * @private
3227
+ */
3228
+ #injectCustomStyles() {
3229
+
3230
+ // Ensure we do not inject duplicate styles when re-entering this view. We make this idempotent for stability across navigations.
3231
+ if(document.getElementById("feature-options-styles")) {
3232
+
3233
+ return;
3234
+ }
3235
+
3236
+ const styles = [
3237
+
3238
+ /* eslint-disable @stylistic/max-len */
3239
+ // Define CSS variables used throughout our webUI. We update these when the theme changes so the UI can respond according to the visual environment.
3240
+ ":root {",
3241
+ " --plugin-primary-bg: " + this.#themeColor.background + ";",
3242
+ " --plugin-primary-fg: " + this.#themeColor.text + ";",
3243
+ " --plugin-primary-hover: rgba(0,0,0,0.05); /* placeholder, JS will override with a color-specific value */",
3244
+ " --plugin-primary-subtle: rgba(0,0,0,0.03); /* placeholder, JS will override with a color-specific value */",
3245
+ " --plugin-body-bg-light: #ffffff;",
3246
+ " --plugin-body-bg-dark: #242424;",
3247
+ " --plugin-sidebar-bg-light: var(--bs-gray-100);",
3248
+ " --plugin-sidebar-bg-dark: #1A1A1A;",
3249
+ "}",
3250
+
3251
+ // We start with a base layout reset - remove margin collapse and enable clean layout flow.
3252
+ "html, body { margin: 0; padding: 0; }",
3253
+
3254
+ // Theme-scoped body and sidebar backgrounds. We scope to app-driven theme first.
3255
+ ":root[data-plugin-theme='light'] body { background-color: var(--plugin-body-bg-light) !important; }",
3256
+ ":root[data-plugin-theme='dark'] body { background-color: var(--plugin-body-bg-dark) !important; }",
3257
+ ":root[data-plugin-theme='light'] #sidebar { background-color: var(--plugin-sidebar-bg-light) !important; }",
3258
+ ":root[data-plugin-theme='dark'] #sidebar { background-color: var(--plugin-sidebar-bg-dark) !important; }",
3259
+
3260
+ // Page root uses a column layout with full width.
3261
+ "#pageFeatureOptions { display: flex !important; flex-direction: column; width: 100%; }",
3262
+
3263
+ // Sidebar + content layout is horizontal (row).
3264
+ ".feature-main-content { display: flex !important; flex-direction: row !important; width: 100%; }",
3265
+
3266
+ // Sidebar layout and appearance.
3267
+ "#sidebar { display: block; width: 200px; min-width: 200px; max-width: 200px; position: relative; }",
3268
+
3269
+ // Remove internal scrolling from sidebar content.
3270
+ "#sidebar .sidebar-content { padding: 0rem; overflow: unset; }",
3271
+
3272
+ // Sidebar containers.
3273
+ "#controllersContainer { padding: 0; margin-bottom: 0; }",
3274
+ "#devicesContainer { padding: 0; margin-top: 0; padding-top: 0 !important; }",
3275
+
3276
+ // Feature content (right-hand pane).
3277
+ ".feature-content { display: flex !important; flex-direction: column !important; flex: 1 1 auto; min-width: 0; }",
3278
+ // ".category-border { border: 1px solid " + this.#themeColor.background + " !important; box-shadow: 0 0 0 1px " + rgba(this.#themeColor.background, 0.1) + "; }",
3279
+ ".category-border { border: 1px solid var(--plugin-primary-bg) !important; box-shadow: 0 0 0 1px var(--plugin-primary-hover); }",
3280
+
3281
+ // Ensure the table itself uses separate borders when we have rounded tbody elements. This is necessary for border-radius to work properly.
3282
+ "table[data-category] { border-collapse: separate !important; border-spacing: 0; }",
3283
+
3284
+ // How we define row visibility for feature options. We need this complexity because we hide or make visible rows depending on what the user has chosen to expose.
3285
+ //
3286
+ // "table[data-category] tbody tr.fo-visible,"
3287
+ // "table[data-category] tbody tr:not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none']){}",
3288
+
3289
+ // Create the outer border of the table on the left and right sides.
3290
+ "table[data-category] tbody tr:not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none']) td:first-child{",
3291
+ " border-left:1px solid var(--plugin-primary-bg);",
3292
+ "}",
3293
+ "table[data-category] tbody tr:not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none']) td:last-child{",
3294
+ " border-right:1px solid var(--plugin-primary-bg);",
3295
+ "}",
3296
+
3297
+ // Provide the top border on the first visible row.
3298
+ "table[data-category] tbody tr:nth-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td{",
3299
+ " border-top:1px solid var(--plugin-primary-bg);",
3300
+ "}",
3301
+
3302
+ // Provide the bottom border on the last visible row.
3303
+ "table[data-category] tbody tr:nth-last-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td{",
3304
+ " border-bottom:1px solid var(--plugin-primary-bg);",
3305
+ "}",
3306
+
3307
+ // Create rounded corners at the top and bottom rows.
3308
+ "table[data-category] tbody tr:nth-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td:first-child{",
3309
+ " border-top-left-radius:.5rem;",
3310
+ "}",
3311
+ "table[data-category] tbody tr:nth-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td:last-child{",
3312
+ " border-top-right-radius:.5rem;",
3313
+ "}",
3314
+ "table[data-category] tbody tr:nth-last-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td:first-child{",
3315
+ " border-bottom-left-radius:.5rem;",
3316
+ "}",
3317
+ "table[data-category] tbody tr:nth-last-child(1 of :not([hidden]):not(.d-none):not(.is-hidden):not([style*='display: none'])) td:last-child{",
3318
+ " border-bottom-right-radius:.5rem;",
3319
+ "}",
3320
+
3321
+ // Main options area - remove scroll behavior, just layout styling.
3322
+ ".options-content { padding: 1rem; margin: 0; }",
3323
+
3324
+ // Info header styling.
3325
+ "#headerInfo { flex-shrink: 0; padding: 0.5rem !important; margin-bottom: 0.5rem !important; }",
3326
+
3327
+ // Device stats grid layout.
3328
+ ".device-stats-grid { display: flex; justify-content: space-between; gap: 0.75rem; margin-bottom: 0.5rem; padding: 0 0.75rem; flex-wrap: nowrap; overflow: hidden; }",
3329
+ ".device-stats-grid .stat-item:first-child { flex: 0 0 25% }",
3330
+ ".device-stats-grid .stat-item:not(:first-child) { flex-grow: 1; min-width: 0; }",
3331
+
3332
+ ".stat-item { display: flex; flex-direction: column; gap: 0.125rem; }",
3333
+ ".stat-label { font-weight: 600; color: var(--bs-gray-600); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }",
3334
+ ".stat-value { font-size: 0.875rem; color: var(--bs-body-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }",
3335
+
3336
+ // Responsive hiding for our device stats grid.
3337
+ "@media (max-width: 700px) { .device-stats-grid .stat-item:nth-last-of-type(1) { display: none !important; } }",
3338
+ "@media (max-width: 500px) { .device-stats-grid .stat-item:nth-last-of-type(2) { display: none !important; } }",
3339
+ "@media (max-width: 300px) { .device-stats-grid .stat-item:nth-last-of-type(3) { display: none !important; } }",
3340
+
3341
+ // Responsive hiding for feature option status information.
3342
+ "@media (max-width: 400px) { #statusInfo { display: none !important; } }",
3343
+
3344
+ // Navigation styles.
3345
+ ".nav-link { border-radius: 0.375rem; transition: all 0.2s; position: relative; padding: 0.25rem 0.75rem !important; line-height: 1.2; font-size: 0.8125rem; }",
3346
+ ".nav-link:hover { background-color: var(--plugin-primary-hover); color: var(--plugin-primary-bg) !important; }",
3347
+ ".nav-link.active { background-color: var(--plugin-primary-bg); color: var(--plugin-primary-fg) !important; }",
3348
+ ".nav-header { border-bottom: 1px solid rgba(0, 0, 0, 0.1); margin-bottom: 0.125rem; padding: 0.25rem 0.75rem !important; font-size: 0.75rem !important; line-height: 1.2; }",
3349
+ "#devicesContainer .nav-header { font-weight: 600; margin-top: 0 !important; padding-top: 0.5rem !important; }",
3350
+ "#controllersContainer .nav-header { font-weight: 600; margin-top: 0 !important; padding-top: 0.5rem !important; }",
3351
+
3352
+ // Search bar.
3353
+ ".search-toolbar { border-radius: 0.5rem; padding: 0 0 0.5rem 0; }",
3354
+ ".search-input-wrapper { min-width: 0; }",
3355
+ ".filter-pills { display: flex; gap: 0.5rem; flex-wrap: wrap; }",
3356
+
3357
+ // Grouped option visual indicator.
3358
+ ".grouped-option { background-color: var(--plugin-primary-subtle); }",
3359
+ ".grouped-option td:nth-child(2) label { padding-left: 20px; position: relative; }",
3360
+ ".grouped-option td:nth-child(2) label::before { content: \"\\21B3\"; position: absolute; left: 4px; color: #666; }",
3361
+
3362
+ // Dark-mode refinements.
3363
+ ":root[data-plugin-theme='dark'] .nav-header { border-bottom-color: rgba(255, 255, 255, 0.1); }",
3364
+ ":root[data-plugin-theme='dark'] .text-body { color: #999 !important; }",
3365
+ ":root[data-plugin-theme='dark'] .text-muted { color: #999 !important; }",
3366
+ ":root[data-plugin-theme='dark'] .device-stats-grid { background-color: #1A1A1A; border-color: #444; }",
3367
+ ":root[data-plugin-theme='dark'] .stat-label { color: #999; }",
3368
+ ":root[data-plugin-theme='dark'] .stat-value { color: #999; }",
3369
+ ":root[data-plugin-theme='dark'] #search .form-control { background-color: #1A1A1A; border-color: #444; color: #F8F9FA; }",
3370
+ ":root[data-plugin-theme='dark'] #search .form-control:focus { background-color: #1A1A1A; border-color: #666; color: #F8F9FA; box-shadow: 0 0 0 0.2rem rgba(255, 160, 0, 0.25); }",
3371
+ ":root[data-plugin-theme='dark'] #search .form-control::placeholder { color: #999; }",
3372
+ ":root[data-plugin-theme='dark'] #statusInfo .text-muted { color: #B8B8B8 !important; }",
3373
+
3374
+ // Table hover styling.
3375
+ ".table-hover tbody tr { transition: background-color 0.15s; }",
3376
+ ":root[data-plugin-theme='light'] .table-hover tbody tr:hover { background-color: rgba(0, 0, 0, 0.03); }",
3377
+ ":root[data-plugin-theme='dark'] .table-hover tbody tr:hover { background-color: rgba(255, 255, 255, 0.20); }",
3378
+
3379
+
3380
+ // Utility styles.
3381
+ ".btn-xs { font-size: 0.75rem !important; padding: 0.125rem 0.5rem !important; line-height: 1.5; touch-action: manipulation; }",
3382
+ ".cursor-pointer { cursor: pointer; }",
3383
+ ".user-select-none { user-select: none; -webkit-user-select: none; }",
3384
+
3385
+ // Use CSS for the category header hover emphasis to avoid JS event handlers for simple hover effects.
3386
+ "table[data-category] thead th[role='button']:hover { color: var(--plugin-primary-bg) !important; }",
3387
+
3388
+ // Respect reduced motion settings for accessibility.
3389
+ "@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }"
3390
+ /* eslint-enable @stylistic/max-len */
3391
+ ];
3392
+
3393
+ const styleElement = this.#createElement("style", { id: "feature-options-styles" }, [styles.join("\n")]);
3394
+
3395
+ document.head.appendChild(styleElement);
3396
+ }
3397
+
3398
+ /**
3399
+ * Set up automatic theme detection and live updates.
3400
+ *
3401
+ * We rely on Config UI X to tell us what the preferred color scheme is. Once we have it, we recompute our color variables derived from .btn-primary whenever the theme
3402
+ * changes before mirroring all that to :root[data-plugin-theme="light|dark"] so our injected CSS is instantly reflected in our webUI.
3403
+ *
3404
+ * @private
3405
+ */
3406
+ async #setupThemeAutoUpdate() {
3407
+
3408
+ // Apply the current theme lighting mode and compute color variables.
3409
+ this.#setPluginTheme(await homebridge.userCurrentLightingMode());
3410
+
3411
+ // Finally, we listen for system and browser changes to the current dark mode setting.
3412
+ this.#addEventListener(window.matchMedia("(prefers-color-scheme: dark)"), "change", async () => this.#setPluginTheme(await homebridge.userCurrentLightingMode()));
3413
+ }
3414
+
3415
+ /**
3416
+ * Apply the current theme to :root and recompute JS-derived CSS variables.
3417
+ *
3418
+ * @param {"light"|"dark"} mode
3419
+ * @private
3420
+ */
3421
+ #setPluginTheme(mode) {
3422
+
3423
+ // Sanity check.
3424
+ if(![ "dark", "light" ].includes(mode)) {
3425
+
3426
+ return;
3427
+ }
3428
+
3429
+ // See if we're already set appropriately. If so, we're done.
3430
+ const current = document.documentElement.getAttribute("data-plugin-theme");
3431
+
3432
+ if(current === mode) {
3433
+
3434
+ return;
3435
+ }
3436
+
3437
+ // Update our theme.
3438
+ document.documentElement.setAttribute("data-plugin-theme", mode);
3439
+
3440
+ this.#computeThemeColors();
3441
+ this.#updateCssVariablesFromTheme();
3442
+ }
3443
+
3444
+ /**
3445
+ * Compute current primary background and foreground from Bootstrap's .btn-primary.
3446
+ *
3447
+ * This gives us a theme-correct color pair regardless of the configured palette in Config UI X.
3448
+ *
3449
+ * @private
3450
+ */
3451
+ #computeThemeColors() {
3452
+
3453
+ const probeBtn = document.createElement("button");
3454
+
3455
+ probeBtn.className = "btn btn-primary";
3456
+ probeBtn.style.display = "none";
3457
+ document.body.appendChild(probeBtn);
3458
+
3459
+ this.#themeColor.background = getComputedStyle(probeBtn).backgroundColor;
3460
+ this.#themeColor.text = getComputedStyle(probeBtn).color;
3461
+
3462
+ document.body.removeChild(probeBtn);
3463
+ }
3464
+
3465
+ /**
3466
+ * Update CSS custom properties used by our injected styles so they immediately reflect the current theme.
3467
+ *
3468
+ * @private
3469
+ */
3470
+ #updateCssVariablesFromTheme() {
3471
+
3472
+ const rootStyle = document.documentElement.style;
3473
+
3474
+ const rgba = (rgb, alpha) => {
3475
+
3476
+ const match = rgb.match(/\d+/g);
3477
+
3478
+ if(!match || (match.length < 3)) {
3479
+
3480
+ return rgb;
3481
+ }
3482
+
3483
+ return "rgba(" + match[0] + ", " + match[1] + ", " + match[2] + ", " + alpha + ")";
3484
+ };
3485
+
3486
+
3487
+ // These variables are consumed by our injected CSS.
3488
+ rootStyle.setProperty("--plugin-primary-bg", this.#themeColor.background);
3489
+ rootStyle.setProperty("--plugin-primary-fg", this.#themeColor.text);
3490
+
3491
+ // Derivatives used for hover/subtle surfaces.
3492
+ rootStyle.setProperty("--plugin-primary-hover", rgba(this.#themeColor.background, 0.10));
3493
+ rootStyle.setProperty("--plugin-primary-subtle", rgba(this.#themeColor.background, 0.08));
3494
+ }
3495
+
3406
3496
  /**
3407
3497
  * Clean up all resources when the instance is no longer needed.
3408
3498
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-plugin-utils",
3
- "version": "1.27.0",
3
+ "version": "1.27.1",
4
4
  "displayName": "Homebridge Plugin Utilities",
5
5
  "description": "Opinionated utilities to provide common capabilities and create rich configuration webUI experiences for Homebridge plugins.",
6
6
  "author": {
@@ -42,7 +42,7 @@
42
42
  "devDependencies": {
43
43
  "@stylistic/eslint-plugin": "5.2.3",
44
44
  "@types/node": "24.2.1",
45
- "eslint": "9.32.0",
45
+ "eslint": "9.33.0",
46
46
  "homebridge": "1.11.0",
47
47
  "shx": "0.4.0",
48
48
  "typedoc": "0.28.9",