@crvouga/mockingbird-service-livekit 1.8.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.
@@ -110,13 +110,31 @@ var Collection = class {
110
110
  };
111
111
 
112
112
  // ../core/dist/control.js
113
- var HEALTH_PATH = "/health";
114
113
  var ADMIN_PREFIX = "/__admin";
114
+ var resolveAdminPrefix = (prefix = ADMIN_PREFIX) => {
115
+ if (!/^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(prefix)) {
116
+ throw new Error("adminPrefix must be an absolute non-root path of literal segments, without a trailing slash");
117
+ }
118
+ return prefix;
119
+ };
120
+ var isAdminPath = (path, prefix) => path === prefix || path.startsWith(`${prefix}/`);
121
+ var matchNamespacePath = (path, prefix) => /^\/ns\/([^/]+)(\/.*)?$/.exec(isAdminPath(path, prefix) ? path.slice(prefix.length) : "");
122
+ var assertAdminPrefixAvailable = (prefix, paths) => {
123
+ const reserved = prefix.split("/").slice(1);
124
+ for (const path of paths) {
125
+ const segments = path.split("/").slice(1);
126
+ if (segments.length < reserved.length || /^\{[^}]+\}$/.test(segments[0] ?? ""))
127
+ continue;
128
+ if (reserved.every((part, i) => segments[i] === part || /^\{[^}]+\}$/.test(segments[i] ?? ""))) {
129
+ throw new Error(`vendor route ${path} collides with adminPrefix ${prefix}; choose a different adminPrefix`);
130
+ }
131
+ }
132
+ };
115
133
  var ADMIN_KEY_HEADER = "x-mockingbird-admin-key";
116
134
  var NAMESPACE_HEADER = "x-mockingbird-namespace";
117
135
  var json = (status, body) => new Response(JSON.stringify(body), {
118
136
  status,
119
- headers: { "content-type": "application/json" }
137
+ headers: { "content-type": "application/json; charset=utf-8" }
120
138
  });
121
139
  var adminError = (status, message) => json(status, { error: { type: "mockingbird_admin", message } });
122
140
  var UNITS = {
@@ -168,11 +186,25 @@ var readJson = async (request) => {
168
186
  };
169
187
  var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
170
188
  var createControlPlane = (context) => {
189
+ const adminPrefix = resolveAdminPrefix(context.adminPrefix);
171
190
  const snapshots = /* @__PURE__ */ new Map();
172
191
  let snapshotCounter = 0;
173
192
  const headerNamespace = (request) => request.headers.get(NAMESPACE_HEADER) ?? context.defaultNamespace;
174
193
  const adminNamespace = (request, url) => url.searchParams.get("namespace") ?? headerNamespace(request);
194
+ const health = () => {
195
+ return json(200, {
196
+ ...context.describe(),
197
+ status: "ok",
198
+ service: context.name,
199
+ uptimeMs: context.wallNow() - context.startedAt,
200
+ clock: context.clock.state(),
201
+ namespaces: context.namespaces().length,
202
+ adminPrefix,
203
+ adminUi: `${adminPrefix}/ui`
204
+ });
205
+ };
175
206
  const builtin = {
207
+ "GET /health": health,
176
208
  "GET /": () => json(200, {
177
209
  service: context.name,
178
210
  routes: [...Object.keys(builtin), ...Object.keys(context.routes)].sort()
@@ -339,7 +371,14 @@ var createControlPlane = (context) => {
339
371
  return json(200, { status: "ok" });
340
372
  }
341
373
  };
342
- const routes = [...Object.entries(context.routes), ...Object.entries(builtin)].map(([key, handler]) => {
374
+ for (const key of Object.keys(context.routes)) {
375
+ if (Object.hasOwn(builtin, key))
376
+ throw new Error(`admin route ${key} overrides a shared route`);
377
+ const path = key.slice(key.indexOf(" ") + 1);
378
+ if (isAdminPath(path, "/ns") || isAdminPath(path, "/blobs"))
379
+ throw new Error(`admin route ${key} overlaps an internal transport`);
380
+ }
381
+ const routes = [...Object.entries(builtin), ...Object.entries(context.routes)].map(([key, handler]) => {
343
382
  const space = key.indexOf(" ");
344
383
  return { method: key.slice(0, space), pattern: key.slice(space + 1), handler };
345
384
  });
@@ -347,21 +386,13 @@ var createControlPlane = (context) => {
347
386
  namespaceOf: headerNamespace,
348
387
  async handle(request) {
349
388
  const url = new URL(request.url);
350
- if (url.pathname === HEALTH_PATH && request.method === "GET") {
351
- return json(200, {
352
- status: "ok",
353
- service: context.name,
354
- uptimeMs: context.wallNow() - context.startedAt,
355
- clock: context.clock.state(),
356
- namespaces: context.namespaces().length,
357
- adminUi: `${ADMIN_PREFIX}/ui`,
358
- ...context.describe()
359
- });
389
+ if (url.pathname === `${adminPrefix}/health` && request.method === "GET") {
390
+ return health();
360
391
  }
361
- if (url.pathname !== ADMIN_PREFIX && !url.pathname.startsWith(`${ADMIN_PREFIX}/`)) {
392
+ if (!isAdminPath(url.pathname, adminPrefix)) {
362
393
  return void 0;
363
394
  }
364
- const path = url.pathname.slice(ADMIN_PREFIX.length) || "/";
395
+ const path = url.pathname.slice(adminPrefix.length) || "/";
365
396
  const shell = request.method === "GET" && (path === "/ui" || path === "/ui/");
366
397
  if (!shell && context.adminKey !== void 0 && request.headers.get(ADMIN_KEY_HEADER) !== context.adminKey) {
367
398
  return adminError(401, `missing or wrong ${ADMIN_KEY_HEADER}`);
@@ -369,7 +400,12 @@ var createControlPlane = (context) => {
369
400
  for (const route of routes) {
370
401
  if (route.method !== request.method)
371
402
  continue;
372
- const params = matchRoute(route.pattern, path);
403
+ let params;
404
+ try {
405
+ params = matchRoute(route.pattern, path);
406
+ } catch {
407
+ return adminError(400, "invalid path encoding");
408
+ }
373
409
  if (!params)
374
410
  continue;
375
411
  let body;
@@ -378,15 +414,18 @@ var createControlPlane = (context) => {
378
414
  } catch {
379
415
  return adminError(400, "request body is not valid JSON");
380
416
  }
417
+ const namespace = adminNamespace(request, url);
418
+ if (!/^[A-Za-z0-9_.-]{1,64}$/.test(namespace))
419
+ return adminError(400, "invalid namespace");
381
420
  return route.handler({
382
421
  request,
383
422
  url,
384
423
  params,
385
- namespace: adminNamespace(request, url),
424
+ namespace,
386
425
  body
387
426
  });
388
427
  }
389
- return adminError(404, `no admin route ${request.method} ${path}; GET ${ADMIN_PREFIX} lists them`);
428
+ return adminError(404, `no admin route ${request.method} ${path}; GET ${adminPrefix} lists them`);
390
429
  }
391
430
  };
392
431
  };
@@ -1391,6 +1430,34 @@ dialog:not([open]) {
1391
1430
  function isRecord2(value) {
1392
1431
  return typeof value === "object" && value !== null && !Array.isArray(value);
1393
1432
  }
1433
+ function highlightJsonText(text) {
1434
+ const escapeToken = (value) => value.replace(/[&<>"']/g, (char) => {
1435
+ switch (char) {
1436
+ case "&":
1437
+ return "&amp;";
1438
+ case "<":
1439
+ return "&lt;";
1440
+ case ">":
1441
+ return "&gt;";
1442
+ case '"':
1443
+ return "&quot;";
1444
+ default:
1445
+ return "&#39;";
1446
+ }
1447
+ });
1448
+ 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;
1449
+ let cursor = 0;
1450
+ let html = "";
1451
+ for (const match2 of text.matchAll(token)) {
1452
+ const index = match2.index ?? 0;
1453
+ const value = match2[0];
1454
+ html += escapeToken(text.slice(cursor, index));
1455
+ const kind = value.startsWith('"') ? value.trimEnd().endsWith(":") ? "key" : "string" : value === "true" || value === "false" ? "boolean" : value === "null" ? "null" : "number";
1456
+ html += `<span class="json-${kind}">${escapeToken(value)}</span>`;
1457
+ cursor = index + value.length;
1458
+ }
1459
+ return html + escapeToken(text.slice(cursor));
1460
+ }
1394
1461
  function mountAdminBrand(doc, host, service, catalog) {
1395
1462
  const clip = (value, max) => {
1396
1463
  if (typeof value !== "string")
@@ -1752,14 +1819,16 @@ function bootAdmin(config) {
1752
1819
  const state = {
1753
1820
  namespace: "default",
1754
1821
  key: sessionStorage.getItem("mockingbird-admin-key") || "",
1755
- view: "overview",
1822
+ view: ["overview", "state", "clock", "faults", "journal", "routes"].includes(location.hash.slice(1)) ? location.hash.slice(1) : "overview",
1756
1823
  collection: "",
1757
1824
  shape: null,
1758
1825
  route: null,
1826
+ routes: [],
1759
1827
  editing: null
1760
1828
  };
1761
1829
  const standard = new Set(config.standardRoutes);
1762
1830
  const banner = el("banner", HTMLElement);
1831
+ const bannerMessage = el("banner-message", HTMLElement);
1763
1832
  const keyInput = el("admin-key", HTMLInputElement);
1764
1833
  keyInput.value = state.key;
1765
1834
  const params = new URLSearchParams(location.search);
@@ -1770,18 +1839,21 @@ function bootAdmin(config) {
1770
1839
  sessionStorage.setItem("mockingbird-admin-key", state.key);
1771
1840
  params.delete("key");
1772
1841
  const next = params.toString();
1773
- history.replaceState(null, "", location.pathname + (next ? `?${next}` : ""));
1842
+ history.replaceState(null, "", location.pathname + (next ? `?${next}` : "") + location.hash);
1774
1843
  }
1775
1844
  const queryNamespace = params.get("namespace");
1776
1845
  if (queryNamespace)
1777
1846
  state.namespace = queryNamespace;
1778
1847
  const showError = (error2) => {
1779
- banner.textContent = error2 instanceof Error ? error2.message : String(error2);
1848
+ bannerMessage.textContent = error2 instanceof Error ? error2.message : String(error2);
1780
1849
  banner.classList.add("show");
1850
+ banner.scrollIntoView({ behavior: "smooth", block: "nearest" });
1781
1851
  };
1782
1852
  const clearError = () => {
1783
1853
  banner.classList.remove("show");
1854
+ bannerMessage.textContent = "";
1784
1855
  };
1856
+ el("banner-dismiss", HTMLButtonElement).addEventListener("click", clearError);
1785
1857
  const api = async (method, path, body) => {
1786
1858
  const headers = { accept: "application/json" };
1787
1859
  if (state.key !== "")
@@ -1825,16 +1897,21 @@ function bootAdmin(config) {
1825
1897
  namespace: () => state.namespace
1826
1898
  };
1827
1899
  const isPanelScript = (value) => typeof value === "function";
1900
+ const themeButton = el("theme", HTMLButtonElement);
1828
1901
  const setTheme = (theme) => {
1829
1902
  if (theme)
1830
1903
  document.documentElement.dataset.theme = theme;
1831
1904
  else
1832
1905
  delete document.documentElement.dataset.theme;
1906
+ const dark = theme ? theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
1907
+ 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>';
1908
+ const label = dark ? "Use light theme" : "Use dark theme";
1909
+ themeButton.setAttribute("aria-label", label);
1910
+ themeButton.title = label;
1833
1911
  };
1834
1912
  const storedTheme = localStorage.getItem("mockingbird-admin-theme");
1835
- if (storedTheme)
1836
- setTheme(storedTheme);
1837
- el("theme", HTMLButtonElement).addEventListener("click", () => {
1913
+ setTheme(storedTheme === "dark" || storedTheme === "light" ? storedTheme : null);
1914
+ themeButton.addEventListener("click", () => {
1838
1915
  const current = document.documentElement.dataset.theme;
1839
1916
  const dark = current ? current === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
1840
1917
  const next = dark ? "light" : "dark";
@@ -1853,6 +1930,7 @@ function bootAdmin(config) {
1853
1930
  continue;
1854
1931
  section.hidden = section.id !== `view-${view}`;
1855
1932
  }
1933
+ history.replaceState(null, "", `${location.pathname}${location.search}#${encodeURIComponent(view)}`);
1856
1934
  };
1857
1935
  el("nav", HTMLElement).addEventListener("click", (event) => {
1858
1936
  const target = event.target;
@@ -1867,7 +1945,7 @@ function bootAdmin(config) {
1867
1945
  show(view);
1868
1946
  refresh().catch(showError);
1869
1947
  });
1870
- 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>`;
1948
+ 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>`;
1871
1949
  const table = (columns, rows) => {
1872
1950
  if (rows.length === 0)
1873
1951
  return '<p class="empty">Nothing here yet.</p>';
@@ -1877,16 +1955,22 @@ function bootAdmin(config) {
1877
1955
  const attrs = "attrs" in row && typeof row.attrs === "string" ? row.attrs : "";
1878
1956
  return `<tr${attrs}>${cells}</tr>`;
1879
1957
  }).join("");
1880
- return `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
1958
+ return `<div class="table-shell"><table><thead>${head}</thead><tbody>${body}</tbody></table></div>`;
1881
1959
  };
1882
1960
  const preview = (value) => {
1883
1961
  const text = JSON.stringify(value) ?? "null";
1884
- return esc(text.length > 180 ? `${text.slice(0, 180)}\u2026` : text);
1962
+ const clipped = text.length > 180 ? `${text.slice(0, 180)}\u2026` : text;
1963
+ return `<code class="json-inline">${highlightJsonText(clipped)}</code>`;
1885
1964
  };
1886
1965
  const when = (epoch) => {
1887
1966
  const date = new Date(epoch);
1888
1967
  return Number.isNaN(date.getTime()) ? String(epoch) : date.toISOString();
1889
1968
  };
1969
+ const journalWhen = (value) => {
1970
+ const numeric = value.trim() === "" ? Number.NaN : Number(value);
1971
+ const epoch = Number.isFinite(numeric) ? numeric : Date.parse(value);
1972
+ return Number.isFinite(epoch) ? when(epoch) : value;
1973
+ };
1890
1974
  const clockCard = (epoch) => {
1891
1975
  const date = new Date(epoch);
1892
1976
  if (Number.isNaN(date.getTime()))
@@ -2006,7 +2090,7 @@ function bootAdmin(config) {
2006
2090
  };
2007
2091
  const renderOverview = async () => {
2008
2092
  const [health, shape, clock, journal] = await Promise.all([
2009
- originGet("/health"),
2093
+ originGet(`${config.adminPrefix}/health`),
2010
2094
  api("GET", "/state"),
2011
2095
  api("GET", "/clock"),
2012
2096
  api("GET", "/requests?limit=8")
@@ -2023,7 +2107,7 @@ function bootAdmin(config) {
2023
2107
  },
2024
2108
  {
2025
2109
  k: "Status",
2026
- v: esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")
2110
+ v: `<span class="badge ok">${esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")}</span>`
2027
2111
  },
2028
2112
  { k: "Namespace", v: esc(state.namespace) },
2029
2113
  { k: "Collections", v: String(decoded.collections.length) },
@@ -2110,26 +2194,46 @@ function bootAdmin(config) {
2110
2194
  const renderJournal = async () => {
2111
2195
  const journal = await api("GET", "/requests?limit=100");
2112
2196
  el("journal", HTMLElement).innerHTML = table([
2113
- { label: "When", cell: (row) => esc(row.at) },
2114
- { label: "Method", cell: (row) => esc(row.method) },
2197
+ { label: "When", cell: (row) => esc(journalWhen(row.at)) },
2198
+ {
2199
+ label: "Method",
2200
+ cell: (row) => `<span class="method">${esc(row.method)}</span>`
2201
+ },
2115
2202
  { label: "Path", cell: (row) => esc(row.path) },
2116
- { label: "Status", cell: (row) => esc(row.status) },
2203
+ {
2204
+ label: "Status",
2205
+ cell: (row) => `<span class="badge ${row.status.startsWith("2") ? "ok" : row.status.startsWith("4") || row.status.startsWith("5") ? "warn" : ""}">${esc(row.status)}</span>`
2206
+ },
2117
2207
  { label: "ms", cell: (row) => esc(row.durationMs) }
2118
2208
  ], decodeJournal(journal));
2119
2209
  };
2120
- const renderRoutes = async () => {
2121
- const data = await api("GET", "/");
2122
- if (!isRecord2(data))
2123
- throw new Error("Route list was not an object");
2124
- const routes = requireList(data.routes, "Routes").map((route) => String(route));
2210
+ const paintRoutes = () => {
2211
+ const query = el("route-search", HTMLInputElement).value.trim().toLocaleLowerCase();
2212
+ const routes = state.routes.filter((route) => route.toLocaleLowerCase().includes(query));
2213
+ el("route-count", HTMLElement).textContent = `${routes.length} of ${state.routes.length}`;
2214
+ if (state.routes.length === 0) {
2215
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No admin routes are available.</p>';
2216
+ return;
2217
+ }
2218
+ if (routes.length === 0) {
2219
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No routes match this filter.</p>';
2220
+ return;
2221
+ }
2125
2222
  el("route-list", HTMLElement).innerHTML = routes.map((route) => {
2126
2223
  const extra = standard.has(route) ? "" : " <span class='badge'>extension</span>";
2127
2224
  const parts = route.split(" ");
2128
2225
  const method = parts[0] ?? "";
2129
2226
  const path = parts.slice(1).join(" ");
2130
- 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>`;
2227
+ 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>`;
2131
2228
  }).join("");
2132
2229
  };
2230
+ const renderRoutes = async () => {
2231
+ const data = await api("GET", "/");
2232
+ if (!isRecord2(data))
2233
+ throw new Error("Route list was not an object");
2234
+ state.routes = requireList(data.routes, "Routes").map((route) => String(route));
2235
+ paintRoutes();
2236
+ };
2133
2237
  const renderers = {
2134
2238
  overview: renderOverview,
2135
2239
  state: renderState,
@@ -2138,19 +2242,50 @@ function bootAdmin(config) {
2138
2242
  journal: renderJournal,
2139
2243
  routes: renderRoutes
2140
2244
  };
2245
+ let refreshToken = 0;
2141
2246
  async function refresh() {
2247
+ const token = ++refreshToken;
2142
2248
  clearError();
2249
+ const main = el("views", HTMLElement);
2250
+ const sync = el("sync-status", HTMLElement);
2251
+ main.setAttribute("aria-busy", "true");
2252
+ sync.classList.add("busy");
2253
+ sync.textContent = "Updating";
2143
2254
  const render = renderers[state.view];
2144
- if (render)
2145
- await render();
2255
+ try {
2256
+ if (render)
2257
+ await render();
2258
+ if (token === refreshToken)
2259
+ sync.textContent = "Up to date";
2260
+ } finally {
2261
+ if (token === refreshToken) {
2262
+ main.removeAttribute("aria-busy");
2263
+ sync.classList.remove("busy");
2264
+ }
2265
+ }
2146
2266
  }
2147
2267
  const delay = (ms) => new Promise((resolve) => {
2148
2268
  window.setTimeout(resolve, ms);
2149
2269
  });
2150
- const confirmAction = async (button, pending, done, work) => {
2270
+ const confirmAction = async (button, pending, done, work, confirmLabel) => {
2151
2271
  if (button.dataset.pending === "1")
2152
2272
  return;
2153
2273
  const label = button.textContent ?? "";
2274
+ if (confirmLabel !== void 0 && button.dataset.confirming !== "1") {
2275
+ button.dataset.confirming = "1";
2276
+ button.classList.add("confirming");
2277
+ button.textContent = confirmLabel;
2278
+ window.setTimeout(() => {
2279
+ if (button.dataset.confirming !== "1")
2280
+ return;
2281
+ button.textContent = label;
2282
+ button.classList.remove("confirming");
2283
+ delete button.dataset.confirming;
2284
+ }, 3e3);
2285
+ return;
2286
+ }
2287
+ button.classList.remove("confirming");
2288
+ delete button.dataset.confirming;
2154
2289
  const width = button.offsetWidth;
2155
2290
  button.dataset.pending = "1";
2156
2291
  button.classList.add("pressed");
@@ -2171,6 +2306,65 @@ function bootAdmin(config) {
2171
2306
  delete button.dataset.pending;
2172
2307
  }
2173
2308
  };
2309
+ const wireJsonEditor = (textareaId, highlightId, statusId, shellId, allowEmpty) => {
2310
+ const textarea = el(textareaId, HTMLTextAreaElement);
2311
+ const highlight = el(highlightId, HTMLElement);
2312
+ const status = el(statusId, HTMLElement);
2313
+ const shell = el(shellId, HTMLElement);
2314
+ const sync = () => {
2315
+ const text = textarea.value;
2316
+ highlight.innerHTML = `${highlightJsonText(text)}
2317
+ `;
2318
+ let valid = true;
2319
+ let message = "Valid JSON";
2320
+ if (allowEmpty && text.trim() === "")
2321
+ message = "No body";
2322
+ else {
2323
+ try {
2324
+ readJson2(text);
2325
+ } catch {
2326
+ valid = false;
2327
+ message = "Invalid JSON";
2328
+ }
2329
+ }
2330
+ shell.classList.toggle("invalid", !valid);
2331
+ status.classList.toggle("invalid", !valid);
2332
+ status.textContent = message;
2333
+ textarea.setAttribute("aria-invalid", valid ? "false" : "true");
2334
+ };
2335
+ const syncScroll = () => {
2336
+ const preview2 = highlight.parentElement;
2337
+ if (!(preview2 instanceof HTMLElement))
2338
+ return;
2339
+ preview2.scrollTop = textarea.scrollTop;
2340
+ preview2.scrollLeft = textarea.scrollLeft;
2341
+ };
2342
+ textarea.addEventListener("input", sync);
2343
+ textarea.addEventListener("scroll", syncScroll);
2344
+ sync();
2345
+ return sync;
2346
+ };
2347
+ const syncRouteEditor = wireJsonEditor("route-body", "route-highlight", "route-json-status", "route-editor", true);
2348
+ const syncRecordEditor = wireJsonEditor("editor-value", "editor-highlight", "editor-json-status", "record-editor", false);
2349
+ for (const button of document.querySelectorAll("[data-format]")) {
2350
+ if (!(button instanceof HTMLButtonElement))
2351
+ continue;
2352
+ button.addEventListener("click", () => {
2353
+ const target = button.dataset.format;
2354
+ if (target === void 0)
2355
+ return;
2356
+ const textarea = document.getElementById(target);
2357
+ if (!(textarea instanceof HTMLTextAreaElement) || textarea.value.trim() === "")
2358
+ return;
2359
+ try {
2360
+ textarea.value = JSON.stringify(readJson2(textarea.value), null, 2) ?? "";
2361
+ textarea.dispatchEvent(new Event("input"));
2362
+ textarea.focus();
2363
+ } catch {
2364
+ showError(new Error("Fix the JSON before formatting it"));
2365
+ }
2366
+ });
2367
+ }
2174
2368
  document.addEventListener("click", (event) => {
2175
2369
  const target = event.target;
2176
2370
  if (!(target instanceof Element))
@@ -2243,6 +2437,7 @@ function bootAdmin(config) {
2243
2437
  } else {
2244
2438
  editorValue.value = "{\n \n}";
2245
2439
  }
2440
+ syncRecordEditor();
2246
2441
  editor.showModal();
2247
2442
  }
2248
2443
  el("new-record", HTMLButtonElement).addEventListener("click", () => {
@@ -2272,10 +2467,10 @@ function bootAdmin(config) {
2272
2467
  const id = state.editing;
2273
2468
  if (!id)
2274
2469
  return;
2275
- api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2470
+ confirmAction(editorDelete, "Deleting...", "Deleted", () => api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2276
2471
  editor.close();
2277
2472
  return renderState();
2278
- }).catch(showError);
2473
+ }), "Confirm delete").catch(showError);
2279
2474
  });
2280
2475
  for (const button of document.querySelectorAll("[data-advance]")) {
2281
2476
  if (!(button instanceof HTMLButtonElement))
@@ -2321,10 +2516,10 @@ function bootAdmin(config) {
2321
2516
  }).then(() => renderFaults()).catch(showError);
2322
2517
  });
2323
2518
  el("fault-clear", HTMLButtonElement).addEventListener("click", () => {
2324
- confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults())).catch(showError);
2519
+ confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults()), "Confirm clear").catch(showError);
2325
2520
  });
2326
2521
  el("journal-clear", HTMLButtonElement).addEventListener("click", () => {
2327
- confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal())).catch(showError);
2522
+ confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal()), "Confirm clear").catch(showError);
2328
2523
  });
2329
2524
  el("route-list", HTMLElement).addEventListener("click", (event) => {
2330
2525
  const target = event.target;
@@ -2338,9 +2533,15 @@ function bootAdmin(config) {
2338
2533
  return;
2339
2534
  state.route = route;
2340
2535
  el("route-title", HTMLElement).textContent = route;
2536
+ el("route-send", HTMLButtonElement).disabled = false;
2341
2537
  const method = route.split(" ")[0] ?? "";
2342
- el("route-body", HTMLTextAreaElement).disabled = method === "GET" || method === "DELETE";
2538
+ const routeBody = el("route-body", HTMLTextAreaElement);
2539
+ routeBody.disabled = method === "GET" || method === "DELETE";
2540
+ el("route-editor", HTMLElement).hidden = routeBody.disabled;
2541
+ el("route-json-status", HTMLElement).parentElement?.toggleAttribute("hidden", routeBody.disabled);
2542
+ syncRouteEditor();
2343
2543
  });
2544
+ el("route-search", HTMLInputElement).addEventListener("input", paintRoutes);
2344
2545
  el("route-form", HTMLFormElement).addEventListener("submit", (event) => {
2345
2546
  event.preventDefault();
2346
2547
  const send = async () => {
@@ -2362,7 +2563,8 @@ function bootAdmin(config) {
2362
2563
  }
2363
2564
  }
2364
2565
  const data = await api(method, concrete, body);
2365
- el("route-result", HTMLElement).textContent = JSON.stringify(data, null, 2);
2566
+ const text = JSON.stringify(data, null, 2) ?? "null";
2567
+ el("route-result", HTMLElement).innerHTML = `<code>${highlightJsonText(text)}</code>`;
2366
2568
  };
2367
2569
  send().catch(showError);
2368
2570
  });
@@ -2480,11 +2682,13 @@ function bootAdmin(config) {
2480
2682
  views.append(section);
2481
2683
  mountSqlExplorer(body, panelApi);
2482
2684
  }
2685
+ show(document.getElementById(`view-${state.view}`) === null ? "overview" : state.view);
2483
2686
  return refresh();
2484
2687
  }).catch(showError);
2485
2688
  }
2486
2689
  var clientPrelude = [
2487
2690
  isRecord2,
2691
+ highlightJsonText,
2488
2692
  mountAdminBrand,
2489
2693
  startAdminBrand,
2490
2694
  faultPresetList,
@@ -2507,6 +2711,7 @@ ${entry}(${payload});
2507
2711
  var defineMock = (runtime) => runtime;
2508
2712
  var STANDARD_ADMIN_ROUTES = [
2509
2713
  "GET /",
2714
+ "GET /health",
2510
2715
  "POST /reset",
2511
2716
  "GET /namespaces",
2512
2717
  "GET /clock",
@@ -2586,11 +2791,11 @@ var escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => {
2586
2791
  }
2587
2792
  });
2588
2793
  var ADMIN_BRANDS_URL = "https://mockingbird.chrisvouga.dev/brands.json";
2589
- var renderAdminDocument = (service) => {
2794
+ var renderAdminDocument = (service, adminPrefix = ADMIN_PREFIX) => {
2590
2795
  const name = escapeHtml(service);
2591
2796
  const script = adminClientSource({
2592
2797
  standardRoutes: STANDARD_ADMIN_ROUTES,
2593
- adminPrefix: ADMIN_PREFIX,
2798
+ adminPrefix,
2594
2799
  adminKeyHeader: ADMIN_KEY_HEADER,
2595
2800
  brandsUrl: ADMIN_BRANDS_URL,
2596
2801
  service
@@ -2607,75 +2812,99 @@ var renderAdminDocument = (service) => {
2607
2812
  ${CSS_RESET}
2608
2813
  :root {
2609
2814
  color-scheme: light;
2610
- --bg: #fafafa;
2815
+ --bg: #f7f8fb;
2611
2816
  --bg-elev: #ffffff;
2612
- --bg-muted: #f4f4f5;
2613
- --bg-inset: #f4f4f5;
2614
- --line: #dedee3;
2615
- --line-strong: #c4c4cc;
2616
- --ink: #18181b;
2617
- --muted: #62626b;
2618
- --faint: #71717a;
2619
- --accent: #27272a;
2817
+ --bg-muted: #f1f3f7;
2818
+ --bg-inset: #f8f9fc;
2819
+ --line: #e4e7ec;
2820
+ --line-strong: #cfd4dc;
2821
+ --ink: #101828;
2822
+ --muted: #475467;
2823
+ --faint: #667085;
2824
+ --accent: #6941c6;
2825
+ --accent-hover: #5933b4;
2620
2826
  --accent-ink: #ffffff;
2621
- --accent-soft: #f4f4f5;
2622
- --ok: #166534;
2623
- --ok-soft: #f0fdf4;
2624
- --warn: #a16207;
2625
- --warn-soft: #fefce8;
2626
- --err: #a82d32;
2627
- --err-soft: #fef2f2;
2628
- --shadow: none;
2827
+ --accent-soft: #f4f0ff;
2828
+ --ok: #067647;
2829
+ --ok-soft: #ecfdf3;
2830
+ --warn: #b54708;
2831
+ --warn-soft: #fffaeb;
2832
+ --err: #b42318;
2833
+ --err-soft: #fef3f2;
2834
+ --code-key: #6941c6;
2835
+ --code-string: #067647;
2836
+ --code-number: #175cd3;
2837
+ --code-boolean: #c11574;
2838
+ --code-null: #667085;
2839
+ --shadow-xs: 0 1px 2px rgb(16 24 40 / 0.05);
2840
+ --shadow-sm: 0 1px 3px rgb(16 24 40 / 0.08), 0 1px 2px rgb(16 24 40 / 0.04);
2841
+ --shadow-lg: 0 18px 48px rgb(16 24 40 / 0.16), 0 4px 12px rgb(16 24 40 / 0.08);
2629
2842
  --sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2630
2843
  --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
2631
- --radius: 10px;
2632
- --header: 56px;
2633
- --nav: 200px;
2844
+ --radius: 12px;
2845
+ --header: 72px;
2846
+ --nav: 224px;
2634
2847
  }
2635
2848
  :root[data-theme="dark"] {
2636
2849
  color-scheme: dark;
2637
- --bg: #111113;
2638
- --bg-elev: #19191c;
2639
- --bg-muted: #242428;
2640
- --bg-inset: #111113;
2641
- --line: #36363c;
2642
- --line-strong: #52525b;
2643
- --ink: #f4f4f5;
2644
- --muted: #a9a9b2;
2645
- --faint: #71717a;
2646
- --accent: #e4e4e7;
2647
- --accent-ink: #18181b;
2648
- --accent-soft: #242428;
2649
- --ok: #86efac;
2650
- --ok-soft: #14241b;
2651
- --warn: #fde68a;
2652
- --warn-soft: #2a2410;
2653
- --err: #ffaaaa;
2654
- --err-soft: #2c1516;
2655
- --shadow: none;
2850
+ --bg: #0c0e14;
2851
+ --bg-elev: #151821;
2852
+ --bg-muted: #20232e;
2853
+ --bg-inset: #11141c;
2854
+ --line: #2b303d;
2855
+ --line-strong: #414858;
2856
+ --ink: #f5f7fa;
2857
+ --muted: #b6bdc9;
2858
+ --faint: #8d96a7;
2859
+ --accent: #9e77ed;
2860
+ --accent-hover: #b692f6;
2861
+ --accent-ink: #ffffff;
2862
+ --accent-soft: #2b2142;
2863
+ --ok: #75e0a7;
2864
+ --ok-soft: #102a20;
2865
+ --warn: #fec84b;
2866
+ --warn-soft: #302611;
2867
+ --err: #fda29b;
2868
+ --err-soft: #321817;
2869
+ --code-key: #c3a6ff;
2870
+ --code-string: #75e0a7;
2871
+ --code-number: #84adff;
2872
+ --code-boolean: #f9a8d4;
2873
+ --code-null: #98a2b3;
2874
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
2875
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
2876
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
2656
2877
  }
2657
2878
  @media (prefers-color-scheme: dark) {
2658
2879
  :root:not([data-theme="light"]) {
2659
2880
  color-scheme: dark;
2660
- --bg: #111113;
2661
- --bg-elev: #19191c;
2662
- --bg-muted: #242428;
2663
- --bg-inset: #111113;
2664
- --line: #36363c;
2665
- --line-strong: #52525b;
2666
- --ink: #f4f4f5;
2667
- --muted: #a9a9b2;
2668
- --faint: #71717a;
2669
- --accent: #e4e4e7;
2670
- --accent-ink: #18181b;
2671
- --accent-soft: #242428;
2672
- --ok: #86efac;
2673
- --ok-soft: #14241b;
2674
- --warn: #fde68a;
2675
- --warn-soft: #2a2410;
2676
- --err: #ffaaaa;
2677
- --err-soft: #2c1516;
2678
- --shadow: none;
2881
+ --bg: #0c0e14;
2882
+ --bg-elev: #151821;
2883
+ --bg-muted: #20232e;
2884
+ --bg-inset: #11141c;
2885
+ --line: #2b303d;
2886
+ --line-strong: #414858;
2887
+ --ink: #f5f7fa;
2888
+ --muted: #b6bdc9;
2889
+ --faint: #8d96a7;
2890
+ --accent: #9e77ed;
2891
+ --accent-hover: #b692f6;
2892
+ --accent-ink: #ffffff;
2893
+ --accent-soft: #2b2142;
2894
+ --ok: #75e0a7;
2895
+ --ok-soft: #102a20;
2896
+ --warn: #fec84b;
2897
+ --warn-soft: #302611;
2898
+ --err: #fda29b;
2899
+ --err-soft: #321817;
2900
+ --code-key: #c3a6ff;
2901
+ --code-string: #75e0a7;
2902
+ --code-number: #84adff;
2903
+ --code-boolean: #f9a8d4;
2904
+ --code-null: #98a2b3;
2905
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
2906
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
2907
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
2679
2908
  }
2680
2909
  }
2681
2910
  /* Fill the viewport, then grow with the content so the background covers the scroll. */
@@ -2693,13 +2922,17 @@ var renderAdminDocument = (service) => {
2693
2922
  padding-right: env(safe-area-inset-right);
2694
2923
  -webkit-text-size-adjust: 100%;
2695
2924
  }
2696
- :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2925
+ ::selection { background: color-mix(in srgb, var(--accent) 28%, transparent); }
2926
+ :focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 2px; }
2927
+ button, input, select, textarea { transition: border-color 140ms ease, box-shadow 140ms ease, transform 100ms ease; }
2928
+ button:disabled, input:disabled, select:disabled, textarea:disabled { cursor: not-allowed; opacity: 0.55; }
2697
2929
  .app { flex: 1 0 auto; display: flex; flex-direction: column; min-width: 0; width: 100%; }
2698
2930
  header.top {
2699
- position: sticky; top: 0; z-index: 5;
2931
+ position: sticky; top: 0; z-index: 20;
2700
2932
  display: flex; align-items: center; gap: 12px;
2701
- min-height: var(--header); padding: 10px 16px;
2702
- background: var(--bg-elev);
2933
+ min-height: var(--header); padding: 12px 20px;
2934
+ background: color-mix(in srgb, var(--bg-elev) 92%, transparent);
2935
+ backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
2703
2936
  border-bottom: 1px solid var(--line);
2704
2937
  min-width: 0; max-width: 100%;
2705
2938
  }
@@ -2707,12 +2940,21 @@ var renderAdminDocument = (service) => {
2707
2940
  display: flex; flex-flow: row wrap; align-items: center; gap: 14px;
2708
2941
  min-width: 0; max-width: 100%; margin-right: auto;
2709
2942
  }
2710
- .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; }
2711
- .brand-id strong { font-size: 15px; letter-spacing: -0.01em; }
2943
+ .brand-lockup { display: flex; align-items: center; gap: 10px; min-width: 0; }
2944
+ .brand-mark {
2945
+ width: 38px; height: 38px; display: grid; place-items: center; flex: none;
2946
+ border-radius: 11px; color: #fff;
2947
+ background: linear-gradient(145deg, #7f56d9 0%, #53389e 100%);
2948
+ box-shadow: 0 6px 16px rgb(105 65 198 / 0.24), inset 0 1px 0 rgb(255 255 255 / 0.25);
2949
+ }
2950
+ .brand-mark svg { width: 22px; height: 22px; }
2951
+ .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; line-height: 1.25; }
2952
+ .brand-id strong { font-size: 15px; letter-spacing: -0.015em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2712
2953
  .brand-id span { color: var(--faint); font-size: 12px; }
2713
2954
  .vendor {
2714
2955
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
2715
2956
  min-width: 0; max-width: 100%; font-size: 12px; color: var(--muted);
2957
+ padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--bg-inset);
2716
2958
  }
2717
2959
  .vendor[hidden] { display: none; }
2718
2960
  /* Logos are drawn for a light tile, same as the docs site, so the plate stays white. */
@@ -2724,12 +2966,17 @@ var renderAdminDocument = (service) => {
2724
2966
  .vendor-sep { color: var(--faint); }
2725
2967
  .vendor-link { color: inherit; text-decoration: none; }
2726
2968
  .vendor-link:hover { color: var(--ink); text-decoration: underline; }
2727
- .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
2728
- label.field { display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: var(--faint); }
2969
+ .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; }
2970
+ .sync-status { align-self: center; display: flex; align-items: center; gap: 6px; color: var(--faint); font-size: 12px; white-space: nowrap; }
2971
+ .sync-status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
2972
+ .sync-status.busy::before { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); animation: pulse 1.2s ease-in-out infinite; }
2973
+ label.field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; font-weight: 600; color: var(--faint); }
2729
2974
  label.field input, label.field select, .controls input[type="password"] {
2730
- background-color: var(--bg-inset); border: 1px solid var(--line); border-radius: 8px;
2731
- padding: 7px 8px; min-height: 36px; line-height: 1.2;
2975
+ background-color: var(--bg-elev); border: 1px solid var(--line-strong); border-radius: 9px;
2976
+ padding: 8px 10px; min-height: 38px; line-height: 1.2; box-shadow: var(--shadow-xs);
2732
2977
  }
2978
+ label.field input:hover, label.field select:hover, textarea:hover { border-color: color-mix(in srgb, var(--accent) 38%, var(--line-strong)); }
2979
+ 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; }
2733
2980
  /* A native menu ignores min-height, so Preset rendered about half as tall as
2734
2981
  the text fields in the same form. Paint it like those fields. */
2735
2982
  label.field select {
@@ -2746,12 +2993,20 @@ var renderAdminDocument = (service) => {
2746
2993
  dialog .field > textarea { width: 100%; }
2747
2994
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger, nav.side button { cursor: pointer; }
2748
2995
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger {
2749
- border-radius: 9px; min-height: 36px; padding: 0 12px; border: 1px solid var(--line);
2750
- background: var(--bg-elev);
2751
- }
2752
- .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
2753
- .btn-quiet { background: transparent; }
2754
- .btn-danger { color: var(--err); }
2996
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px;
2997
+ border-radius: 9px; min-height: 38px; padding: 0 13px; border: 1px solid var(--line-strong);
2998
+ background: var(--bg-elev); color: var(--ink); font-size: 13px; font-weight: 600; line-height: 1;
2999
+ box-shadow: var(--shadow-xs);
3000
+ }
3001
+ .icon-btn { width: 38px; padding: 0; }
3002
+ .icon-btn svg, .btn svg, .btn-primary svg { width: 16px; height: 16px; }
3003
+ .icon-btn:hover, .btn:hover { background: var(--bg-muted); border-color: var(--line-strong); }
3004
+ .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; }
3005
+ .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
3006
+ .btn-quiet { background: transparent; border-color: transparent; box-shadow: none; }
3007
+ .btn-quiet:hover { background: var(--bg-muted); }
3008
+ .btn-danger { color: var(--err); border-color: color-mix(in srgb, var(--err) 25%, var(--line)); }
3009
+ .btn-danger:hover, .btn-danger.confirming { background: var(--err-soft); border-color: color-mix(in srgb, var(--err) 45%, var(--line)); }
2755
3010
  /* Held after mouseup. Clearing an empty journal changes nothing on screen,
2756
3011
  so the press itself has to stay visible. */
2757
3012
  .icon-btn:active, .btn:active, .btn-quiet:active, .btn-danger:active, nav.side button:active,
@@ -2767,93 +3022,159 @@ var renderAdminDocument = (service) => {
2767
3022
  flex: 1 0 auto; min-width: 0;
2768
3023
  }
2769
3024
  nav.side {
2770
- border-right: 1px solid var(--line); padding: 12px;
3025
+ position: sticky; top: var(--header); align-self: start; height: calc(100vh - var(--header));
3026
+ border-right: 1px solid var(--line); padding: 18px 12px;
2771
3027
  display: flex; flex-direction: column; gap: 4px;
2772
- background: var(--bg-elev);
3028
+ background: var(--bg-elev); overflow-y: auto;
2773
3029
  }
3030
+ .nav-label { padding: 0 10px 8px; color: var(--faint); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; }
2774
3031
  nav.side button {
3032
+ position: relative; display: flex; align-items: center; gap: 10px; width: 100%;
2775
3033
  text-align: left; border: 0; background: transparent; border-radius: 9px;
2776
- padding: 9px 10px; min-height: 40px; color: var(--muted);
2777
- }
2778
- nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--ink); font-weight: 600; }
2779
- main { padding: 20px; max-width: 1100px; width: 100%; min-width: 0; }
2780
- .view h2 { margin: 0 0 4px; font-size: 22px; letter-spacing: -0.02em; }
2781
- .lede { margin: 0 0 16px; color: var(--muted); }
2782
- .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: 12px; }
3034
+ padding: 9px 10px; min-height: 42px; color: var(--muted); font-size: 13px; font-weight: 550;
3035
+ }
3036
+ nav.side button:hover { background: var(--bg-muted); color: var(--ink); }
3037
+ nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 650; }
3038
+ 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); }
3039
+ .nav-icon { width: 18px; height: 18px; display: grid; place-items: center; flex: none; }
3040
+ .nav-icon svg { width: 18px; height: 18px; }
3041
+ main { padding: 32px clamp(20px, 4vw, 48px) 64px; max-width: 1320px; width: 100%; min-width: 0; }
3042
+ main[aria-busy="true"] .view { opacity: 0.62; }
3043
+ .view { animation: view-in 180ms ease-out; }
3044
+ .view[hidden] { display: none; }
3045
+ .view-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 22px; }
3046
+ .view h2 { margin: 0 0 5px; font-size: clamp(24px, 3vw, 30px); line-height: 1.2; letter-spacing: -0.035em; }
3047
+ .view h3 { margin: 24px 0 10px; font-size: 15px; letter-spacing: -0.01em; }
3048
+ .lede { margin: 0; color: var(--muted); max-width: 72ch; font-size: 14px; }
3049
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 175px), 1fr)); gap: 14px; }
2783
3050
  .card {
2784
3051
  background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius);
2785
- padding: 14px 16px; box-shadow: var(--shadow);
3052
+ padding: 18px; box-shadow: var(--shadow-sm);
2786
3053
  }
2787
- .card .k { color: var(--faint); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
2788
- .card .v { display: flex; flex-direction: column; gap: 2px; font-size: 22px; margin-top: 4px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
3054
+ .stat-card { position: relative; overflow: hidden; }
3055
+ .stat-card::after { content: ""; position: absolute; inset: 0 0 auto; height: 2px; background: linear-gradient(90deg, var(--accent), transparent 78%); opacity: 0.7; }
3056
+ .card .k { color: var(--faint); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; }
3057
+ .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; }
2789
3058
  .card .v .clock-time { font-size: 14px; color: var(--muted); }
2790
- .split { display: grid; grid-template-columns: minmax(0, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
3059
+ .section-card { background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-xs); overflow: hidden; }
3060
+ .section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--line); }
3061
+ .section-title h3 { margin: 0; }
3062
+ .split { display: grid; grid-template-columns: minmax(0, 250px) minmax(0, 1fr); gap: 18px; align-items: start; }
3063
+ #collections { padding: 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); box-shadow: var(--shadow-xs); }
2791
3064
  .collection {
2792
3065
  width: 100%; text-align: left; border: 1px solid transparent; background: transparent;
2793
- border-radius: 10px; padding: 8px 10px; min-height: 44px;
3066
+ border-radius: 9px; padding: 10px 11px; min-height: 48px; color: var(--ink); cursor: pointer;
2794
3067
  }
2795
- .collection[aria-current="true"] { background: var(--bg-elev); border-color: var(--line); }
3068
+ .collection:hover { background: var(--bg-muted); }
3069
+ .collection[aria-current="true"] { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 18%, var(--line)); color: var(--accent); }
2796
3070
  .collection small { display: block; color: var(--faint); }
2797
3071
  .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 14px; }
2798
- .chip {
2799
- font-family: var(--mono); font-size: 12px; background: var(--bg-muted);
2800
- border-radius: 999px; padding: 4px 8px;
2801
- }
3072
+ .chip { font-family: var(--mono); font-size: 11px; background: var(--bg-muted); border: 1px solid var(--line); border-radius: 999px; padding: 4px 8px; }
2802
3073
  .chip i { color: var(--faint); font-style: normal; }
2803
3074
  .sql-layout {
2804
3075
  display: grid; grid-template-columns: minmax(10rem, 16rem) minmax(0, 1fr); gap: 16px;
2805
3076
  }
2806
- .sql-tables { display: flex; flex-direction: column; gap: 4px; max-height: 28rem; overflow: auto; }
2807
- .sql-tables button { text-align: left; }
3077
+ .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); }
3078
+ .sql-tables button { text-align: left; border: 0; border-radius: 8px; padding: 9px 10px; background: transparent; color: var(--muted); cursor: pointer; }
3079
+ .sql-tables button:hover, .sql-tables button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); }
2808
3080
  .sql-input { font-family: var(--mono); min-height: 7rem; width: 100%; }
2809
- table { width: 100%; border-collapse: collapse; font-size: 14px; }
2810
- th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
2811
- th { color: var(--faint); font-weight: 600; font-size: 12px; }
3081
+ .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); }
3082
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
3083
+ th, td { text-align: left; padding: 11px 13px; border-bottom: 1px solid var(--line); vertical-align: top; }
3084
+ th { color: var(--faint); background: var(--bg-inset); font-weight: 650; font-size: 11px; text-transform: uppercase; letter-spacing: 0.045em; }
3085
+ tbody tr:last-child td { border-bottom: 0; }
2812
3086
  tr.clickable { cursor: pointer; }
2813
- tr.clickable:hover { background: var(--bg-muted); }
3087
+ tr.clickable:hover { background: var(--accent-soft); }
2814
3088
  pre, code, .mono { font-family: var(--mono); overflow-wrap: anywhere; }
2815
3089
  pre {
2816
3090
  margin: 0; white-space: pre-wrap; word-break: break-word;
2817
- background: var(--bg-inset); border-radius: 10px; padding: 12px; font-size: 12px;
2818
- }
2819
- .row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
3091
+ background: var(--bg-inset); border: 1px solid var(--line); border-radius: 10px; padding: 14px; font-size: 12px; line-height: 1.65;
3092
+ }
3093
+ .json-key { color: var(--code-key); }
3094
+ .json-string { color: var(--code-string); }
3095
+ .json-number { color: var(--code-number); }
3096
+ .json-boolean { color: var(--code-boolean); }
3097
+ .json-null { color: var(--code-null); font-style: italic; }
3098
+ .json-inline { display: block; max-width: 44rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
3099
+ .json-view { position: relative; max-height: 26rem; overflow: auto; }
3100
+ .json-editor { position: relative; min-height: 190px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--bg-inset); overflow: hidden; }
3101
+ .json-editor:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
3102
+ .json-editor.invalid { border-color: var(--err); }
3103
+ .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; }
3104
+ .json-editor pre { position: absolute; inset: 0; pointer-events: none; background: transparent; }
3105
+ .json-editor textarea { position: relative; z-index: 1; resize: vertical; background: transparent; color: transparent; caret-color: var(--ink); -webkit-text-fill-color: transparent; }
3106
+ .json-editor textarea::selection { background: color-mix(in srgb, var(--accent) 32%, transparent); }
3107
+ .editor-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 7px; }
3108
+ .json-status { display: inline-flex; align-items: center; gap: 6px; color: var(--ok); font-size: 11px; }
3109
+ .json-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
3110
+ .json-status.invalid { color: var(--err); }
3111
+ .format-json { border: 0; padding: 3px 5px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 650; cursor: pointer; }
3112
+ .row-actions { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; margin: 14px 0; }
2820
3113
  .banner {
2821
- display: none; margin-bottom: 12px; padding: 10px 12px; border-radius: 10px;
2822
- background: var(--err-soft); color: var(--err);
2823
- }
2824
- .banner.show { display: block; }
2825
- .empty { color: var(--muted); padding: 24px 8px; }
3114
+ display: none; position: sticky; top: calc(var(--header) + 12px); z-index: 15;
3115
+ align-items: center; gap: 10px; margin: 0 0 16px; padding: 11px 12px 11px 14px;
3116
+ border: 1px solid color-mix(in srgb, var(--err) 30%, var(--line)); border-radius: 10px;
3117
+ background: var(--err-soft); color: var(--err); box-shadow: var(--shadow-sm); font-size: 13px;
3118
+ }
3119
+ .banner.show { display: flex; }
3120
+ .banner span { flex: 1; }
3121
+ .banner button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 7px; background: transparent; color: currentColor; cursor: pointer; }
3122
+ .banner button:hover { background: color-mix(in srgb, var(--err) 10%, transparent); }
3123
+ .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); }
2826
3124
  dialog {
2827
- border: 1px solid var(--line); border-radius: var(--radius); padding: 0; background: var(--bg-elev);
2828
- color: var(--ink); width: min(640px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: none;
2829
- }
2830
- dialog::backdrop { background: rgb(20 19 17 / 0.45); }
2831
- dialog form, .dialog-body { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
3125
+ border: 1px solid var(--line); border-radius: 16px; padding: 0; background: var(--bg-elev);
3126
+ color: var(--ink); width: min(720px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: var(--shadow-lg);
3127
+ }
3128
+ dialog[open] { animation: dialog-in 160ms ease-out; }
3129
+ dialog::backdrop { background: rgb(12 14 20 / 0.58); backdrop-filter: blur(3px); }
3130
+ dialog form, .dialog-body { padding: 22px; display: flex; flex-direction: column; gap: 14px; }
3131
+ .dialog-heading { display: flex; justify-content: space-between; gap: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
3132
+ .dialog-heading strong { font-size: 18px; letter-spacing: -0.02em; }
3133
+ .dialog-heading span { color: var(--muted); font-size: 12px; }
2832
3134
  textarea {
2833
3135
  width: 100%; min-height: 180px; resize: vertical; border-radius: 10px;
2834
3136
  border: 1px solid var(--line); background: var(--bg-inset); padding: 10px;
2835
3137
  font-family: var(--mono); font-size: 12px;
2836
3138
  }
2837
- .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
2838
- .badge { font-size: 11px; border-radius: 999px; padding: 2px 7px; background: var(--bg-muted); color: var(--muted); }
3139
+ .dialog-actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; padding-top: 4px; }
3140
+ .dialog-actions .btn-danger { margin-right: auto; }
3141
+ .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); }
2839
3142
  .badge.ok { background: var(--ok-soft); color: var(--ok); }
2840
3143
  .badge.warn { background: var(--warn-soft); color: var(--warn); }
2841
3144
  .status-2 { color: var(--ok); }
2842
3145
  .status-4, .status-5, .status-0 { color: var(--err); }
2843
3146
  .stack { display: flex; flex-direction: column; gap: 12px; }
3147
+ .form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
3148
+ .form-grid .wide { grid-column: span 2; }
2844
3149
  .route {
2845
- display: grid; grid-template-columns: 88px 1fr auto; gap: 8px; align-items: center;
2846
- padding: 8px 0; border-bottom: 1px solid var(--line);
2847
- }
2848
- .method { font-family: var(--mono); font-size: 12px; font-weight: 700; }
3150
+ display: grid; grid-template-columns: 84px minmax(0, 1fr) auto; gap: 10px; align-items: center;
3151
+ padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--bg-elev);
3152
+ }
3153
+ .route:first-child { border-radius: var(--radius) var(--radius) 0 0; }
3154
+ .route:last-child { border-bottom: 0; border-radius: 0 0 var(--radius) var(--radius); }
3155
+ .route-tools { display: flex; align-items: flex-end; gap: 12px; margin-bottom: 12px; }
3156
+ .route-tools .field { flex: 1; }
3157
+ .route-tools input { width: 100%; }
3158
+ .route-count { padding-bottom: 10px; color: var(--faint); font-size: 12px; white-space: nowrap; }
3159
+ #route-list { max-height: 34rem; border: 1px solid var(--line); border-radius: var(--radius); overflow: auto; box-shadow: var(--shadow-xs); }
3160
+ .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; }
3161
+ @keyframes view-in { from { transform: translateY(3px); } to { transform: translateY(0); } }
3162
+ @keyframes dialog-in { from { transform: translateY(8px) scale(0.99); } to { transform: translateY(0) scale(1); } }
3163
+ @keyframes pulse { 50% { opacity: 0.45; } }
3164
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
3165
+ @media (forced-colors: active) { .json-editor pre { display: none; } .json-editor textarea { color: CanvasText; -webkit-text-fill-color: CanvasText; } }
2849
3166
  @media (max-width: 800px) {
3167
+ :root { --header: 0px; }
2850
3168
  header.top {
2851
- align-items: stretch; flex-direction: column;
2852
- padding-top: max(10px, env(safe-area-inset-top));
3169
+ position: relative; align-items: stretch; flex-direction: column;
3170
+ padding: max(12px, env(safe-area-inset-top)) 14px 12px;
2853
3171
  }
2854
3172
  input, select, textarea { font-size: 16px; }
2855
3173
  .brand, .vendor, .controls { width: 100%; }
3174
+ .brand { gap: 10px; }
3175
+ .vendor { order: 2; border-radius: 9px; }
2856
3176
  label.field, label.field select, label.field input { flex: 1; min-width: 0; }
3177
+ .sync-status { display: none; }
2857
3178
  /* The layout fills the screen. A single-column grid stretches every auto
2858
3179
  row, so the tab bar grew with the leftover height and the buttons floated
2859
3180
  in the middle of it. The bar stays one row; the page below takes the rest. */
@@ -2863,14 +3184,23 @@ var renderAdminDocument = (service) => {
2863
3184
  align-content: start;
2864
3185
  }
2865
3186
  nav.side {
2866
- align-self: start; height: auto;
3187
+ position: sticky; top: 0; z-index: 12; align-self: start; height: auto;
2867
3188
  flex-direction: row; align-items: center; overflow-x: auto; min-width: 0;
2868
3189
  border-right: 0; border-bottom: 1px solid var(--line);
2869
- padding: 8px;
3190
+ padding: 8px; box-shadow: var(--shadow-xs);
2870
3191
  }
2871
- nav.side button { white-space: nowrap; flex: none; }
3192
+ .nav-label { display: none; }
3193
+ nav.side button { width: auto; white-space: nowrap; flex: none; }
3194
+ nav.side button[aria-current="true"]::before { inset: auto 9px -8px; width: auto; height: 3px; border-radius: 3px 3px 0 0; }
2872
3195
  .split, .sql-layout { grid-template-columns: minmax(0, 1fr); }
2873
- main { padding: 14px; }
3196
+ .form-grid { grid-template-columns: minmax(0, 1fr); }
3197
+ .form-grid .wide { grid-column: auto; }
3198
+ main { padding: 22px 14px 48px; }
3199
+ .view-head { margin-bottom: 18px; }
3200
+ .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
3201
+ .card { padding: 14px; }
3202
+ .card .v { font-size: 20px; }
3203
+ .table-shell { border: 0; background: transparent; box-shadow: none; overflow: visible; }
2874
3204
  table, thead, tbody, tr, th, td { display: block; }
2875
3205
  thead { display: none; }
2876
3206
  tr {
@@ -2885,50 +3215,63 @@ var renderAdminDocument = (service) => {
2885
3215
  .route { grid-template-columns: 72px 1fr; }
2886
3216
  .route button { grid-column: 1 / -1; }
2887
3217
  }
3218
+ @media (max-width: 460px) {
3219
+ .controls label.field { min-width: calc(50% - 4px); }
3220
+ .controls .icon-btn, .controls .btn { flex: 1; }
3221
+ .grid { grid-template-columns: minmax(0, 1fr); }
3222
+ .dialog-actions { flex-wrap: wrap; }
3223
+ .dialog-actions button { flex: 1; }
3224
+ .dialog-actions .btn-danger { flex-basis: 100%; margin-right: 0; }
3225
+ }
2888
3226
  </style>
2889
3227
  </head>
2890
3228
  <body>
2891
3229
  <div class="app" data-service="${name}" data-mockingbird-admin>
2892
3230
  <header class="top">
2893
3231
  <div class="brand">
2894
- <div class="brand-id">
2895
- <strong>${name}</strong>
2896
- <span>Admin</span>
3232
+ <div class="brand-lockup">
3233
+ <div class="brand-mark" aria-hidden="true">
3234
+ <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>
3235
+ </div>
3236
+ <div class="brand-id">
3237
+ <strong>${name}</strong>
3238
+ <span>Mock admin</span>
3239
+ </div>
2897
3240
  </div>
2898
3241
  <div class="vendor" id="vendor" hidden></div>
2899
3242
  </div>
2900
3243
  <div class="controls">
3244
+ <span class="sync-status" id="sync-status" role="status" aria-live="polite">Ready</span>
2901
3245
  <label class="field">Namespace
2902
3246
  <select id="namespace" aria-label="Namespace"></select>
2903
3247
  </label>
2904
3248
  <label class="field">Admin key
2905
3249
  <input id="admin-key" type="password" autocomplete="off" spellcheck="false" aria-label="Admin key">
2906
3250
  </label>
2907
- <button class="icon-btn" id="theme" type="button" aria-label="Toggle color theme">Theme</button>
2908
- <button class="btn" id="refresh" type="button">Refresh</button>
3251
+ <button class="icon-btn" id="theme" type="button" aria-label="Use dark theme" title="Use dark theme"></button>
3252
+ <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>
2909
3253
  </div>
2910
3254
  </header>
2911
3255
  <div class="layout">
2912
3256
  <nav class="side" id="nav" aria-label="Admin sections">
2913
- <button type="button" data-view="overview" aria-current="true">Overview</button>
2914
- <button type="button" data-view="state">State</button>
2915
- <button type="button" data-view="clock">Clock</button>
2916
- <button type="button" data-view="faults">Faults</button>
2917
- <button type="button" data-view="journal">Journal</button>
2918
- <button type="button" data-view="routes">Routes</button>
3257
+ <span class="nav-label">Workspace</span>
3258
+ <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>
3259
+ <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>
3260
+ <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>
3261
+ <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>
3262
+ <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>
3263
+ <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>
2919
3264
  </nav>
2920
3265
  <main id="views">
2921
- <p class="banner" id="banner" role="alert"></p>
3266
+ <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>
2922
3267
  <section class="view" id="view-overview">
2923
- <h2>Overview</h2>
2924
- <p class="lede">Health, clock, and the collections this namespace is holding.</p>
3268
+ <div class="view-head"><div><h2>Overview</h2><p class="lede">Health, clock, and the collections this namespace is holding.</p></div></div>
2925
3269
  <div class="grid" id="overview-cards"></div>
2926
3270
  <h3>Recent requests</h3>
2927
3271
  <div id="overview-requests"></div>
2928
3272
  </section>
2929
3273
  <section class="view" id="view-state" hidden>
2930
- <h2>State</h2>
2931
- <p class="lede">Every collection in this namespace. Declared fields stay visible before any row exists; stored rows add whatever else they contain.</p>
3274
+ <div class="view-head"><div><h2>State</h2><p class="lede">Inspect and edit every collection in this namespace.</p></div></div>
2932
3275
  <div class="split">
2933
3276
  <div id="collections"></div>
2934
3277
  <div>
@@ -2941,8 +3284,7 @@ var renderAdminDocument = (service) => {
2941
3284
  </div>
2942
3285
  </section>
2943
3286
  <section class="view" id="view-clock" hidden>
2944
- <h2>Clock</h2>
2945
- <p class="lede">The clock every timestamp in this mock reads.</p>
3287
+ <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>
2946
3288
  <div class="card" id="clock-readout"></div>
2947
3289
  <div class="row-actions">
2948
3290
  <button class="btn" type="button" data-advance="60000">+1m</button>
@@ -2961,21 +3303,22 @@ var renderAdminDocument = (service) => {
2961
3303
  </form>
2962
3304
  </section>
2963
3305
  <section class="view" id="view-faults" hidden>
2964
- <h2>Faults</h2>
2965
- <p class="lede">Inject a response, a delay, or a named preset. Rules apply to this namespace.</p>
3306
+ <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>
2966
3307
  <form id="fault-form" class="card stack">
2967
- <label class="field">Preset
2968
- <select id="fault-preset"><option value="">Custom rule</option></select>
2969
- </label>
2970
- <label class="field">Status
2971
- <input id="fault-status" inputmode="numeric" placeholder="503">
2972
- </label>
2973
- <label class="field">Path prefix
2974
- <input id="fault-path" placeholder="/v1">
2975
- </label>
2976
- <label class="field">Method
2977
- <input id="fault-method" placeholder="GET">
2978
- </label>
3308
+ <div class="form-grid">
3309
+ <label class="field wide">Preset
3310
+ <select id="fault-preset"><option value="">Custom rule</option></select>
3311
+ </label>
3312
+ <label class="field">Status
3313
+ <input id="fault-status" inputmode="numeric" placeholder="503">
3314
+ </label>
3315
+ <label class="field wide">Path prefix
3316
+ <input id="fault-path" placeholder="/v1">
3317
+ </label>
3318
+ <label class="field">Method
3319
+ <input id="fault-method" placeholder="GET">
3320
+ </label>
3321
+ </div>
2979
3322
  <div class="dialog-actions">
2980
3323
  <button class="btn-primary" type="submit">Add fault</button>
2981
3324
  <button class="btn-danger" id="fault-clear" type="button">Clear all</button>
@@ -2984,26 +3327,31 @@ var renderAdminDocument = (service) => {
2984
3327
  <div id="fault-list"></div>
2985
3328
  </section>
2986
3329
  <section class="view" id="view-journal" hidden>
2987
- <h2>Journal</h2>
2988
- <p class="lede">Requests this namespace has seen. Bodies are not stored.</p>
2989
- <div class="row-actions">
2990
- <button class="btn-danger" id="journal-clear" type="button">Clear journal</button>
2991
- </div>
3330
+ <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>
2992
3331
  <div id="journal"></div>
2993
3332
  </section>
2994
3333
  <section class="view" id="view-routes" hidden>
2995
- <h2>Routes</h2>
2996
- <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>
3334
+ <div class="view-head"><div><h2>Routes</h2><p class="lede">Explore shared controls and service-specific admin endpoints.</p></div></div>
3335
+ <div class="route-tools">
3336
+ <label class="field">Filter routes
3337
+ <input id="route-search" type="search" placeholder="Method, path, or extension" autocomplete="off">
3338
+ </label>
3339
+ <span class="route-count" id="route-count"></span>
3340
+ </div>
2997
3341
  <div id="route-list"></div>
2998
- <form id="route-form" class="card stack">
3342
+ <form id="route-form" class="card stack" style="margin-top: 18px">
2999
3343
  <strong id="route-title">Select a route</strong>
3000
3344
  <label class="field">JSON body
3001
- <textarea id="route-body" placeholder="{ }"></textarea>
3345
+ <div class="json-editor" id="route-editor" hidden>
3346
+ <pre aria-hidden="true"><code id="route-highlight"></code></pre>
3347
+ <textarea id="route-body" placeholder="{ }" spellcheck="false" aria-describedby="route-json-status" disabled></textarea>
3348
+ </div>
3349
+ <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>
3002
3350
  </label>
3003
3351
  <div class="dialog-actions">
3004
- <button class="btn-primary" type="submit">Send</button>
3352
+ <button class="btn-primary" id="route-send" type="submit" disabled>Send request</button>
3005
3353
  </div>
3006
- <pre id="route-result">Response will show here.</pre>
3354
+ <pre class="json-view" id="route-result"><code>Response will show here.</code></pre>
3007
3355
  </form>
3008
3356
  </section>
3009
3357
  </main>
@@ -3011,12 +3359,16 @@ var renderAdminDocument = (service) => {
3011
3359
  </div>
3012
3360
  <dialog id="editor">
3013
3361
  <form method="dialog" id="editor-form">
3014
- <strong id="editor-title">Record</strong>
3362
+ <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>
3015
3363
  <label class="field">Id
3016
3364
  <input id="editor-id" required>
3017
3365
  </label>
3018
3366
  <label class="field">JSON value
3019
- <textarea id="editor-value"></textarea>
3367
+ <div class="json-editor" id="record-editor">
3368
+ <pre aria-hidden="true"><code id="editor-highlight"></code></pre>
3369
+ <textarea id="editor-value" spellcheck="false" aria-describedby="editor-json-status"></textarea>
3370
+ </div>
3371
+ <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>
3020
3372
  </label>
3021
3373
  <div class="dialog-actions">
3022
3374
  <button class="btn-danger" id="editor-delete" type="button" value="delete">Delete</button>
@@ -3058,8 +3410,8 @@ var describeExtension = (extension) => {
3058
3410
  ...extension.script !== void 0 ? { script: extension.script } : {}
3059
3411
  };
3060
3412
  };
3061
- var adminUiRoutes = (service, ui) => {
3062
- const shell = () => renderAdminDocument(service);
3413
+ var adminUiRoutes = (service, ui, adminPrefix = ADMIN_PREFIX) => {
3414
+ const shell = () => renderAdminDocument(service, adminPrefix);
3063
3415
  const document3 = () => ui?.render ? ui.render({ service, defaultHtml: shell }) : shell();
3064
3416
  return {
3065
3417
  "GET /ui": () => htmlResponse(document3()),
@@ -3980,7 +4332,7 @@ var stateAdminRoutes = (routes) => {
3980
4332
  };
3981
4333
 
3982
4334
  // ../core/dist/version.js
3983
- var PACKAGE_VERSION = true ? "1.8.0" : UNRELEASED_VERSION;
4335
+ var PACKAGE_VERSION = true ? "2.0.0" : UNRELEASED_VERSION;
3984
4336
 
3985
4337
  // ../core/dist/signing.js
3986
4338
  var encoder = new TextEncoder();
@@ -4353,7 +4705,10 @@ var createWebhookHub = (options) => {
4353
4705
  };
4354
4706
  return hub;
4355
4707
  };
4356
- var json4 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
4708
+ var json4 = (status, body) => new Response(JSON.stringify(body), {
4709
+ status,
4710
+ headers: { "content-type": "application/json; charset=utf-8" }
4711
+ });
4357
4712
  var adminError3 = (status, message) => json4(status, { error: { type: "mockingbird_admin", message } });
4358
4713
  var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4359
4714
  var parseEndpoint = (value) => {
@@ -4461,7 +4816,6 @@ var AT_HEADER = "x-mockingbird-at";
4461
4816
  var CHECKPOINT_HEADER = "x-mockingbird-checkpoint";
4462
4817
  var DEFAULT_NAMESPACE = "default";
4463
4818
  var NAMESPACE_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
4464
- var PATH_PREFIX = /^\/ns\/([^/]+)(\/.*)?$/;
4465
4819
  var BRANCH_PATTERN2 = /^[A-Za-z0-9_.-]{1,64}$/;
4466
4820
  var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
4467
4821
  var effects = /* @__PURE__ */ new WeakMap();
@@ -4500,6 +4854,22 @@ var operationMatcher = (document3) => {
4500
4854
  return (request, path) => matchers.find((m) => m.method === request.method && m.pattern.test(path))?.operationId;
4501
4855
  };
4502
4856
  var createRuntime = (options) => {
4857
+ const adminPrefix = resolveAdminPrefix(options.adminPrefix);
4858
+ const internalPaths = Object.entries(options.document?.paths ?? {}).filter(([, item]) => item["x-mockingbird-internal"] === true).map(([path]) => path);
4859
+ for (const path of internalPaths) {
4860
+ if (!path.startsWith(`${ADMIN_PREFIX}/blobs/`))
4861
+ throw new Error(`internal transport ${path} must live under ${ADMIN_PREFIX}/blobs/`);
4862
+ }
4863
+ assertAdminPrefixAvailable(adminPrefix, Object.keys(options.document?.paths ?? {}).filter((path) => !internalPaths.includes(path)));
4864
+ const internalMatch = (path) => {
4865
+ for (const pattern of internalPaths) {
4866
+ const actual = path.split("/");
4867
+ const expected = pattern.split("/");
4868
+ if (actual.length === expected.length && expected.every((part, i) => part.startsWith("{") || part === actual[i]))
4869
+ return pattern;
4870
+ }
4871
+ return void 0;
4872
+ };
4503
4873
  assertAdminUi(options.adminUi);
4504
4874
  const sqlite = bootSqlite(options.sqlite);
4505
4875
  const clock = options.clock ?? createClock();
@@ -4545,6 +4915,7 @@ var createRuntime = (options) => {
4545
4915
  const created = options.create({
4546
4916
  namespace: storageNamespace(key),
4547
4917
  publicNamespace,
4918
+ adminPrefix,
4548
4919
  sqlite,
4549
4920
  clock: namespaceClock(publicNamespace),
4550
4921
  rng: createdContextRng
@@ -4777,10 +5148,10 @@ var createRuntime = (options) => {
4777
5148
  namespaceClock,
4778
5149
  namespaceOf(request) {
4779
5150
  const url = new URL(request.url);
4780
- const prefixed = PATH_PREFIX.exec(url.pathname);
5151
+ const prefixed = matchNamespacePath(url.pathname, adminPrefix);
4781
5152
  const selected = request.headers.get(NAMESPACE_HEADER) ?? (prefixed ? decodeURIComponent(prefixed[1]) : void 0);
4782
5153
  const path = prefixed ? prefixed[2] ?? "/" : url.pathname;
4783
- if (path === "/__admin" || path.startsWith("/__admin/"))
5154
+ if (isAdminPath(path, adminPrefix))
4784
5155
  return url.searchParams.get("namespace") ?? selected ?? DEFAULT_NAMESPACE;
4785
5156
  if (selected !== void 0)
4786
5157
  return selected;
@@ -4846,7 +5217,7 @@ var createRuntime = (options) => {
4846
5217
  state: (namespace = DEFAULT_NAMESPACE) => inspectState(stateScope(namespace)),
4847
5218
  fetch: async (incoming) => {
4848
5219
  let request = incoming;
4849
- const prefixed = PATH_PREFIX.exec(new URL(request.url).pathname);
5220
+ const prefixed = matchNamespacePath(new URL(request.url).pathname, adminPrefix);
4850
5221
  if (prefixed) {
4851
5222
  const url2 = new URL(request.url);
4852
5223
  url2.pathname = prefixed[2] ?? "/";
@@ -4862,7 +5233,15 @@ var createRuntime = (options) => {
4862
5233
  signal: request.signal
4863
5234
  });
4864
5235
  }
4865
- let namespace = control.namespaceOf(request);
5236
+ const internalUrl = new URL(request.url);
5237
+ const internalPath = isAdminPath(internalUrl.pathname, adminPrefix) ? `${ADMIN_PREFIX}${internalUrl.pathname.slice(adminPrefix.length)}` : "";
5238
+ const internal = internalMatch(internalPath);
5239
+ const obsoleteInternalPath = adminPrefix !== ADMIN_PREFIX && internalMatch(internalUrl.pathname) && !internal;
5240
+ if (internal) {
5241
+ internalUrl.pathname = internalPath;
5242
+ request = new Request(internalUrl, request);
5243
+ }
5244
+ let namespace = internal ? internalUrl.searchParams.get("namespace") ?? control.namespaceOf(request) : control.namespaceOf(request);
4866
5245
  if (!request.headers.has(NAMESPACE_HEADER) && options.credential) {
4867
5246
  const credential = options.credential(request);
4868
5247
  const mapped = credential !== void 0 ? credentials.get(credential) : void 0;
@@ -4882,7 +5261,9 @@ var createRuntime = (options) => {
4882
5261
  return copy;
4883
5262
  }
4884
5263
  };
4885
- const handled = await control.handle(request);
5264
+ if (obsoleteInternalPath)
5265
+ return stamp(adminFail(404, "internal transport is only available under adminPrefix"));
5266
+ const handled = internal ? void 0 : await control.handle(request);
4886
5267
  if (handled)
4887
5268
  return stamp(handled);
4888
5269
  const started = monotonicNow();
@@ -4923,7 +5304,7 @@ var createRuntime = (options) => {
4923
5304
  type: "mockingbird_admin",
4924
5305
  message: `${NAMESPACE_HEADER} must match ${NAMESPACE_PATTERN}`
4925
5306
  }
4926
- }), { status: 400, headers: { "content-type": "application/json" } }));
5307
+ }), { status: 400, headers: { "content-type": "application/json; charset=utf-8" } }));
4927
5308
  }
4928
5309
  if (!BRANCH_PATTERN2.test(selectedBranch)) {
4929
5310
  log(400);
@@ -5040,15 +5421,16 @@ var createRuntime = (options) => {
5040
5421
  ...options.admin?.(runtime) ?? {},
5041
5422
  // Shell routes win a colliding key. Fault presets are one of them: the admin page
5042
5423
  // maps `presets`, and a service route must not replace that list with a record.
5043
- ...options.presets ? presetRoutes(options.presets, runtime) : {},
5424
+ ...options.presets ? presetRoutes(options.presets, runtime, adminPrefix) : {},
5044
5425
  ...stateAdminRoutes({
5045
5426
  open: stateScope,
5046
5427
  afterWrite: (namespace) => {
5047
5428
  checkpoint(namespace, "main");
5048
5429
  }
5049
5430
  }),
5050
- ...adminUiRoutes(options.name, options.adminUi)
5431
+ ...adminUiRoutes(options.name, options.adminUi, adminPrefix)
5051
5432
  },
5433
+ adminPrefix,
5052
5434
  adminKey: options.adminKey
5053
5435
  });
5054
5436
  return defineMock(runtime);
@@ -5062,7 +5444,10 @@ var mutableResponse = (response) => {
5062
5444
  return new Response(response.body, response);
5063
5445
  }
5064
5446
  };
5065
- var adminJson = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
5447
+ var adminJson = (status, body) => new Response(JSON.stringify(body), {
5448
+ status,
5449
+ headers: { "content-type": "application/json; charset=utf-8" }
5450
+ });
5066
5451
  var adminFail = (status, message) => adminJson(status, { error: { type: "mockingbird_admin", message } });
5067
5452
  var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5068
5453
  var credentialRoutes = (registry) => ({
@@ -5114,7 +5499,7 @@ var credentialRoutes = (registry) => ({
5114
5499
  return adminJson(200, { status: "ok" });
5115
5500
  }
5116
5501
  });
5117
- var presetRoutes = (presets, runtime) => ({
5502
+ var presetRoutes = (presets, runtime, adminPrefix) => ({
5118
5503
  "GET /faults/presets": () => {
5119
5504
  const body = {
5120
5505
  presets: Object.entries(presets).map(([name, preset]) => ({ name, ...preset }))
@@ -5124,7 +5509,7 @@ var presetRoutes = (presets, runtime) => ({
5124
5509
  "POST /faults/presets/:name": ({ params, body, namespace }) => {
5125
5510
  const name = params.name;
5126
5511
  if (!presets[name])
5127
- return adminFail(404, `no fault preset ${name}; GET /__admin/faults/presets`);
5512
+ return adminFail(404, `no fault preset ${name}; GET ${adminPrefix}/faults/presets`);
5128
5513
  const overrides = isObject(body) ? body : {};
5129
5514
  return adminJson(201, { preset: name, rules: runtime.applyPreset(name, namespace, overrides) });
5130
5515
  }
@@ -5301,6 +5686,7 @@ var createRuntime2 = (options = {}) => {
5301
5686
  ...options.sqlite ? { sqlite: options.sqlite } : {},
5302
5687
  ...options.clock ? { clock: options.clock } : {},
5303
5688
  ...options.seed !== void 0 ? { seed: options.seed } : {},
5689
+ ...options.adminPrefix !== void 0 ? { adminPrefix: options.adminPrefix } : {},
5304
5690
  ...options.adminKey !== void 0 ? { adminKey: options.adminKey } : {},
5305
5691
  ...options.onLog ? { onLog: options.onLog } : {},
5306
5692
  create: ({ sqlite, namespace, clock }) => new LiveKitAPI({
@@ -5676,6 +6062,7 @@ var LiveKitAPI = class {
5676
6062
 
5677
6063
  export {
5678
6064
  createClock,
6065
+ resolveAdminPrefix,
5679
6066
  parseDuration,
5680
6067
  document2 as document,
5681
6068
  operationIds,
@@ -5685,4 +6072,4 @@ export {
5685
6072
  LIVEKIT_NAMESPACE,
5686
6073
  LiveKitAPI
5687
6074
  };
5688
- //# sourceMappingURL=chunk-OYDPGLNO.js.map
6075
+ //# sourceMappingURL=chunk-QIRIAV7T.js.map