typed messages.js, unified returns from message handlers

Enable stricter type checking (checkJs, allowJs, strict, ES2022/ESNext) in jsconfig.json and add comprehensive JSDoc typedefs and return types across the web UI message code. Key changes: - simple-action-buttons: add JSDoc for createActionButton, handle missing icons gracefully (use text fallback), and compute feedback flag safely. - initFw.js: ignore TS on importing Alpine and expose Alpine from globalThis. - messages.js: introduce typedefs (MessageHandlerArgs, MessageHandlerResult, SetMessageResult, ProcessStepArgs), return richer handler results, make setMessage return { args, result }, tighten null/undefined handling, annotate scroller and other locals, and update many drawMessage/drawProcessStep functions to return consistent objects. - Several safety/refactor fixes: use globalThis.katex for rendering, safer DOM helpers (ensureChild typed), avoid early null returns, and build actionButtons arrays incrementally instead of relying on filtering nulls. Overall these changes improve type safety, null-safety, and clarity of message handler contracts to support better tooling and fewer runtime errors.

frdel committed Feb 25, 2026 at 10:28 UTC d358deda64d1fda15f1f8c27d72edcb517710588
4 files changed +253 -76
jsconfig.json
+6
@@ -1,5 +1,11 @@
1 {
2 "compilerOptions": {
3 + "checkJs": true,
4 + "allowJs": true,
5 + "strict": true,
6 + "noImplicitAny": false,
7 + "module": "esnext",
8 + "target": "es2022",
9 "baseUrl": ".",
10 "paths": {
11 "*": ["webui/*"],
webui/components/messages/action-buttons/simple-action-buttons.js
+12 -3
@@ -59,10 +59,14 @@ export function showButtonFeedback(button, success, originalIcon) {
59
60 /**
61 * Create action button element
62 + *
63 + * @param {string} icon
64 + * @param {string} [text]
65 + * @param {(() => (any | Promise<any>)) | null} [handler]
66 + * @returns {HTMLButtonElement}
67 */
68 export function createActionButton(icon, text = "", handler = null) {
69 const iconName = resolveActionIcon(icon);
65 - if (!iconName) return null;
70
71 const button = document.createElement("button");
72 button.type = "button";
@@ -72,12 +76,17 @@ export function createActionButton(icon, text = "", handler = null) {
76 button.setAttribute("aria-label", label);
77 button.setAttribute("title", label);
78 }
75 - button.innerHTML = `<span class="material-symbols-outlined">${iconName}</span>`;
79 +
80 + if (iconName) {
81 + button.innerHTML = `<span class="material-symbols-outlined">${iconName}</span>`;
82 + } else if (text) {
83 + button.textContent = text;
84 + }
85
86 if (typeof handler === "function") {
87 button.addEventListener("click", async (event) => {
88 event.stopPropagation();
80 - const shouldShowFeedback = true; // icon === "copy" || icon === "speak";
89 + const shouldShowFeedback = Boolean(iconName); // icon === "copy" || icon === "speak";
90 try {
91 await handler();
92 if (shouldShowFeedback) {
webui/js/initFw.js
+3
@@ -8,8 +8,11 @@ import { registerAlpineMagic } from "./confirmClick.js";
8 await initializer.initialize();
9
10 // import alpine library
11 +// @ts-ignore
12 await import("../vendor/alpine/alpine.min.js");
13
14 +const Alpine = globalThis.Alpine;
15 +
16 // register $confirmClick magic helper for inline button confirmations
17 registerAlpineMagic();
18
webui/js/messages.js
+232 -73
@@ -29,11 +29,61 @@ let _chatHistory = null;
29 let _massRender = false;
30 let _scrollOnNextProcessGroup = null;
31
32 +/**
33 + * @typedef {object} MessageHandlerArgs
34 + * @property {number} [no]
35 + * @property {string | number} id
36 + * @property {string} type
37 + * @property {string | undefined} [heading]
38 + * @property {string | undefined} [content]
39 + * @property {object | undefined} [kvps]
40 + * @property {number | undefined} [timestamp]
41 + * @property {number} [agentno]
42 + */
43 +
44 +/**
45 + * @typedef {{ element: Element } & Record<string, any>} MessageHandlerResult
46 + */
47 +
48 +/**
49 + * @typedef {object} SetMessageResult
50 + * @property {IArguments} args
51 + * @property {MessageHandlerResult} result
52 + */
53 +
54 +/**
55 + * @typedef {(args: MessageHandlerArgs & Record<string, any>) => MessageHandlerResult} MessageHandler
56 + */
57 +
58 +/**
59 + * @typedef {object} ProcessStepArgs
60 + * @property {string | number} id
61 + * @property {string} title
62 + * @property {string} code
63 + * @property {string[] | undefined} [classes]
64 + * @property {any} [kvps]
65 + * @property {string | undefined} [content]
66 + * @property {string[] | undefined} [contentClasses]
67 + * @property {Element[] | undefined} [actionButtons]
68 + * @property {any} log
69 + * @property {boolean} [allowCompletedGroup]
70 + */
71 +
72 +
73 export function scrollOnNextProcessGroup() {
74 _scrollOnNextProcessGroup = "wait";
75 }
76
77 // handlers for log message rendering
78 +/**
79 + * Returns a message renderer for a given log message type.
80 + *
81 + * The returned handler has the same input object shape as `setMessage(...)` passes through
82 + * and may return a rich object `{ element, actionButtons?, ...additional }`.
83 + *
84 + * @param {string} type
85 + * @returns {MessageHandler}
86 + */
87 export function getMessageHandler(type) {
88 switch (type) {
89 case "user":
@@ -87,7 +137,9 @@ export async function setMessages(messages) {
137 reapplyDelayMs: 1000,
138 applyStabilization: true,
139 },
140 + /** @type {Scroller | null} */
141 mainScroller: null,
142 + /** @type {SetMessageResult[]} */
143 results: [],
144 };
145
@@ -99,12 +151,13 @@ export async function setMessages(messages) {
151
152 await callJsExtensions("set_messages_before_loop", context);
153
154 + //@ts-ignore
155 context.mainScroller = new Scroller(context.history, context.scrollerOptions);
156
157 // process messages
158 for (let i = 0; i < context.messages.length; i++) {
159 _massRender = context.historyEmpty || (context.isLargeAppend && i < context.cutoff);
107 - context.results.push(setMessage(context.messages[i]) || {});
160 + context.results.push(setMessage(context.messages[i]));
161 }
162
163 await callJsExtensions("set_messages_after_loop", context);
@@ -112,13 +165,13 @@ export async function setMessages(messages) {
165 // reset _massRender flag
166 _massRender = false;
167
115 - const shouldScroll = context.historyEmpty || !context.results[context.results.length - 1]?.dontScroll;
168 + const shouldScroll = context.historyEmpty || !context.results[context.results.length - 1]?.result?.dontScroll;
169
117 - if (shouldScroll) context.mainScroller.reApplyScroll();
170 + if (shouldScroll) context.mainScroller?.reApplyScroll();
171
172 if (_scrollOnNextProcessGroup === "scroll") {
173 requestAnimationFrame(() => {
121 - context.mainScroller.scrollToBottom();
174 + context.mainScroller?.scrollToBottom();
175 _scrollOnNextProcessGroup = null;
176 });
177 }
@@ -126,6 +179,10 @@ export async function setMessages(messages) {
179
180 // entrypoint called from poll/WS communication, this is how all messages are rendered and updated
181 // input is raw log format
182 +/**
183 + * @param {MessageHandlerArgs & Record<string, any>} param0
184 + * @returns {SetMessageResult}
185 + */
186 export function setMessage({
187 no,
188 id,
@@ -139,8 +196,9 @@ export function setMessage({
196 }) {
197 const handler = getMessageHandler(type);
198 // prefer log ID if set to match user message created on frontend with backend updates
142 - return handler({
143 - id: id || no,
199 + const handlerResult = handler({
200 + no,
201 + id: id || String(no) || "",
202 type,
203 heading,
204 content,
@@ -149,6 +207,10 @@ export function setMessage({
207 agentno,
208 ...additional,
209 });
210 + return {
211 + args: arguments[0],
212 + result: handlerResult,
213 + }
214 }
215
216 function getOrCreateMessageContainer(
@@ -255,6 +317,10 @@ function buildDetailPayload(stepData, extras = {}) {
317 };
318 }
319
320 +/**
321 + * @param {ProcessStepArgs & Record<string, any>} param0
322 + * @returns {MessageHandlerResult}
323 + */
324 function drawProcessStep({
325 id,
326 title,
@@ -289,7 +355,7 @@ function drawProcessStep({
355 group.setAttribute("data-start-timestamp", String(log.timestamp));
356 }
357
292 - if (isNewStep) {
358 + if (!step) {
359 // create the base DOM element for the step
360 step = document.createElement("div");
361 step.id = stepId;
@@ -297,7 +363,7 @@ function drawProcessStep({
363
364 // set data attributes of the step
365 step.setAttribute("data-log-type", log.type);
300 - step.setAttribute("data-step-id", id);
366 + step.setAttribute("data-step-id", String(id));
367 step.setAttribute("data-agent-number", log.agentno);
368
369 // set timestamp attribute (convert to milliseconds for duration calculation)
@@ -409,7 +475,6 @@ function drawProcessStep({
475 if (prevCode) step.classList.remove(prevCode);
476 step.setAttribute("data-step-code", code);
477 step.classList.add(code);
412 - step.querySelector(".step-badge").textContent = code;
478 badge.innerText = code;
479 }
480
@@ -471,12 +536,13 @@ function drawProcessStep({
536
537 // return anything useful
538 return {
539 + element: step,
540 + actionButtons,
541 step,
542 detail: stepDetail,
543 content: stepDetailContent,
544 contentScroller: detailScroller,
545 kvpsTable,
479 - actionButtons: stepActionBtns,
546 isExpanded,
547 };
548 }
@@ -645,7 +711,7 @@ export function _drawMessage({
711 // KaTeX rendering for markdown
712 if (latex) {
713 contentDiv.querySelectorAll("latex").forEach((element) => {
648 - katex.render(element.innerHTML, element, {
714 + globalThis.katex.render(element.innerHTML, element, {
715 throwOnError: false,
716 });
717 });
@@ -715,6 +781,10 @@ export function addBlankTargetsToLinks(str) {
781 return doc.body.innerHTML;
782 }
783
784 +/**
785 + * @param {MessageHandlerArgs & Record<string, any>} param0
786 + * @returns {MessageHandlerResult}
787 + */
788 export function drawMessageDefault({
789 id,
790 heading,
@@ -730,7 +800,7 @@ export function drawMessageDefault({
800 ].filter(Boolean)
801 : [];
802
733 - return drawStandaloneMessage({
803 + const element = drawStandaloneMessage({
804 id,
805 heading,
806 content,
@@ -742,15 +812,21 @@ export function drawMessageDefault({
812 kvps,
813 actionButtons,
814 });
815 +
816 + return { element };
817 }
818
819 +/**
820 + * @param {MessageHandlerArgs & Record<string, any>} param0
821 + * @returns {MessageHandlerResult}
822 + */
823 export function drawMessageAgent({
824 id,
825 type,
826 heading,
827 content,
752 - kvps = null,
753 - timestamp = null,
828 + kvps = undefined,
829 + timestamp = undefined,
830 agentno = 0,
831 ...additional
832 }) {
@@ -783,20 +859,24 @@ export function drawMessageAgent({
859 id,
860 title,
861 code: "GEN",
786 - classes: null,
862 + classes: undefined,
863 kvps: displayKvps,
864 actionButtons,
865 log: arguments[0],
866 });
867 }
868
869 +/**
870 + * @param {MessageHandlerArgs & Record<string, any>} param0
871 + * @returns {MessageHandlerResult}
872 + */
873 export function drawMessageResponse({
874 id,
875 type,
876 heading,
877 content,
798 - kvps = null,
799 - timestamp = null,
878 + kvps = undefined,
879 + timestamp = undefined,
880 agentno = 0,
881 ...additional
882 }) {
@@ -858,9 +938,9 @@ export function drawMessageResponse({
938
939 const messageDiv = _drawMessage({
940 messageContainer: container,
861 - heading: null,
941 + heading: undefined,
942 content,
863 - kvps: null,
943 + kvps: undefined,
944 messageClasses: [],
945 contentClasses: [],
946 markdown: true,
@@ -886,9 +966,13 @@ export function drawMessageResponse({
966
967 if (group) updateProcessGroupHeader(group);
968
889 - return container;
969 + return { element: container };
970 }
971
972 +/**
973 + * @param {MessageHandlerArgs & Record<string, any>} param0
974 + * @returns {MessageHandlerResult}
975 + */
976 export function drawMessageUser({
977 id,
978 heading,
@@ -988,6 +1072,7 @@ export function drawMessageUser({
1072
1073 attachmentDiv.addEventListener("click", displayInfo.clickHandler);
1074
1075 + // @ts-ignore
1076 attachmentsContainer.appendChild(attachmentDiv);
1077 });
1078 } else {
@@ -1025,15 +1110,21 @@ export function drawMessageUser({
1110 userActionButtons.forEach((button) =>
1111 actionButtonsContainer.appendChild(button),
1112 );
1113 +
1114 + return { element: messageContainer };
1115 }
1116
1117 +/**
1118 + * @param {MessageHandlerArgs & Record<string, any>} param0
1119 + * @returns {MessageHandlerResult}
1120 + */
1121 export function drawMessageTool({
1122 id,
1123 type,
1124 heading,
1125 content,
1035 - kvps = null,
1036 - timestamp = null,
1126 + kvps,
1127 + timestamp,
1128 agentno = 0,
1129 ...additional
1130 }) {
@@ -1056,13 +1147,17 @@ export function drawMessageTool({
1147 }
1148 }
1149
1150 +/**
1151 + * @param {MessageHandlerArgs & Record<string, any>} param0
1152 + * @returns {MessageHandlerResult}
1153 + */
1154 export function drawMessageToolSimple({
1155 id,
1156 type,
1157 heading,
1158 content,
1064 - kvps = null,
1065 - timestamp = null,
1159 + kvps,
1160 + timestamp,
1161 agentno = 0,
1162 code,
1163 displayKvps,
@@ -1090,7 +1185,7 @@ export function drawMessageToolSimple({
1185 id,
1186 title,
1187 code: code || "USE",
1093 - classes: null,
1188 + classes: undefined,
1189 kvps: displayKvps,
1190 content,
1191 // contentClasses: [],
@@ -1099,13 +1194,18 @@ export function drawMessageToolSimple({
1194 });
1195 }
1196
1197 +/**
1198 + * @param {MessageHandlerArgs & Record<string, any>} param0
1199 + * @returns {MessageHandlerResult}
1200 + */
1201 export function drawMessageCodeExe({
1202 id,
1203 type,
1204 heading,
1205 + test,
1206 content,
1107 - kvps = null,
1108 - timestamp = null,
1207 + kvps,
1208 + timestamp,
1209 agentno = 0,
1210 ...additional
1211 }) {
@@ -1135,41 +1235,51 @@ export function drawMessageCodeExe({
1235 // render the standard step
1236 const commandText = String(kvps?.code ?? "");
1237 const outputText = String(content ?? "");
1138 - const actionButtons = [
1238 +
1239 + const actionButtons = [];
1240 + actionButtons.push(
1241 createActionButton("detail", "", () =>
1242 stepDetailStore.showStepDetail(
1243 buildDetailPayload(arguments[0], { headerLabels }),
1244 ),
1245 ),
1144 - commandText.trim()
1145 - ? createActionButton("copy", "Command", () =>
1146 - copyToClipboard(commandText),
1147 - )
1148 - : null,
1149 - outputText.trim()
1150 - ? createActionButton("copy", "Output", () => copyToClipboard(outputText))
1151 - : null,
1152 - ].filter(Boolean);
1246 + );
1247 + if (commandText.trim()) {
1248 + actionButtons.push(
1249 + createActionButton("copy", "Command", () => copyToClipboard(commandText)),
1250 + );
1251 + }
1252 + if (outputText.trim()) {
1253 + actionButtons.push(
1254 + createActionButton("copy", "Output", () => copyToClipboard(outputText)),
1255 + );
1256 + }
1257 const stepData = drawProcessStep({
1258 id,
1259 title,
1260 code: "EXE",
1157 - classes: null,
1261 + classes: undefined,
1262 kvps: displayKvps,
1263 content,
1264 contentClasses: ["terminal-output"],
1265 actionButtons,
1266 log: arguments[0],
1267 });
1268 +
1269 + return stepData;
1270 }
1271
1272 +/**
1273 + * @param {MessageHandlerArgs & Record<string, any>} param0
1274 + * @returns {MessageHandlerResult}
1275 + */
1276 export function drawMessageBrowser({
1277 id,
1278 type,
1279 heading,
1280 content,
1171 - kvps = null,
1172 - timestamp = null,
1281 + kvps,
1282 + timestamp,
1283 agentno = 0,
1284 ...additional
1285 }) {
@@ -1192,7 +1302,7 @@ export function drawMessageBrowser({
1302 id,
1303 title,
1304 code: "WWW",
1195 - classes: null,
1305 + classes: undefined,
1306 kvps: displayKvps,
1307 content,
1308 // contentClasses: [],
@@ -1201,13 +1311,17 @@ export function drawMessageBrowser({
1311 });
1312 }
1313
1314 +/**
1315 + * @param {MessageHandlerArgs & Record<string, any>} param0
1316 + * @returns {MessageHandlerResult}
1317 + */
1318 export function drawMessageMcp({
1319 id,
1320 type,
1321 heading,
1322 content,
1209 - kvps = null,
1210 - timestamp = null,
1323 + kvps,
1324 + timestamp,
1325 agentno = 0,
1326 ...additional
1327 }) {
@@ -1233,7 +1347,7 @@ export function drawMessageMcp({
1347 id,
1348 title,
1349 code: "MCP",
1236 - classes: null,
1350 + classes: undefined,
1351 kvps: displayKvps,
1352 content,
1353 // contentClasses: [],
@@ -1242,13 +1356,17 @@ export function drawMessageMcp({
1356 });
1357 }
1358
1359 +/**
1360 + * @param {MessageHandlerArgs & Record<string, any>} param0
1361 + * @returns {MessageHandlerResult}
1362 + */
1363 export function drawMessageSubagent({
1364 id,
1365 type,
1366 heading,
1367 content,
1250 - kvps = null,
1251 - timestamp = null,
1368 + kvps,
1369 + timestamp,
1370 agentno = 0,
1371 ...additional
1372 }) {
@@ -1274,7 +1392,7 @@ export function drawMessageSubagent({
1392 id,
1393 title,
1394 code: "SUB",
1277 - classes: null,
1395 + classes: undefined,
1396 kvps: displayKvps,
1397 content,
1398 // contentClasses: [],
@@ -1283,11 +1401,15 @@ export function drawMessageSubagent({
1401 });
1402 }
1403
1404 +/**
1405 + * @param {MessageHandlerArgs & Record<string, any>} param0
1406 + * @returns {MessageHandlerResult}
1407 + */
1408 export function drawMessageInfo({
1409 id,
1410 heading,
1411 content,
1290 - kvps = null,
1412 + kvps,
1413 ...additional
1414 }) {
1415 const title = cleanStepTitle(heading || content);
@@ -1304,7 +1426,7 @@ export function drawMessageInfo({
1426 id,
1427 title,
1428 code: "INF",
1307 - classes: null,
1429 + classes: undefined,
1430 kvps: displayKvps,
1431 content,
1432 // contentClasses: [],
@@ -1313,13 +1435,17 @@ export function drawMessageInfo({
1435 });
1436 }
1437
1438 +/**
1439 + * @param {MessageHandlerArgs & Record<string, any>} param0
1440 + * @returns {MessageHandlerResult}
1441 + */
1442 export function drawMessageUtil({
1443 id,
1444 type,
1445 heading,
1446 content,
1321 - kvps = null,
1322 - timestamp = null,
1447 + kvps,
1448 + timestamp,
1449 agentno = 0,
1450 ...additional
1451 }) {
@@ -1348,13 +1474,17 @@ export function drawMessageUtil({
1474 return result;
1475 }
1476
1477 +/**
1478 + * @param {MessageHandlerArgs & Record<string, any>} param0
1479 + * @returns {MessageHandlerResult}
1480 + */
1481 export function drawMessageHint({
1482 id,
1483 type,
1484 heading,
1485 content,
1356 - kvps = null,
1357 - timestamp = null,
1486 + kvps,
1487 + timestamp,
1488 agentno = 0,
1489 ...additional
1490 }) {
@@ -1367,28 +1497,33 @@ export function drawMessageHint({
1497 ].filter(Boolean)
1498 : [];
1499
1370 - return drawStandaloneMessage({
1500 + const element = drawStandaloneMessage({
1501 id,
1372 - title,
1502 + heading: title,
1503 // statusClass,
1374 - statusCode: "HNT",
1504 + // statusCode: "HNT",
1505 kvps,
1376 - type,
1377 - heading,
1506 + // type,
1507 content,
1379 - timestamp,
1380 - agentno,
1508 + // timestamp,
1509 + // agentno,
1510 actionButtons,
1511 });
1512 +
1513 + return { element };
1514 }
1515
1516 +/**
1517 + * @param {MessageHandlerArgs & Record<string, any>} param0
1518 + * @returns {MessageHandlerResult}
1519 + */
1520 export function drawMessageProgress({
1521 id,
1522 type,
1523 heading,
1524 content,
1390 - kvps = null,
1391 - timestamp = null,
1525 + kvps,
1526 + timestamp,
1527 agentno = 0,
1528 ...additional
1529 }) {
@@ -1399,7 +1534,7 @@ export function drawMessageProgress({
1534 id,
1535 title,
1536 code: "HDL",
1402 - classes: null,
1537 + classes: undefined,
1538 kvps: displayKvps,
1539 content,
1540 // contentClasses: [],
@@ -1408,6 +1543,10 @@ export function drawMessageProgress({
1543 });
1544 }
1545
1546 +/**
1547 + * @param {MessageHandlerArgs & Record<string, any>} param0
1548 + * @returns {MessageHandlerResult}
1549 + */
1550 export function drawMessageWarning({
1551 id,
1552 type,
@@ -1443,9 +1582,9 @@ export function drawMessageWarning({
1582 }
1583
1584 // if no process group is running, draw as standalone
1446 - return drawStandaloneMessage({
1585 + const element = drawStandaloneMessage({
1586 id,
1448 - title,
1587 + heading: title,
1588 content,
1589 position: "mid",
1590 containerClasses: ["ai-container", "center-container"],
@@ -1453,8 +1592,14 @@ export function drawMessageWarning({
1592 kvps: displayKvps,
1593 actionButtons,
1594 });
1595 +
1596 + return { element };
1597 }
1598
1599 +/**
1600 + * @param {MessageHandlerArgs & Record<string, any>} param0
1601 + * @returns {MessageHandlerResult}
1602 + */
1603 export function drawMessageError({
1604 id,
1605 type,
@@ -1466,18 +1611,22 @@ export function drawMessageError({
1611 const contentText = String(content ?? "");
1612 let title = getStepTitle(heading, content, type);
1613 let displayKvps = { ...kvps };
1469 - const actionButtons = [
1614 +
1615 + const actionButtons = [];
1616 + actionButtons.push(
1617 createActionButton("detail", "", () =>
1618 stepDetailStore.showStepDetail(
1619 buildDetailPayload(arguments[0], { headerLabels: [] }),
1620 ),
1621 ),
1475 - contentText.trim()
1476 - ? createActionButton("copy", "", () => copyToClipboard(contentText))
1477 - : null,
1478 - ].filter(Boolean);
1622 + );
1623 + if (contentText.trim()) {
1624 + actionButtons.push(
1625 + createActionButton("copy", "", () => copyToClipboard(contentText)),
1626 + );
1627 + }
1628
1480 - return drawStandaloneMessage({
1629 + const element = drawStandaloneMessage({
1630 id,
1631 heading: title,
1632 content: contentText,
@@ -1487,6 +1636,8 @@ export function drawMessageError({
1636 kvps: displayKvps,
1637 actionButtons,
1638 });
1639 +
1640 + return { element };
1641 }
1642
1643 function drawKvpsIncremental(container, kvps, latex) {
@@ -1589,7 +1740,7 @@ function drawKvpsIncremental(container, kvps, latex) {
1740 // KaTeX rendering for markdown
1741 if (latex) {
1742 span.querySelectorAll("latex").forEach((element) => {
1592 - katex.render(element.innerHTML, element, {
1743 + globalThis.katex.render(element.innerHTML, element, {
1744 throwOnError: false,
1745 });
1746 });
@@ -2161,8 +2312,16 @@ function truncateText(text, maxLength) {
2312 }
2313
2314 // gets or creates a child DOM element
2315 +/**
2316 + * @param {Element} parent
2317 + * @param {string} selector
2318 + * @param {string} tagName
2319 + * @param {...string} classNames
2320 + * @returns {HTMLElement}
2321 + */
2322 function ensureChild(parent, selector, tagName, ...classNames) {
2165 - let el = parent.querySelector(selector);
2323 + /** @type {HTMLElement | null} */
2324 + let el = /** @type {any} */ (parent.querySelector(selector));
2325 if (!el) {
2326 el = document.createElement(tagName);
2327 if (classNames.length) el.classList.add(...classNames);