@crvouga/mockingbird-service-postgres 1.7.0 → 2.0.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/admin.js CHANGED
@@ -375,6 +375,34 @@ dialog:not([open]) {
375
375
  function isRecord(value) {
376
376
  return typeof value === "object" && value !== null && !Array.isArray(value);
377
377
  }
378
+ function highlightJsonText(text) {
379
+ const escapeToken = (value) => value.replace(/[&<>"']/g, (char) => {
380
+ switch (char) {
381
+ case "&":
382
+ return "&amp;";
383
+ case "<":
384
+ return "&lt;";
385
+ case ">":
386
+ return "&gt;";
387
+ case '"':
388
+ return "&quot;";
389
+ default:
390
+ return "&#39;";
391
+ }
392
+ });
393
+ const token = /("(?:\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"\s*:|"(?:\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"|\b(?:true|false)\b|\bnull\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g;
394
+ let cursor = 0;
395
+ let html = "";
396
+ for (const match of text.matchAll(token)) {
397
+ const index = match.index ?? 0;
398
+ const value = match[0];
399
+ html += escapeToken(text.slice(cursor, index));
400
+ const kind = value.startsWith('"') ? value.trimEnd().endsWith(":") ? "key" : "string" : value === "true" || value === "false" ? "boolean" : value === "null" ? "null" : "number";
401
+ html += `<span class="json-${kind}">${escapeToken(value)}</span>`;
402
+ cursor = index + value.length;
403
+ }
404
+ return html + escapeToken(text.slice(cursor));
405
+ }
378
406
  function mountAdminBrand(doc, host, service, catalog) {
379
407
  const clip = (value, max) => {
380
408
  if (typeof value !== "string")
@@ -736,14 +764,16 @@ function bootAdmin(config) {
736
764
  const state = {
737
765
  namespace: "default",
738
766
  key: sessionStorage.getItem("mockingbird-admin-key") || "",
739
- view: "overview",
767
+ view: ["overview", "state", "clock", "faults", "journal", "routes"].includes(location.hash.slice(1)) ? location.hash.slice(1) : "overview",
740
768
  collection: "",
741
769
  shape: null,
742
770
  route: null,
771
+ routes: [],
743
772
  editing: null
744
773
  };
745
774
  const standard = new Set(config.standardRoutes);
746
775
  const banner = el("banner", HTMLElement);
776
+ const bannerMessage = el("banner-message", HTMLElement);
747
777
  const keyInput = el("admin-key", HTMLInputElement);
748
778
  keyInput.value = state.key;
749
779
  const params = new URLSearchParams(location.search);
@@ -754,18 +784,21 @@ function bootAdmin(config) {
754
784
  sessionStorage.setItem("mockingbird-admin-key", state.key);
755
785
  params.delete("key");
756
786
  const next = params.toString();
757
- history.replaceState(null, "", location.pathname + (next ? `?${next}` : ""));
787
+ history.replaceState(null, "", location.pathname + (next ? `?${next}` : "") + location.hash);
758
788
  }
759
789
  const queryNamespace = params.get("namespace");
760
790
  if (queryNamespace)
761
791
  state.namespace = queryNamespace;
762
792
  const showError = (error) => {
763
- banner.textContent = error instanceof Error ? error.message : String(error);
793
+ bannerMessage.textContent = error instanceof Error ? error.message : String(error);
764
794
  banner.classList.add("show");
795
+ banner.scrollIntoView({ behavior: "smooth", block: "nearest" });
765
796
  };
766
797
  const clearError = () => {
767
798
  banner.classList.remove("show");
799
+ bannerMessage.textContent = "";
768
800
  };
801
+ el("banner-dismiss", HTMLButtonElement).addEventListener("click", clearError);
769
802
  const api = async (method, path, body) => {
770
803
  const headers = { accept: "application/json" };
771
804
  if (state.key !== "")
@@ -809,16 +842,21 @@ function bootAdmin(config) {
809
842
  namespace: () => state.namespace
810
843
  };
811
844
  const isPanelScript = (value) => typeof value === "function";
845
+ const themeButton = el("theme", HTMLButtonElement);
812
846
  const setTheme = (theme) => {
813
847
  if (theme)
814
848
  document.documentElement.dataset.theme = theme;
815
849
  else
816
850
  delete document.documentElement.dataset.theme;
851
+ const dark = theme ? theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
852
+ themeButton.innerHTML = dark ? '<svg viewBox="0 0 20 20" fill="none" aria-hidden="true"><circle cx="10" cy="10" r="3.2" stroke="currentColor" stroke-width="1.5"/><path d="M10 2v1.5M10 16.5V18M18 10h-1.5M3.5 10H2M15.7 4.3l-1 1M5.3 14.7l-1 1M15.7 15.7l-1-1M5.3 5.3l-1-1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>' : '<svg viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M16.8 12.2A7 7 0 0 1 7.8 3.2a7 7 0 1 0 9 9Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>';
853
+ const label = dark ? "Use light theme" : "Use dark theme";
854
+ themeButton.setAttribute("aria-label", label);
855
+ themeButton.title = label;
817
856
  };
818
857
  const storedTheme = localStorage.getItem("mockingbird-admin-theme");
819
- if (storedTheme)
820
- setTheme(storedTheme);
821
- el("theme", HTMLButtonElement).addEventListener("click", () => {
858
+ setTheme(storedTheme === "dark" || storedTheme === "light" ? storedTheme : null);
859
+ themeButton.addEventListener("click", () => {
822
860
  const current = document.documentElement.dataset.theme;
823
861
  const dark = current ? current === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
824
862
  const next = dark ? "light" : "dark";
@@ -837,6 +875,7 @@ function bootAdmin(config) {
837
875
  continue;
838
876
  section.hidden = section.id !== `view-${view}`;
839
877
  }
878
+ history.replaceState(null, "", `${location.pathname}${location.search}#${encodeURIComponent(view)}`);
840
879
  };
841
880
  el("nav", HTMLElement).addEventListener("click", (event) => {
842
881
  const target = event.target;
@@ -851,7 +890,7 @@ function bootAdmin(config) {
851
890
  show(view);
852
891
  refresh().catch(showError);
853
892
  });
854
- const cards = (items) => `<div class="grid">${items.map((item) => `<article class="card"><div class="k">${item.k}</div><div class="v">${item.v}</div></article>`).join("")}</div>`;
893
+ const cards = (items) => `<div class="grid">${items.map((item) => `<article class="card stat-card"><div class="k">${item.k}</div><div class="v">${item.v}</div></article>`).join("")}</div>`;
855
894
  const table = (columns, rows) => {
856
895
  if (rows.length === 0)
857
896
  return '<p class="empty">Nothing here yet.</p>';
@@ -861,16 +900,22 @@ function bootAdmin(config) {
861
900
  const attrs = "attrs" in row && typeof row.attrs === "string" ? row.attrs : "";
862
901
  return `<tr${attrs}>${cells}</tr>`;
863
902
  }).join("");
864
- return `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
903
+ return `<div class="table-shell"><table><thead>${head}</thead><tbody>${body}</tbody></table></div>`;
865
904
  };
866
905
  const preview = (value) => {
867
906
  const text = JSON.stringify(value) ?? "null";
868
- return esc(text.length > 180 ? `${text.slice(0, 180)}\u2026` : text);
907
+ const clipped = text.length > 180 ? `${text.slice(0, 180)}\u2026` : text;
908
+ return `<code class="json-inline">${highlightJsonText(clipped)}</code>`;
869
909
  };
870
910
  const when = (epoch) => {
871
911
  const date = new Date(epoch);
872
912
  return Number.isNaN(date.getTime()) ? String(epoch) : date.toISOString();
873
913
  };
914
+ const journalWhen = (value) => {
915
+ const numeric = value.trim() === "" ? Number.NaN : Number(value);
916
+ const epoch = Number.isFinite(numeric) ? numeric : Date.parse(value);
917
+ return Number.isFinite(epoch) ? when(epoch) : value;
918
+ };
874
919
  const clockCard = (epoch) => {
875
920
  const date = new Date(epoch);
876
921
  if (Number.isNaN(date.getTime()))
@@ -990,7 +1035,7 @@ function bootAdmin(config) {
990
1035
  };
991
1036
  const renderOverview = async () => {
992
1037
  const [health, shape, clock, journal] = await Promise.all([
993
- originGet("/health"),
1038
+ originGet(`${config.adminPrefix}/health`),
994
1039
  api("GET", "/state"),
995
1040
  api("GET", "/clock"),
996
1041
  api("GET", "/requests?limit=8")
@@ -1007,7 +1052,7 @@ function bootAdmin(config) {
1007
1052
  },
1008
1053
  {
1009
1054
  k: "Status",
1010
- v: esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")
1055
+ v: `<span class="badge ok">${esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")}</span>`
1011
1056
  },
1012
1057
  { k: "Namespace", v: esc(state.namespace) },
1013
1058
  { k: "Collections", v: String(decoded.collections.length) },
@@ -1094,26 +1139,46 @@ function bootAdmin(config) {
1094
1139
  const renderJournal = async () => {
1095
1140
  const journal = await api("GET", "/requests?limit=100");
1096
1141
  el("journal", HTMLElement).innerHTML = table([
1097
- { label: "When", cell: (row) => esc(row.at) },
1098
- { label: "Method", cell: (row) => esc(row.method) },
1142
+ { label: "When", cell: (row) => esc(journalWhen(row.at)) },
1143
+ {
1144
+ label: "Method",
1145
+ cell: (row) => `<span class="method">${esc(row.method)}</span>`
1146
+ },
1099
1147
  { label: "Path", cell: (row) => esc(row.path) },
1100
- { label: "Status", cell: (row) => esc(row.status) },
1148
+ {
1149
+ label: "Status",
1150
+ cell: (row) => `<span class="badge ${row.status.startsWith("2") ? "ok" : row.status.startsWith("4") || row.status.startsWith("5") ? "warn" : ""}">${esc(row.status)}</span>`
1151
+ },
1101
1152
  { label: "ms", cell: (row) => esc(row.durationMs) }
1102
1153
  ], decodeJournal(journal));
1103
1154
  };
1104
- const renderRoutes = async () => {
1105
- const data = await api("GET", "/");
1106
- if (!isRecord(data))
1107
- throw new Error("Route list was not an object");
1108
- const routes = requireList(data.routes, "Routes").map((route) => String(route));
1155
+ const paintRoutes = () => {
1156
+ const query = el("route-search", HTMLInputElement).value.trim().toLocaleLowerCase();
1157
+ const routes = state.routes.filter((route) => route.toLocaleLowerCase().includes(query));
1158
+ el("route-count", HTMLElement).textContent = `${routes.length} of ${state.routes.length}`;
1159
+ if (state.routes.length === 0) {
1160
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No admin routes are available.</p>';
1161
+ return;
1162
+ }
1163
+ if (routes.length === 0) {
1164
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No routes match this filter.</p>';
1165
+ return;
1166
+ }
1109
1167
  el("route-list", HTMLElement).innerHTML = routes.map((route) => {
1110
1168
  const extra = standard.has(route) ? "" : " <span class='badge'>extension</span>";
1111
1169
  const parts = route.split(" ");
1112
1170
  const method = parts[0] ?? "";
1113
1171
  const path = parts.slice(1).join(" ");
1114
- return `<div class="route"><span class="method">${esc(method)}</span><span>${esc(path)}${extra}</span><button class="btn" type="button" data-route="${esc(route)}">Use</button></div>`;
1172
+ return `<div class="route"><span class="method">${esc(method)}</span><span>${esc(path)}${extra}</span><button class="btn" type="button" data-route="${esc(route)}" aria-label="Use ${esc(route)}">Use</button></div>`;
1115
1173
  }).join("");
1116
1174
  };
1175
+ const renderRoutes = async () => {
1176
+ const data = await api("GET", "/");
1177
+ if (!isRecord(data))
1178
+ throw new Error("Route list was not an object");
1179
+ state.routes = requireList(data.routes, "Routes").map((route) => String(route));
1180
+ paintRoutes();
1181
+ };
1117
1182
  const renderers = {
1118
1183
  overview: renderOverview,
1119
1184
  state: renderState,
@@ -1122,19 +1187,50 @@ function bootAdmin(config) {
1122
1187
  journal: renderJournal,
1123
1188
  routes: renderRoutes
1124
1189
  };
1190
+ let refreshToken = 0;
1125
1191
  async function refresh() {
1192
+ const token = ++refreshToken;
1126
1193
  clearError();
1194
+ const main = el("views", HTMLElement);
1195
+ const sync = el("sync-status", HTMLElement);
1196
+ main.setAttribute("aria-busy", "true");
1197
+ sync.classList.add("busy");
1198
+ sync.textContent = "Updating";
1127
1199
  const render = renderers[state.view];
1128
- if (render)
1129
- await render();
1200
+ try {
1201
+ if (render)
1202
+ await render();
1203
+ if (token === refreshToken)
1204
+ sync.textContent = "Up to date";
1205
+ } finally {
1206
+ if (token === refreshToken) {
1207
+ main.removeAttribute("aria-busy");
1208
+ sync.classList.remove("busy");
1209
+ }
1210
+ }
1130
1211
  }
1131
1212
  const delay = (ms) => new Promise((resolve) => {
1132
1213
  window.setTimeout(resolve, ms);
1133
1214
  });
1134
- const confirmAction = async (button, pending, done, work) => {
1215
+ const confirmAction = async (button, pending, done, work, confirmLabel) => {
1135
1216
  if (button.dataset.pending === "1")
1136
1217
  return;
1137
1218
  const label = button.textContent ?? "";
1219
+ if (confirmLabel !== void 0 && button.dataset.confirming !== "1") {
1220
+ button.dataset.confirming = "1";
1221
+ button.classList.add("confirming");
1222
+ button.textContent = confirmLabel;
1223
+ window.setTimeout(() => {
1224
+ if (button.dataset.confirming !== "1")
1225
+ return;
1226
+ button.textContent = label;
1227
+ button.classList.remove("confirming");
1228
+ delete button.dataset.confirming;
1229
+ }, 3e3);
1230
+ return;
1231
+ }
1232
+ button.classList.remove("confirming");
1233
+ delete button.dataset.confirming;
1138
1234
  const width = button.offsetWidth;
1139
1235
  button.dataset.pending = "1";
1140
1236
  button.classList.add("pressed");
@@ -1155,6 +1251,65 @@ function bootAdmin(config) {
1155
1251
  delete button.dataset.pending;
1156
1252
  }
1157
1253
  };
1254
+ const wireJsonEditor = (textareaId, highlightId, statusId, shellId, allowEmpty) => {
1255
+ const textarea = el(textareaId, HTMLTextAreaElement);
1256
+ const highlight = el(highlightId, HTMLElement);
1257
+ const status = el(statusId, HTMLElement);
1258
+ const shell = el(shellId, HTMLElement);
1259
+ const sync = () => {
1260
+ const text = textarea.value;
1261
+ highlight.innerHTML = `${highlightJsonText(text)}
1262
+ `;
1263
+ let valid = true;
1264
+ let message = "Valid JSON";
1265
+ if (allowEmpty && text.trim() === "")
1266
+ message = "No body";
1267
+ else {
1268
+ try {
1269
+ readJson2(text);
1270
+ } catch {
1271
+ valid = false;
1272
+ message = "Invalid JSON";
1273
+ }
1274
+ }
1275
+ shell.classList.toggle("invalid", !valid);
1276
+ status.classList.toggle("invalid", !valid);
1277
+ status.textContent = message;
1278
+ textarea.setAttribute("aria-invalid", valid ? "false" : "true");
1279
+ };
1280
+ const syncScroll = () => {
1281
+ const preview2 = highlight.parentElement;
1282
+ if (!(preview2 instanceof HTMLElement))
1283
+ return;
1284
+ preview2.scrollTop = textarea.scrollTop;
1285
+ preview2.scrollLeft = textarea.scrollLeft;
1286
+ };
1287
+ textarea.addEventListener("input", sync);
1288
+ textarea.addEventListener("scroll", syncScroll);
1289
+ sync();
1290
+ return sync;
1291
+ };
1292
+ const syncRouteEditor = wireJsonEditor("route-body", "route-highlight", "route-json-status", "route-editor", true);
1293
+ const syncRecordEditor = wireJsonEditor("editor-value", "editor-highlight", "editor-json-status", "record-editor", false);
1294
+ for (const button of document.querySelectorAll("[data-format]")) {
1295
+ if (!(button instanceof HTMLButtonElement))
1296
+ continue;
1297
+ button.addEventListener("click", () => {
1298
+ const target = button.dataset.format;
1299
+ if (target === void 0)
1300
+ return;
1301
+ const textarea = document.getElementById(target);
1302
+ if (!(textarea instanceof HTMLTextAreaElement) || textarea.value.trim() === "")
1303
+ return;
1304
+ try {
1305
+ textarea.value = JSON.stringify(readJson2(textarea.value), null, 2) ?? "";
1306
+ textarea.dispatchEvent(new Event("input"));
1307
+ textarea.focus();
1308
+ } catch {
1309
+ showError(new Error("Fix the JSON before formatting it"));
1310
+ }
1311
+ });
1312
+ }
1158
1313
  document.addEventListener("click", (event) => {
1159
1314
  const target = event.target;
1160
1315
  if (!(target instanceof Element))
@@ -1227,6 +1382,7 @@ function bootAdmin(config) {
1227
1382
  } else {
1228
1383
  editorValue.value = "{\n \n}";
1229
1384
  }
1385
+ syncRecordEditor();
1230
1386
  editor.showModal();
1231
1387
  }
1232
1388
  el("new-record", HTMLButtonElement).addEventListener("click", () => {
@@ -1256,10 +1412,10 @@ function bootAdmin(config) {
1256
1412
  const id = state.editing;
1257
1413
  if (!id)
1258
1414
  return;
1259
- api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
1415
+ confirmAction(editorDelete, "Deleting...", "Deleted", () => api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
1260
1416
  editor.close();
1261
1417
  return renderState();
1262
- }).catch(showError);
1418
+ }), "Confirm delete").catch(showError);
1263
1419
  });
1264
1420
  for (const button of document.querySelectorAll("[data-advance]")) {
1265
1421
  if (!(button instanceof HTMLButtonElement))
@@ -1305,10 +1461,10 @@ function bootAdmin(config) {
1305
1461
  }).then(() => renderFaults()).catch(showError);
1306
1462
  });
1307
1463
  el("fault-clear", HTMLButtonElement).addEventListener("click", () => {
1308
- confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults())).catch(showError);
1464
+ confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults()), "Confirm clear").catch(showError);
1309
1465
  });
1310
1466
  el("journal-clear", HTMLButtonElement).addEventListener("click", () => {
1311
- confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal())).catch(showError);
1467
+ confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal()), "Confirm clear").catch(showError);
1312
1468
  });
1313
1469
  el("route-list", HTMLElement).addEventListener("click", (event) => {
1314
1470
  const target = event.target;
@@ -1322,9 +1478,15 @@ function bootAdmin(config) {
1322
1478
  return;
1323
1479
  state.route = route;
1324
1480
  el("route-title", HTMLElement).textContent = route;
1481
+ el("route-send", HTMLButtonElement).disabled = false;
1325
1482
  const method = route.split(" ")[0] ?? "";
1326
- el("route-body", HTMLTextAreaElement).disabled = method === "GET" || method === "DELETE";
1483
+ const routeBody = el("route-body", HTMLTextAreaElement);
1484
+ routeBody.disabled = method === "GET" || method === "DELETE";
1485
+ el("route-editor", HTMLElement).hidden = routeBody.disabled;
1486
+ el("route-json-status", HTMLElement).parentElement?.toggleAttribute("hidden", routeBody.disabled);
1487
+ syncRouteEditor();
1327
1488
  });
1489
+ el("route-search", HTMLInputElement).addEventListener("input", paintRoutes);
1328
1490
  el("route-form", HTMLFormElement).addEventListener("submit", (event) => {
1329
1491
  event.preventDefault();
1330
1492
  const send = async () => {
@@ -1346,7 +1508,8 @@ function bootAdmin(config) {
1346
1508
  }
1347
1509
  }
1348
1510
  const data = await api(method, concrete, body);
1349
- el("route-result", HTMLElement).textContent = JSON.stringify(data, null, 2);
1511
+ const text = JSON.stringify(data, null, 2) ?? "null";
1512
+ el("route-result", HTMLElement).innerHTML = `<code>${highlightJsonText(text)}</code>`;
1350
1513
  };
1351
1514
  send().catch(showError);
1352
1515
  });
@@ -1464,11 +1627,13 @@ function bootAdmin(config) {
1464
1627
  views.append(section);
1465
1628
  mountSqlExplorer(body, panelApi);
1466
1629
  }
1630
+ show(document.getElementById(`view-${state.view}`) === null ? "overview" : state.view);
1467
1631
  return refresh();
1468
1632
  }).catch(showError);
1469
1633
  }
1470
1634
  var clientPrelude = [
1471
1635
  isRecord,
1636
+ highlightJsonText,
1472
1637
  mountAdminBrand,
1473
1638
  startAdminBrand,
1474
1639
  faultPresetList,
@@ -1488,13 +1653,19 @@ ${entry}(${payload});
1488
1653
  };
1489
1654
 
1490
1655
  // ../core/dist/control.js
1491
- var HEALTH_PATH = "/health";
1492
1656
  var ADMIN_PREFIX = "/__admin";
1657
+ var resolveAdminPrefix = (prefix = ADMIN_PREFIX) => {
1658
+ if (!/^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(prefix)) {
1659
+ throw new Error("adminPrefix must be an absolute non-root path of literal segments, without a trailing slash");
1660
+ }
1661
+ return prefix;
1662
+ };
1663
+ var isAdminPath = (path, prefix) => path === prefix || path.startsWith(`${prefix}/`);
1493
1664
  var ADMIN_KEY_HEADER = "x-mockingbird-admin-key";
1494
1665
  var NAMESPACE_HEADER = "x-mockingbird-namespace";
1495
1666
  var json = (status, body) => new Response(JSON.stringify(body), {
1496
1667
  status,
1497
- headers: { "content-type": "application/json" }
1668
+ headers: { "content-type": "application/json; charset=utf-8" }
1498
1669
  });
1499
1670
  var adminError = (status, message) => json(status, { error: { type: "mockingbird_admin", message } });
1500
1671
  var UNITS = {
@@ -1546,11 +1717,25 @@ var readJson = async (request) => {
1546
1717
  };
1547
1718
  var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1548
1719
  var createControlPlane = (context) => {
1720
+ const adminPrefix = resolveAdminPrefix(context.adminPrefix);
1549
1721
  const snapshots = /* @__PURE__ */ new Map();
1550
1722
  let snapshotCounter = 0;
1551
1723
  const headerNamespace = (request) => request.headers.get(NAMESPACE_HEADER) ?? context.defaultNamespace;
1552
1724
  const adminNamespace = (request, url) => url.searchParams.get("namespace") ?? headerNamespace(request);
1725
+ const health = () => {
1726
+ return json(200, {
1727
+ ...context.describe(),
1728
+ status: "ok",
1729
+ service: context.name,
1730
+ uptimeMs: context.wallNow() - context.startedAt,
1731
+ clock: context.clock.state(),
1732
+ namespaces: context.namespaces().length,
1733
+ adminPrefix,
1734
+ adminUi: `${adminPrefix}/ui`
1735
+ });
1736
+ };
1553
1737
  const builtin = {
1738
+ "GET /health": health,
1554
1739
  "GET /": () => json(200, {
1555
1740
  service: context.name,
1556
1741
  routes: [...Object.keys(builtin), ...Object.keys(context.routes)].sort()
@@ -1717,7 +1902,14 @@ var createControlPlane = (context) => {
1717
1902
  return json(200, { status: "ok" });
1718
1903
  }
1719
1904
  };
1720
- const routes = [...Object.entries(context.routes), ...Object.entries(builtin)].map(([key, handler]) => {
1905
+ for (const key of Object.keys(context.routes)) {
1906
+ if (Object.hasOwn(builtin, key))
1907
+ throw new Error(`admin route ${key} overrides a shared route`);
1908
+ const path = key.slice(key.indexOf(" ") + 1);
1909
+ if (isAdminPath(path, "/ns") || isAdminPath(path, "/blobs"))
1910
+ throw new Error(`admin route ${key} overlaps an internal transport`);
1911
+ }
1912
+ const routes = [...Object.entries(builtin), ...Object.entries(context.routes)].map(([key, handler]) => {
1721
1913
  const space = key.indexOf(" ");
1722
1914
  return { method: key.slice(0, space), pattern: key.slice(space + 1), handler };
1723
1915
  });
@@ -1725,21 +1917,13 @@ var createControlPlane = (context) => {
1725
1917
  namespaceOf: headerNamespace,
1726
1918
  async handle(request) {
1727
1919
  const url = new URL(request.url);
1728
- if (url.pathname === HEALTH_PATH && request.method === "GET") {
1729
- return json(200, {
1730
- status: "ok",
1731
- service: context.name,
1732
- uptimeMs: context.wallNow() - context.startedAt,
1733
- clock: context.clock.state(),
1734
- namespaces: context.namespaces().length,
1735
- adminUi: `${ADMIN_PREFIX}/ui`,
1736
- ...context.describe()
1737
- });
1920
+ if (url.pathname === `${adminPrefix}/health` && request.method === "GET") {
1921
+ return health();
1738
1922
  }
1739
- if (url.pathname !== ADMIN_PREFIX && !url.pathname.startsWith(`${ADMIN_PREFIX}/`)) {
1923
+ if (!isAdminPath(url.pathname, adminPrefix)) {
1740
1924
  return void 0;
1741
1925
  }
1742
- const path = url.pathname.slice(ADMIN_PREFIX.length) || "/";
1926
+ const path = url.pathname.slice(adminPrefix.length) || "/";
1743
1927
  const shell = request.method === "GET" && (path === "/ui" || path === "/ui/");
1744
1928
  if (!shell && context.adminKey !== void 0 && request.headers.get(ADMIN_KEY_HEADER) !== context.adminKey) {
1745
1929
  return adminError(401, `missing or wrong ${ADMIN_KEY_HEADER}`);
@@ -1747,7 +1931,12 @@ var createControlPlane = (context) => {
1747
1931
  for (const route of routes) {
1748
1932
  if (route.method !== request.method)
1749
1933
  continue;
1750
- const params = matchRoute(route.pattern, path);
1934
+ let params;
1935
+ try {
1936
+ params = matchRoute(route.pattern, path);
1937
+ } catch {
1938
+ return adminError(400, "invalid path encoding");
1939
+ }
1751
1940
  if (!params)
1752
1941
  continue;
1753
1942
  let body;
@@ -1756,15 +1945,18 @@ var createControlPlane = (context) => {
1756
1945
  } catch {
1757
1946
  return adminError(400, "request body is not valid JSON");
1758
1947
  }
1948
+ const namespace = adminNamespace(request, url);
1949
+ if (!/^[A-Za-z0-9_.-]{1,64}$/.test(namespace))
1950
+ return adminError(400, "invalid namespace");
1759
1951
  return route.handler({
1760
1952
  request,
1761
1953
  url,
1762
1954
  params,
1763
- namespace: adminNamespace(request, url),
1955
+ namespace,
1764
1956
  body
1765
1957
  });
1766
1958
  }
1767
- return adminError(404, `no admin route ${request.method} ${path}; GET ${ADMIN_PREFIX} lists them`);
1959
+ return adminError(404, `no admin route ${request.method} ${path}; GET ${adminPrefix} lists them`);
1768
1960
  }
1769
1961
  };
1770
1962
  };
@@ -1772,6 +1964,7 @@ var createControlPlane = (context) => {
1772
1964
  // ../core/dist/surface.js
1773
1965
  var STANDARD_ADMIN_ROUTES = [
1774
1966
  "GET /",
1967
+ "GET /health",
1775
1968
  "POST /reset",
1776
1969
  "GET /namespaces",
1777
1970
  "GET /clock",
@@ -1821,11 +2014,11 @@ var escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => {
1821
2014
  }
1822
2015
  });
1823
2016
  var ADMIN_BRANDS_URL = "https://mockingbird.chrisvouga.dev/brands.json";
1824
- var renderAdminDocument = (service) => {
2017
+ var renderAdminDocument = (service, adminPrefix = ADMIN_PREFIX) => {
1825
2018
  const name = escapeHtml(service);
1826
2019
  const script = adminClientSource({
1827
2020
  standardRoutes: STANDARD_ADMIN_ROUTES,
1828
- adminPrefix: ADMIN_PREFIX,
2021
+ adminPrefix,
1829
2022
  adminKeyHeader: ADMIN_KEY_HEADER,
1830
2023
  brandsUrl: ADMIN_BRANDS_URL,
1831
2024
  service
@@ -1842,75 +2035,99 @@ var renderAdminDocument = (service) => {
1842
2035
  ${CSS_RESET}
1843
2036
  :root {
1844
2037
  color-scheme: light;
1845
- --bg: #fafafa;
2038
+ --bg: #f7f8fb;
1846
2039
  --bg-elev: #ffffff;
1847
- --bg-muted: #f4f4f5;
1848
- --bg-inset: #f4f4f5;
1849
- --line: #dedee3;
1850
- --line-strong: #c4c4cc;
1851
- --ink: #18181b;
1852
- --muted: #62626b;
1853
- --faint: #71717a;
1854
- --accent: #27272a;
2040
+ --bg-muted: #f1f3f7;
2041
+ --bg-inset: #f8f9fc;
2042
+ --line: #e4e7ec;
2043
+ --line-strong: #cfd4dc;
2044
+ --ink: #101828;
2045
+ --muted: #475467;
2046
+ --faint: #667085;
2047
+ --accent: #6941c6;
2048
+ --accent-hover: #5933b4;
1855
2049
  --accent-ink: #ffffff;
1856
- --accent-soft: #f4f4f5;
1857
- --ok: #166534;
1858
- --ok-soft: #f0fdf4;
1859
- --warn: #a16207;
1860
- --warn-soft: #fefce8;
1861
- --err: #a82d32;
1862
- --err-soft: #fef2f2;
1863
- --shadow: none;
2050
+ --accent-soft: #f4f0ff;
2051
+ --ok: #067647;
2052
+ --ok-soft: #ecfdf3;
2053
+ --warn: #b54708;
2054
+ --warn-soft: #fffaeb;
2055
+ --err: #b42318;
2056
+ --err-soft: #fef3f2;
2057
+ --code-key: #6941c6;
2058
+ --code-string: #067647;
2059
+ --code-number: #175cd3;
2060
+ --code-boolean: #c11574;
2061
+ --code-null: #667085;
2062
+ --shadow-xs: 0 1px 2px rgb(16 24 40 / 0.05);
2063
+ --shadow-sm: 0 1px 3px rgb(16 24 40 / 0.08), 0 1px 2px rgb(16 24 40 / 0.04);
2064
+ --shadow-lg: 0 18px 48px rgb(16 24 40 / 0.16), 0 4px 12px rgb(16 24 40 / 0.08);
1864
2065
  --sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1865
2066
  --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
1866
- --radius: 10px;
1867
- --header: 56px;
1868
- --nav: 200px;
2067
+ --radius: 12px;
2068
+ --header: 72px;
2069
+ --nav: 224px;
1869
2070
  }
1870
2071
  :root[data-theme="dark"] {
1871
2072
  color-scheme: dark;
1872
- --bg: #111113;
1873
- --bg-elev: #19191c;
1874
- --bg-muted: #242428;
1875
- --bg-inset: #111113;
1876
- --line: #36363c;
1877
- --line-strong: #52525b;
1878
- --ink: #f4f4f5;
1879
- --muted: #a9a9b2;
1880
- --faint: #71717a;
1881
- --accent: #e4e4e7;
1882
- --accent-ink: #18181b;
1883
- --accent-soft: #242428;
1884
- --ok: #86efac;
1885
- --ok-soft: #14241b;
1886
- --warn: #fde68a;
1887
- --warn-soft: #2a2410;
1888
- --err: #ffaaaa;
1889
- --err-soft: #2c1516;
1890
- --shadow: none;
2073
+ --bg: #0c0e14;
2074
+ --bg-elev: #151821;
2075
+ --bg-muted: #20232e;
2076
+ --bg-inset: #11141c;
2077
+ --line: #2b303d;
2078
+ --line-strong: #414858;
2079
+ --ink: #f5f7fa;
2080
+ --muted: #b6bdc9;
2081
+ --faint: #8d96a7;
2082
+ --accent: #9e77ed;
2083
+ --accent-hover: #b692f6;
2084
+ --accent-ink: #ffffff;
2085
+ --accent-soft: #2b2142;
2086
+ --ok: #75e0a7;
2087
+ --ok-soft: #102a20;
2088
+ --warn: #fec84b;
2089
+ --warn-soft: #302611;
2090
+ --err: #fda29b;
2091
+ --err-soft: #321817;
2092
+ --code-key: #c3a6ff;
2093
+ --code-string: #75e0a7;
2094
+ --code-number: #84adff;
2095
+ --code-boolean: #f9a8d4;
2096
+ --code-null: #98a2b3;
2097
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
2098
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
2099
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
1891
2100
  }
1892
2101
  @media (prefers-color-scheme: dark) {
1893
2102
  :root:not([data-theme="light"]) {
1894
2103
  color-scheme: dark;
1895
- --bg: #111113;
1896
- --bg-elev: #19191c;
1897
- --bg-muted: #242428;
1898
- --bg-inset: #111113;
1899
- --line: #36363c;
1900
- --line-strong: #52525b;
1901
- --ink: #f4f4f5;
1902
- --muted: #a9a9b2;
1903
- --faint: #71717a;
1904
- --accent: #e4e4e7;
1905
- --accent-ink: #18181b;
1906
- --accent-soft: #242428;
1907
- --ok: #86efac;
1908
- --ok-soft: #14241b;
1909
- --warn: #fde68a;
1910
- --warn-soft: #2a2410;
1911
- --err: #ffaaaa;
1912
- --err-soft: #2c1516;
1913
- --shadow: none;
2104
+ --bg: #0c0e14;
2105
+ --bg-elev: #151821;
2106
+ --bg-muted: #20232e;
2107
+ --bg-inset: #11141c;
2108
+ --line: #2b303d;
2109
+ --line-strong: #414858;
2110
+ --ink: #f5f7fa;
2111
+ --muted: #b6bdc9;
2112
+ --faint: #8d96a7;
2113
+ --accent: #9e77ed;
2114
+ --accent-hover: #b692f6;
2115
+ --accent-ink: #ffffff;
2116
+ --accent-soft: #2b2142;
2117
+ --ok: #75e0a7;
2118
+ --ok-soft: #102a20;
2119
+ --warn: #fec84b;
2120
+ --warn-soft: #302611;
2121
+ --err: #fda29b;
2122
+ --err-soft: #321817;
2123
+ --code-key: #c3a6ff;
2124
+ --code-string: #75e0a7;
2125
+ --code-number: #84adff;
2126
+ --code-boolean: #f9a8d4;
2127
+ --code-null: #98a2b3;
2128
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
2129
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
2130
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
1914
2131
  }
1915
2132
  }
1916
2133
  /* Fill the viewport, then grow with the content so the background covers the scroll. */
@@ -1928,13 +2145,17 @@ var renderAdminDocument = (service) => {
1928
2145
  padding-right: env(safe-area-inset-right);
1929
2146
  -webkit-text-size-adjust: 100%;
1930
2147
  }
1931
- :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2148
+ ::selection { background: color-mix(in srgb, var(--accent) 28%, transparent); }
2149
+ :focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 2px; }
2150
+ button, input, select, textarea { transition: border-color 140ms ease, box-shadow 140ms ease, transform 100ms ease; }
2151
+ button:disabled, input:disabled, select:disabled, textarea:disabled { cursor: not-allowed; opacity: 0.55; }
1932
2152
  .app { flex: 1 0 auto; display: flex; flex-direction: column; min-width: 0; width: 100%; }
1933
2153
  header.top {
1934
- position: sticky; top: 0; z-index: 5;
2154
+ position: sticky; top: 0; z-index: 20;
1935
2155
  display: flex; align-items: center; gap: 12px;
1936
- min-height: var(--header); padding: 10px 16px;
1937
- background: var(--bg-elev);
2156
+ min-height: var(--header); padding: 12px 20px;
2157
+ background: color-mix(in srgb, var(--bg-elev) 92%, transparent);
2158
+ backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
1938
2159
  border-bottom: 1px solid var(--line);
1939
2160
  min-width: 0; max-width: 100%;
1940
2161
  }
@@ -1942,12 +2163,21 @@ var renderAdminDocument = (service) => {
1942
2163
  display: flex; flex-flow: row wrap; align-items: center; gap: 14px;
1943
2164
  min-width: 0; max-width: 100%; margin-right: auto;
1944
2165
  }
1945
- .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; }
1946
- .brand-id strong { font-size: 15px; letter-spacing: -0.01em; }
2166
+ .brand-lockup { display: flex; align-items: center; gap: 10px; min-width: 0; }
2167
+ .brand-mark {
2168
+ width: 38px; height: 38px; display: grid; place-items: center; flex: none;
2169
+ border-radius: 11px; color: #fff;
2170
+ background: linear-gradient(145deg, #7f56d9 0%, #53389e 100%);
2171
+ box-shadow: 0 6px 16px rgb(105 65 198 / 0.24), inset 0 1px 0 rgb(255 255 255 / 0.25);
2172
+ }
2173
+ .brand-mark svg { width: 22px; height: 22px; }
2174
+ .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; line-height: 1.25; }
2175
+ .brand-id strong { font-size: 15px; letter-spacing: -0.015em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1947
2176
  .brand-id span { color: var(--faint); font-size: 12px; }
1948
2177
  .vendor {
1949
2178
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
1950
2179
  min-width: 0; max-width: 100%; font-size: 12px; color: var(--muted);
2180
+ padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--bg-inset);
1951
2181
  }
1952
2182
  .vendor[hidden] { display: none; }
1953
2183
  /* Logos are drawn for a light tile, same as the docs site, so the plate stays white. */
@@ -1959,12 +2189,17 @@ var renderAdminDocument = (service) => {
1959
2189
  .vendor-sep { color: var(--faint); }
1960
2190
  .vendor-link { color: inherit; text-decoration: none; }
1961
2191
  .vendor-link:hover { color: var(--ink); text-decoration: underline; }
1962
- .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
1963
- label.field { display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: var(--faint); }
2192
+ .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; }
2193
+ .sync-status { align-self: center; display: flex; align-items: center; gap: 6px; color: var(--faint); font-size: 12px; white-space: nowrap; }
2194
+ .sync-status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
2195
+ .sync-status.busy::before { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); animation: pulse 1.2s ease-in-out infinite; }
2196
+ label.field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; font-weight: 600; color: var(--faint); }
1964
2197
  label.field input, label.field select, .controls input[type="password"] {
1965
- background-color: var(--bg-inset); border: 1px solid var(--line); border-radius: 8px;
1966
- padding: 7px 8px; min-height: 36px; line-height: 1.2;
2198
+ background-color: var(--bg-elev); border: 1px solid var(--line-strong); border-radius: 9px;
2199
+ padding: 8px 10px; min-height: 38px; line-height: 1.2; box-shadow: var(--shadow-xs);
1967
2200
  }
2201
+ label.field input:hover, label.field select:hover, textarea:hover { border-color: color-mix(in srgb, var(--accent) 38%, var(--line-strong)); }
2202
+ label.field input:focus, label.field select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); outline: none; }
1968
2203
  /* A native menu ignores min-height, so Preset rendered about half as tall as
1969
2204
  the text fields in the same form. Paint it like those fields. */
1970
2205
  label.field select {
@@ -1981,12 +2216,20 @@ var renderAdminDocument = (service) => {
1981
2216
  dialog .field > textarea { width: 100%; }
1982
2217
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger, nav.side button { cursor: pointer; }
1983
2218
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger {
1984
- border-radius: 9px; min-height: 36px; padding: 0 12px; border: 1px solid var(--line);
1985
- background: var(--bg-elev);
1986
- }
1987
- .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
1988
- .btn-quiet { background: transparent; }
1989
- .btn-danger { color: var(--err); }
2219
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px;
2220
+ border-radius: 9px; min-height: 38px; padding: 0 13px; border: 1px solid var(--line-strong);
2221
+ background: var(--bg-elev); color: var(--ink); font-size: 13px; font-weight: 600; line-height: 1;
2222
+ box-shadow: var(--shadow-xs);
2223
+ }
2224
+ .icon-btn { width: 38px; padding: 0; }
2225
+ .icon-btn svg, .btn svg, .btn-primary svg { width: 16px; height: 16px; }
2226
+ .icon-btn:hover, .btn:hover { background: var(--bg-muted); border-color: var(--line-strong); }
2227
+ .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); box-shadow: 0 1px 2px rgb(16 24 40 / 0.08), 0 0 0 1px rgb(255 255 255 / 0.08) inset; }
2228
+ .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
2229
+ .btn-quiet { background: transparent; border-color: transparent; box-shadow: none; }
2230
+ .btn-quiet:hover { background: var(--bg-muted); }
2231
+ .btn-danger { color: var(--err); border-color: color-mix(in srgb, var(--err) 25%, var(--line)); }
2232
+ .btn-danger:hover, .btn-danger.confirming { background: var(--err-soft); border-color: color-mix(in srgb, var(--err) 45%, var(--line)); }
1990
2233
  /* Held after mouseup. Clearing an empty journal changes nothing on screen,
1991
2234
  so the press itself has to stay visible. */
1992
2235
  .icon-btn:active, .btn:active, .btn-quiet:active, .btn-danger:active, nav.side button:active,
@@ -2002,93 +2245,159 @@ var renderAdminDocument = (service) => {
2002
2245
  flex: 1 0 auto; min-width: 0;
2003
2246
  }
2004
2247
  nav.side {
2005
- border-right: 1px solid var(--line); padding: 12px;
2248
+ position: sticky; top: var(--header); align-self: start; height: calc(100vh - var(--header));
2249
+ border-right: 1px solid var(--line); padding: 18px 12px;
2006
2250
  display: flex; flex-direction: column; gap: 4px;
2007
- background: var(--bg-elev);
2251
+ background: var(--bg-elev); overflow-y: auto;
2008
2252
  }
2253
+ .nav-label { padding: 0 10px 8px; color: var(--faint); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; }
2009
2254
  nav.side button {
2255
+ position: relative; display: flex; align-items: center; gap: 10px; width: 100%;
2010
2256
  text-align: left; border: 0; background: transparent; border-radius: 9px;
2011
- padding: 9px 10px; min-height: 40px; color: var(--muted);
2012
- }
2013
- nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--ink); font-weight: 600; }
2014
- main { padding: 20px; max-width: 1100px; width: 100%; min-width: 0; }
2015
- .view h2 { margin: 0 0 4px; font-size: 22px; letter-spacing: -0.02em; }
2016
- .lede { margin: 0 0 16px; color: var(--muted); }
2017
- .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: 12px; }
2257
+ padding: 9px 10px; min-height: 42px; color: var(--muted); font-size: 13px; font-weight: 550;
2258
+ }
2259
+ nav.side button:hover { background: var(--bg-muted); color: var(--ink); }
2260
+ nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 650; }
2261
+ nav.side button[aria-current="true"]::before { content: ""; position: absolute; left: -12px; width: 3px; height: 22px; border-radius: 0 4px 4px 0; background: var(--accent); }
2262
+ .nav-icon { width: 18px; height: 18px; display: grid; place-items: center; flex: none; }
2263
+ .nav-icon svg { width: 18px; height: 18px; }
2264
+ main { padding: 32px clamp(20px, 4vw, 48px) 64px; max-width: 1320px; width: 100%; min-width: 0; }
2265
+ main[aria-busy="true"] .view { opacity: 0.62; }
2266
+ .view { animation: view-in 180ms ease-out; }
2267
+ .view[hidden] { display: none; }
2268
+ .view-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 22px; }
2269
+ .view h2 { margin: 0 0 5px; font-size: clamp(24px, 3vw, 30px); line-height: 1.2; letter-spacing: -0.035em; }
2270
+ .view h3 { margin: 24px 0 10px; font-size: 15px; letter-spacing: -0.01em; }
2271
+ .lede { margin: 0; color: var(--muted); max-width: 72ch; font-size: 14px; }
2272
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 175px), 1fr)); gap: 14px; }
2018
2273
  .card {
2019
2274
  background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius);
2020
- padding: 14px 16px; box-shadow: var(--shadow);
2275
+ padding: 18px; box-shadow: var(--shadow-sm);
2021
2276
  }
2022
- .card .k { color: var(--faint); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
2023
- .card .v { display: flex; flex-direction: column; gap: 2px; font-size: 22px; margin-top: 4px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
2277
+ .stat-card { position: relative; overflow: hidden; }
2278
+ .stat-card::after { content: ""; position: absolute; inset: 0 0 auto; height: 2px; background: linear-gradient(90deg, var(--accent), transparent 78%); opacity: 0.7; }
2279
+ .card .k { color: var(--faint); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; }
2280
+ .card .v { display: flex; flex-direction: column; gap: 2px; font-size: 24px; font-weight: 650; margin-top: 7px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; letter-spacing: -0.025em; }
2024
2281
  .card .v .clock-time { font-size: 14px; color: var(--muted); }
2025
- .split { display: grid; grid-template-columns: minmax(0, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
2282
+ .section-card { background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-xs); overflow: hidden; }
2283
+ .section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--line); }
2284
+ .section-title h3 { margin: 0; }
2285
+ .split { display: grid; grid-template-columns: minmax(0, 250px) minmax(0, 1fr); gap: 18px; align-items: start; }
2286
+ #collections { padding: 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); box-shadow: var(--shadow-xs); }
2026
2287
  .collection {
2027
2288
  width: 100%; text-align: left; border: 1px solid transparent; background: transparent;
2028
- border-radius: 10px; padding: 8px 10px; min-height: 44px;
2289
+ border-radius: 9px; padding: 10px 11px; min-height: 48px; color: var(--ink); cursor: pointer;
2029
2290
  }
2030
- .collection[aria-current="true"] { background: var(--bg-elev); border-color: var(--line); }
2291
+ .collection:hover { background: var(--bg-muted); }
2292
+ .collection[aria-current="true"] { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 18%, var(--line)); color: var(--accent); }
2031
2293
  .collection small { display: block; color: var(--faint); }
2032
2294
  .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 14px; }
2033
- .chip {
2034
- font-family: var(--mono); font-size: 12px; background: var(--bg-muted);
2035
- border-radius: 999px; padding: 4px 8px;
2036
- }
2295
+ .chip { font-family: var(--mono); font-size: 11px; background: var(--bg-muted); border: 1px solid var(--line); border-radius: 999px; padding: 4px 8px; }
2037
2296
  .chip i { color: var(--faint); font-style: normal; }
2038
2297
  .sql-layout {
2039
2298
  display: grid; grid-template-columns: minmax(10rem, 16rem) minmax(0, 1fr); gap: 16px;
2040
2299
  }
2041
- .sql-tables { display: flex; flex-direction: column; gap: 4px; max-height: 28rem; overflow: auto; }
2042
- .sql-tables button { text-align: left; }
2300
+ .sql-tables { display: flex; flex-direction: column; gap: 4px; max-height: 28rem; overflow: auto; padding: 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); }
2301
+ .sql-tables button { text-align: left; border: 0; border-radius: 8px; padding: 9px 10px; background: transparent; color: var(--muted); cursor: pointer; }
2302
+ .sql-tables button:hover, .sql-tables button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); }
2043
2303
  .sql-input { font-family: var(--mono); min-height: 7rem; width: 100%; }
2044
- table { width: 100%; border-collapse: collapse; font-size: 14px; }
2045
- th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
2046
- th { color: var(--faint); font-weight: 600; font-size: 12px; }
2304
+ .table-shell { width: 100%; overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); box-shadow: var(--shadow-xs); }
2305
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
2306
+ th, td { text-align: left; padding: 11px 13px; border-bottom: 1px solid var(--line); vertical-align: top; }
2307
+ th { color: var(--faint); background: var(--bg-inset); font-weight: 650; font-size: 11px; text-transform: uppercase; letter-spacing: 0.045em; }
2308
+ tbody tr:last-child td { border-bottom: 0; }
2047
2309
  tr.clickable { cursor: pointer; }
2048
- tr.clickable:hover { background: var(--bg-muted); }
2310
+ tr.clickable:hover { background: var(--accent-soft); }
2049
2311
  pre, code, .mono { font-family: var(--mono); overflow-wrap: anywhere; }
2050
2312
  pre {
2051
2313
  margin: 0; white-space: pre-wrap; word-break: break-word;
2052
- background: var(--bg-inset); border-radius: 10px; padding: 12px; font-size: 12px;
2053
- }
2054
- .row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
2314
+ background: var(--bg-inset); border: 1px solid var(--line); border-radius: 10px; padding: 14px; font-size: 12px; line-height: 1.65;
2315
+ }
2316
+ .json-key { color: var(--code-key); }
2317
+ .json-string { color: var(--code-string); }
2318
+ .json-number { color: var(--code-number); }
2319
+ .json-boolean { color: var(--code-boolean); }
2320
+ .json-null { color: var(--code-null); font-style: italic; }
2321
+ .json-inline { display: block; max-width: 44rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
2322
+ .json-view { position: relative; max-height: 26rem; overflow: auto; }
2323
+ .json-editor { position: relative; min-height: 190px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--bg-inset); overflow: hidden; }
2324
+ .json-editor:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
2325
+ .json-editor.invalid { border-color: var(--err); }
2326
+ .json-editor pre, .json-editor textarea { margin: 0; padding: 12px; border: 0; border-radius: 0; min-height: 190px; width: 100%; font: 12px/1.65 var(--mono); tab-size: 2; white-space: pre; overflow: auto; }
2327
+ .json-editor pre { position: absolute; inset: 0; pointer-events: none; background: transparent; }
2328
+ .json-editor textarea { position: relative; z-index: 1; resize: vertical; background: transparent; color: transparent; caret-color: var(--ink); -webkit-text-fill-color: transparent; }
2329
+ .json-editor textarea::selection { background: color-mix(in srgb, var(--accent) 32%, transparent); }
2330
+ .editor-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 7px; }
2331
+ .json-status { display: inline-flex; align-items: center; gap: 6px; color: var(--ok); font-size: 11px; }
2332
+ .json-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
2333
+ .json-status.invalid { color: var(--err); }
2334
+ .format-json { border: 0; padding: 3px 5px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 650; cursor: pointer; }
2335
+ .row-actions { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; margin: 14px 0; }
2055
2336
  .banner {
2056
- display: none; margin-bottom: 12px; padding: 10px 12px; border-radius: 10px;
2057
- background: var(--err-soft); color: var(--err);
2058
- }
2059
- .banner.show { display: block; }
2060
- .empty { color: var(--muted); padding: 24px 8px; }
2337
+ display: none; position: sticky; top: calc(var(--header) + 12px); z-index: 15;
2338
+ align-items: center; gap: 10px; margin: 0 0 16px; padding: 11px 12px 11px 14px;
2339
+ border: 1px solid color-mix(in srgb, var(--err) 30%, var(--line)); border-radius: 10px;
2340
+ background: var(--err-soft); color: var(--err); box-shadow: var(--shadow-sm); font-size: 13px;
2341
+ }
2342
+ .banner.show { display: flex; }
2343
+ .banner span { flex: 1; }
2344
+ .banner button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 7px; background: transparent; color: currentColor; cursor: pointer; }
2345
+ .banner button:hover { background: color-mix(in srgb, var(--err) 10%, transparent); }
2346
+ .empty { color: var(--muted); padding: 34px 18px; text-align: center; border: 1px dashed var(--line-strong); border-radius: var(--radius); background: color-mix(in srgb, var(--bg-elev) 55%, transparent); }
2061
2347
  dialog {
2062
- border: 1px solid var(--line); border-radius: var(--radius); padding: 0; background: var(--bg-elev);
2063
- color: var(--ink); width: min(640px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: none;
2064
- }
2065
- dialog::backdrop { background: rgb(20 19 17 / 0.45); }
2066
- dialog form, .dialog-body { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
2348
+ border: 1px solid var(--line); border-radius: 16px; padding: 0; background: var(--bg-elev);
2349
+ color: var(--ink); width: min(720px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: var(--shadow-lg);
2350
+ }
2351
+ dialog[open] { animation: dialog-in 160ms ease-out; }
2352
+ dialog::backdrop { background: rgb(12 14 20 / 0.58); backdrop-filter: blur(3px); }
2353
+ dialog form, .dialog-body { padding: 22px; display: flex; flex-direction: column; gap: 14px; }
2354
+ .dialog-heading { display: flex; justify-content: space-between; gap: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
2355
+ .dialog-heading strong { font-size: 18px; letter-spacing: -0.02em; }
2356
+ .dialog-heading span { color: var(--muted); font-size: 12px; }
2067
2357
  textarea {
2068
2358
  width: 100%; min-height: 180px; resize: vertical; border-radius: 10px;
2069
2359
  border: 1px solid var(--line); background: var(--bg-inset); padding: 10px;
2070
2360
  font-family: var(--mono); font-size: 12px;
2071
2361
  }
2072
- .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
2073
- .badge { font-size: 11px; border-radius: 999px; padding: 2px 7px; background: var(--bg-muted); color: var(--muted); }
2362
+ .dialog-actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; padding-top: 4px; }
2363
+ .dialog-actions .btn-danger { margin-right: auto; }
2364
+ .badge { display: inline-flex; align-items: center; font-size: 10px; font-weight: 700; letter-spacing: 0.03em; border: 1px solid var(--line); border-radius: 999px; padding: 3px 7px; background: var(--bg-muted); color: var(--muted); }
2074
2365
  .badge.ok { background: var(--ok-soft); color: var(--ok); }
2075
2366
  .badge.warn { background: var(--warn-soft); color: var(--warn); }
2076
2367
  .status-2 { color: var(--ok); }
2077
2368
  .status-4, .status-5, .status-0 { color: var(--err); }
2078
2369
  .stack { display: flex; flex-direction: column; gap: 12px; }
2370
+ .form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
2371
+ .form-grid .wide { grid-column: span 2; }
2079
2372
  .route {
2080
- display: grid; grid-template-columns: 88px 1fr auto; gap: 8px; align-items: center;
2081
- padding: 8px 0; border-bottom: 1px solid var(--line);
2082
- }
2083
- .method { font-family: var(--mono); font-size: 12px; font-weight: 700; }
2373
+ display: grid; grid-template-columns: 84px minmax(0, 1fr) auto; gap: 10px; align-items: center;
2374
+ padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--bg-elev);
2375
+ }
2376
+ .route:first-child { border-radius: var(--radius) var(--radius) 0 0; }
2377
+ .route:last-child { border-bottom: 0; border-radius: 0 0 var(--radius) var(--radius); }
2378
+ .route-tools { display: flex; align-items: flex-end; gap: 12px; margin-bottom: 12px; }
2379
+ .route-tools .field { flex: 1; }
2380
+ .route-tools input { width: 100%; }
2381
+ .route-count { padding-bottom: 10px; color: var(--faint); font-size: 12px; white-space: nowrap; }
2382
+ #route-list { max-height: 34rem; border: 1px solid var(--line); border-radius: var(--radius); overflow: auto; box-shadow: var(--shadow-xs); }
2383
+ .method { width: fit-content; font-family: var(--mono); font-size: 11px; font-weight: 750; color: var(--accent); background: var(--accent-soft); border-radius: 6px; padding: 4px 6px; }
2384
+ @keyframes view-in { from { transform: translateY(3px); } to { transform: translateY(0); } }
2385
+ @keyframes dialog-in { from { transform: translateY(8px) scale(0.99); } to { transform: translateY(0) scale(1); } }
2386
+ @keyframes pulse { 50% { opacity: 0.45; } }
2387
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
2388
+ @media (forced-colors: active) { .json-editor pre { display: none; } .json-editor textarea { color: CanvasText; -webkit-text-fill-color: CanvasText; } }
2084
2389
  @media (max-width: 800px) {
2390
+ :root { --header: 0px; }
2085
2391
  header.top {
2086
- align-items: stretch; flex-direction: column;
2087
- padding-top: max(10px, env(safe-area-inset-top));
2392
+ position: relative; align-items: stretch; flex-direction: column;
2393
+ padding: max(12px, env(safe-area-inset-top)) 14px 12px;
2088
2394
  }
2089
2395
  input, select, textarea { font-size: 16px; }
2090
2396
  .brand, .vendor, .controls { width: 100%; }
2397
+ .brand { gap: 10px; }
2398
+ .vendor { order: 2; border-radius: 9px; }
2091
2399
  label.field, label.field select, label.field input { flex: 1; min-width: 0; }
2400
+ .sync-status { display: none; }
2092
2401
  /* The layout fills the screen. A single-column grid stretches every auto
2093
2402
  row, so the tab bar grew with the leftover height and the buttons floated
2094
2403
  in the middle of it. The bar stays one row; the page below takes the rest. */
@@ -2098,14 +2407,23 @@ var renderAdminDocument = (service) => {
2098
2407
  align-content: start;
2099
2408
  }
2100
2409
  nav.side {
2101
- align-self: start; height: auto;
2410
+ position: sticky; top: 0; z-index: 12; align-self: start; height: auto;
2102
2411
  flex-direction: row; align-items: center; overflow-x: auto; min-width: 0;
2103
2412
  border-right: 0; border-bottom: 1px solid var(--line);
2104
- padding: 8px;
2413
+ padding: 8px; box-shadow: var(--shadow-xs);
2105
2414
  }
2106
- nav.side button { white-space: nowrap; flex: none; }
2415
+ .nav-label { display: none; }
2416
+ nav.side button { width: auto; white-space: nowrap; flex: none; }
2417
+ nav.side button[aria-current="true"]::before { inset: auto 9px -8px; width: auto; height: 3px; border-radius: 3px 3px 0 0; }
2107
2418
  .split, .sql-layout { grid-template-columns: minmax(0, 1fr); }
2108
- main { padding: 14px; }
2419
+ .form-grid { grid-template-columns: minmax(0, 1fr); }
2420
+ .form-grid .wide { grid-column: auto; }
2421
+ main { padding: 22px 14px 48px; }
2422
+ .view-head { margin-bottom: 18px; }
2423
+ .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
2424
+ .card { padding: 14px; }
2425
+ .card .v { font-size: 20px; }
2426
+ .table-shell { border: 0; background: transparent; box-shadow: none; overflow: visible; }
2109
2427
  table, thead, tbody, tr, th, td { display: block; }
2110
2428
  thead { display: none; }
2111
2429
  tr {
@@ -2120,50 +2438,63 @@ var renderAdminDocument = (service) => {
2120
2438
  .route { grid-template-columns: 72px 1fr; }
2121
2439
  .route button { grid-column: 1 / -1; }
2122
2440
  }
2441
+ @media (max-width: 460px) {
2442
+ .controls label.field { min-width: calc(50% - 4px); }
2443
+ .controls .icon-btn, .controls .btn { flex: 1; }
2444
+ .grid { grid-template-columns: minmax(0, 1fr); }
2445
+ .dialog-actions { flex-wrap: wrap; }
2446
+ .dialog-actions button { flex: 1; }
2447
+ .dialog-actions .btn-danger { flex-basis: 100%; margin-right: 0; }
2448
+ }
2123
2449
  </style>
2124
2450
  </head>
2125
2451
  <body>
2126
2452
  <div class="app" data-service="${name}" data-mockingbird-admin>
2127
2453
  <header class="top">
2128
2454
  <div class="brand">
2129
- <div class="brand-id">
2130
- <strong>${name}</strong>
2131
- <span>Admin</span>
2455
+ <div class="brand-lockup">
2456
+ <div class="brand-mark" aria-hidden="true">
2457
+ <svg viewBox="0 0 24 24" fill="none"><path d="M5 17.5V7l7 5 7-5v10.5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/><path d="M8 15.5 12 18l4-2.5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
2458
+ </div>
2459
+ <div class="brand-id">
2460
+ <strong>${name}</strong>
2461
+ <span>Mock admin</span>
2462
+ </div>
2132
2463
  </div>
2133
2464
  <div class="vendor" id="vendor" hidden></div>
2134
2465
  </div>
2135
2466
  <div class="controls">
2467
+ <span class="sync-status" id="sync-status" role="status" aria-live="polite">Ready</span>
2136
2468
  <label class="field">Namespace
2137
2469
  <select id="namespace" aria-label="Namespace"></select>
2138
2470
  </label>
2139
2471
  <label class="field">Admin key
2140
2472
  <input id="admin-key" type="password" autocomplete="off" spellcheck="false" aria-label="Admin key">
2141
2473
  </label>
2142
- <button class="icon-btn" id="theme" type="button" aria-label="Toggle color theme">Theme</button>
2143
- <button class="btn" id="refresh" type="button">Refresh</button>
2474
+ <button class="icon-btn" id="theme" type="button" aria-label="Use dark theme" title="Use dark theme"></button>
2475
+ <button class="btn" id="refresh" type="button"><svg viewBox="0 0 20 20" fill="none" aria-hidden="true"><path d="M15.8 7.2A6.2 6.2 0 1 0 16 12" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><path d="M12.8 7.2h3v-3" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>Refresh</button>
2144
2476
  </div>
2145
2477
  </header>
2146
2478
  <div class="layout">
2147
2479
  <nav class="side" id="nav" aria-label="Admin sections">
2148
- <button type="button" data-view="overview" aria-current="true">Overview</button>
2149
- <button type="button" data-view="state">State</button>
2150
- <button type="button" data-view="clock">Clock</button>
2151
- <button type="button" data-view="faults">Faults</button>
2152
- <button type="button" data-view="journal">Journal</button>
2153
- <button type="button" data-view="routes">Routes</button>
2480
+ <span class="nav-label">Workspace</span>
2481
+ <button type="button" data-view="overview" aria-current="true"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><rect x="2.5" y="2.5" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.5"/><rect x="11.5" y="2.5" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.5"/><rect x="2.5" y="11.5" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.5"/><rect x="11.5" y="11.5" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.5"/></svg></span>Overview</button>
2482
+ <button type="button" data-view="state"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><ellipse cx="10" cy="5" rx="6.5" ry="2.5" stroke="currentColor" stroke-width="1.5"/><path d="M3.5 5v5c0 1.4 2.9 2.5 6.5 2.5s6.5-1.1 6.5-2.5V5M3.5 10v5c0 1.4 2.9 2.5 6.5 2.5s6.5-1.1 6.5-2.5v-5" stroke="currentColor" stroke-width="1.5"/></svg></span>State</button>
2483
+ <button type="button" data-view="clock"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="7.5" stroke="currentColor" stroke-width="1.5"/><path d="M10 5.8v4.6l3 1.8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></span>Clock</button>
2484
+ <button type="button" data-view="faults"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><path d="m11.2 2.8-6.5 9h5.1l-1 5.4 6.5-9h-5.1l1-5.4Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg></span>Faults</button>
2485
+ <button type="button" data-view="journal"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><path d="M5 3.5h10a1.5 1.5 0 0 1 1.5 1.5v12l-2.2-1.4-2.2 1.4-2.1-1.4L7.8 17l-2.2-1.4L3.5 17V5A1.5 1.5 0 0 1 5 3.5Z" stroke="currentColor" stroke-width="1.5"/><path d="M6.7 7h6.6M6.7 10h6.6M6.7 13h3.8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></span>Journal</button>
2486
+ <button type="button" data-view="routes"><span class="nav-icon" aria-hidden="true"><svg viewBox="0 0 20 20" fill="none"><path d="M6.5 4.5h7a2 2 0 0 1 2 2v7M13 11l2.5 2.5L18 11M13.5 15.5h-7a2 2 0 0 1-2-2v-7M7 9 4.5 6.5 2 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></span>Routes</button>
2154
2487
  </nav>
2155
2488
  <main id="views">
2156
- <p class="banner" id="banner" role="alert"></p>
2489
+ <div class="banner" id="banner" role="alert"><span id="banner-message"></span><button id="banner-dismiss" type="button" aria-label="Dismiss error">\xD7</button></div>
2157
2490
  <section class="view" id="view-overview">
2158
- <h2>Overview</h2>
2159
- <p class="lede">Health, clock, and the collections this namespace is holding.</p>
2491
+ <div class="view-head"><div><h2>Overview</h2><p class="lede">Health, clock, and the collections this namespace is holding.</p></div></div>
2160
2492
  <div class="grid" id="overview-cards"></div>
2161
2493
  <h3>Recent requests</h3>
2162
2494
  <div id="overview-requests"></div>
2163
2495
  </section>
2164
2496
  <section class="view" id="view-state" hidden>
2165
- <h2>State</h2>
2166
- <p class="lede">Every collection in this namespace. Declared fields stay visible before any row exists; stored rows add whatever else they contain.</p>
2497
+ <div class="view-head"><div><h2>State</h2><p class="lede">Inspect and edit every collection in this namespace.</p></div></div>
2167
2498
  <div class="split">
2168
2499
  <div id="collections"></div>
2169
2500
  <div>
@@ -2176,8 +2507,7 @@ var renderAdminDocument = (service) => {
2176
2507
  </div>
2177
2508
  </section>
2178
2509
  <section class="view" id="view-clock" hidden>
2179
- <h2>Clock</h2>
2180
- <p class="lede">The clock every timestamp in this mock reads.</p>
2510
+ <div class="view-head"><div><h2>Clock</h2><p class="lede">Control the source of time used by every timestamp in this mock.</p></div></div>
2181
2511
  <div class="card" id="clock-readout"></div>
2182
2512
  <div class="row-actions">
2183
2513
  <button class="btn" type="button" data-advance="60000">+1m</button>
@@ -2196,21 +2526,22 @@ var renderAdminDocument = (service) => {
2196
2526
  </form>
2197
2527
  </section>
2198
2528
  <section class="view" id="view-faults" hidden>
2199
- <h2>Faults</h2>
2200
- <p class="lede">Inject a response, a delay, or a named preset. Rules apply to this namespace.</p>
2529
+ <div class="view-head"><div><h2>Faults</h2><p class="lede">Test unhappy paths with a response, delay, or named preset scoped to this namespace.</p></div></div>
2201
2530
  <form id="fault-form" class="card stack">
2202
- <label class="field">Preset
2203
- <select id="fault-preset"><option value="">Custom rule</option></select>
2204
- </label>
2205
- <label class="field">Status
2206
- <input id="fault-status" inputmode="numeric" placeholder="503">
2207
- </label>
2208
- <label class="field">Path prefix
2209
- <input id="fault-path" placeholder="/v1">
2210
- </label>
2211
- <label class="field">Method
2212
- <input id="fault-method" placeholder="GET">
2213
- </label>
2531
+ <div class="form-grid">
2532
+ <label class="field wide">Preset
2533
+ <select id="fault-preset"><option value="">Custom rule</option></select>
2534
+ </label>
2535
+ <label class="field">Status
2536
+ <input id="fault-status" inputmode="numeric" placeholder="503">
2537
+ </label>
2538
+ <label class="field wide">Path prefix
2539
+ <input id="fault-path" placeholder="/v1">
2540
+ </label>
2541
+ <label class="field">Method
2542
+ <input id="fault-method" placeholder="GET">
2543
+ </label>
2544
+ </div>
2214
2545
  <div class="dialog-actions">
2215
2546
  <button class="btn-primary" type="submit">Add fault</button>
2216
2547
  <button class="btn-danger" id="fault-clear" type="button">Clear all</button>
@@ -2219,26 +2550,31 @@ var renderAdminDocument = (service) => {
2219
2550
  <div id="fault-list"></div>
2220
2551
  </section>
2221
2552
  <section class="view" id="view-journal" hidden>
2222
- <h2>Journal</h2>
2223
- <p class="lede">Requests this namespace has seen. Bodies are not stored.</p>
2224
- <div class="row-actions">
2225
- <button class="btn-danger" id="journal-clear" type="button">Clear journal</button>
2226
- </div>
2553
+ <div class="view-head"><div><h2>Journal</h2><p class="lede">Requests this namespace has seen. Bodies are never stored.</p></div><button class="btn-danger" id="journal-clear" type="button">Clear journal</button></div>
2227
2554
  <div id="journal"></div>
2228
2555
  </section>
2229
2556
  <section class="view" id="view-routes" hidden>
2230
- <h2>Routes</h2>
2231
- <p class="lede">Shared controls, plus any routes this mock adds. Extra routes are how a bespoke workflow shows up beside the default UI.</p>
2557
+ <div class="view-head"><div><h2>Routes</h2><p class="lede">Explore shared controls and service-specific admin endpoints.</p></div></div>
2558
+ <div class="route-tools">
2559
+ <label class="field">Filter routes
2560
+ <input id="route-search" type="search" placeholder="Method, path, or extension" autocomplete="off">
2561
+ </label>
2562
+ <span class="route-count" id="route-count"></span>
2563
+ </div>
2232
2564
  <div id="route-list"></div>
2233
- <form id="route-form" class="card stack">
2565
+ <form id="route-form" class="card stack" style="margin-top: 18px">
2234
2566
  <strong id="route-title">Select a route</strong>
2235
2567
  <label class="field">JSON body
2236
- <textarea id="route-body" placeholder="{ }"></textarea>
2568
+ <div class="json-editor" id="route-editor" hidden>
2569
+ <pre aria-hidden="true"><code id="route-highlight"></code></pre>
2570
+ <textarea id="route-body" placeholder="{ }" spellcheck="false" aria-describedby="route-json-status" disabled></textarea>
2571
+ </div>
2572
+ <span class="editor-meta" hidden><span class="json-status" id="route-json-status">Valid JSON</span><button class="format-json" type="button" data-format="route-body">Format JSON</button></span>
2237
2573
  </label>
2238
2574
  <div class="dialog-actions">
2239
- <button class="btn-primary" type="submit">Send</button>
2575
+ <button class="btn-primary" id="route-send" type="submit" disabled>Send request</button>
2240
2576
  </div>
2241
- <pre id="route-result">Response will show here.</pre>
2577
+ <pre class="json-view" id="route-result"><code>Response will show here.</code></pre>
2242
2578
  </form>
2243
2579
  </section>
2244
2580
  </main>
@@ -2246,12 +2582,16 @@ var renderAdminDocument = (service) => {
2246
2582
  </div>
2247
2583
  <dialog id="editor">
2248
2584
  <form method="dialog" id="editor-form">
2249
- <strong id="editor-title">Record</strong>
2585
+ <div class="dialog-heading"><div><strong id="editor-title">Record</strong><br><span>Edit the ID and JSON value stored in this collection.</span></div></div>
2250
2586
  <label class="field">Id
2251
2587
  <input id="editor-id" required>
2252
2588
  </label>
2253
2589
  <label class="field">JSON value
2254
- <textarea id="editor-value"></textarea>
2590
+ <div class="json-editor" id="record-editor">
2591
+ <pre aria-hidden="true"><code id="editor-highlight"></code></pre>
2592
+ <textarea id="editor-value" spellcheck="false" aria-describedby="editor-json-status"></textarea>
2593
+ </div>
2594
+ <span class="editor-meta"><span class="json-status" id="editor-json-status">Valid JSON</span><button class="format-json" type="button" data-format="editor-value">Format JSON</button></span>
2255
2595
  </label>
2256
2596
  <div class="dialog-actions">
2257
2597
  <button class="btn-danger" id="editor-delete" type="button" value="delete">Delete</button>
@@ -2293,8 +2633,8 @@ var describeExtension = (extension) => {
2293
2633
  ...extension.script !== void 0 ? { script: extension.script } : {}
2294
2634
  };
2295
2635
  };
2296
- var adminUiRoutes = (service, ui) => {
2297
- const shell = () => renderAdminDocument(service);
2636
+ var adminUiRoutes = (service, ui, adminPrefix = ADMIN_PREFIX) => {
2637
+ const shell = () => renderAdminDocument(service, adminPrefix);
2298
2638
  const document2 = () => ui?.render ? ui.render({ service, defaultHtml: shell }) : shell();
2299
2639
  return {
2300
2640
  "GET /ui": () => htmlResponse(document2()),
@@ -2625,7 +2965,7 @@ var PAGE_DEFAULT = 50;
2625
2965
  var PAGE_MAX = 200;
2626
2966
  var json3 = (status, body) => new Response(JSON.stringify(body), {
2627
2967
  status,
2628
- headers: { "content-type": "application/json" }
2968
+ headers: { "content-type": "application/json; charset=utf-8" }
2629
2969
  });
2630
2970
  var adminError2 = (status, message) => json3(status, { error: { type: "mockingbird_admin", message } });
2631
2971
  var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -2878,6 +3218,7 @@ var sqlRoutes = (engine) => ({
2878
3218
  }
2879
3219
  });
2880
3220
  var createEngineAdmin = (options) => {
3221
+ const adminPrefix = resolveAdminPrefix(options.adminPrefix);
2881
3222
  const engines = /* @__PURE__ */ new Map();
2882
3223
  const engine = (namespace) => {
2883
3224
  const existing = engines.get(namespace);
@@ -2892,7 +3233,7 @@ var createEngineAdmin = (options) => {
2892
3233
  ...credentialRoutes(createCredentialRegistry()),
2893
3234
  ...stateRoutes(engine),
2894
3235
  ...sqlRoutes(engine),
2895
- ...adminUiRoutes(options.name, { extensions: [SQL_ADMIN_EXTENSION] })
3236
+ ...adminUiRoutes(options.name, { extensions: [SQL_ADMIN_EXTENSION] }, adminPrefix)
2896
3237
  };
2897
3238
  const startedAt = Date.now();
2898
3239
  const plane = createControlPlane({
@@ -2923,6 +3264,7 @@ var createEngineAdmin = (options) => {
2923
3264
  },
2924
3265
  describe: () => options.dialect === void 0 ? {} : { dialect: options.dialect },
2925
3266
  routes,
3267
+ adminPrefix,
2926
3268
  adminKey: options.adminKey
2927
3269
  });
2928
3270
  return {
@@ -26762,6 +27104,7 @@ function createAdmin(options = {}) {
26762
27104
  return createEngineAdmin({
26763
27105
  name: "postgres",
26764
27106
  dialect: "postgres",
27107
+ ...options.adminPrefix !== void 0 ? { adminPrefix: options.adminPrefix } : {},
26765
27108
  ...options.adminKey !== void 0 ? { adminKey: options.adminKey } : {},
26766
27109
  open(namespace) {
26767
27110
  if (namespace === "default" && seeded) return adapt(seeded);