@mp-consulting/homebridge-ui-kit 1.2.1 → 1.3.0

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/dist/kit.js CHANGED
@@ -1,302 +1,365 @@
1
- /* @mp-consulting/homebridge-ui-kit v1.2.1
1
+ /* @mp-consulting/homebridge-ui-kit v1.3.0
2
2
  Brand design system for Homebridge plugins
3
3
  https://github.com/mp-consulting/homebridge-ui-kit */
4
+ (() => {
5
+ // src/js/env.js
6
+ var root = typeof window !== "undefined" ? window : globalThis;
7
+ function doc() {
8
+ return typeof document !== "undefined" ? document : root.document;
9
+ }
10
+ function hbClient() {
11
+ return root.homebridge || (typeof homebridge !== "undefined" ? homebridge : void 0);
12
+ }
4
13
 
5
- (function (global) {
6
- 'use strict';
7
-
8
- var ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;' };
9
-
10
- /** Escapes a value for safe use in HTML text and quoted attributes. */
14
+ // src/js/util.js
15
+ var ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
11
16
  function escapeHtml(value) {
12
- return String(value == null ? '' : value).replace(/[&<>"']/g, function (c) {
13
- return ESCAPES[c];
14
- });
17
+ return String(value == null ? "" : value).replace(/[&<>"']/g, (c) => ESCAPES[c]);
15
18
  }
16
-
17
- /** Returns the URL if it uses http(s), otherwise null (blocks javascript:, data:, …). */
18
19
  function safeUrl(url) {
19
- if (typeof url !== 'string') { return null; }
20
+ if (typeof url !== "string") {
21
+ return null;
22
+ }
20
23
  return /^https?:\/\//i.test(url.trim()) ? url.trim() : null;
21
24
  }
25
+ var uidSeq = 0;
26
+ function uid(prefix) {
27
+ uidSeq += 1;
28
+ return (prefix || "mp") + "-" + uidSeq;
29
+ }
30
+ function toElement(target) {
31
+ if (!target) {
32
+ return null;
33
+ }
34
+ if (typeof target === "string") {
35
+ const d = doc();
36
+ return d ? d.querySelector(target) : null;
37
+ }
38
+ return target;
39
+ }
40
+ function errorMessage(err, fallback) {
41
+ if (typeof err === "string" && err) {
42
+ return err;
43
+ }
44
+ return err && err.message || fallback;
45
+ }
46
+ var liveRegion = null;
47
+ function announce(message, politeness) {
48
+ const d = doc();
49
+ if (!d || !d.body || typeof d.createElement !== "function") {
50
+ return;
51
+ }
52
+ if (!liveRegion || !liveRegion.isConnected) {
53
+ liveRegion = d.createElement("div");
54
+ liveRegion.className = "mp-sr-only";
55
+ liveRegion.setAttribute("data-mp-live", "");
56
+ liveRegion.setAttribute("aria-atomic", "true");
57
+ d.body.appendChild(liveRegion);
58
+ }
59
+ liveRegion.setAttribute("aria-live", politeness === "assertive" ? "assertive" : "polite");
60
+ liveRegion.textContent = "";
61
+ const text = String(message == null ? "" : message);
62
+ setTimeout(() => {
63
+ liveRegion.textContent = text;
64
+ }, 30);
65
+ }
66
+ var FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
67
+ function focusables(el) {
68
+ return Array.from(el.querySelectorAll(FOCUSABLE)).filter((n) => !n.hidden && !n.closest("[hidden]"));
69
+ }
70
+ function focusElement(el) {
71
+ if (!el || typeof el.focus !== "function") {
72
+ return;
73
+ }
74
+ if (!el.matches(FOCUSABLE) && !el.hasAttribute("tabindex")) {
75
+ el.setAttribute("tabindex", "-1");
76
+ el.classList.add("mp-focus-target");
77
+ }
78
+ try {
79
+ el.focus({ preventScroll: true });
80
+ } catch {
81
+ el.focus();
82
+ }
83
+ }
84
+ function textOf(el) {
85
+ return el ? String(el.textContent || "").replace(/\s+/g, " ").trim() : "";
86
+ }
22
87
 
88
+ // src/js/core.js
23
89
  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>';
90
+ return '<span class="badge ' + badgeClass + '">' + (dotClass ? '<span class="mp-status ' + dotClass + '" aria-hidden="true"></span>' : "") + escapeHtml(label) + "</span>";
27
91
  }
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
92
  var FOOTER_LINKS = [
32
93
  {
33
- key: 'github',
34
- text: 'GitHub',
94
+ key: "github",
95
+ text: "GitHub",
35
96
  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
- ],
97
+ "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"
98
+ ]
38
99
  },
39
100
  {
40
- key: 'npm',
41
- text: 'npm',
101
+ key: "npm",
102
+ text: "npm",
42
103
  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
- ],
104
+ "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"
105
+ ]
45
106
  },
46
107
  {
47
- key: 'changelog',
48
- text: 'Changelog',
108
+ key: "changelog",
109
+ text: "Changelog",
49
110
  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
- },
111
+ "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",
112
+ "M8 1a7 7 0 1 0 4.95 11.95l.707.707A8.001 8.001 0 1 1 8 0z",
113
+ "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"
114
+ ]
115
+ }
55
116
  ];
56
-
57
117
  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>';
118
+ return '<svg class="mp-footer-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false">' + paths.map((d) => '<path d="' + d + '"/>').join("") + "</svg>";
61
119
  }
62
-
63
- var MpKit = {
64
-
65
- escapeHtml: escapeHtml,
66
-
67
- /**
68
- * StatusBadge — returns inline HTML for device status badges.
69
- * Labels are HTML-escaped.
70
- *
71
- * MpKit.StatusBadge.online() → green dot + "Online"
72
- * MpKit.StatusBadge.offline() → red dot + "Offline"
73
- * MpKit.StatusBadge.checking() → animated dot + "Checking…"
74
- * MpKit.StatusBadge.disabled() → grey badge + "Disabled"
75
- */
76
- StatusBadge: {
77
- online: function (label) {
78
- return badge('bg-success-subtle text-success', 'mp-status-online', label || 'Online');
79
- },
80
- offline: function (label) {
81
- return badge('bg-danger-subtle text-danger', 'mp-status-offline', label || 'Offline');
82
- },
83
- checking: function (label) {
84
- return badge('bg-secondary-subtle text-secondary', 'mp-status-checking', label || 'Checking…');
85
- },
86
- disabled: function (label) {
87
- return badge('bg-secondary', null, label || 'Disabled');
88
- },
89
- },
90
-
91
- /**
92
- * EmptyState — returns HTML for an empty list placeholder.
93
- * All options are HTML-escaped.
94
- *
95
- * MpKit.EmptyState.render({
96
- * iconClass: 'bi bi-lightbulb',
97
- * title: 'No devices found',
98
- * hint: 'Click Discover to scan your network',
99
- * })
100
- */
101
- EmptyState: {
102
- render: function (opts) {
103
- opts = opts || {};
104
- var icon = opts.iconClass || 'bi bi-inbox';
105
- var title = opts.title || 'No items';
106
- var hint = opts.hint || '';
107
- return '<div class="mp-empty-state">'
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>' : '')
111
- + '</div>';
112
- },
113
- },
114
-
115
- /**
116
- * Loading — returns HTML for a centred loading spinner with message.
117
- * The message is HTML-escaped and announced to screen readers.
118
- *
119
- * MpKit.Loading.render('Loading device information...')
120
- */
121
- Loading: {
122
- render: function (message) {
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>'
127
- + '</div>';
128
- },
129
- },
130
-
131
- /**
132
- * View — controls which .mp-view element is visible.
133
- *
134
- * MpKit.View.show('listView') → shows #listView, hides all others
135
- * MpKit.View.show('settingsView') → shows #settingsView, hides all others
136
- */
137
- View: {
138
- show: function (id) {
139
- document.querySelectorAll('.mp-view').forEach(function (v) {
140
- v.classList.toggle('active', v.id === id);
141
- });
142
- },
143
- },
144
-
145
- /**
146
- * Footer — renders support links into a .mp-footer element.
147
- * Only http(s) URLs are rendered; anything else is skipped.
148
- *
149
- * MpKit.Footer.render({
150
- * github: 'https://github.com/mp-consulting/homebridge-...',
151
- * npm: 'https://www.npmjs.com/package/@mp-consulting/...',
152
- * changelog: 'https://github.com/.../blob/main/CHANGELOG.md',
153
- * target: '.mp-footer', // optional selector or element
154
- * })
155
- */
156
- Footer: {
157
- render: function (opts) {
158
- opts = opts || {};
159
- var target = opts.target || '.mp-footer';
160
- var el = typeof target === 'string' ? document.querySelector(target) : target;
161
- if (!el) { return; }
162
- var links = [];
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>');
170
- },
171
- },
172
-
120
+ var StatusBadge = {
121
+ online: (label) => badge("bg-success-subtle text-success-emphasis", "mp-status-online", label || "Online"),
122
+ offline: (label) => badge("bg-danger-subtle text-danger-emphasis", "mp-status-offline", label || "Offline"),
123
+ checking: (label) => badge("bg-secondary-subtle text-secondary-emphasis", "mp-status-checking", label || "Checking…"),
124
+ disabled: (label) => badge("bg-secondary", null, label || "Disabled")
125
+ };
126
+ var EmptyState = {
127
+ render(opts) {
128
+ opts = opts || {};
129
+ const iconClass = opts.iconClass || "bi bi-inbox";
130
+ const title = opts.title || "No items";
131
+ const hint = opts.hint || "";
132
+ return '<div class="mp-empty-state"><i class="' + escapeHtml(iconClass) + ' mp-empty-state-icon" aria-hidden="true"></i><p class="mp-empty-state-title">' + escapeHtml(title) + "</p>" + (hint ? '<p class="mp-empty-state-hint">' + escapeHtml(hint) + "</p>" : "") + "</div>";
133
+ }
134
+ };
135
+ var Loading = {
136
+ render(message) {
137
+ message = message || "Loading…";
138
+ return '<div class="mp-loading" role="status" aria-live="polite"><div class="spinner-border spinner-border-sm text-secondary" aria-hidden="true"></div><span>' + escapeHtml(message) + "</span></div>";
139
+ }
140
+ };
141
+ var HEADING = "h1, h2, h3, h4, h5, h6, [data-mp-focus]";
142
+ var View = {
143
+ show(id, opts) {
144
+ opts = opts || {};
145
+ let shown = null;
146
+ let changed = false;
147
+ let previous = false;
148
+ doc().querySelectorAll(".mp-view").forEach((v) => {
149
+ const active = v.id === id;
150
+ const was = v.classList.contains("active");
151
+ if (active) {
152
+ shown = v;
153
+ changed = !was;
154
+ } else if (was) {
155
+ previous = true;
156
+ }
157
+ v.classList.toggle("active", active);
158
+ });
159
+ if (!shown || !changed || typeof shown.querySelector !== "function") {
160
+ return shown;
161
+ }
162
+ const heading = shown.querySelector(HEADING);
163
+ if (opts.focus === void 0 ? previous : opts.focus) {
164
+ focusElement(heading || shown);
165
+ }
166
+ if (opts.announce === void 0 ? previous : opts.announce) {
167
+ const label = typeof opts.announce === "string" ? opts.announce : shown.getAttribute("aria-label") || textOf(heading);
168
+ if (label) {
169
+ announce(label);
170
+ }
171
+ }
172
+ return shown;
173
+ }
174
+ };
175
+ var Footer = {
176
+ render(opts) {
177
+ opts = opts || {};
178
+ const el = toElement(opts.target || ".mp-footer");
179
+ if (!el) {
180
+ return;
181
+ }
182
+ const links = [];
183
+ FOOTER_LINKS.forEach((link) => {
184
+ const url = safeUrl(opts[link.key]);
185
+ if (!url) {
186
+ return;
187
+ }
188
+ links.push('<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + icon(link.paths) + link.text + "</a>");
189
+ });
190
+ el.innerHTML = links.join('<span class="mp-footer-sep" aria-hidden="true">|</span>');
191
+ }
173
192
  };
174
193
 
175
- // ── Assistant (AI) ─────────────────────────────────────────────────────────
176
- // Talks to the plugin's server-side routes registered by
177
- // @mp-consulting/homebridge-ai-core/plugin (`registerAiRoutes`) through the global
178
- // `homebridge` object from @homebridge/plugin-ui-utils.
179
-
180
- // Generic four-point sparkle (not a product logo).
181
- var SPARKLE = '<svg class="mp-ai-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false">'
182
- + '<path d="M8 0c.4 3.9 2.1 5.6 6 6-3.9.4-5.6 2.1-6 6-.4-3.9-2.1-5.6-6-6 3.9-.4 5.6-2.1 6-6z"/>'
183
- + '<path d="M13.5 10.5c.15 1.5.85 2.2 2.5 2.5-1.65.3-2.35 1-2.5 2.5-.15-1.5-.85-2.2-2.5-2.5 1.65-.3 2.35-1 2.5-2.5z"/>'
184
- + '</svg>';
194
+ // src/js/ai.js
195
+ var SPARKLE = '<svg class="mp-ai-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M8 0c.4 3.9 2.1 5.6 6 6-3.9.4-5.6 2.1-6 6-.4-3.9-2.1-5.6-6-6 3.9-.4 5.6-2.1 6-6z"/><path d="M13.5 10.5c.15 1.5.85 2.2 2.5 2.5-1.65.3-2.35 1-2.5 2.5-.15-1.5-.85-2.2-2.5-2.5 1.65-.3 2.35-1 2.5-2.5z"/></svg>';
185
196
  var CARET = '<span class="mp-ai-caret" aria-hidden="true"></span>';
186
- var DEFAULT_NOTE = 'Generated by the Assistant. Check it before acting on it.';
187
- var DEFAULT_ERROR = 'The Assistant could not answer. Please try again.';
188
- var LCS_LIMIT = 4000000; // cells; larger diffs fall back to remove-all/add-all
189
-
197
+ var DEFAULT_NOTE = "Generated by the Assistant. Check it before acting on it.";
198
+ var DEFAULT_ERROR = "The Assistant could not answer. Please try again.";
199
+ var LCS_LIMIT = 4e6;
190
200
  var aiSeq = 0;
191
- var uid = 0;
192
-
193
- function hbClient() {
194
- return global.homebridge || (typeof homebridge !== 'undefined' ? homebridge : undefined);
195
- }
196
-
197
201
  function newRequestId() {
198
202
  aiSeq += 1;
199
- return 'mpai-' + Date.now().toString(36) + '-' + aiSeq.toString(36) + '-'
200
- + Math.random().toString(36).slice(2, 8);
203
+ return "mpai-" + Date.now().toString(36) + "-" + aiSeq.toString(36) + "-" + Math.random().toString(36).slice(2, 8);
201
204
  }
202
-
203
205
  function eventData(ev) {
204
- if (ev && ev.data !== undefined) { return ev.data; }
205
- if (ev && ev.detail !== undefined) { return ev.detail; }
206
+ if (ev && ev.data !== void 0) {
207
+ return ev.data;
208
+ }
209
+ if (ev && ev.detail !== void 0) {
210
+ return ev.detail;
211
+ }
206
212
  return ev;
207
213
  }
208
-
209
- function errorMessage(err, fallback) {
210
- if (typeof err === 'string' && err) { return err; }
211
- return (err && err.message) || fallback;
214
+ function abortError(reason) {
215
+ if (reason != null) {
216
+ return reason;
217
+ }
218
+ var message = "The Assistant request was cancelled";
219
+ if (typeof DOMException === "function") {
220
+ try {
221
+ return new DOMException(message, "AbortError");
222
+ } catch (e) {
223
+ }
224
+ }
225
+ var err = new Error(message);
226
+ err.name = "AbortError";
227
+ return err;
228
+ }
229
+ function notifyCancel(hb, requestId) {
230
+ try {
231
+ Promise.resolve(hb.request("/ai/cancel", { requestId })).catch(function() {
232
+ });
233
+ } catch (e) {
234
+ }
212
235
  }
213
-
214
- /**
215
- * Sends a request to an /ai/* route with a generated requestId and, while it
216
- * runs, forwards matching `ai:chunk` / `ai:done` / `ai:error` events to the
217
- * onChunk / onDone / onError callbacks. Resolves with the route's result.
218
- */
219
236
  function aiRequest(path, body, opts) {
220
237
  opts = opts || {};
221
238
  var hb = hbClient();
222
- if (!hb || typeof hb.request !== 'function') {
223
- return Promise.reject(new Error('MpKit.ai needs the Homebridge plugin UI (window.homebridge)'));
239
+ if (!hb || typeof hb.request !== "function") {
240
+ return withCancel(Promise.reject(new Error("MpKit.ai needs the Homebridge plugin UI (window.homebridge)")));
241
+ }
242
+ var signal = opts.signal;
243
+ if (signal && signal.aborted) {
244
+ return withCancel(Promise.reject(abortError(signal.reason)));
224
245
  }
225
246
  var requestId = newRequestId();
226
247
  var payload = {};
227
- Object.keys(body || {}).forEach(function (k) { payload[k] = body[k]; });
248
+ Object.keys(body || {}).forEach(function(k) {
249
+ payload[k] = body[k];
250
+ });
228
251
  payload.requestId = requestId;
229
-
252
+ var settled = false;
230
253
  var listeners = [];
231
- if (typeof hb.addEventListener === 'function') {
232
- var on = function (type, fn) {
233
- var handler = function (ev) {
254
+ if (typeof hb.addEventListener === "function") {
255
+ var on = function(type, fn) {
256
+ var handler = function(ev) {
234
257
  var data = eventData(ev);
235
- if (data && data.requestId === requestId) { fn(data); }
258
+ if (!settled && data && data.requestId === requestId) {
259
+ fn(data);
260
+ }
236
261
  };
237
262
  hb.addEventListener(type, handler);
238
263
  listeners.push([type, handler]);
239
264
  };
240
- if (typeof opts.onChunk === 'function') {
241
- on('ai:chunk', function (d) {
242
- if (typeof d.delta === 'string' && d.delta) { opts.onChunk(d.delta, d); }
265
+ if (typeof opts.onChunk === "function") {
266
+ on("ai:chunk", function(d) {
267
+ if (typeof d.delta === "string" && d.delta) {
268
+ opts.onChunk(d.delta, d);
269
+ }
243
270
  });
244
271
  }
245
- if (typeof opts.onDone === 'function') {
246
- on('ai:done', function (d) { opts.onDone(d); });
272
+ if (typeof opts.onDone === "function") {
273
+ on("ai:done", function(d) {
274
+ opts.onDone(d);
275
+ });
247
276
  }
248
- if (typeof opts.onError === 'function') {
249
- on('ai:error', function (d) { opts.onError(d.message || DEFAULT_ERROR, d); });
277
+ if (typeof opts.onError === "function") {
278
+ on("ai:error", function(d) {
279
+ opts.onError(d.message || DEFAULT_ERROR, d);
280
+ });
250
281
  }
251
282
  }
283
+ var rejectCancelled;
284
+ var cancelled = new Promise(function(resolve, reject) {
285
+ rejectCancelled = reject;
286
+ });
287
+ cancelled.catch(function() {
288
+ });
289
+ function onAbort() {
290
+ cancel(signal.reason);
291
+ }
252
292
  function cleanup() {
253
- if (typeof hb.removeEventListener !== 'function') { return; }
254
- listeners.forEach(function (l) { hb.removeEventListener(l[0], l[1]); });
293
+ settled = true;
294
+ if (signal && typeof signal.removeEventListener === "function") {
295
+ signal.removeEventListener("abort", onAbort);
296
+ }
297
+ if (typeof hb.removeEventListener !== "function") {
298
+ return;
299
+ }
300
+ listeners.forEach(function(l) {
301
+ hb.removeEventListener(l[0], l[1]);
302
+ });
303
+ listeners = [];
304
+ }
305
+ function cancel(reason) {
306
+ if (settled) {
307
+ return false;
308
+ }
309
+ cleanup();
310
+ if (opts.notifyServer !== false) {
311
+ notifyCancel(hb, requestId);
312
+ }
313
+ rejectCancelled(abortError(reason));
314
+ return true;
315
+ }
316
+ if (signal && typeof signal.addEventListener === "function") {
317
+ signal.addEventListener("abort", onAbort);
255
318
  }
256
-
257
319
  var pending;
258
320
  try {
259
321
  pending = Promise.resolve(hb.request(path, payload));
260
322
  } catch (err) {
261
323
  pending = Promise.reject(err);
262
324
  }
263
- return pending.then(function (result) {
325
+ var result = Promise.race([pending, cancelled]).then(function(value) {
264
326
  cleanup();
265
- return result;
266
- }, function (err) {
327
+ return value;
328
+ }, function(err) {
267
329
  cleanup();
268
330
  throw err;
269
331
  });
332
+ return withCancel(result, cancel, requestId);
333
+ }
334
+ function withCancel(promise, cancel, requestId) {
335
+ promise.cancel = cancel || function() {
336
+ return false;
337
+ };
338
+ promise.requestId = requestId || null;
339
+ return promise;
270
340
  }
271
-
272
- // ── Safe markdown subset ──
273
- // Paragraphs, **bold**, `inline code`, fenced code blocks, bullet and
274
- // numbered lists, and #-headings (rendered as bold paragraphs). Every piece of
275
- // text is HTML-escaped before any tag is added, so model output can never
276
- // inject markup. Links are left as plain text.
277
-
278
341
  function inlineMarkdown(text) {
279
- return String(text).split(/(`[^`\n]+`)/).map(function (part, i) {
280
- if (i % 2 === 1) { return '<code>' + escapeHtml(part.slice(1, -1)) + '</code>'; }
281
- return escapeHtml(part).replace(/\*\*([^*\n]+?)\*\*|__([^_\n]+?)__/g, function (m, a, b) {
282
- return '<strong>' + (a || b) + '</strong>';
342
+ return String(text).split(/(`[^`\n]+`)/).map(function(part, i) {
343
+ if (i % 2 === 1) {
344
+ return "<code>" + escapeHtml(part.slice(1, -1)) + "</code>";
345
+ }
346
+ return escapeHtml(part).replace(/\*\*([^*\n]+?)\*\*|__([^_\n]+?)__/g, function(m, a, b) {
347
+ return "<strong>" + (a || b) + "</strong>";
283
348
  });
284
- }).join('');
349
+ }).join("");
285
350
  }
286
-
287
351
  var UL_ITEM = /^\s*[-*+]\s+(.*)$/;
288
352
  var OL_ITEM = /^\s*\d+[.)]\s+(.*)$/;
289
- var HEADING = /^\s*#{1,6}\s+(.*)$/;
353
+ var HEADING2 = /^\s*#{1,6}\s+(.*)$/;
290
354
  var FENCE = /^\s*(```|~~~)/;
291
-
292
355
  function markdown(text) {
293
- var lines = String(text == null ? '' : text).replace(/\r\n?/g, '\n').split('\n');
356
+ var lines = String(text == null ? "" : text).replace(/\r\n?/g, "\n").split("\n");
294
357
  var out = [];
295
358
  var para = [];
296
359
  var i = 0;
297
360
  function flush() {
298
361
  if (para.length) {
299
- out.push('<p>' + para.map(inlineMarkdown).join('<br>') + '</p>');
362
+ out.push("<p>" + para.map(inlineMarkdown).join("<br>") + "</p>");
300
363
  para = [];
301
364
  }
302
365
  }
@@ -311,26 +374,26 @@
311
374
  code.push(lines[i]);
312
375
  i += 1;
313
376
  }
314
- i += 1; // closing fence (absent while a block is still streaming)
315
- out.push('<pre><code>' + escapeHtml(code.join('\n')) + '</code></pre>');
377
+ i += 1;
378
+ out.push("<pre><code>" + escapeHtml(code.join("\n")) + "</code></pre>");
316
379
  continue;
317
380
  }
318
- var listRe = UL_ITEM.test(line) ? UL_ITEM : (OL_ITEM.test(line) ? OL_ITEM : null);
381
+ var listRe = UL_ITEM.test(line) ? UL_ITEM : OL_ITEM.test(line) ? OL_ITEM : null;
319
382
  if (listRe) {
320
383
  flush();
321
- var tag = listRe === UL_ITEM ? 'ul' : 'ol';
384
+ var tag = listRe === UL_ITEM ? "ul" : "ol";
322
385
  var items = [];
323
386
  while (i < lines.length && listRe.test(lines[i])) {
324
- items.push('<li>' + inlineMarkdown(listRe.exec(lines[i])[1]) + '</li>');
387
+ items.push("<li>" + inlineMarkdown(listRe.exec(lines[i])[1]) + "</li>");
325
388
  i += 1;
326
389
  }
327
- out.push('<' + tag + '>' + items.join('') + '</' + tag + '>');
390
+ out.push("<" + tag + ">" + items.join("") + "</" + tag + ">");
328
391
  continue;
329
392
  }
330
- var heading = HEADING.exec(line);
393
+ var heading = HEADING2.exec(line);
331
394
  if (heading) {
332
395
  flush();
333
- out.push('<p><strong>' + inlineMarkdown(heading[1]) + '</strong></p>');
396
+ out.push("<p><strong>" + inlineMarkdown(heading[1]) + "</strong></p>");
334
397
  } else if (!line.trim()) {
335
398
  flush();
336
399
  } else {
@@ -339,34 +402,38 @@
339
402
  i += 1;
340
403
  }
341
404
  flush();
342
- return out.join('');
405
+ return out.join("");
343
406
  }
344
-
345
- /** Puts the streaming caret at the end of the last paragraph or list item. */
346
407
  function withCaret(html) {
347
- if (/<\/p>$/.test(html)) { return html.slice(0, -4) + CARET + '</p>'; }
348
- if (/<\/li><\/[ou]l>$/.test(html)) { return html.slice(0, -10) + CARET + html.slice(-10); }
408
+ if (/<\/p>$/.test(html)) {
409
+ return html.slice(0, -4) + CARET + "</p>";
410
+ }
411
+ if (/<\/li><\/[ou]l>$/.test(html)) {
412
+ return html.slice(0, -10) + CARET + html.slice(-10);
413
+ }
349
414
  return html + CARET;
350
415
  }
351
-
352
- // ── Line diff (longest common subsequence) ──
353
-
354
416
  function toLines(value) {
355
- if (value == null) { return []; }
356
- var text = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
357
- text = text.replace(/\r\n?/g, '\n');
358
- if (text === '') { return []; }
359
- if (text.charAt(text.length - 1) === '\n') { text = text.slice(0, -1); }
360
- return text.split('\n');
417
+ if (value == null) {
418
+ return [];
419
+ }
420
+ var text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
421
+ text = text.replace(/\r\n?/g, "\n");
422
+ if (text === "") {
423
+ return [];
424
+ }
425
+ if (text.charAt(text.length - 1) === "\n") {
426
+ text = text.slice(0, -1);
427
+ }
428
+ return text.split("\n");
361
429
  }
362
-
363
430
  function diffLines(before, after) {
364
431
  var a = toLines(before);
365
432
  var b = toLines(after);
366
433
  var out = [];
367
434
  var start = 0;
368
435
  while (start < a.length && start < b.length && a[start] === b[start]) {
369
- out.push({ type: 'same', text: a[start] });
436
+ out.push({ type: "same", text: a[start] });
370
437
  start += 1;
371
438
  }
372
439
  var endA = a.length;
@@ -382,117 +449,111 @@
382
449
  var i;
383
450
  var j;
384
451
  if (n * m > LCS_LIMIT) {
385
- x.forEach(function (t) { out.push({ type: 'del', text: t }); });
386
- y.forEach(function (t) { out.push({ type: 'add', text: t }); });
452
+ x.forEach(function(t) {
453
+ out.push({ type: "del", text: t });
454
+ });
455
+ y.forEach(function(t) {
456
+ out.push({ type: "add", text: t });
457
+ });
387
458
  } else {
388
459
  var w = m + 1;
389
460
  var dp = new Int32Array((n + 1) * w);
390
461
  for (i = n - 1; i >= 0; i -= 1) {
391
462
  for (j = m - 1; j >= 0; j -= 1) {
392
- dp[i * w + j] = x[i] === y[j]
393
- ? dp[(i + 1) * w + j + 1] + 1
394
- : Math.max(dp[(i + 1) * w + j], dp[i * w + j + 1]);
463
+ dp[i * w + j] = x[i] === y[j] ? dp[(i + 1) * w + j + 1] + 1 : Math.max(dp[(i + 1) * w + j], dp[i * w + j + 1]);
395
464
  }
396
465
  }
397
466
  i = 0;
398
467
  j = 0;
399
468
  while (i < n && j < m) {
400
469
  if (x[i] === y[j]) {
401
- out.push({ type: 'same', text: x[i] });
470
+ out.push({ type: "same", text: x[i] });
402
471
  i += 1;
403
472
  j += 1;
404
473
  } else if (dp[(i + 1) * w + j] >= dp[i * w + j + 1]) {
405
- out.push({ type: 'del', text: x[i] });
474
+ out.push({ type: "del", text: x[i] });
406
475
  i += 1;
407
476
  } else {
408
- out.push({ type: 'add', text: y[j] });
477
+ out.push({ type: "add", text: y[j] });
409
478
  j += 1;
410
479
  }
411
480
  }
412
- for (; i < n; i += 1) { out.push({ type: 'del', text: x[i] }); }
413
- for (; j < m; j += 1) { out.push({ type: 'add', text: y[j] }); }
481
+ for (; i < n; i += 1) {
482
+ out.push({ type: "del", text: x[i] });
483
+ }
484
+ for (; j < m; j += 1) {
485
+ out.push({ type: "add", text: y[j] });
486
+ }
487
+ }
488
+ for (i = endA; i < a.length; i += 1) {
489
+ out.push({ type: "same", text: a[i] });
414
490
  }
415
- for (i = endA; i < a.length; i += 1) { out.push({ type: 'same', text: a[i] }); }
416
491
  return out;
417
492
  }
418
-
419
- var DIFF_SIGN = { add: '+', del: '−', same: ' ' };
420
- var DIFF_SR = { add: 'Added: ', del: 'Removed: ', same: '' };
421
-
493
+ var DIFF_SIGN = { add: "+", del: "−", same: " " };
494
+ var DIFF_SR = { add: "Added: ", del: "Removed: ", same: "" };
422
495
  function diffLineHtml(line) {
423
- return '<span class="mp-ai-diff-line is-' + line.type + '">'
424
- + '<span class="mp-ai-diff-sign" aria-hidden="true">' + DIFF_SIGN[line.type] + '</span>'
425
- + (DIFF_SR[line.type] ? '<span class="mp-ai-sr-only">' + DIFF_SR[line.type] + '</span>' : '')
426
- + escapeHtml(line.text) + '</span>';
496
+ return '<span class="mp-ai-diff-line is-' + line.type + '"><span class="mp-ai-diff-sign" aria-hidden="true">' + DIFF_SIGN[line.type] + "</span>" + (DIFF_SR[line.type] ? '<span class="mp-ai-sr-only">' + DIFF_SR[line.type] + "</span>" : "") + escapeHtml(line.text) + "</span>";
427
497
  }
428
-
429
- /** Replaces a previous listener of the same kind that MpKit.ai bound to el. */
430
498
  function bind(el, type, fn) {
431
- var store = el.__mpAiListeners || (el.__mpAiListeners = {});
432
- if (store[type]) { el.removeEventListener(type, store[type]); }
433
- store[type] = fn;
499
+ var store2 = el.__mpAiListeners || (el.__mpAiListeners = {});
500
+ if (store2[type]) {
501
+ el.removeEventListener(type, store2[type]);
502
+ }
503
+ store2[type] = fn;
434
504
  el.addEventListener(type, fn);
435
505
  }
436
-
437
506
  function actionTarget(ev) {
438
507
  var t = ev && ev.target;
439
- return t && typeof t.closest === 'function' ? t.closest('[data-mp-ai-action]') : null;
508
+ return t && typeof t.closest === "function" ? t.closest("[data-mp-ai-action]") : null;
440
509
  }
441
-
442
510
  function badgeHtml(label) {
443
- return '<span class="mp-ai-badge">' + SPARKLE + escapeHtml(label || 'Assistant') + '</span>';
511
+ return '<span class="mp-ai-badge">' + SPARKLE + escapeHtml(label || "Assistant") + "</span>";
444
512
  }
445
-
446
513
  function thinkingHtml(label) {
447
- return '<span class="mp-ai-thinking" role="status">' + escapeHtml(label || 'Thinking…') + '</span>';
514
+ return '<span class="mp-ai-thinking" role="status">' + escapeHtml(label || "Thinking…") + "</span>";
448
515
  }
449
-
450
516
  var edgeEl = null;
451
-
452
517
  var ai = {
453
-
454
518
  /** GET-style status of the Assistant: { enabled, provider, model, capabilities }. */
455
- status: function () {
519
+ status: function() {
456
520
  var hb = hbClient();
457
- if (!hb || typeof hb.request !== 'function') {
458
- return Promise.reject(new Error('MpKit.ai needs the Homebridge plugin UI (window.homebridge)'));
521
+ if (!hb || typeof hb.request !== "function") {
522
+ return Promise.reject(new Error("MpKit.ai needs the Homebridge plugin UI (window.homebridge)"));
459
523
  }
460
- return Promise.resolve(hb.request('/ai/status'));
524
+ return Promise.resolve(hb.request("/ai/status"));
525
+ },
526
+ /**
527
+ * Explains a device/plugin error. body: { error, context?, device? } → { text, usage }
528
+ * opts: { onChunk, onDone, onError, signal, notifyServer = true }; the
529
+ * returned promise has .cancel() and .requestId.
530
+ */
531
+ explain: function(body, opts) {
532
+ return aiRequest("/ai/explain", body, opts);
461
533
  },
462
-
463
- /** Explains a device/plugin error. body: { error, context?, device? } → { text, usage } */
464
- explain: function (body, opts) { return aiRequest('/ai/explain', body, opts); },
465
-
466
534
  /** Free-form question. body: { prompt, context? } → { text, usage } */
467
- ask: function (body, opts) { return aiRequest('/ai/ask', body, opts); },
468
-
535
+ ask: function(body, opts) {
536
+ return aiRequest("/ai/ask", body, opts);
537
+ },
469
538
  /** Generates plugin config. body: { schema, request, current? } → { config, explanation, usage } */
470
- config: function (body, opts) { return aiRequest('/ai/config', body, opts); },
471
-
472
- markdown: markdown,
473
- diffLines: diffLines,
474
-
539
+ config: function(body, opts) {
540
+ return aiRequest("/ai/config", body, opts);
541
+ },
542
+ markdown,
543
+ diffLines,
475
544
  /**
476
545
  * Returns HTML for an Assistant button (sparkle icon + escaped label).
477
546
  * opts: { label = 'Ask Assistant', id, className, size: 'sm' | 'lg', title }
478
547
  */
479
- renderButton: function (opts) {
548
+ renderButton: function(opts) {
480
549
  opts = opts || {};
481
- var cls = 'mp-ai-button'
482
- + (opts.size === 'sm' || opts.size === 'lg' ? ' mp-ai-button-' + opts.size : '')
483
- + (opts.className ? ' ' + escapeHtml(opts.className) : '');
484
- return '<button type="button" class="' + cls + '"'
485
- + (opts.id ? ' id="' + escapeHtml(opts.id) + '"' : '')
486
- + (opts.title ? ' title="' + escapeHtml(opts.title) + '"' : '')
487
- + '>' + SPARKLE + '<span>' + escapeHtml(opts.label || 'Ask Assistant') + '</span></button>';
550
+ var cls = "mp-ai-button" + (opts.size === "sm" || opts.size === "lg" ? " mp-ai-button-" + opts.size : "") + (opts.className ? " " + escapeHtml(opts.className) : "");
551
+ return '<button type="button" class="' + cls + '"' + (opts.id ? ' id="' + escapeHtml(opts.id) + '"' : "") + (opts.title ? ' title="' + escapeHtml(opts.title) + '"' : "") + ">" + SPARKLE + "<span>" + escapeHtml(opts.label || "Ask Assistant") + "</span></button>";
488
552
  },
489
-
490
553
  /** Returns HTML for a small "Assistant" badge. */
491
554
  renderBadge: badgeHtml,
492
-
493
555
  /** Returns HTML for a pulsing "Thinking…" indicator (a status live region). */
494
556
  renderThinking: thinkingHtml,
495
-
496
557
  /**
497
558
  * Renders an answer panel into el and returns a controller for streaming:
498
559
  * const answer = MpKit.ai.renderAnswer(el);
@@ -501,62 +562,58 @@
501
562
  * opts: { label = 'Assistant', title, text, streaming = true, note }
502
563
  * The text is rendered as escaped, safe markdown.
503
564
  */
504
- renderAnswer: function (el, opts) {
565
+ renderAnswer: function(el, opts) {
505
566
  opts = opts || {};
506
- var note = opts.note === undefined ? DEFAULT_NOTE : opts.note;
507
- el.innerHTML = '<section class="mp-ai-panel" aria-label="' + escapeHtml(opts.label || 'Assistant') + ' answer">'
508
- + '<div class="mp-ai-panel-header">' + badgeHtml(opts.label)
509
- + (opts.title ? '<p class="mp-ai-panel-title">' + escapeHtml(opts.title) + '</p>' : '')
510
- + '</div>'
511
- + '<div class="mp-ai-panel-body mp-ai-prose"></div>'
512
- + '<p class="mp-ai-error" role="alert" hidden></p>'
513
- + (note ? '<p class="mp-ai-panel-note">' + escapeHtml(note) + '</p>' : '')
514
- + '</section>';
515
- var panel = el.querySelector('.mp-ai-panel');
516
- var body = el.querySelector('.mp-ai-panel-body');
517
- var errorEl = el.querySelector('.mp-ai-error');
518
- var text = opts.text == null ? '' : String(opts.text);
567
+ var note = opts.note === void 0 ? DEFAULT_NOTE : opts.note;
568
+ el.innerHTML = '<section class="mp-ai-panel" aria-label="' + escapeHtml(opts.label || "Assistant") + ' answer"><div class="mp-ai-panel-header">' + badgeHtml(opts.label) + (opts.title ? '<p class="mp-ai-panel-title">' + escapeHtml(opts.title) + "</p>" : "") + '</div><div class="mp-ai-panel-body mp-ai-prose"></div><p class="mp-ai-error" role="alert" hidden></p>' + (note ? '<p class="mp-ai-panel-note">' + escapeHtml(note) + "</p>" : "") + "</section>";
569
+ var panel = el.querySelector(".mp-ai-panel");
570
+ var body = el.querySelector(".mp-ai-panel-body");
571
+ var errorEl = el.querySelector(".mp-ai-error");
572
+ var text = opts.text == null ? "" : String(opts.text);
519
573
  var streaming = opts.streaming !== false;
520
-
521
574
  function paint() {
522
575
  var html = markdown(text);
523
- if (streaming) { html = text ? withCaret(html) : thinkingHtml(); }
576
+ if (streaming) {
577
+ html = text ? withCaret(html) : thinkingHtml();
578
+ }
524
579
  body.innerHTML = html;
525
- panel.classList.toggle('is-streaming', streaming);
526
- panel.setAttribute('aria-busy', streaming ? 'true' : 'false');
580
+ panel.classList.toggle("is-streaming", streaming);
581
+ panel.setAttribute("aria-busy", streaming ? "true" : "false");
527
582
  }
528
-
529
583
  var controller = {
530
- append: function (delta) {
531
- text += delta == null ? '' : String(delta);
584
+ append: function(delta) {
585
+ text += delta == null ? "" : String(delta);
532
586
  paint();
533
587
  return controller;
534
588
  },
535
- setText: function (value) {
536
- text = value == null ? '' : String(value);
589
+ setText: function(value) {
590
+ text = value == null ? "" : String(value);
537
591
  paint();
538
592
  return controller;
539
593
  },
540
- done: function (result) {
541
- var final = result && typeof result === 'object' ? result.text : result;
542
- if (typeof final === 'string' && final) { text = final; }
594
+ done: function(result) {
595
+ var final = result && typeof result === "object" ? result.text : result;
596
+ if (typeof final === "string" && final) {
597
+ text = final;
598
+ }
543
599
  streaming = false;
544
600
  paint();
545
601
  return controller;
546
602
  },
547
- error: function (err) {
603
+ error: function(err) {
548
604
  streaming = false;
549
605
  paint();
550
606
  errorEl.textContent = errorMessage(err, DEFAULT_ERROR);
551
607
  errorEl.hidden = false;
552
608
  return controller;
553
609
  },
554
- getText: function () { return text; },
610
+ getText: function() {
611
+ return text;
612
+ }
555
613
  };
556
614
  paint();
557
615
  return controller;
558
616
  },
559
-
560
617
  /**
561
618
  * Renders a line diff with Apply / Reject buttons into el.
562
619
  * before/after may be strings or JSON-serialisable values (pretty-printed).
@@ -565,79 +622,60 @@
565
622
  * opts: { before, after, onApply, onReject, title, applyLabel, rejectLabel }
566
623
  * Returns { lines, added, removed }.
567
624
  */
568
- renderDiff: function (el, opts) {
625
+ renderDiff: function(el, opts) {
569
626
  opts = opts || {};
570
627
  var lines = diffLines(opts.before, opts.after);
571
- var added = lines.filter(function (l) { return l.type === 'add'; }).length;
572
- var removed = lines.filter(function (l) { return l.type === 'del'; }).length;
573
- var hasActions = typeof opts.onApply === 'function' || typeof opts.onReject === 'function';
574
- var title = opts.title || 'Suggested changes';
575
-
576
- el.innerHTML = '<div class="mp-ai-diff">'
577
- + '<div class="mp-ai-diff-header">' + badgeHtml(opts.label)
578
- + '<span class="mp-ai-diff-title">' + escapeHtml(title) + '</span>'
579
- + '<span class="mp-ai-diff-stats">'
580
- + '<span class="mp-ai-diff-stat-add">+' + added + '<span class="mp-ai-sr-only"> added</span></span>'
581
- + '<span class="mp-ai-diff-stat-del">−' + removed + '<span class="mp-ai-sr-only"> removed</span></span>'
582
- + '</span></div>'
583
- + '<pre class="mp-ai-diff-body" tabindex="0" aria-label="' + escapeHtml(title) + '"><code>'
584
- + (added || removed ? lines.map(diffLineHtml).join('') : '<span class="mp-ai-diff-line">No changes</span>')
585
- + '</code></pre>'
586
- + (hasActions
587
- ? '<div class="mp-ai-diff-actions">'
588
- + '<span class="mp-ai-diff-status" role="status"></span>'
589
- + '<button type="button" class="mp-ai-action" data-mp-ai-action="reject">'
590
- + escapeHtml(opts.rejectLabel || 'Reject') + '</button>'
591
- + '<button type="button" class="mp-ai-action mp-ai-action-primary" data-mp-ai-action="apply">'
592
- + escapeHtml(opts.applyLabel || 'Apply') + '</button>'
593
- + '</div>'
594
- : '')
595
- + '</div>';
596
-
597
- var result = { lines: lines, added: added, removed: removed };
598
- if (!hasActions) { return result; }
599
-
600
- var root = el.querySelector('.mp-ai-diff');
601
- var statusEl = el.querySelector('.mp-ai-diff-status');
628
+ var added = lines.filter(function(l) {
629
+ return l.type === "add";
630
+ }).length;
631
+ var removed = lines.filter(function(l) {
632
+ return l.type === "del";
633
+ }).length;
634
+ var hasActions = typeof opts.onApply === "function" || typeof opts.onReject === "function";
635
+ var title = opts.title || "Suggested changes";
636
+ el.innerHTML = '<div class="mp-ai-diff"><div class="mp-ai-diff-header">' + badgeHtml(opts.label) + '<span class="mp-ai-diff-title">' + escapeHtml(title) + '</span><span class="mp-ai-diff-stats"><span class="mp-ai-diff-stat-add">+' + added + '<span class="mp-ai-sr-only"> added</span></span><span class="mp-ai-diff-stat-del">−' + removed + '<span class="mp-ai-sr-only"> removed</span></span></span></div><pre class="mp-ai-diff-body" tabindex="0" aria-label="' + escapeHtml(title) + '"><code>' + (added || removed ? lines.map(diffLineHtml).join("") : '<span class="mp-ai-diff-line">No changes</span>') + "</code></pre>" + (hasActions ? '<div class="mp-ai-diff-actions"><span class="mp-ai-diff-status" role="status"></span><button type="button" class="mp-ai-action" data-mp-ai-action="reject">' + escapeHtml(opts.rejectLabel || "Reject") + '</button><button type="button" class="mp-ai-action mp-ai-action-primary" data-mp-ai-action="apply">' + escapeHtml(opts.applyLabel || "Apply") + "</button></div>" : "") + "</div>";
637
+ var result = { lines, added, removed };
638
+ if (!hasActions) {
639
+ return result;
640
+ }
641
+ var root2 = el.querySelector(".mp-ai-diff");
642
+ var statusEl = el.querySelector(".mp-ai-diff-status");
602
643
  var applyBtn = el.querySelector('[data-mp-ai-action="apply"]');
603
644
  var rejectBtn = el.querySelector('[data-mp-ai-action="reject"]');
604
645
  var settled = false;
605
646
  var busy = false;
606
-
607
647
  function setDisabled(disabled) {
608
648
  applyBtn.disabled = disabled;
609
649
  rejectBtn.disabled = disabled;
610
650
  }
611
-
612
- bind(el, 'click', function (ev) {
651
+ bind(el, "click", function(ev) {
613
652
  var btn = actionTarget(ev);
614
- if (!btn || settled || busy) { return; }
615
- var action = btn.getAttribute('data-mp-ai-action');
616
- if (action !== 'apply' && action !== 'reject') { return; }
617
- var cb = action === 'apply' ? opts.onApply : opts.onReject;
653
+ if (!btn || settled || busy) {
654
+ return;
655
+ }
656
+ var action = btn.getAttribute("data-mp-ai-action");
657
+ if (action !== "apply" && action !== "reject") {
658
+ return;
659
+ }
660
+ var cb = action === "apply" ? opts.onApply : opts.onReject;
618
661
  busy = true;
619
662
  setDisabled(true);
620
- statusEl.textContent = '';
621
- Promise.resolve()
622
- .then(function () {
623
- return typeof cb === 'function'
624
- ? cb({ before: opts.before, after: opts.after, lines: lines })
625
- : undefined;
626
- })
627
- .then(function () {
628
- busy = false;
629
- settled = true;
630
- root.classList.add(action === 'apply' ? 'is-applied' : 'is-rejected');
631
- statusEl.textContent = action === 'apply' ? 'Applied' : 'Rejected';
632
- }, function (err) {
633
- busy = false;
634
- setDisabled(false);
635
- statusEl.textContent = errorMessage(err, 'Something went wrong');
636
- });
663
+ statusEl.textContent = "";
664
+ Promise.resolve().then(function() {
665
+ return typeof cb === "function" ? cb({ before: opts.before, after: opts.after, lines }) : void 0;
666
+ }).then(function() {
667
+ busy = false;
668
+ settled = true;
669
+ root2.classList.add(action === "apply" ? "is-applied" : "is-rejected");
670
+ statusEl.textContent = action === "apply" ? "Applied" : "Rejected";
671
+ }, function(err) {
672
+ busy = false;
673
+ setDisabled(false);
674
+ statusEl.textContent = errorMessage(err, "Something went wrong");
675
+ });
637
676
  });
638
677
  return result;
639
678
  },
640
-
641
679
  /**
642
680
  * Renders a chat (log + input) into el. Each message is sent with
643
681
  * MpKit.ai.ask({ prompt, context }) and streamed into the log; the context is
@@ -645,150 +683,1605 @@
645
683
  * { onChunk, history }) to use another transport; it may resolve with a
646
684
  * string or { text }.
647
685
  * opts: { label = 'Assistant', context, placeholder, emptyText, onSend }
648
- * Returns { send(text?), clear(), messages() }.
686
+ * onSend also receives ctx.signal (an AbortSignal) for chat.cancel().
687
+ * Returns { send(text?), cancel(), clear(), messages() }.
649
688
  */
650
- renderChat: function (el, opts) {
689
+ renderChat: function(el, opts) {
651
690
  opts = opts || {};
652
- uid += 1;
653
- var inputId = 'mp-ai-chat-input-' + uid;
654
- el.innerHTML = '<div class="mp-ai-chat">'
655
- + '<div class="mp-ai-chat-header">' + badgeHtml(opts.label) + '</div>'
656
- + '<div class="mp-ai-chat-log" role="log" aria-live="polite" aria-label="'
657
- + escapeHtml(opts.label || 'Assistant') + ' conversation"></div>'
658
- + '<form class="mp-ai-chat-form">'
659
- + '<label class="mp-ai-sr-only" for="' + inputId + '">Message</label>'
660
- + '<textarea class="mp-ai-chat-input" id="' + inputId + '" rows="1" placeholder="'
661
- + escapeHtml(opts.placeholder || 'Ask the Assistant…') + '"></textarea>'
662
- + '<button type="submit" class="mp-ai-button">' + SPARKLE + '<span>Send</span></button>'
663
- + '</form></div>';
664
-
665
- var log = el.querySelector('.mp-ai-chat-log');
666
- var input = el.querySelector('.mp-ai-chat-input');
667
- var sendBtn = el.querySelector('.mp-ai-chat-form .mp-ai-button');
691
+ var inputId = uid("mp-ai-chat-input");
692
+ el.innerHTML = '<div class="mp-ai-chat"><div class="mp-ai-chat-header">' + badgeHtml(opts.label) + '</div><div class="mp-ai-chat-log" role="log" aria-live="polite" aria-label="' + escapeHtml(opts.label || "Assistant") + ' conversation"></div><form class="mp-ai-chat-form"><label class="mp-ai-sr-only" for="' + inputId + '">Message</label><textarea class="mp-ai-chat-input" id="' + inputId + '" rows="1" placeholder="' + escapeHtml(opts.placeholder || "Ask the Assistant…") + '"></textarea><button type="submit" class="mp-ai-button">' + SPARKLE + "<span>Send</span></button></form></div>";
693
+ var log = el.querySelector(".mp-ai-chat-log");
694
+ var input = el.querySelector(".mp-ai-chat-input");
695
+ var sendBtn = el.querySelector(".mp-ai-chat-form .mp-ai-button");
668
696
  var messages = [];
669
697
  var busy = false;
670
-
671
698
  function messageHtml(m) {
672
- if (m.role === 'user') {
673
- return '<div class="mp-ai-chat-msg is-user"><div class="mp-ai-chat-bubble">'
674
- + '<span class="mp-ai-sr-only">You: </span>'
675
- + escapeHtml(m.text).replace(/\n/g, '<br>') + '</div></div>';
699
+ if (m.role === "user") {
700
+ return '<div class="mp-ai-chat-msg is-user"><div class="mp-ai-chat-bubble"><span class="mp-ai-sr-only">You: </span>' + escapeHtml(m.text).replace(/\n/g, "<br>") + "</div></div>";
676
701
  }
677
702
  var html = markdown(m.text);
678
- if (m.streaming) { html = m.text ? withCaret(html) : thinkingHtml(); }
679
- if (m.error) { html += '<p class="mp-ai-error" role="alert">' + escapeHtml(m.error) + '</p>'; }
680
- return '<div class="mp-ai-chat-msg is-assistant"><div class="mp-ai-chat-bubble mp-ai-prose">'
681
- + '<span class="mp-ai-sr-only">' + escapeHtml(opts.label || 'Assistant') + ': </span>'
682
- + html + '</div></div>';
703
+ if (m.streaming) {
704
+ html = m.text ? withCaret(html) : thinkingHtml();
705
+ }
706
+ if (m.error) {
707
+ html += '<p class="mp-ai-error" role="alert">' + escapeHtml(m.error) + "</p>";
708
+ }
709
+ if (m.stopped) {
710
+ html += '<p class="mp-ai-panel-note">Stopped</p>';
711
+ }
712
+ return '<div class="mp-ai-chat-msg is-assistant"><div class="mp-ai-chat-bubble mp-ai-prose"><span class="mp-ai-sr-only">' + escapeHtml(opts.label || "Assistant") + ": </span>" + html + "</div></div>";
683
713
  }
684
-
685
714
  function paint() {
686
- log.innerHTML = messages.length
687
- ? messages.map(messageHtml).join('')
688
- : '<p class="mp-ai-chat-empty">' + escapeHtml(opts.emptyText || 'Ask the Assistant a question.') + '</p>';
689
- log.setAttribute('aria-busy', busy ? 'true' : 'false');
715
+ log.innerHTML = messages.length ? messages.map(messageHtml).join("") : '<p class="mp-ai-chat-empty">' + escapeHtml(opts.emptyText || "Ask the Assistant a question.") + "</p>";
716
+ log.setAttribute("aria-busy", busy ? "true" : "false");
690
717
  log.scrollTop = log.scrollHeight;
691
718
  sendBtn.disabled = busy;
692
- sendBtn.setAttribute('aria-busy', busy ? 'true' : 'false');
719
+ sendBtn.setAttribute("aria-busy", busy ? "true" : "false");
693
720
  }
694
-
695
721
  function defaultSend(prompt, ctx) {
696
- var transcript = ctx.history.slice(-10).map(function (m) {
697
- return (m.role === 'user' ? 'User' : 'Assistant') + ': ' + m.text;
698
- }).join('\n');
699
- var context = [opts.context, transcript ? 'Conversation so far:\n' + transcript : '']
700
- .filter(Boolean).join('\n\n');
701
- var body = { prompt: prompt };
702
- if (context) { body.context = context; }
703
- return ai.ask(body, { onChunk: ctx.onChunk });
722
+ var transcript = ctx.history.slice(-10).map(function(m) {
723
+ return (m.role === "user" ? "User" : "Assistant") + ": " + m.text;
724
+ }).join("\n");
725
+ var context = [opts.context, transcript ? "Conversation so far:\n" + transcript : ""].filter(Boolean).join("\n\n");
726
+ var body = { prompt };
727
+ if (context) {
728
+ body.context = context;
729
+ }
730
+ return ai.ask(body, { onChunk: ctx.onChunk, signal: ctx.signal });
704
731
  }
705
-
732
+ var controller = null;
706
733
  function send(text) {
707
734
  var prompt = String(text == null ? input.value : text).trim();
708
- if (!prompt || busy) { return Promise.resolve(null); }
709
- if (text == null) { input.value = ''; }
710
- var history = messages.filter(function (m) { return !m.error; }).map(function (m) {
735
+ if (!prompt || busy) {
736
+ return Promise.resolve(null);
737
+ }
738
+ if (text == null) {
739
+ input.value = "";
740
+ }
741
+ var history = messages.filter(function(m) {
742
+ return !m.error && !m.stopped;
743
+ }).map(function(m) {
711
744
  return { role: m.role, text: m.text };
712
745
  });
713
- var reply = { role: 'assistant', text: '', streaming: true };
714
- messages.push({ role: 'user', text: prompt }, reply);
746
+ var reply = { role: "assistant", text: "", streaming: true };
747
+ messages.push({ role: "user", text: prompt }, reply);
715
748
  busy = true;
716
749
  paint();
717
- var handler = typeof opts.onSend === 'function' ? opts.onSend : defaultSend;
718
- return Promise.resolve()
719
- .then(function () {
720
- return handler(prompt, {
721
- history: history,
722
- onChunk: function (delta) {
723
- reply.text += delta == null ? '' : String(delta);
724
- paint();
725
- },
726
- });
727
- })
728
- .then(function (res) {
729
- var final = res && typeof res === 'object' ? res.text : res;
730
- if (typeof final === 'string' && final) { reply.text = final; }
731
- }, function (err) {
732
- reply.error = errorMessage(err, DEFAULT_ERROR);
733
- })
734
- .then(function () {
735
- reply.streaming = false;
736
- busy = false;
737
- paint();
738
- return reply.error ? null : reply.text;
750
+ var handler = typeof opts.onSend === "function" ? opts.onSend : defaultSend;
751
+ controller = typeof AbortController === "function" ? new AbortController() : null;
752
+ var signal = controller ? controller.signal : void 0;
753
+ return Promise.resolve().then(function() {
754
+ return handler(prompt, {
755
+ history,
756
+ signal,
757
+ onChunk: function(delta) {
758
+ reply.text += delta == null ? "" : String(delta);
759
+ paint();
760
+ }
739
761
  });
762
+ }).then(function(res) {
763
+ var final = res && typeof res === "object" ? res.text : res;
764
+ if (typeof final === "string" && final) {
765
+ reply.text = final;
766
+ }
767
+ }, function(err) {
768
+ if (err && err.name === "AbortError" || signal && signal.aborted) {
769
+ reply.stopped = true;
770
+ } else {
771
+ reply.error = errorMessage(err, DEFAULT_ERROR);
772
+ }
773
+ }).then(function() {
774
+ reply.streaming = false;
775
+ busy = false;
776
+ controller = null;
777
+ paint();
778
+ return reply.error || reply.stopped ? null : reply.text;
779
+ });
740
780
  }
741
-
742
- bind(el, 'submit', function (ev) {
781
+ bind(el, "submit", function(ev) {
743
782
  ev.preventDefault();
744
783
  send();
745
784
  });
746
- bind(el, 'keydown', function (ev) {
785
+ bind(el, "keydown", function(ev) {
747
786
  var t = ev.target;
748
- if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing && t === input) {
787
+ if (ev.key === "Enter" && !ev.shiftKey && !ev.isComposing && t === input) {
749
788
  ev.preventDefault();
750
789
  send();
751
790
  }
752
791
  });
753
792
  paint();
754
-
755
793
  return {
756
- send: send,
757
- clear: function () {
794
+ send,
795
+ /** Stops the reply in progress (aborts the signal passed to onSend / ask). */
796
+ cancel: function() {
797
+ if (!controller) {
798
+ return false;
799
+ }
800
+ controller.abort();
801
+ return true;
802
+ },
803
+ clear: function() {
758
804
  messages = [];
759
805
  paint();
760
806
  },
761
- messages: function () {
762
- return messages.map(function (m) {
807
+ messages: function() {
808
+ return messages.map(function(m) {
763
809
  return { role: m.role, text: m.text, error: m.error };
764
810
  });
765
- },
811
+ }
766
812
  };
767
813
  },
768
-
769
814
  /**
770
815
  * Shows (default) or hides a fixed glow around the page edges, e.g. while
771
816
  * the Assistant works. Returns the element.
772
817
  */
773
- edgeGlow: function (show) {
774
- if (show === false) {
775
- if (edgeEl) { edgeEl.hidden = true; }
818
+ edgeGlow: function(show2) {
819
+ if (show2 === false) {
820
+ if (edgeEl) {
821
+ edgeEl.hidden = true;
822
+ }
776
823
  return edgeEl;
777
824
  }
778
825
  if (!edgeEl) {
779
- edgeEl = document.createElement('div');
780
- edgeEl.className = 'mp-ai-edge-glow';
781
- edgeEl.setAttribute('aria-hidden', 'true');
782
- document.body.appendChild(edgeEl);
826
+ edgeEl = doc().createElement("div");
827
+ edgeEl.className = "mp-ai-edge-glow";
828
+ edgeEl.setAttribute("aria-hidden", "true");
829
+ doc().body.appendChild(edgeEl);
783
830
  }
784
831
  edgeEl.hidden = false;
785
832
  return edgeEl;
786
- },
787
-
833
+ }
788
834
  };
789
835
 
790
- MpKit.ai = ai;
836
+ // src/js/theme.js
837
+ var THEME_STORAGE_KEY = "mp-kit-theme";
838
+ var QUERY = "(prefers-color-scheme: dark)";
839
+ function normalizeTheme(value) {
840
+ if (typeof value !== "string") {
841
+ return null;
842
+ }
843
+ const v = value.trim().toLowerCase();
844
+ if (v === "dark" || v === "dark-mode") {
845
+ return "dark";
846
+ }
847
+ if (v === "light" || v === "light-mode") {
848
+ return "light";
849
+ }
850
+ if (v === "auto" || v === "system") {
851
+ return "auto";
852
+ }
853
+ return null;
854
+ }
855
+ function resolveTheme(preference, systemDark) {
856
+ return preference === "dark" || preference !== "light" && !!systemDark ? "dark" : "light";
857
+ }
858
+ function themeFromSettings(settings) {
859
+ if (!settings || typeof settings !== "object") {
860
+ return null;
861
+ }
862
+ return normalizeTheme(settings.colorScheme) || normalizeTheme(settings.theme) || normalizeTheme(settings.lightingMode);
863
+ }
864
+ function mediaQuery() {
865
+ try {
866
+ return typeof root.matchMedia === "function" ? root.matchMedia(QUERY) : null;
867
+ } catch {
868
+ return null;
869
+ }
870
+ }
871
+ function store(key, value) {
872
+ if (!key) {
873
+ return;
874
+ }
875
+ try {
876
+ root.localStorage.setItem(key, value);
877
+ } catch {
878
+ }
879
+ }
880
+ function stored(key) {
881
+ if (!key) {
882
+ return null;
883
+ }
884
+ try {
885
+ return normalizeTheme(root.localStorage.getItem(key));
886
+ } catch {
887
+ return null;
888
+ }
889
+ }
890
+ async function homebridgeTheme() {
891
+ const hb = hbClient();
892
+ if (!hb) {
893
+ return null;
894
+ }
895
+ if (typeof hb.getUserSettings === "function") {
896
+ try {
897
+ const pref = themeFromSettings(await hb.getUserSettings());
898
+ if (pref) {
899
+ return pref;
900
+ }
901
+ } catch {
902
+ }
903
+ }
904
+ if (typeof hb.userCurrentLightingMode === "function") {
905
+ try {
906
+ const pref = normalizeTheme(await hb.userCurrentLightingMode());
907
+ if (pref) {
908
+ return pref;
909
+ }
910
+ } catch {
911
+ }
912
+ }
913
+ return null;
914
+ }
915
+ function applyTheme(resolved, opts) {
916
+ opts = opts || {};
917
+ const d = doc();
918
+ const target = opts.target || d && d.documentElement;
919
+ if (!target) {
920
+ return;
921
+ }
922
+ const dark = resolved === "dark";
923
+ if (opts.attribute !== false) {
924
+ target.setAttribute("data-bs-theme", dark ? "dark" : "light");
925
+ }
926
+ if (opts.className !== false) {
927
+ target.classList.toggle("mp-theme-dark", dark);
928
+ }
929
+ }
930
+ var Theme = {
931
+ /**
932
+ * Applies the theme now (stored/system preference), then the Homebridge
933
+ * user's setting once it arrives, and follows system changes while the
934
+ * preference is 'auto'.
935
+ * opts: { target = <html>, theme ('light'|'dark'|'auto': skip Homebridge),
936
+ * homebridge = true, storageKey = 'mp-kit-theme' (false: none),
937
+ * attribute = true, className = true, onChange(resolved, preference) }
938
+ */
939
+ init(opts) {
940
+ opts = opts || {};
941
+ const key = opts.storageKey === void 0 ? THEME_STORAGE_KEY : opts.storageKey;
942
+ const explicit = normalizeTheme(opts.theme);
943
+ let preference = explicit || stored(key) || "auto";
944
+ let current = null;
945
+ let pinned = !!explicit;
946
+ let destroyed = false;
947
+ const media = mediaQuery();
948
+ function update() {
949
+ const resolved = resolveTheme(preference, media && media.matches);
950
+ applyTheme(resolved, opts);
951
+ store(key, preference);
952
+ if (resolved !== current) {
953
+ const first = current === null;
954
+ current = resolved;
955
+ if (!first && typeof opts.onChange === "function") {
956
+ opts.onChange(resolved, preference);
957
+ }
958
+ }
959
+ }
960
+ function onMedia() {
961
+ if (!destroyed && preference === "auto") {
962
+ update();
963
+ }
964
+ }
965
+ if (media) {
966
+ if (typeof media.addEventListener === "function") {
967
+ media.addEventListener("change", onMedia);
968
+ } else if (typeof media.addListener === "function") {
969
+ media.addListener(onMedia);
970
+ }
971
+ }
972
+ update();
973
+ const controller = {
974
+ /** Resolves with the resolved theme once the Homebridge setting was applied. */
975
+ ready: null,
976
+ preference: () => preference,
977
+ resolved: () => current,
978
+ /** Overrides the preference ('light' | 'dark' | 'auto'). */
979
+ set(mode) {
980
+ preference = normalizeTheme(mode) || "auto";
981
+ pinned = true;
982
+ update();
983
+ return current;
984
+ },
985
+ destroy() {
986
+ destroyed = true;
987
+ if (!media) {
988
+ return;
989
+ }
990
+ if (typeof media.removeEventListener === "function") {
991
+ media.removeEventListener("change", onMedia);
992
+ } else if (typeof media.removeListener === "function") {
993
+ media.removeListener(onMedia);
994
+ }
995
+ }
996
+ };
997
+ controller.ready = (pinned || opts.homebridge === false ? Promise.resolve(null) : homebridgeTheme()).then((pref) => {
998
+ if (pref && !pinned && !destroyed) {
999
+ preference = pref;
1000
+ update();
1001
+ }
1002
+ return current;
1003
+ });
1004
+ return controller;
1005
+ },
1006
+ apply: applyTheme,
1007
+ resolve: resolveTheme,
1008
+ normalize: normalizeTheme,
1009
+ fromSettings: themeFromSettings
1010
+ };
791
1011
 
792
- global.MpKit = MpKit;
1012
+ // src/js/tabs.js
1013
+ function panelId(tab) {
1014
+ const ref = tab.getAttribute("data-mp-view") || tab.getAttribute("aria-controls") || tab.getAttribute("data-bs-target") || tab.getAttribute("href") || "";
1015
+ const id = ref.charAt(0) === "#" ? ref.slice(1) : ref;
1016
+ return /^[A-Za-z][\w:.-]*$/.test(id) ? id : null;
1017
+ }
1018
+ function isRtl(el) {
1019
+ const holder = el.closest("[dir]");
1020
+ if (holder) {
1021
+ return holder.getAttribute("dir").toLowerCase() === "rtl";
1022
+ }
1023
+ try {
1024
+ const view = el.ownerDocument.defaultView;
1025
+ return view.getComputedStyle(el).direction === "rtl";
1026
+ } catch {
1027
+ return false;
1028
+ }
1029
+ }
1030
+ var Tabs = {
1031
+ /**
1032
+ * Enhances a tab bar. el: .mp-tabs element or selector.
1033
+ * opts: { activation: 'auto' | 'manual', onChange(tab, index, panel) }
1034
+ * Tabs reference their panel with data-mp-view (an .mp-view id, shown with
1035
+ * MpKit.View.show), aria-controls, data-bs-target or href="#id".
1036
+ * Returns { select(indexOrTab), selected(), tabs(), destroy() }.
1037
+ */
1038
+ init(el, opts) {
1039
+ opts = opts || {};
1040
+ const root2 = typeof el === "string" ? doc().querySelector(el) : el;
1041
+ if (!root2) {
1042
+ return null;
1043
+ }
1044
+ const tabs = Array.from(root2.querySelectorAll('.nav-link, [role="tab"]'));
1045
+ const vertical = root2.getAttribute("aria-orientation") === "vertical";
1046
+ root2.setAttribute("role", "tablist");
1047
+ tabs.forEach((tab) => {
1048
+ if (tab.parentElement !== root2 && tab.parentElement.tagName === "LI") {
1049
+ tab.parentElement.setAttribute("role", "presentation");
1050
+ }
1051
+ tab.setAttribute("role", "tab");
1052
+ if (!tab.id) {
1053
+ tab.id = uid("mp-tab");
1054
+ }
1055
+ const id = panelId(tab);
1056
+ const panel = id && root2.ownerDocument.getElementById(id);
1057
+ if (panel) {
1058
+ tab.setAttribute("aria-controls", id);
1059
+ panel.setAttribute("role", "tabpanel");
1060
+ if (!panel.hasAttribute("aria-labelledby")) {
1061
+ panel.setAttribute("aria-labelledby", tab.id);
1062
+ }
1063
+ }
1064
+ });
1065
+ let current = -1;
1066
+ function panelOf(tab) {
1067
+ const id = tab.getAttribute("aria-controls");
1068
+ return id ? root2.ownerDocument.getElementById(id) : null;
1069
+ }
1070
+ function select(target, fromUser) {
1071
+ const index = typeof target === "number" ? target : tabs.indexOf(target);
1072
+ if (index < 0 || index >= tabs.length) {
1073
+ return;
1074
+ }
1075
+ const changed = index !== current;
1076
+ tabs.forEach((tab, i) => {
1077
+ const on = i === index;
1078
+ tab.setAttribute("aria-selected", on ? "true" : "false");
1079
+ tab.setAttribute("tabindex", on ? "0" : "-1");
1080
+ tab.classList.toggle("active", on);
1081
+ const panel2 = panelOf(tab);
1082
+ if (!panel2 || panel2.classList.contains("mp-view")) {
1083
+ return;
1084
+ }
1085
+ if (panel2.classList.contains("tab-pane")) {
1086
+ panel2.classList.toggle("active", on);
1087
+ panel2.classList.toggle("show", on);
1088
+ } else {
1089
+ panel2.hidden = !on;
1090
+ }
1091
+ });
1092
+ const panel = panelOf(tabs[index]);
1093
+ if (panel && panel.classList.contains("mp-view")) {
1094
+ View.show(panel.id, { focus: false, announce: false });
1095
+ }
1096
+ current = index;
1097
+ if (changed && fromUser && typeof opts.onChange === "function") {
1098
+ opts.onChange(tabs[index], index, panel);
1099
+ }
1100
+ }
1101
+ function onClick2(ev) {
1102
+ const tab = ev.target.closest('[role="tab"]');
1103
+ if (!tab || !root2.contains(tab)) {
1104
+ return;
1105
+ }
1106
+ if (tab.tagName === "A") {
1107
+ ev.preventDefault();
1108
+ }
1109
+ select(tab, true);
1110
+ }
1111
+ function onKeydown2(ev) {
1112
+ const tab = ev.target.closest('[role="tab"]');
1113
+ if (!tab || !root2.contains(tab)) {
1114
+ return;
1115
+ }
1116
+ const enabled = tabs.filter((t) => !t.disabled && t.getAttribute("aria-disabled") !== "true");
1117
+ const pos = enabled.indexOf(tab);
1118
+ const rtl = !vertical && isRtl(root2);
1119
+ const next = vertical ? "ArrowDown" : rtl ? "ArrowLeft" : "ArrowRight";
1120
+ const prev = vertical ? "ArrowUp" : rtl ? "ArrowRight" : "ArrowLeft";
1121
+ let to = null;
1122
+ if (ev.key === next) {
1123
+ to = enabled[(pos + 1) % enabled.length];
1124
+ } else if (ev.key === prev) {
1125
+ to = enabled[(pos - 1 + enabled.length) % enabled.length];
1126
+ } else if (ev.key === "Home") {
1127
+ to = enabled[0];
1128
+ } else if (ev.key === "End") {
1129
+ to = enabled[enabled.length - 1];
1130
+ } else if ((ev.key === "Enter" || ev.key === " ") && opts.activation === "manual") {
1131
+ ev.preventDefault();
1132
+ select(tab, true);
1133
+ return;
1134
+ }
1135
+ if (!to) {
1136
+ return;
1137
+ }
1138
+ ev.preventDefault();
1139
+ if (opts.activation === "manual") {
1140
+ tabs.forEach((t) => t.setAttribute("tabindex", t === to ? "0" : "-1"));
1141
+ } else {
1142
+ select(to, true);
1143
+ }
1144
+ to.focus();
1145
+ }
1146
+ root2.addEventListener("click", onClick2);
1147
+ root2.addEventListener("keydown", onKeydown2);
1148
+ const initial = tabs.findIndex((t) => t.classList.contains("active") || t.getAttribute("aria-selected") === "true");
1149
+ select(initial < 0 ? 0 : initial, false);
1150
+ return {
1151
+ select: (target) => select(target, true),
1152
+ selected: () => current,
1153
+ tabs: () => tabs.slice(),
1154
+ destroy() {
1155
+ root2.removeEventListener("click", onClick2);
1156
+ root2.removeEventListener("keydown", onKeydown2);
1157
+ }
1158
+ };
1159
+ }
1160
+ };
1161
+
1162
+ // src/js/toast.js
1163
+ var TYPES = ["success", "error", "warning", "info"];
1164
+ var DURATION = { success: 5e3, info: 5e3, warning: 7e3, error: 8e3 };
1165
+ var ICON = { success: "✓", info: "i", warning: "!", error: "✕" };
1166
+ var container = null;
1167
+ function ensureContainer(d) {
1168
+ if (!container || !container.isConnected) {
1169
+ container = d.createElement("div");
1170
+ container.className = "mp-toast-container";
1171
+ d.body.appendChild(container);
1172
+ }
1173
+ return container;
1174
+ }
1175
+ function show(type, message, opts) {
1176
+ if (TYPES.indexOf(type) < 0) {
1177
+ type = "info";
1178
+ }
1179
+ opts = typeof opts === "string" ? { title: opts } : opts || {};
1180
+ const text = String(message == null ? "" : message);
1181
+ const hb = hbClient();
1182
+ if (!opts.local && hb && hb.toast && typeof hb.toast[type] === "function") {
1183
+ hb.toast[type](text, opts.title);
1184
+ return { close() {
1185
+ }, element: null, native: true };
1186
+ }
1187
+ const d = doc();
1188
+ if (!d || !d.body) {
1189
+ return { close() {
1190
+ }, element: null, native: false };
1191
+ }
1192
+ const el = d.createElement("div");
1193
+ el.className = "mp-toast mp-toast-" + type;
1194
+ el.setAttribute("role", type === "error" || type === "warning" ? "alert" : "status");
1195
+ el.setAttribute("aria-atomic", "true");
1196
+ el.innerHTML = '<span class="mp-toast-icon" aria-hidden="true">' + ICON[type] + '</span><div class="mp-toast-body">' + (opts.title ? '<p class="mp-toast-title">' + escapeHtml(opts.title) + "</p>" : "") + '<p class="mp-toast-message">' + escapeHtml(text) + '</p></div><button type="button" class="mp-toast-close" aria-label="Close">×</button>';
1197
+ ensureContainer(d).appendChild(el);
1198
+ let timer = null;
1199
+ let closed = false;
1200
+ const duration = opts.duration === void 0 ? DURATION[type] : Number(opts.duration) || 0;
1201
+ function close() {
1202
+ if (closed) {
1203
+ return;
1204
+ }
1205
+ closed = true;
1206
+ clearTimeout(timer);
1207
+ el.remove();
1208
+ }
1209
+ function arm() {
1210
+ clearTimeout(timer);
1211
+ if (duration > 0) {
1212
+ timer = setTimeout(close, duration);
1213
+ }
1214
+ }
1215
+ el.addEventListener("mouseenter", () => clearTimeout(timer));
1216
+ el.addEventListener("mouseleave", arm);
1217
+ el.addEventListener("focusin", () => clearTimeout(timer));
1218
+ el.addEventListener("focusout", arm);
1219
+ el.querySelector(".mp-toast-close").addEventListener("click", close);
1220
+ arm();
1221
+ return { close, element: el, native: false };
1222
+ }
1223
+ var Toast = {
1224
+ show,
1225
+ success: (message, opts) => show("success", message, opts),
1226
+ error: (message, opts) => show("error", message, opts),
1227
+ warning: (message, opts) => show("warning", message, opts),
1228
+ info: (message, opts) => show("info", message, opts)
1229
+ };
1230
+
1231
+ // src/js/dialog.js
1232
+ function confirm(opts) {
1233
+ opts = typeof opts === "string" ? { message: opts } : opts || {};
1234
+ const d = doc();
1235
+ if (!d || !d.body) {
1236
+ return Promise.resolve(false);
1237
+ }
1238
+ const titleId = uid("mp-dialog-title");
1239
+ const messageId = uid("mp-dialog-message");
1240
+ const danger = !!opts.danger;
1241
+ const backdrop = d.createElement("div");
1242
+ backdrop.className = "mp-dialog-backdrop";
1243
+ backdrop.innerHTML = '<div class="mp-dialog' + (danger ? " mp-dialog-danger" : "") + '" role="alertdialog" aria-modal="true" aria-labelledby="' + titleId + '"' + (opts.message ? ' aria-describedby="' + messageId + '"' : "") + '><h2 class="mp-dialog-title" id="' + titleId + '">' + escapeHtml(opts.title || "Are you sure?") + "</h2>" + (opts.message ? '<p class="mp-dialog-message" id="' + messageId + '">' + escapeHtml(opts.message) + "</p>" : "") + '<div class="mp-dialog-actions"><button type="button" class="btn btn-outline-secondary" data-mp-dialog="cancel">' + escapeHtml(opts.cancelLabel || "Cancel") + '</button><button type="button" class="btn ' + (danger ? "btn-danger" : "btn-primary") + '" data-mp-dialog="confirm">' + escapeHtml(opts.confirmLabel || "Confirm") + "</button></div></div>";
1244
+ const dialog = backdrop.firstChild;
1245
+ const previous = d.activeElement;
1246
+ const inerted = Array.from(d.body.children).filter((n) => !n.hasAttribute("inert") && !n.hasAttribute("data-mp-live") && !n.classList.contains("mp-toast-container"));
1247
+ inerted.forEach((n) => n.setAttribute("inert", ""));
1248
+ d.body.appendChild(backdrop);
1249
+ d.body.classList.add("mp-dialog-open");
1250
+ return new Promise((resolve) => {
1251
+ function finish(result) {
1252
+ d.removeEventListener("keydown", onKey, true);
1253
+ backdrop.remove();
1254
+ inerted.forEach((n) => n.removeAttribute("inert"));
1255
+ if (!d.querySelector(".mp-dialog-backdrop")) {
1256
+ d.body.classList.remove("mp-dialog-open");
1257
+ }
1258
+ if (previous && typeof previous.focus === "function" && previous.isConnected) {
1259
+ try {
1260
+ previous.focus({ preventScroll: true });
1261
+ } catch {
1262
+ previous.focus();
1263
+ }
1264
+ }
1265
+ resolve(result);
1266
+ }
1267
+ function onKey(ev) {
1268
+ if (ev.key === "Escape") {
1269
+ ev.preventDefault();
1270
+ ev.stopPropagation();
1271
+ finish(false);
1272
+ } else if (ev.key === "Tab") {
1273
+ const items = focusables(dialog);
1274
+ if (!items.length) {
1275
+ return;
1276
+ }
1277
+ const first = items[0];
1278
+ const last = items[items.length - 1];
1279
+ const active = d.activeElement;
1280
+ if (ev.shiftKey && (active === first || !dialog.contains(active))) {
1281
+ ev.preventDefault();
1282
+ last.focus();
1283
+ } else if (!ev.shiftKey && (active === last || !dialog.contains(active))) {
1284
+ ev.preventDefault();
1285
+ first.focus();
1286
+ }
1287
+ }
1288
+ }
1289
+ backdrop.addEventListener("click", (ev) => {
1290
+ const action = ev.target.closest && ev.target.closest("[data-mp-dialog]");
1291
+ if (action) {
1292
+ finish(action.getAttribute("data-mp-dialog") === "confirm");
1293
+ } else if (ev.target === backdrop) {
1294
+ finish(false);
1295
+ }
1296
+ });
1297
+ d.addEventListener("keydown", onKey, true);
1298
+ dialog.querySelector('[data-mp-dialog="' + (danger ? "cancel" : "confirm") + '"]').focus();
1299
+ });
1300
+ }
1301
+
1302
+ // src/js/form.js
1303
+ function copyText(text) {
1304
+ const value = String(text == null ? "" : text);
1305
+ const nav = root.navigator;
1306
+ if (nav && nav.clipboard && typeof nav.clipboard.writeText === "function") {
1307
+ return nav.clipboard.writeText(value).then(() => true, () => legacyCopy(value));
1308
+ }
1309
+ return Promise.resolve(legacyCopy(value));
1310
+ }
1311
+ function legacyCopy(value) {
1312
+ const d = doc();
1313
+ if (!d || !d.body || typeof d.execCommand !== "function") {
1314
+ return false;
1315
+ }
1316
+ const area = d.createElement("textarea");
1317
+ area.value = value;
1318
+ area.setAttribute("readonly", "");
1319
+ area.className = "mp-sr-only";
1320
+ d.body.appendChild(area);
1321
+ area.select();
1322
+ let ok = false;
1323
+ try {
1324
+ ok = d.execCommand("copy");
1325
+ } catch {
1326
+ ok = false;
1327
+ }
1328
+ area.remove();
1329
+ return !!ok;
1330
+ }
1331
+ function attrs(map) {
1332
+ return Object.keys(map).map((k) => {
1333
+ const v = map[k];
1334
+ if (v === void 0 || v === null || v === false) {
1335
+ return "";
1336
+ }
1337
+ return v === true ? " " + k : " " + k + '="' + escapeHtml(v) + '"';
1338
+ }).join("");
1339
+ }
1340
+ var CopyButton = {
1341
+ /** opts: { text, target (selector), label = 'Copy', copiedLabel = 'Copied', className, size: 'sm' } */
1342
+ render(opts) {
1343
+ opts = opts || {};
1344
+ const cls = "btn btn-outline-secondary mp-copy-btn" + (opts.size === "sm" ? " btn-sm" : "") + (opts.className ? " " + opts.className : "");
1345
+ return '<button type="button"' + attrs({
1346
+ class: cls,
1347
+ "data-mp-copy": opts.target ? void 0 : String(opts.text == null ? "" : opts.text),
1348
+ "data-mp-copy-target": opts.target,
1349
+ "data-mp-copied-label": opts.copiedLabel || "Copied",
1350
+ "aria-label": opts.ariaLabel
1351
+ }) + ">" + escapeHtml(opts.label || "Copy") + "</button>";
1352
+ }
1353
+ };
1354
+ function copyFromButton(btn) {
1355
+ let text = btn.getAttribute("data-mp-copy");
1356
+ const targetSel = btn.getAttribute("data-mp-copy-target");
1357
+ if (targetSel) {
1358
+ const target = toElement(targetSel);
1359
+ if (!target) {
1360
+ return Promise.resolve(false);
1361
+ }
1362
+ text = "value" in target && target.tagName !== "BUTTON" ? target.value : target.textContent;
1363
+ }
1364
+ return copyText(text).then((ok) => {
1365
+ const label = btn.getAttribute("data-mp-copied-label") || "Copied";
1366
+ if (!btn.__mpLabel) {
1367
+ btn.__mpLabel = btn.textContent;
1368
+ }
1369
+ btn.textContent = ok ? label : "Copy failed";
1370
+ btn.classList.toggle("is-copied", ok);
1371
+ announce(ok ? "Copied to clipboard" : "Could not copy");
1372
+ clearTimeout(btn.__mpCopyTimer);
1373
+ btn.__mpCopyTimer = setTimeout(() => {
1374
+ btn.textContent = btn.__mpLabel;
1375
+ btn.classList.remove("is-copied");
1376
+ }, 2e3);
1377
+ return ok;
1378
+ });
1379
+ }
1380
+ function toggleReveal(btn) {
1381
+ const input = doc().getElementById(btn.getAttribute("data-mp-reveal"));
1382
+ if (!input) {
1383
+ return;
1384
+ }
1385
+ const show2 = input.type === "password";
1386
+ input.type = show2 ? "text" : "password";
1387
+ btn.setAttribute("aria-pressed", show2 ? "true" : "false");
1388
+ btn.textContent = show2 ? btn.getAttribute("data-mp-hide-label") || "Hide" : btn.getAttribute("data-mp-show-label") || "Show";
1389
+ }
1390
+ function normaliseOptions(options) {
1391
+ return (options || []).map((o) => o && typeof o === "object" ? o : { value: o, label: String(o) });
1392
+ }
1393
+ function fieldParts(opts, kind) {
1394
+ const id = opts.id || uid("mp-field");
1395
+ const helpId = opts.help ? id + "-help" : null;
1396
+ const errorId = opts.error ? id + "-error" : null;
1397
+ const describedBy = [helpId, errorId].filter(Boolean).join(" ") || void 0;
1398
+ const label = '<label class="' + (kind === "check" ? "form-check-label" : "form-label") + '" for="' + escapeHtml(id) + '">' + escapeHtml(opts.label || opts.name || "") + (opts.required ? '<span class="mp-required" aria-hidden="true"> *</span>' : "") + "</label>";
1399
+ const help = opts.help ? '<div class="form-text" id="' + escapeHtml(helpId) + '">' + escapeHtml(opts.help) + "</div>" : "";
1400
+ const error = opts.error ? '<div class="invalid-feedback d-block" id="' + escapeHtml(errorId) + '">' + escapeHtml(opts.error) + "</div>" : "";
1401
+ return { id, describedBy, label, help, error };
1402
+ }
1403
+ function field(opts) {
1404
+ opts = opts || {};
1405
+ const type = opts.type || "text";
1406
+ const check = type === "checkbox" || type === "switch";
1407
+ const p = fieldParts(opts, check ? "check" : "field");
1408
+ const common = {
1409
+ id: p.id,
1410
+ name: opts.name,
1411
+ required: !!opts.required,
1412
+ disabled: !!opts.disabled,
1413
+ "aria-describedby": p.describedBy,
1414
+ "aria-invalid": opts.error ? "true" : void 0
1415
+ };
1416
+ const invalid = opts.error ? " is-invalid" : "";
1417
+ if (check) {
1418
+ return '<div class="form-check' + (type === "switch" ? " form-switch" : "") + ' mb-3 mp-field"><input' + attrs(Object.assign({
1419
+ class: "form-check-input" + invalid,
1420
+ type: "checkbox",
1421
+ role: type === "switch" ? "switch" : void 0,
1422
+ checked: !!(opts.checked !== void 0 ? opts.checked : opts.value)
1423
+ }, common)) + ">" + p.label + p.help + p.error + "</div>";
1424
+ }
1425
+ let control;
1426
+ if (type === "select") {
1427
+ control = "<select" + attrs(Object.assign({ class: "form-select" + invalid, multiple: !!opts.multiple }, common)) + ">" + normaliseOptions(opts.options).map((o) => {
1428
+ const selected = Array.isArray(opts.value) ? opts.value.map(String).indexOf(String(o.value)) >= 0 : String(opts.value) === String(o.value);
1429
+ return "<option" + attrs({ value: String(o.value), selected, disabled: !!o.disabled }) + ">" + escapeHtml(o.label) + "</option>";
1430
+ }).join("") + "</select>";
1431
+ } else if (type === "textarea") {
1432
+ control = "<textarea" + attrs(Object.assign({
1433
+ class: "form-control" + invalid,
1434
+ rows: opts.rows || 3,
1435
+ placeholder: opts.placeholder,
1436
+ readonly: !!opts.readonly
1437
+ }, common)) + ">" + escapeHtml(opts.value == null ? "" : opts.value) + "</textarea>";
1438
+ } else {
1439
+ control = "<input" + attrs(Object.assign({
1440
+ class: "form-control" + invalid,
1441
+ type,
1442
+ value: opts.value == null ? void 0 : String(opts.value),
1443
+ placeholder: opts.placeholder,
1444
+ readonly: !!opts.readonly,
1445
+ min: opts.min,
1446
+ max: opts.max,
1447
+ step: opts.step,
1448
+ pattern: opts.pattern,
1449
+ autocomplete: opts.autocomplete,
1450
+ inputmode: opts.inputmode
1451
+ }, common)) + ">";
1452
+ }
1453
+ return '<div class="mb-3 mp-field">' + p.label + control + p.help + p.error + "</div>";
1454
+ }
1455
+ function secret(opts) {
1456
+ opts = opts || {};
1457
+ const p = fieldParts(opts, "field");
1458
+ const input = "<input" + attrs({
1459
+ class: "form-control mp-secret-input" + (opts.error ? " is-invalid" : ""),
1460
+ type: "password",
1461
+ id: p.id,
1462
+ name: opts.name,
1463
+ value: opts.value == null ? void 0 : String(opts.value),
1464
+ placeholder: opts.placeholder,
1465
+ required: !!opts.required,
1466
+ disabled: !!opts.disabled,
1467
+ readonly: !!opts.readonly,
1468
+ autocomplete: opts.autocomplete || "off",
1469
+ spellcheck: "false",
1470
+ autocapitalize: "off",
1471
+ "aria-describedby": p.describedBy,
1472
+ "aria-invalid": opts.error ? "true" : void 0
1473
+ }) + ">";
1474
+ const label = escapeHtml(opts.label || opts.name || "value");
1475
+ const reveal = opts.reveal === false ? "" : '<button type="button" class="btn btn-outline-secondary mp-reveal-btn" data-mp-reveal="' + escapeHtml(p.id) + '" aria-controls="' + escapeHtml(p.id) + '" aria-pressed="false" aria-label="Show ' + label + '">Show</button>';
1476
+ const copy = opts.copy === false ? "" : CopyButton.render({ target: "#" + p.id, ariaLabel: "Copy " + label });
1477
+ return '<div class="mb-3 mp-field">' + p.label + '<div class="input-group' + (opts.error ? " has-validation" : "") + '">' + input + reveal + copy + "</div>" + p.help + p.error + "</div>";
1478
+ }
1479
+ function setPath(obj, path, value) {
1480
+ const keys = path.split(".");
1481
+ let node = obj;
1482
+ keys.slice(0, -1).forEach((k) => {
1483
+ if (!node[k] || typeof node[k] !== "object") {
1484
+ node[k] = {};
1485
+ }
1486
+ node = node[k];
1487
+ });
1488
+ node[keys[keys.length - 1]] = value;
1489
+ }
1490
+ function getPath(obj, path) {
1491
+ return path.split(".").reduce((node, k) => node == null ? void 0 : node[k], obj);
1492
+ }
1493
+ function controls(form) {
1494
+ return Array.from(form.querySelectorAll("input[name], select[name], textarea[name]")).filter((el) => !el.disabled && !el.hasAttribute("data-mp-ignore") && ["button", "submit", "reset", "file", "image"].indexOf(el.type) < 0);
1495
+ }
1496
+ function values(form) {
1497
+ form = toElement(form);
1498
+ const out = {};
1499
+ const els = controls(form);
1500
+ const counts = {};
1501
+ els.forEach((el) => {
1502
+ if (el.type === "checkbox") {
1503
+ counts[el.name] = (counts[el.name] || 0) + 1;
1504
+ }
1505
+ });
1506
+ els.forEach((el) => {
1507
+ const name = el.name;
1508
+ if (el.type === "checkbox") {
1509
+ if (counts[name] > 1) {
1510
+ const list = getPath(out, name) || [];
1511
+ if (el.checked) {
1512
+ list.push(el.value);
1513
+ }
1514
+ setPath(out, name, list);
1515
+ } else {
1516
+ setPath(out, name, el.checked);
1517
+ }
1518
+ } else if (el.type === "radio") {
1519
+ if (el.checked) {
1520
+ setPath(out, name, el.value);
1521
+ } else if (getPath(out, name) === void 0) {
1522
+ setPath(out, name, null);
1523
+ }
1524
+ } else if (el.type === "number" || el.type === "range") {
1525
+ setPath(out, name, el.value === "" ? null : Number(el.value));
1526
+ } else if (el.tagName === "SELECT" && el.multiple) {
1527
+ setPath(out, name, Array.from(el.selectedOptions).map((o) => o.value));
1528
+ } else {
1529
+ setPath(out, name, el.value);
1530
+ }
1531
+ });
1532
+ return out;
1533
+ }
1534
+ function fill(form, data) {
1535
+ form = toElement(form);
1536
+ data = data || {};
1537
+ controls(form).forEach((el) => {
1538
+ const v = getPath(data, el.name);
1539
+ if (v === void 0) {
1540
+ return;
1541
+ }
1542
+ if (el.type === "checkbox") {
1543
+ el.checked = Array.isArray(v) ? v.map(String).indexOf(el.value) >= 0 : !!v;
1544
+ } else if (el.type === "radio") {
1545
+ el.checked = String(v) === el.value;
1546
+ } else if (el.tagName === "SELECT" && el.multiple) {
1547
+ const list = (Array.isArray(v) ? v : [v]).map(String);
1548
+ Array.from(el.options).forEach((o) => {
1549
+ o.selected = list.indexOf(o.value) >= 0;
1550
+ });
1551
+ } else {
1552
+ el.value = v == null ? "" : String(v);
1553
+ }
1554
+ });
1555
+ return form;
1556
+ }
1557
+ function isPlainObject(v) {
1558
+ return !!v && typeof v === "object" && !Array.isArray(v);
1559
+ }
1560
+ function mergeConfig(base, patch) {
1561
+ const out = Object.assign({}, base);
1562
+ Object.keys(patch || {}).forEach((k) => {
1563
+ out[k] = isPlainObject(out[k]) && isPlainObject(patch[k]) ? mergeConfig(out[k], patch[k]) : patch[k];
1564
+ });
1565
+ return out;
1566
+ }
1567
+ function track(form, opts) {
1568
+ opts = opts || {};
1569
+ form = toElement(form);
1570
+ const d = form.ownerDocument;
1571
+ let baseline = JSON.stringify(values(form));
1572
+ let baselineValues = values(form);
1573
+ let dirty = false;
1574
+ let busy = false;
1575
+ let bar = null;
1576
+ if (opts.bar !== false) {
1577
+ bar = d.createElement("div");
1578
+ bar.className = "mp-savebar";
1579
+ bar.setAttribute("role", "region");
1580
+ bar.setAttribute("aria-label", "Unsaved changes");
1581
+ bar.hidden = true;
1582
+ bar.innerHTML = '<span class="mp-savebar-message" role="status">' + escapeHtml(opts.message || "You have unsaved changes") + '</span><span class="mp-savebar-actions"><button type="button" class="btn btn-sm btn-outline-secondary" data-mp-save="discard">' + escapeHtml(opts.discardLabel || "Discard") + '</button><button type="button" class="btn btn-sm btn-primary" data-mp-save="save">' + escapeHtml(opts.saveLabel || "Save") + "</button></span>";
1583
+ const host = opts.bar && opts.bar !== true ? toElement(opts.bar) : null;
1584
+ if (host) {
1585
+ host.appendChild(bar);
1586
+ } else {
1587
+ form.insertAdjacentElement("afterend", bar);
1588
+ }
1589
+ }
1590
+ function setDirty(next) {
1591
+ if (next === dirty) {
1592
+ return;
1593
+ }
1594
+ dirty = next;
1595
+ if (bar) {
1596
+ bar.hidden = !dirty;
1597
+ }
1598
+ form.classList.toggle("is-dirty", dirty);
1599
+ if (typeof opts.onDirtyChange === "function") {
1600
+ opts.onDirtyChange(dirty);
1601
+ }
1602
+ }
1603
+ function check() {
1604
+ setDirty(JSON.stringify(values(form)) !== baseline);
1605
+ }
1606
+ function reset() {
1607
+ baselineValues = values(form);
1608
+ baseline = JSON.stringify(baselineValues);
1609
+ setDirty(false);
1610
+ }
1611
+ function setBusy(next) {
1612
+ busy = next;
1613
+ if (!bar) {
1614
+ return;
1615
+ }
1616
+ bar.setAttribute("aria-busy", next ? "true" : "false");
1617
+ bar.querySelectorAll("button").forEach((b) => {
1618
+ b.disabled = next;
1619
+ });
1620
+ }
1621
+ async function defaultSave(vals) {
1622
+ const hb = hbClient();
1623
+ if (!hb || typeof hb.updatePluginConfig !== "function") {
1624
+ return null;
1625
+ }
1626
+ const blocks = typeof hb.getPluginConfig === "function" ? await hb.getPluginConfig() || [] : [];
1627
+ let config = typeof opts.toConfig === "function" ? await opts.toConfig(vals, blocks) : [mergeConfig(blocks[0] || {}, vals)].concat(blocks.slice(1));
1628
+ if (!Array.isArray(config)) {
1629
+ config = [config];
1630
+ }
1631
+ await hb.updatePluginConfig(config);
1632
+ if (opts.persist !== false && typeof hb.savePluginConfig === "function") {
1633
+ await hb.savePluginConfig();
1634
+ }
1635
+ return config;
1636
+ }
1637
+ async function save() {
1638
+ if (busy) {
1639
+ return false;
1640
+ }
1641
+ const vals = values(form);
1642
+ setBusy(true);
1643
+ try {
1644
+ const custom = typeof opts.save === "function";
1645
+ const result = custom ? await opts.save(vals) : await defaultSave(vals);
1646
+ const saved = custom || result !== null;
1647
+ reset();
1648
+ if (saved) {
1649
+ if (opts.toast !== false) {
1650
+ Toast.success(opts.savedMessage || "Settings saved");
1651
+ }
1652
+ if (typeof opts.onSaved === "function") {
1653
+ opts.onSaved(result);
1654
+ }
1655
+ }
1656
+ return saved;
1657
+ } catch (err) {
1658
+ if (opts.toast !== false) {
1659
+ Toast.error(errorMessage(err, "Could not save the settings"));
1660
+ }
1661
+ if (typeof opts.onError === "function") {
1662
+ opts.onError(err);
1663
+ }
1664
+ throw err;
1665
+ } finally {
1666
+ setBusy(false);
1667
+ }
1668
+ }
1669
+ function discard() {
1670
+ fill(form, baselineValues);
1671
+ check();
1672
+ }
1673
+ function onBarClick(ev) {
1674
+ const btn = ev.target.closest("[data-mp-save]");
1675
+ if (!btn) {
1676
+ return;
1677
+ }
1678
+ if (btn.getAttribute("data-mp-save") === "save") {
1679
+ save().catch(() => {
1680
+ });
1681
+ } else {
1682
+ discard();
1683
+ }
1684
+ }
1685
+ function onSubmit(ev) {
1686
+ ev.preventDefault();
1687
+ save().catch(() => {
1688
+ });
1689
+ }
1690
+ form.addEventListener("input", check);
1691
+ form.addEventListener("change", check);
1692
+ if (opts.submit !== false) {
1693
+ form.addEventListener("submit", onSubmit);
1694
+ }
1695
+ if (bar) {
1696
+ bar.addEventListener("click", onBarClick);
1697
+ }
1698
+ return {
1699
+ bar,
1700
+ isDirty: () => dirty,
1701
+ values: () => values(form),
1702
+ save,
1703
+ discard,
1704
+ /** Takes the current values as the new saved state. */
1705
+ reset,
1706
+ /** Re-checks after programmatic changes. */
1707
+ check,
1708
+ destroy() {
1709
+ form.removeEventListener("input", check);
1710
+ form.removeEventListener("change", check);
1711
+ form.removeEventListener("submit", onSubmit);
1712
+ if (bar) {
1713
+ bar.remove();
1714
+ }
1715
+ }
1716
+ };
1717
+ }
1718
+ var Form = { field, secret, values, fill, track };
1719
+
1720
+ // src/js/delegates.js
1721
+ var ACTIVATABLE = '.mp-device-card[role="button"], [data-mp-activate][role="button"]';
1722
+ function onClick(ev) {
1723
+ const t = ev.target;
1724
+ if (!t || typeof t.closest !== "function") {
1725
+ return;
1726
+ }
1727
+ const copy = t.closest("[data-mp-copy], [data-mp-copy-target]");
1728
+ if (copy) {
1729
+ copyFromButton(copy);
1730
+ return;
1731
+ }
1732
+ const reveal = t.closest("[data-mp-reveal]");
1733
+ if (reveal) {
1734
+ toggleReveal(reveal);
1735
+ }
1736
+ }
1737
+ function onKeydown(ev) {
1738
+ if (ev.defaultPrevented || ev.key !== "Enter" && ev.key !== " ") {
1739
+ return;
1740
+ }
1741
+ const t = ev.target;
1742
+ if (!t || typeof t.matches !== "function" || !t.matches(ACTIVATABLE)) {
1743
+ return;
1744
+ }
1745
+ ev.preventDefault();
1746
+ t.click();
1747
+ }
1748
+ function installDelegates() {
1749
+ const d = doc();
1750
+ if (!d || typeof d.addEventListener !== "function" || d.__mpKitDelegates) {
1751
+ return;
1752
+ }
1753
+ d.__mpKitDelegates = true;
1754
+ d.addEventListener("click", onClick);
1755
+ d.addEventListener("keydown", onKeydown);
1756
+ }
1757
+
1758
+ // src/js/devices.js
1759
+ var Skeleton = {
1760
+ /** opts: { lines = 3, avatar = false, className } */
1761
+ render(opts) {
1762
+ opts = opts || {};
1763
+ const lines = Math.max(1, opts.lines || 3);
1764
+ let html = "";
1765
+ for (let i = 0; i < lines; i += 1) {
1766
+ html += '<span class="mp-skeleton mp-skeleton-text"' + (i === lines - 1 && lines > 1 ? ' style="width:60%"' : "") + "></span>";
1767
+ }
1768
+ return '<div class="mp-skeleton-group' + (opts.className ? " " + escapeHtml(opts.className) : "") + '" aria-hidden="true">' + (opts.avatar ? '<span class="mp-skeleton mp-skeleton-circle"></span>' : "") + '<span class="mp-skeleton-lines">' + html + "</span></div>";
1769
+ }
1770
+ };
1771
+ var STATUS = {
1772
+ online: StatusBadge.online,
1773
+ offline: StatusBadge.offline,
1774
+ checking: StatusBadge.checking,
1775
+ disabled: StatusBadge.disabled
1776
+ };
1777
+ function defaultItem(item, index, interactive) {
1778
+ const name = item.name || item.displayName || item.id || "Device " + (index + 1);
1779
+ const status = STATUS[item.status] ? STATUS[item.status](item.statusLabel) : "";
1780
+ return '<div class="card mp-device-card"' + (interactive ? ' role="button" tabindex="0"' : "") + '><div class="card-body mp-device-card-body">' + (item.iconClass ? '<i class="' + escapeHtml(item.iconClass) + ' mp-device-card-icon" aria-hidden="true"></i>' : "") + '<div class="mp-device-card-text"><div class="mp-device-card-name">' + escapeHtml(name) + "</div>" + (item.subtitle ? '<div class="mp-device-card-subtitle">' + escapeHtml(item.subtitle) + "</div>" : "") + "</div>" + status + "</div></div>";
1781
+ }
1782
+ function defaultFilter(keys) {
1783
+ return (item, query) => keys.some((k) => {
1784
+ const v = item && item[k];
1785
+ return v != null && String(v).toLowerCase().indexOf(query) >= 0;
1786
+ });
1787
+ }
1788
+ var DeviceList = {
1789
+ /**
1790
+ * Renders a device list into el and returns a controller.
1791
+ * opts: { items = [], loading = false, label = 'Devices',
1792
+ * renderItem(item, index) → HTML (default: an .mp-device-card with
1793
+ * name/displayName, subtitle, iconClass and status badge),
1794
+ * search = true | { placeholder, label }, keys = ['name', 'subtitle', 'id'],
1795
+ * filter(item, query), onActivate(item, index, event),
1796
+ * empty: { iconClass, title, hint }, noResults: { title, hint },
1797
+ * skeletonCount = 3 }
1798
+ * With onActivate the default cards are role="button" (Enter / Space work).
1799
+ * Returns { setItems(items), setLoading(bool), setQuery(q), items(), visible(), destroy() }.
1800
+ */
1801
+ render(el, opts) {
1802
+ opts = opts || {};
1803
+ el = toElement(el);
1804
+ const label = opts.label || "Devices";
1805
+ const searchOpts = opts.search === false ? null : typeof opts.search === "object" ? opts.search : {};
1806
+ const searchId = uid("mp-device-search");
1807
+ const filter = typeof opts.filter === "function" ? opts.filter : defaultFilter(opts.keys || ["name", "displayName", "subtitle", "id"]);
1808
+ const interactive = typeof opts.onActivate === "function";
1809
+ let items = Array.isArray(opts.items) ? opts.items.slice() : [];
1810
+ let loading = !!opts.loading;
1811
+ let query = "";
1812
+ let visible = [];
1813
+ el.innerHTML = '<div class="mp-device-list">' + (searchOpts ? '<div class="mp-device-list-search"><label class="mp-sr-only" for="' + searchId + '">' + escapeHtml(searchOpts.label || "Search " + label.toLowerCase()) + '</label><input type="search" class="form-control" id="' + searchId + '" autocomplete="off" placeholder="' + escapeHtml(searchOpts.placeholder || "Search…") + '"></div>' : "") + '<p class="mp-sr-only" role="status" aria-live="polite" aria-atomic="true"></p><div class="mp-device-list-items" role="list" aria-label="' + escapeHtml(label) + '"></div></div>';
1814
+ const list = el.querySelector(".mp-device-list-items");
1815
+ const status = el.querySelector('.mp-device-list [role="status"]');
1816
+ const input = searchOpts ? el.querySelector('input[type="search"]') : null;
1817
+ function announce2(text) {
1818
+ if (status.textContent !== text) {
1819
+ status.textContent = text;
1820
+ }
1821
+ }
1822
+ function paint() {
1823
+ list.setAttribute("aria-busy", loading ? "true" : "false");
1824
+ if (input) {
1825
+ input.disabled = loading && !items.length;
1826
+ }
1827
+ if (loading && !items.length) {
1828
+ const n = opts.skeletonCount || 3;
1829
+ let html = "";
1830
+ for (let i = 0; i < n; i += 1) {
1831
+ html += '<div class="card mp-device-card mp-device-card-skeleton" role="listitem"><div class="card-body">' + Skeleton.render({ lines: 2, avatar: true }) + "</div></div>";
1832
+ }
1833
+ list.innerHTML = html;
1834
+ announce2("Loading " + label.toLowerCase() + "…");
1835
+ visible = [];
1836
+ return;
1837
+ }
1838
+ visible = query ? items.filter((item) => filter(item, query)) : items.slice();
1839
+ if (!items.length) {
1840
+ list.innerHTML = '<div role="listitem">' + EmptyState.render(Object.assign({ title: "No " + label.toLowerCase() + " found" }, opts.empty)) + "</div>";
1841
+ announce2("No " + label.toLowerCase());
1842
+ return;
1843
+ }
1844
+ if (!visible.length) {
1845
+ const nr = opts.noResults || {};
1846
+ list.innerHTML = '<div role="listitem">' + EmptyState.render({
1847
+ iconClass: nr.iconClass || "bi bi-search",
1848
+ title: nr.title || "No matches for “" + query + "”",
1849
+ hint: nr.hint || "Try a different search"
1850
+ }) + "</div>";
1851
+ announce2("No matches");
1852
+ return;
1853
+ }
1854
+ list.innerHTML = visible.map((item) => {
1855
+ const index = items.indexOf(item);
1856
+ const html = typeof opts.renderItem === "function" ? opts.renderItem(item, index) : defaultItem(item, index, interactive);
1857
+ return '<div role="listitem" class="mp-device-list-item" data-mp-index="' + index + '">' + html + "</div>";
1858
+ }).join("");
1859
+ announce2(query ? visible.length + " of " + items.length + " " + label.toLowerCase() : "");
1860
+ }
1861
+ function onInput() {
1862
+ query = input.value.trim().toLowerCase();
1863
+ paint();
1864
+ }
1865
+ function onClick2(ev) {
1866
+ if (!interactive) {
1867
+ return;
1868
+ }
1869
+ const row = ev.target.closest("[data-mp-index]");
1870
+ if (!row || !list.contains(row)) {
1871
+ return;
1872
+ }
1873
+ const control = ev.target.closest('a, button, input, select, textarea, label, [role="button"]');
1874
+ if (control && row.contains(control) && !control.classList.contains("mp-device-card")) {
1875
+ return;
1876
+ }
1877
+ const index = Number(row.getAttribute("data-mp-index"));
1878
+ opts.onActivate(items[index], index, ev);
1879
+ }
1880
+ if (input) {
1881
+ input.addEventListener("input", onInput);
1882
+ }
1883
+ list.addEventListener("click", onClick2);
1884
+ paint();
1885
+ return {
1886
+ setItems(next) {
1887
+ items = Array.isArray(next) ? next.slice() : [];
1888
+ loading = false;
1889
+ paint();
1890
+ },
1891
+ setLoading(next) {
1892
+ loading = !!next;
1893
+ paint();
1894
+ },
1895
+ setQuery(q) {
1896
+ query = String(q == null ? "" : q).trim().toLowerCase();
1897
+ if (input) {
1898
+ input.value = q == null ? "" : String(q);
1899
+ }
1900
+ paint();
1901
+ },
1902
+ items: () => items.slice(),
1903
+ visible: () => visible.slice(),
1904
+ /** Text announced to screen readers for the current state. */
1905
+ status: () => textOf(status),
1906
+ destroy() {
1907
+ if (input) {
1908
+ input.removeEventListener("input", onInput);
1909
+ }
1910
+ list.removeEventListener("click", onClick2);
1911
+ el.innerHTML = "";
1912
+ }
1913
+ };
1914
+ }
1915
+ };
1916
+
1917
+ // src/js/log.js
1918
+ var ANSI = /[\u001b\u009b][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
1919
+ function stripAnsi(text) {
1920
+ return String(text == null ? "" : text).replace(ANSI, "");
1921
+ }
1922
+ var NEAR_BOTTOM = 8;
1923
+ var LogViewer = {
1924
+ /**
1925
+ * Creates a log viewer in el.
1926
+ * opts: { maxLines = 1000, label = 'Log', live = 'off' | 'polite',
1927
+ * autoscroll = true, wrap = false, lineClass(line) → extra class,
1928
+ * jumpLabel = 'Jump to latest' }
1929
+ * Returns { append(textOrLines), clear(), lines(), scrollToBottom(), isFollowing(), destroy(), element }.
1930
+ */
1931
+ create(el, opts) {
1932
+ opts = opts || {};
1933
+ el = toElement(el);
1934
+ const d = el.ownerDocument;
1935
+ const maxLines = opts.maxLines > 0 ? opts.maxLines : 1e3;
1936
+ el.innerHTML = '<div class="mp-log' + (opts.wrap ? " mp-log-wrap" : "") + '"><div class="mp-log-body" role="log" tabindex="0"></div><button type="button" class="btn btn-sm btn-primary mp-log-jump" hidden></button></div>';
1937
+ const body = el.querySelector(".mp-log-body");
1938
+ const jump = el.querySelector(".mp-log-jump");
1939
+ body.setAttribute("aria-label", opts.label || "Log");
1940
+ body.setAttribute("aria-live", opts.live === "polite" ? "polite" : "off");
1941
+ jump.textContent = opts.jumpLabel || "Jump to latest";
1942
+ let follow = opts.autoscroll !== false;
1943
+ let lines = [];
1944
+ function atBottom() {
1945
+ return body.scrollHeight - body.scrollTop - body.clientHeight <= NEAR_BOTTOM;
1946
+ }
1947
+ function scrollToBottom() {
1948
+ body.scrollTop = body.scrollHeight;
1949
+ follow = opts.autoscroll !== false;
1950
+ jump.hidden = true;
1951
+ }
1952
+ function onScroll() {
1953
+ if (opts.autoscroll === false) {
1954
+ return;
1955
+ }
1956
+ follow = atBottom();
1957
+ if (follow) {
1958
+ jump.hidden = true;
1959
+ }
1960
+ }
1961
+ function append(input) {
1962
+ const incoming = (Array.isArray(input) ? input : String(input == null ? "" : input).split(/\r?\n/)).map(stripAnsi);
1963
+ if (!Array.isArray(input) && incoming.length > 1 && incoming[incoming.length - 1] === "") {
1964
+ incoming.pop();
1965
+ }
1966
+ if (!incoming.length) {
1967
+ return;
1968
+ }
1969
+ const stick = follow && opts.autoscroll !== false;
1970
+ const frag = d.createDocumentFragment();
1971
+ incoming.forEach((line) => {
1972
+ const row = d.createElement("div");
1973
+ const extra = typeof opts.lineClass === "function" ? opts.lineClass(line) : "";
1974
+ row.className = "mp-log-line" + (extra ? " " + extra : "");
1975
+ row.textContent = line;
1976
+ frag.appendChild(row);
1977
+ });
1978
+ body.appendChild(frag);
1979
+ lines = lines.concat(incoming);
1980
+ const excess = lines.length - maxLines;
1981
+ if (excess > 0) {
1982
+ lines = lines.slice(excess);
1983
+ for (let i = 0; i < excess && body.firstChild; i += 1) {
1984
+ body.removeChild(body.firstChild);
1985
+ }
1986
+ }
1987
+ if (stick) {
1988
+ body.scrollTop = body.scrollHeight;
1989
+ } else if (opts.autoscroll !== false) {
1990
+ jump.hidden = false;
1991
+ }
1992
+ }
1993
+ body.addEventListener("scroll", onScroll);
1994
+ jump.addEventListener("click", () => {
1995
+ scrollToBottom();
1996
+ body.focus();
1997
+ });
1998
+ return {
1999
+ element: body,
2000
+ append,
2001
+ clear() {
2002
+ lines = [];
2003
+ body.textContent = "";
2004
+ jump.hidden = true;
2005
+ },
2006
+ lines: () => lines.slice(),
2007
+ scrollToBottom,
2008
+ isFollowing: () => follow,
2009
+ destroy() {
2010
+ body.removeEventListener("scroll", onScroll);
2011
+ el.innerHTML = "";
2012
+ }
2013
+ };
2014
+ },
2015
+ stripAnsi
2016
+ };
2017
+
2018
+ // src/js/pairing.js
2019
+ function formatPin(code) {
2020
+ const digits = String(code == null ? "" : code).replace(/\D/g, "");
2021
+ return digits.length === 8 ? digits.slice(0, 3) + "-" + digits.slice(3, 5) + "-" + digits.slice(5) : "";
2022
+ }
2023
+ function safeImageSrc(src) {
2024
+ if (typeof src !== "string") {
2025
+ return null;
2026
+ }
2027
+ const s = src.trim();
2028
+ if (safeUrl(s)) {
2029
+ return s;
2030
+ }
2031
+ if (/^data:image\/(png|gif|jpeg|webp|svg\+xml)[;,]/i.test(s)) {
2032
+ return s;
2033
+ }
2034
+ if (/^(\.{0,2}\/|[\w-]+(\/|\.))/.test(s) && !/^[a-z][\w+.-]*:/i.test(s)) {
2035
+ return s;
2036
+ }
2037
+ return null;
2038
+ }
2039
+ function pinHtml(opts) {
2040
+ opts = opts || {};
2041
+ const formatted = formatPin(opts.pin) || escapeHtml(opts.pin || "");
2042
+ const spoken = String(opts.pin == null ? "" : opts.pin).replace(/\D/g, "").split("").join(" ");
2043
+ const id = uid("mp-pin");
2044
+ const src = safeImageSrc(opts.qrSrc);
2045
+ const label = opts.label || "HomeKit setup code";
2046
+ return '<figure class="mp-pin" aria-labelledby="' + id + '"><div class="mp-pin-label-box"><span class="mp-pin-label" id="' + id + '">' + escapeHtml(label) + '</span><span class="mp-pin-code" aria-hidden="true">' + formatted + '</span><span class="mp-sr-only">' + escapeHtml(spoken || formatted) + "</span></div>" + (src ? '<img class="mp-pin-qr" src="' + escapeHtml(src) + '" alt="' + escapeHtml(opts.qrAlt || "QR code for " + label) + '">' : "") + '<span class="mp-pin-qr-slot"></span>' + (opts.copy === false ? "" : '<figcaption class="mp-pin-actions">' + CopyButton.render({ text: formatted, label: opts.copyLabel || "Copy code", size: "sm" }) + "</figcaption>") + "</figure>";
2047
+ }
2048
+ var Pairing = {
2049
+ formatPin,
2050
+ /**
2051
+ * HTML for a HomeKit setup-code block (XXX-XX-XXX, read digit by digit by
2052
+ * screen readers) with an optional QR image and a Copy button.
2053
+ * opts: { pin, label = 'HomeKit setup code', qrSrc (http(s), path or
2054
+ * data:image/*), qrAlt, copy = true, copyLabel }
2055
+ * The kit does not generate QR codes: pass an image, or an SVG element to renderPin().
2056
+ */
2057
+ pin: pinHtml,
2058
+ /** Renders pin() into el; opts.qr may be an SVG/IMG element (appended) or an image URL. */
2059
+ renderPin(el, opts) {
2060
+ opts = opts || {};
2061
+ el = toElement(el);
2062
+ const qr = opts.qr;
2063
+ el.innerHTML = pinHtml(Object.assign({}, opts, { qrSrc: typeof qr === "string" ? qr : opts.qrSrc }));
2064
+ if (qr && typeof qr === "object" && qr.nodeType === 1) {
2065
+ const slot = el.querySelector(".mp-pin-qr-slot");
2066
+ if (qr.tagName.toLowerCase() === "svg") {
2067
+ qr.setAttribute("role", "img");
2068
+ if (!qr.getAttribute("aria-label")) {
2069
+ qr.setAttribute("aria-label", opts.qrAlt || "QR code for " + (opts.label || "HomeKit setup code"));
2070
+ }
2071
+ }
2072
+ qr.classList.add("mp-pin-qr");
2073
+ slot.appendChild(qr);
2074
+ }
2075
+ return el.querySelector(".mp-pin");
2076
+ }
2077
+ };
2078
+ var STATE_TEXT = {
2079
+ waiting: "Waiting for you to sign in…",
2080
+ polling: "Waiting for authorization…",
2081
+ success: "Signed in.",
2082
+ error: "Sign-in failed.",
2083
+ expired: "The code expired. Start again to get a new one."
2084
+ };
2085
+ var Auth = {
2086
+ /**
2087
+ * Renders a device-code / OAuth step card into el:
2088
+ * 1. open the sign-in URL, 2. enter the code (with Copy), then a status
2089
+ * line that follows the polling state.
2090
+ * opts: { title = 'Sign in', url, code, instructions, openLabel,
2091
+ * poll() → true | { done, error, interval } (called every `interval` ms
2092
+ * until done; a rejection or { error } shows the error),
2093
+ * interval = 5000, expiresIn (seconds), onSuccess(result), onError(err),
2094
+ * onRetry() (shows a Try again button on error/expiry), successMessage }
2095
+ * Returns { setState(state, message), state(), start(), stop(), destroy(), element }.
2096
+ */
2097
+ deviceCode(el, opts) {
2098
+ opts = opts || {};
2099
+ el = toElement(el);
2100
+ const titleId = uid("mp-auth-title");
2101
+ const url = safeUrl(opts.url);
2102
+ el.innerHTML = '<section class="card mp-auth-card" aria-labelledby="' + titleId + '"><div class="card-body"><h3 class="mp-auth-title" id="' + titleId + '">' + escapeHtml(opts.title || "Sign in") + "</h3>" + (opts.instructions ? '<p class="mp-auth-instructions">' + escapeHtml(opts.instructions) + "</p>" : "") + '<ol class="mp-auth-steps">' + (url ? '<li><span class="mp-auth-step-text">Open the sign-in page</span><a class="btn btn-primary btn-sm mp-auth-open" href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(opts.openLabel || "Open sign-in page") + '<span class="mp-sr-only"> (opens in a new tab)</span></a></li>' : "") + (opts.code ? '<li><span class="mp-auth-step-text">Enter this code</span><span class="mp-auth-code-row"><code class="mp-auth-code">' + escapeHtml(opts.code) + "</code>" + CopyButton.render({ text: opts.code, label: "Copy", size: "sm", ariaLabel: "Copy code" }) + "</span></li>" : "") + '</ol><p class="mp-auth-status" role="status" aria-live="polite"><span class="mp-auth-status-icon" aria-hidden="true"></span><span class="mp-auth-status-text"></span></p><button type="button" class="btn btn-outline-secondary btn-sm mp-auth-retry" hidden>Try again</button></div></section>';
2103
+ const card = el.querySelector(".mp-auth-card");
2104
+ const statusEl = el.querySelector(".mp-auth-status");
2105
+ const statusText = el.querySelector(".mp-auth-status-text");
2106
+ const retry = el.querySelector(".mp-auth-retry");
2107
+ let state = null;
2108
+ let timer = null;
2109
+ let expiry = null;
2110
+ let running = false;
2111
+ let interval = opts.interval > 0 ? opts.interval : 5e3;
2112
+ function setState(next, message) {
2113
+ state = STATE_TEXT[next] ? next : "waiting";
2114
+ ["waiting", "polling", "success", "error", "expired"].forEach((s) => card.classList.toggle("is-" + s, s === state));
2115
+ statusText.textContent = message || state === "success" && opts.successMessage || STATE_TEXT[state];
2116
+ statusEl.setAttribute("role", state === "error" || state === "expired" ? "alert" : "status");
2117
+ retry.hidden = !((state === "error" || state === "expired") && typeof opts.onRetry === "function");
2118
+ if (state === "success" || state === "error" || state === "expired") {
2119
+ stop();
2120
+ }
2121
+ return controller;
2122
+ }
2123
+ function stop() {
2124
+ running = false;
2125
+ clearTimeout(timer);
2126
+ clearTimeout(expiry);
2127
+ }
2128
+ function schedule() {
2129
+ if (!running) {
2130
+ return;
2131
+ }
2132
+ timer = setTimeout(tick, interval);
2133
+ }
2134
+ function tick() {
2135
+ if (!running) {
2136
+ return;
2137
+ }
2138
+ Promise.resolve().then(() => opts.poll()).then((res) => {
2139
+ if (!running) {
2140
+ return;
2141
+ }
2142
+ if (res && typeof res === "object" && res.error) {
2143
+ throw res.error;
2144
+ }
2145
+ if (res && typeof res === "object" && res.interval > 0) {
2146
+ interval = res.interval;
2147
+ }
2148
+ if (res === true || res && typeof res === "object" && res.done) {
2149
+ setState("success");
2150
+ if (typeof opts.onSuccess === "function") {
2151
+ opts.onSuccess(res);
2152
+ }
2153
+ } else {
2154
+ schedule();
2155
+ }
2156
+ }).catch((err) => {
2157
+ if (!running) {
2158
+ return;
2159
+ }
2160
+ setState("error", err && err.message || (typeof err === "string" ? err : ""));
2161
+ if (typeof opts.onError === "function") {
2162
+ opts.onError(err);
2163
+ }
2164
+ });
2165
+ }
2166
+ function start() {
2167
+ if (typeof opts.poll !== "function") {
2168
+ return controller;
2169
+ }
2170
+ stop();
2171
+ running = true;
2172
+ setState("polling");
2173
+ if (opts.expiresIn > 0) {
2174
+ expiry = setTimeout(() => setState("expired"), opts.expiresIn * 1e3);
2175
+ }
2176
+ schedule();
2177
+ return controller;
2178
+ }
2179
+ retry.addEventListener("click", () => {
2180
+ setState("waiting");
2181
+ opts.onRetry();
2182
+ });
2183
+ const controller = {
2184
+ element: card,
2185
+ setState,
2186
+ state: () => state,
2187
+ start,
2188
+ stop,
2189
+ destroy() {
2190
+ stop();
2191
+ el.innerHTML = "";
2192
+ }
2193
+ };
2194
+ setState("waiting");
2195
+ if (typeof opts.poll === "function" && opts.autoStart !== false) {
2196
+ start();
2197
+ }
2198
+ return controller;
2199
+ }
2200
+ };
2201
+ var Steps = {
2202
+ /**
2203
+ * Renders an .mp-steps progress list into el and shows one panel at a time.
2204
+ * opts: { steps: [{ title, panel (selector/element) }], current = 0,
2205
+ * label = 'Progress', onChange(index, step), focus = true }
2206
+ * Returns { go(index), next(), prev(), current(), destroy() }.
2207
+ */
2208
+ create(el, opts) {
2209
+ opts = opts || {};
2210
+ el = toElement(el);
2211
+ const steps = (opts.steps || []).map((s) => typeof s === "string" ? { title: s } : s);
2212
+ let current = -1;
2213
+ function paint() {
2214
+ el.innerHTML = '<ol class="mp-steps" aria-label="' + escapeHtml(opts.label || "Progress") + '">' + steps.map((s, i) => {
2215
+ const st = i < current ? "complete" : i === current ? "current" : "upcoming";
2216
+ return '<li class="mp-step is-' + st + '"' + (st === "current" ? ' aria-current="step"' : "") + '><span class="mp-step-marker" aria-hidden="true">' + (st === "complete" ? "✓" : i + 1) + '</span><span class="mp-step-title">' + escapeHtml(s.title) + "</span>" + (st === "complete" ? '<span class="mp-sr-only"> (completed)</span>' : "") + "</li>";
2217
+ }).join("") + "</ol>";
2218
+ }
2219
+ function go(index, initial) {
2220
+ if (index < 0 || index >= steps.length || index === current) {
2221
+ return current;
2222
+ }
2223
+ current = index;
2224
+ paint();
2225
+ steps.forEach((s, i) => {
2226
+ const panel = toElement(s.panel);
2227
+ if (panel) {
2228
+ panel.hidden = i !== index;
2229
+ }
2230
+ });
2231
+ if (!initial) {
2232
+ const panel = toElement(steps[index].panel);
2233
+ if (panel && opts.focus !== false) {
2234
+ focusElement(panel.querySelector("h1, h2, h3, h4, h5, h6, [data-mp-focus]") || panel);
2235
+ }
2236
+ announce("Step " + (index + 1) + " of " + steps.length + ": " + steps[index].title);
2237
+ if (typeof opts.onChange === "function") {
2238
+ opts.onChange(index, steps[index]);
2239
+ }
2240
+ }
2241
+ return current;
2242
+ }
2243
+ go(Math.min(Math.max(opts.current || 0, 0), Math.max(steps.length - 1, 0)), true);
2244
+ return {
2245
+ go: (i) => go(i),
2246
+ next: () => go(current + 1),
2247
+ prev: () => go(current - 1),
2248
+ current: () => current,
2249
+ destroy() {
2250
+ el.innerHTML = "";
2251
+ }
2252
+ };
2253
+ }
2254
+ };
2255
+
2256
+ // src/js/index.js
2257
+ var version = "1.3.0";
2258
+ var MpKit = {
2259
+ version,
2260
+ escapeHtml,
2261
+ announce,
2262
+ StatusBadge,
2263
+ EmptyState,
2264
+ Loading,
2265
+ View,
2266
+ Footer,
2267
+ Tabs,
2268
+ Theme,
2269
+ Toast,
2270
+ confirm,
2271
+ Form,
2272
+ CopyButton,
2273
+ copy: copyText,
2274
+ DeviceList,
2275
+ Skeleton,
2276
+ LogViewer,
2277
+ stripAnsi,
2278
+ Pairing,
2279
+ Auth,
2280
+ Steps,
2281
+ ai
2282
+ };
2283
+ installDelegates();
793
2284
 
794
- })(typeof window !== 'undefined' ? window : globalThis);
2285
+ // src/js/global.js
2286
+ root.MpKit = MpKit;
2287
+ })();