@mp-consulting/homebridge-ui-kit 1.0.1 → 1.1.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.
package/README.md CHANGED
@@ -52,21 +52,23 @@ The plugin's `homebridge-ui/public/index.html` must be a full HTML document. Loa
52
52
  } catch(e) {}
53
53
  })();
54
54
  </script>
55
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
56
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
55
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
56
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" integrity="sha384-XGjxtQfXaH2tnPFa9x+ruJTuLE3Aa6LhHSWRr1XeTyhezb4abCG4ccI5AkVDxqC+" crossorigin="anonymous">
57
57
  <link rel="stylesheet" href="kit.css">
58
58
  <link rel="stylesheet" href="styles.css">
59
59
  </head>
60
60
  <body>
61
61
  <!-- your UI here -->
62
62
 
63
- <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
63
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
64
64
  <script src="kit.js"></script>
65
65
  <script src="app.js"></script>
66
66
  </body>
67
67
  </html>
68
68
  ```
69
69
 
70
+ The `integrity` hashes pin the exact CDN files; update them if you change a version (jsDelivr shows the SRI hash for each file).
71
+
70
72
  The early inline script applies `data-bs-theme="dark"` from the system preference before any CSS loads, preventing a flash of wrong theme. In your app script, also apply the Homebridge user's saved theme setting:
71
73
 
72
74
  ```js
@@ -90,7 +92,7 @@ homebridge-ui/public/kit.js
90
92
 
91
93
  ### 5. Update `eslint.config.js`
92
94
 
93
- Add `kit.js` to the ignore list (it is a minified third-party bundle) and expose `MpKit` as a browser global:
95
+ Add `kit.js` to the ignore list (it is a generated, vendored file) and expose `MpKit` as a browser global:
94
96
 
95
97
  ```js
96
98
  export default tseslint.config(
@@ -127,8 +129,18 @@ MpKit.Loading.render('Loading...')
127
129
 
128
130
  // View switching (.mp-view elements)
129
131
  MpKit.View.show('viewId')
132
+
133
+ // Support footer (only http/https URLs are rendered; icons are inline SVG,
134
+ // so the Bootstrap Icons font is not required)
135
+ MpKit.Footer.render({ github, npm, changelog, target: '.mp-footer' })
136
+
137
+ // Escape untrusted text before interpolating it into your own HTML
138
+ MpKit.escapeHtml(device.name)
130
139
  ```
131
140
 
141
+ All text passed to the helpers is HTML-escaped, so it is safe to pass device names or other
142
+ values that came from the network. To include markup, build the element yourself.
143
+
132
144
  ## Dark Mode
133
145
 
134
146
  Dark mode is handled entirely by Bootstrap's `data-bs-theme="dark"` attribute on `<html>`. Do **not** use `@media (prefers-color-scheme: dark)` blocks, `.dark-mode` CSS classes, or custom CSS variable overrides — Bootstrap handles all of this automatically. Use Bootstrap CSS variables (`var(--bs-body-bg)`, `var(--bs-primary)`, etc.) in your custom CSS instead of hardcoded hex values.
@@ -137,9 +149,14 @@ Dark mode is handled entirely by Bootstrap's `data-bs-theme="dark"` attribute on
137
149
 
138
150
  ```bash
139
151
  npm install
140
- npm run build # outputs to dist/
152
+ npm run build # outputs to dist/ (commit the result)
153
+ npm run build:check # fails if dist/ is out of date (run in CI)
154
+ npm test
141
155
  ```
142
156
 
157
+ Use the `--mp-*` tokens for brand colors. For text in the brand color use `--mp-primary-text`,
158
+ which switches to a lighter shade in dark mode so it keeps WCAG AA contrast.
159
+
143
160
  ## License
144
161
 
145
162
  MIT © MP Consulting
package/dist/kit.css CHANGED
@@ -1,8 +1,9 @@
1
- /* @mp-consulting/homebridge-ui-kit v1.0.1 */
1
+ /* @mp-consulting/homebridge-ui-kit v1.1.1 */
2
2
 
3
3
  /* ── Design tokens ──────────────────────────────────────────────────────────
4
4
  CSS custom properties used throughout the design system.
5
5
  Override these in your plugin's styles.css to customize per-plugin.
6
+ Light values live on :root; dark values follow Bootstrap's data-bs-theme.
6
7
  ────────────────────────────────────────────────────────────────────────── */
7
8
 
8
9
  :root {
@@ -10,7 +11,7 @@
10
11
  --mp-primary: #4f46e5;
11
12
  --mp-primary-rgb: 79, 70, 229;
12
13
  --mp-primary-hover: #4338ca;
13
- --mp-primary-subtle: rgba(79, 70, 229, 0.12);
14
+ --mp-primary-text: #4f46e5; /* brand color for text; meets WCAG AA on the body bg */
14
15
 
15
16
  /* Status */
16
17
  --mp-status-online: #10b981;
@@ -19,11 +20,18 @@
19
20
  --mp-status-checking: #94a3b8;
20
21
 
21
22
  /* Surfaces */
22
- --mp-surface: rgba(255, 255, 255, 0.03);
23
- --mp-border: rgba(255, 255, 255, 0.08);
23
+ --mp-surface: rgba(0, 0, 0, 0.02);
24
+ --mp-border: rgba(0, 0, 0, 0.1);
24
25
 
25
26
  /* Shape */
26
27
  --mp-radius: 0.5rem;
28
+ --mp-shadow-hover: 0 0.5rem 1rem rgba(0, 0, 0, 0.12);
29
+ }
30
+
31
+ [data-bs-theme="dark"] {
32
+ --mp-primary-text: #818cf8;
33
+ --mp-surface: rgba(255, 255, 255, 0.03);
34
+ --mp-border: rgba(255, 255, 255, 0.08);
27
35
  --mp-shadow-hover: 0 0.5rem 1rem rgba(0, 0, 0, 0.3);
28
36
  }
29
37
 
@@ -40,6 +48,8 @@
40
48
  --bs-btn-active-bg: var(--mp-primary-hover);
41
49
  --bs-btn-active-border-color: var(--mp-primary-hover);
42
50
  --bs-btn-focus-shadow-rgb: var(--mp-primary-rgb);
51
+ --bs-btn-disabled-bg: var(--mp-primary);
52
+ --bs-btn-disabled-border-color: var(--mp-primary);
43
53
  }
44
54
 
45
55
  /* Primary spinner */
@@ -122,6 +132,11 @@
122
132
  box-shadow: var(--mp-shadow-hover) !important;
123
133
  }
124
134
 
135
+ .mp-device-card:focus-visible {
136
+ outline: 2px solid var(--mp-primary);
137
+ outline-offset: 2px;
138
+ }
139
+
125
140
  /* ── Settings surface card ──────────────────────────────── */
126
141
 
127
142
  .mp-settings-card {
@@ -184,20 +199,20 @@
184
199
  .mp-tabs .nav-link {
185
200
  color: var(--bs-secondary-color);
186
201
  border: none;
202
+ border-bottom: 2px solid transparent; /* reserve space so the active tab doesn't shift */
187
203
  padding: 0.75rem 1.25rem;
188
204
  font-weight: 500;
189
205
  }
190
206
 
191
207
  .mp-tabs .nav-link:hover {
192
208
  color: var(--bs-body-color);
193
- border: none;
209
+ border-color: transparent;
194
210
  }
195
211
 
196
212
  .mp-tabs .nav-link.active {
197
- color: var(--mp-primary);
213
+ color: var(--mp-primary-text);
198
214
  background: transparent;
199
- border: none;
200
- border-bottom: 2px solid var(--mp-primary);
215
+ border-bottom-color: var(--mp-primary);
201
216
  }
202
217
 
203
218
  /* ── Metadata label ─────────────────────────────────────── */
@@ -244,10 +259,34 @@
244
259
  }
245
260
 
246
261
  .mp-footer a:hover {
247
- color: var(--mp-primary);
262
+ color: var(--mp-primary-text);
263
+ }
264
+
265
+ .mp-footer-icon {
266
+ width: 1em;
267
+ height: 1em;
268
+ flex-shrink: 0;
269
+ fill: currentColor;
248
270
  }
249
271
 
250
272
  .mp-footer-sep {
251
273
  color: var(--mp-border);
252
274
  user-select: none;
253
275
  }
276
+
277
+ /* ── Reduced motion ─────────────────────────────────────── */
278
+
279
+ @media (prefers-reduced-motion: reduce) {
280
+ .mp-status-checking {
281
+ animation: none;
282
+ }
283
+
284
+ .mp-device-card,
285
+ .mp-footer a {
286
+ transition: none;
287
+ }
288
+
289
+ .mp-device-card:hover {
290
+ transform: none;
291
+ }
292
+ }
package/dist/kit.js CHANGED
@@ -1,47 +1,96 @@
1
- /* @mp-consulting/homebridge-ui-kit v1.0.0
1
+ /* @mp-consulting/homebridge-ui-kit v1.1.1
2
2
  Brand design system for Homebridge plugins
3
3
  https://github.com/mp-consulting/homebridge-ui-kit */
4
4
 
5
5
  (function (global) {
6
6
  'use strict';
7
7
 
8
+ var ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;' };
9
+
10
+ /** Escapes a value for safe use in HTML text and quoted attributes. */
11
+ function escapeHtml(value) {
12
+ return String(value == null ? '' : value).replace(/[&<>"']/g, function (c) {
13
+ return ESCAPES[c];
14
+ });
15
+ }
16
+
17
+ /** Returns the URL if it uses http(s), otherwise null (blocks javascript:, data:, …). */
18
+ function safeUrl(url) {
19
+ if (typeof url !== 'string') { return null; }
20
+ return /^https?:\/\//i.test(url.trim()) ? url.trim() : null;
21
+ }
22
+
23
+ function badge(badgeClass, dotClass, label) {
24
+ return '<span class="badge ' + badgeClass + '">'
25
+ + (dotClass ? '<span class="mp-status ' + dotClass + ' me-1" aria-hidden="true"></span>' : '')
26
+ + escapeHtml(label) + '</span>';
27
+ }
28
+
29
+ // Icon paths from Bootstrap Icons 1.11.3 (MIT), inlined so the footer
30
+ // works whether or not the plugin loads the icon font.
31
+ var FOOTER_LINKS = [
32
+ {
33
+ key: 'github',
34
+ text: 'GitHub',
35
+ paths: [
36
+ 'M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8',
37
+ ],
38
+ },
39
+ {
40
+ key: 'npm',
41
+ text: 'npm',
42
+ paths: [
43
+ 'M8.186 1.113a.5.5 0 0 0-.372 0L1.846 3.5l2.404.961L10.404 2zm3.564 1.426L5.596 5 8 5.961 14.154 3.5zm3.25 1.7-6.5 2.6v7.922l6.5-2.6V4.24zM7.5 14.762V6.838L1 4.239v7.923zM7.443.184a1.5 1.5 0 0 1 1.114 0l7.129 2.852A.5.5 0 0 1 16 3.5v8.662a1 1 0 0 1-.629.928l-7.185 2.874a.5.5 0 0 1-.372 0L.63 13.09a1 1 0 0 1-.63-.928V3.5a.5.5 0 0 1 .314-.464z',
44
+ ],
45
+ },
46
+ {
47
+ key: 'changelog',
48
+ text: 'Changelog',
49
+ paths: [
50
+ 'M8.515 1.019A7 7 0 0 0 8 1V0a8 8 0 0 1 .589.022zm2.004.45a7 7 0 0 0-.985-.299l.219-.976q.576.129 1.126.342zm1.37.71a7 7 0 0 0-.439-.27l.493-.87a8 8 0 0 1 .979.654l-.615.789a7 7 0 0 0-.418-.302zm1.834 1.79a7 7 0 0 0-.653-.796l.724-.69q.406.429.747.91zm.744 1.352a7 7 0 0 0-.214-.468l.893-.45a8 8 0 0 1 .45 1.088l-.95.313a7 7 0 0 0-.179-.483m.53 2.507a7 7 0 0 0-.1-1.025l.985-.17q.1.58.116 1.17zm-.131 1.538q.05-.254.081-.51l.993.123a8 8 0 0 1-.23 1.155l-.964-.267q.069-.247.12-.501m-.952 2.379q.276-.436.486-.908l.914.405q-.24.54-.555 1.038zm-.964 1.205q.183-.183.35-.378l.758.653a8 8 0 0 1-.401.432z',
51
+ 'M8 1a7 7 0 1 0 4.95 11.95l.707.707A8.001 8.001 0 1 1 8 0z',
52
+ 'M7.5 3a.5.5 0 0 1 .5.5v5.21l3.248 1.856a.5.5 0 0 1-.496.868l-3.5-2A.5.5 0 0 1 7 9V3.5a.5.5 0 0 1 .5-.5',
53
+ ],
54
+ },
55
+ ];
56
+
57
+ function icon(paths) {
58
+ return '<svg class="mp-footer-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false">'
59
+ + paths.map(function (d) { return '<path d="' + d + '"/>'; }).join('')
60
+ + '</svg>';
61
+ }
62
+
8
63
  var MpKit = {
9
64
 
65
+ escapeHtml: escapeHtml,
66
+
10
67
  /**
11
68
  * StatusBadge — returns inline HTML for device status badges.
69
+ * Labels are HTML-escaped.
12
70
  *
13
71
  * MpKit.StatusBadge.online() → green dot + "Online"
14
72
  * MpKit.StatusBadge.offline() → red dot + "Offline"
15
- * MpKit.StatusBadge.checking() → animated dot + "Checking..."
73
+ * MpKit.StatusBadge.checking() → animated dot + "Checking…"
16
74
  * MpKit.StatusBadge.disabled() → grey badge + "Disabled"
17
75
  */
18
76
  StatusBadge: {
19
77
  online: function (label) {
20
- label = label || 'Online';
21
- return '<span class="badge bg-success-subtle text-success">'
22
- + '<span class="mp-status mp-status-online me-1"></span>'
23
- + label + '</span>';
78
+ return badge('bg-success-subtle text-success', 'mp-status-online', label || 'Online');
24
79
  },
25
80
  offline: function (label) {
26
- label = label || 'Offline';
27
- return '<span class="badge bg-danger-subtle text-danger">'
28
- + '<span class="mp-status mp-status-offline me-1"></span>'
29
- + label + '</span>';
81
+ return badge('bg-danger-subtle text-danger', 'mp-status-offline', label || 'Offline');
30
82
  },
31
83
  checking: function (label) {
32
- label = label || 'Checking...';
33
- return '<span class="badge bg-secondary-subtle text-secondary">'
34
- + '<span class="mp-status mp-status-checking me-1"></span>'
35
- + label + '</span>';
84
+ return badge('bg-secondary-subtle text-secondary', 'mp-status-checking', label || 'Checking…');
36
85
  },
37
86
  disabled: function (label) {
38
- label = label || 'Disabled';
39
- return '<span class="badge bg-secondary">' + label + '</span>';
87
+ return badge('bg-secondary', null, label || 'Disabled');
40
88
  },
41
89
  },
42
90
 
43
91
  /**
44
92
  * EmptyState — returns HTML for an empty list placeholder.
93
+ * All options are HTML-escaped.
45
94
  *
46
95
  * MpKit.EmptyState.render({
47
96
  * iconClass: 'bi bi-lightbulb',
@@ -56,24 +105,25 @@
56
105
  var title = opts.title || 'No items';
57
106
  var hint = opts.hint || '';
58
107
  return '<div class="mp-empty-state">'
59
- + '<i class="' + icon + ' mp-empty-state-icon"></i>'
60
- + '<p class="mp-empty-state-title">' + title + '</p>'
61
- + (hint ? '<p class="mp-empty-state-hint">' + hint + '</p>' : '')
108
+ + '<i class="' + escapeHtml(icon) + ' mp-empty-state-icon" aria-hidden="true"></i>'
109
+ + '<p class="mp-empty-state-title">' + escapeHtml(title) + '</p>'
110
+ + (hint ? '<p class="mp-empty-state-hint">' + escapeHtml(hint) + '</p>' : '')
62
111
  + '</div>';
63
112
  },
64
113
  },
65
114
 
66
115
  /**
67
116
  * Loading — returns HTML for a centred loading spinner with message.
117
+ * The message is HTML-escaped and announced to screen readers.
68
118
  *
69
119
  * MpKit.Loading.render('Loading device information...')
70
120
  */
71
121
  Loading: {
72
122
  render: function (message) {
73
- message = message || 'Loading...';
74
- return '<div class="mp-loading">'
75
- + '<div class="spinner-border spinner-border-sm text-secondary" role="status" aria-hidden="true"></div>'
76
- + '<span>' + message + '</span>'
123
+ message = message || 'Loading…';
124
+ return '<div class="mp-loading" role="status" aria-live="polite">'
125
+ + '<div class="spinner-border spinner-border-sm text-secondary" aria-hidden="true"></div>'
126
+ + '<span>' + escapeHtml(message) + '</span>'
77
127
  + '</div>';
78
128
  },
79
129
  },
@@ -87,20 +137,20 @@
87
137
  View: {
88
138
  show: function (id) {
89
139
  document.querySelectorAll('.mp-view').forEach(function (v) {
90
- v.classList.remove('active');
140
+ v.classList.toggle('active', v.id === id);
91
141
  });
92
- var el = document.getElementById(id);
93
- if (el) { el.classList.add('active'); }
94
142
  },
95
143
  },
96
144
 
97
145
  /**
98
146
  * Footer — renders support links into a .mp-footer element.
147
+ * Only http(s) URLs are rendered; anything else is skipped.
99
148
  *
100
149
  * MpKit.Footer.render({
101
150
  * github: 'https://github.com/mp-consulting/homebridge-...',
102
151
  * npm: 'https://www.npmjs.com/package/@mp-consulting/...',
103
152
  * changelog: 'https://github.com/.../blob/main/CHANGELOG.md',
153
+ * target: '.mp-footer', // optional selector or element
104
154
  * })
105
155
  */
106
156
  Footer: {
@@ -110,19 +160,13 @@
110
160
  var el = typeof target === 'string' ? document.querySelector(target) : target;
111
161
  if (!el) { return; }
112
162
  var links = [];
113
- if (opts.github) {
114
- links.push('<a href="' + opts.github + '" target="_blank" rel="noopener">'
115
- + '<i class="bi bi-github"></i>GitHub</a>');
116
- }
117
- if (opts.npm) {
118
- links.push('<a href="' + opts.npm + '" target="_blank" rel="noopener">'
119
- + '<i class="bi bi-box-seam"></i>npm</a>');
120
- }
121
- if (opts.changelog) {
122
- links.push('<a href="' + opts.changelog + '" target="_blank" rel="noopener">'
123
- + '<i class="bi bi-clock-history"></i>Changelog</a>');
124
- }
125
- el.innerHTML = links.join('<span class="mp-footer-sep">|</span>');
163
+ FOOTER_LINKS.forEach(function (link) {
164
+ var url = safeUrl(opts[link.key]);
165
+ if (!url) { return; }
166
+ links.push('<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">'
167
+ + icon(link.paths) + link.text + '</a>');
168
+ });
169
+ el.innerHTML = links.join('<span class="mp-footer-sep" aria-hidden="true">|</span>');
126
170
  },
127
171
  },
128
172
 
@@ -130,4 +174,4 @@
130
174
 
131
175
  global.MpKit = MpKit;
132
176
 
133
- })(window);
177
+ })(typeof window !== 'undefined' ? window : globalThis);
package/package.json CHANGED
@@ -1,7 +1,17 @@
1
1
  {
2
2
  "name": "@mp-consulting/homebridge-ui-kit",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "description": "Brand design system for @mp-consulting Homebridge plugins",
5
+ "keywords": [
6
+ "homebridge",
7
+ "homebridge-ui",
8
+ "bootstrap",
9
+ "design-system"
10
+ ],
11
+ "homepage": "https://github.com/mp-consulting/homebridge-ui-kit#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/mp-consulting/homebridge-ui-kit/issues"
14
+ },
5
15
  "author": "MP Consulting",
6
16
  "license": "MIT",
7
17
  "type": "module",
@@ -12,7 +22,12 @@
12
22
  "files": [
13
23
  "dist"
14
24
  ],
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
15
28
  "scripts": {
16
- "build": "node scripts/build.js"
29
+ "build": "node scripts/build.js",
30
+ "build:check": "node scripts/build.js --check",
31
+ "test": "node --test test/*.test.js"
17
32
  }
18
33
  }