@crvouga/mockingbird-service-aws-speech 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
  };
@@ -1555,6 +1594,34 @@ dialog:not([open]) {
1555
1594
  function isRecord2(value) {
1556
1595
  return typeof value === "object" && value !== null && !Array.isArray(value);
1557
1596
  }
1597
+ function highlightJsonText(text2) {
1598
+ const escapeToken = (value) => value.replace(/[&<>"']/g, (char) => {
1599
+ switch (char) {
1600
+ case "&":
1601
+ return "&amp;";
1602
+ case "<":
1603
+ return "&lt;";
1604
+ case ">":
1605
+ return "&gt;";
1606
+ case '"':
1607
+ return "&quot;";
1608
+ default:
1609
+ return "&#39;";
1610
+ }
1611
+ });
1612
+ 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;
1613
+ let cursor = 0;
1614
+ let html = "";
1615
+ for (const match of text2.matchAll(token)) {
1616
+ const index = match.index ?? 0;
1617
+ const value = match[0];
1618
+ html += escapeToken(text2.slice(cursor, index));
1619
+ const kind = value.startsWith('"') ? value.trimEnd().endsWith(":") ? "key" : "string" : value === "true" || value === "false" ? "boolean" : value === "null" ? "null" : "number";
1620
+ html += `<span class="json-${kind}">${escapeToken(value)}</span>`;
1621
+ cursor = index + value.length;
1622
+ }
1623
+ return html + escapeToken(text2.slice(cursor));
1624
+ }
1558
1625
  function mountAdminBrand(doc, host, service, catalog) {
1559
1626
  const clip = (value, max) => {
1560
1627
  if (typeof value !== "string")
@@ -1916,14 +1983,16 @@ function bootAdmin(config) {
1916
1983
  const state = {
1917
1984
  namespace: "default",
1918
1985
  key: sessionStorage.getItem("mockingbird-admin-key") || "",
1919
- view: "overview",
1986
+ view: ["overview", "state", "clock", "faults", "journal", "routes"].includes(location.hash.slice(1)) ? location.hash.slice(1) : "overview",
1920
1987
  collection: "",
1921
1988
  shape: null,
1922
1989
  route: null,
1990
+ routes: [],
1923
1991
  editing: null
1924
1992
  };
1925
1993
  const standard = new Set(config.standardRoutes);
1926
1994
  const banner = el("banner", HTMLElement);
1995
+ const bannerMessage = el("banner-message", HTMLElement);
1927
1996
  const keyInput = el("admin-key", HTMLInputElement);
1928
1997
  keyInput.value = state.key;
1929
1998
  const params = new URLSearchParams(location.search);
@@ -1934,18 +2003,21 @@ function bootAdmin(config) {
1934
2003
  sessionStorage.setItem("mockingbird-admin-key", state.key);
1935
2004
  params.delete("key");
1936
2005
  const next = params.toString();
1937
- history.replaceState(null, "", location.pathname + (next ? `?${next}` : ""));
2006
+ history.replaceState(null, "", location.pathname + (next ? `?${next}` : "") + location.hash);
1938
2007
  }
1939
2008
  const queryNamespace = params.get("namespace");
1940
2009
  if (queryNamespace)
1941
2010
  state.namespace = queryNamespace;
1942
2011
  const showError = (error) => {
1943
- banner.textContent = error instanceof Error ? error.message : String(error);
2012
+ bannerMessage.textContent = error instanceof Error ? error.message : String(error);
1944
2013
  banner.classList.add("show");
2014
+ banner.scrollIntoView({ behavior: "smooth", block: "nearest" });
1945
2015
  };
1946
2016
  const clearError = () => {
1947
2017
  banner.classList.remove("show");
2018
+ bannerMessage.textContent = "";
1948
2019
  };
2020
+ el("banner-dismiss", HTMLButtonElement).addEventListener("click", clearError);
1949
2021
  const api = async (method, path, body) => {
1950
2022
  const headers = { accept: "application/json" };
1951
2023
  if (state.key !== "")
@@ -1989,16 +2061,21 @@ function bootAdmin(config) {
1989
2061
  namespace: () => state.namespace
1990
2062
  };
1991
2063
  const isPanelScript = (value) => typeof value === "function";
2064
+ const themeButton = el("theme", HTMLButtonElement);
1992
2065
  const setTheme = (theme) => {
1993
2066
  if (theme)
1994
2067
  document.documentElement.dataset.theme = theme;
1995
2068
  else
1996
2069
  delete document.documentElement.dataset.theme;
2070
+ const dark = theme ? theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
2071
+ 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>';
2072
+ const label = dark ? "Use light theme" : "Use dark theme";
2073
+ themeButton.setAttribute("aria-label", label);
2074
+ themeButton.title = label;
1997
2075
  };
1998
2076
  const storedTheme = localStorage.getItem("mockingbird-admin-theme");
1999
- if (storedTheme)
2000
- setTheme(storedTheme);
2001
- el("theme", HTMLButtonElement).addEventListener("click", () => {
2077
+ setTheme(storedTheme === "dark" || storedTheme === "light" ? storedTheme : null);
2078
+ themeButton.addEventListener("click", () => {
2002
2079
  const current = document.documentElement.dataset.theme;
2003
2080
  const dark = current ? current === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
2004
2081
  const next = dark ? "light" : "dark";
@@ -2017,6 +2094,7 @@ function bootAdmin(config) {
2017
2094
  continue;
2018
2095
  section.hidden = section.id !== `view-${view}`;
2019
2096
  }
2097
+ history.replaceState(null, "", `${location.pathname}${location.search}#${encodeURIComponent(view)}`);
2020
2098
  };
2021
2099
  el("nav", HTMLElement).addEventListener("click", (event) => {
2022
2100
  const target = event.target;
@@ -2031,7 +2109,7 @@ function bootAdmin(config) {
2031
2109
  show(view);
2032
2110
  refresh().catch(showError);
2033
2111
  });
2034
- 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>`;
2112
+ 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>`;
2035
2113
  const table = (columns, rows) => {
2036
2114
  if (rows.length === 0)
2037
2115
  return '<p class="empty">Nothing here yet.</p>';
@@ -2041,16 +2119,22 @@ function bootAdmin(config) {
2041
2119
  const attrs = "attrs" in row && typeof row.attrs === "string" ? row.attrs : "";
2042
2120
  return `<tr${attrs}>${cells}</tr>`;
2043
2121
  }).join("");
2044
- return `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
2122
+ return `<div class="table-shell"><table><thead>${head}</thead><tbody>${body}</tbody></table></div>`;
2045
2123
  };
2046
2124
  const preview = (value) => {
2047
2125
  const text2 = JSON.stringify(value) ?? "null";
2048
- return esc(text2.length > 180 ? `${text2.slice(0, 180)}\u2026` : text2);
2126
+ const clipped = text2.length > 180 ? `${text2.slice(0, 180)}\u2026` : text2;
2127
+ return `<code class="json-inline">${highlightJsonText(clipped)}</code>`;
2049
2128
  };
2050
2129
  const when = (epoch) => {
2051
2130
  const date = new Date(epoch);
2052
2131
  return Number.isNaN(date.getTime()) ? String(epoch) : date.toISOString();
2053
2132
  };
2133
+ const journalWhen = (value) => {
2134
+ const numeric = value.trim() === "" ? Number.NaN : Number(value);
2135
+ const epoch = Number.isFinite(numeric) ? numeric : Date.parse(value);
2136
+ return Number.isFinite(epoch) ? when(epoch) : value;
2137
+ };
2054
2138
  const clockCard = (epoch) => {
2055
2139
  const date = new Date(epoch);
2056
2140
  if (Number.isNaN(date.getTime()))
@@ -2170,7 +2254,7 @@ function bootAdmin(config) {
2170
2254
  };
2171
2255
  const renderOverview = async () => {
2172
2256
  const [health, shape, clock, journal] = await Promise.all([
2173
- originGet("/health"),
2257
+ originGet(`${config.adminPrefix}/health`),
2174
2258
  api("GET", "/state"),
2175
2259
  api("GET", "/clock"),
2176
2260
  api("GET", "/requests?limit=8")
@@ -2187,7 +2271,7 @@ function bootAdmin(config) {
2187
2271
  },
2188
2272
  {
2189
2273
  k: "Status",
2190
- v: esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")
2274
+ v: `<span class="badge ok">${esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")}</span>`
2191
2275
  },
2192
2276
  { k: "Namespace", v: esc(state.namespace) },
2193
2277
  { k: "Collections", v: String(decoded.collections.length) },
@@ -2274,26 +2358,46 @@ function bootAdmin(config) {
2274
2358
  const renderJournal = async () => {
2275
2359
  const journal = await api("GET", "/requests?limit=100");
2276
2360
  el("journal", HTMLElement).innerHTML = table([
2277
- { label: "When", cell: (row) => esc(row.at) },
2278
- { label: "Method", cell: (row) => esc(row.method) },
2361
+ { label: "When", cell: (row) => esc(journalWhen(row.at)) },
2362
+ {
2363
+ label: "Method",
2364
+ cell: (row) => `<span class="method">${esc(row.method)}</span>`
2365
+ },
2279
2366
  { label: "Path", cell: (row) => esc(row.path) },
2280
- { label: "Status", cell: (row) => esc(row.status) },
2367
+ {
2368
+ label: "Status",
2369
+ cell: (row) => `<span class="badge ${row.status.startsWith("2") ? "ok" : row.status.startsWith("4") || row.status.startsWith("5") ? "warn" : ""}">${esc(row.status)}</span>`
2370
+ },
2281
2371
  { label: "ms", cell: (row) => esc(row.durationMs) }
2282
2372
  ], decodeJournal(journal));
2283
2373
  };
2284
- const renderRoutes = async () => {
2285
- const data = await api("GET", "/");
2286
- if (!isRecord2(data))
2287
- throw new Error("Route list was not an object");
2288
- const routes = requireList(data.routes, "Routes").map((route) => String(route));
2374
+ const paintRoutes = () => {
2375
+ const query = el("route-search", HTMLInputElement).value.trim().toLocaleLowerCase();
2376
+ const routes = state.routes.filter((route) => route.toLocaleLowerCase().includes(query));
2377
+ el("route-count", HTMLElement).textContent = `${routes.length} of ${state.routes.length}`;
2378
+ if (state.routes.length === 0) {
2379
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No admin routes are available.</p>';
2380
+ return;
2381
+ }
2382
+ if (routes.length === 0) {
2383
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No routes match this filter.</p>';
2384
+ return;
2385
+ }
2289
2386
  el("route-list", HTMLElement).innerHTML = routes.map((route) => {
2290
2387
  const extra = standard.has(route) ? "" : " <span class='badge'>extension</span>";
2291
2388
  const parts = route.split(" ");
2292
2389
  const method = parts[0] ?? "";
2293
2390
  const path = parts.slice(1).join(" ");
2294
- 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>`;
2391
+ 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>`;
2295
2392
  }).join("");
2296
2393
  };
2394
+ const renderRoutes = async () => {
2395
+ const data = await api("GET", "/");
2396
+ if (!isRecord2(data))
2397
+ throw new Error("Route list was not an object");
2398
+ state.routes = requireList(data.routes, "Routes").map((route) => String(route));
2399
+ paintRoutes();
2400
+ };
2297
2401
  const renderers = {
2298
2402
  overview: renderOverview,
2299
2403
  state: renderState,
@@ -2302,19 +2406,50 @@ function bootAdmin(config) {
2302
2406
  journal: renderJournal,
2303
2407
  routes: renderRoutes
2304
2408
  };
2409
+ let refreshToken = 0;
2305
2410
  async function refresh() {
2411
+ const token = ++refreshToken;
2306
2412
  clearError();
2413
+ const main = el("views", HTMLElement);
2414
+ const sync = el("sync-status", HTMLElement);
2415
+ main.setAttribute("aria-busy", "true");
2416
+ sync.classList.add("busy");
2417
+ sync.textContent = "Updating";
2307
2418
  const render = renderers[state.view];
2308
- if (render)
2309
- await render();
2419
+ try {
2420
+ if (render)
2421
+ await render();
2422
+ if (token === refreshToken)
2423
+ sync.textContent = "Up to date";
2424
+ } finally {
2425
+ if (token === refreshToken) {
2426
+ main.removeAttribute("aria-busy");
2427
+ sync.classList.remove("busy");
2428
+ }
2429
+ }
2310
2430
  }
2311
2431
  const delay = (ms) => new Promise((resolve) => {
2312
2432
  window.setTimeout(resolve, ms);
2313
2433
  });
2314
- const confirmAction = async (button, pending, done, work) => {
2434
+ const confirmAction = async (button, pending, done, work, confirmLabel) => {
2315
2435
  if (button.dataset.pending === "1")
2316
2436
  return;
2317
2437
  const label = button.textContent ?? "";
2438
+ if (confirmLabel !== void 0 && button.dataset.confirming !== "1") {
2439
+ button.dataset.confirming = "1";
2440
+ button.classList.add("confirming");
2441
+ button.textContent = confirmLabel;
2442
+ window.setTimeout(() => {
2443
+ if (button.dataset.confirming !== "1")
2444
+ return;
2445
+ button.textContent = label;
2446
+ button.classList.remove("confirming");
2447
+ delete button.dataset.confirming;
2448
+ }, 3e3);
2449
+ return;
2450
+ }
2451
+ button.classList.remove("confirming");
2452
+ delete button.dataset.confirming;
2318
2453
  const width = button.offsetWidth;
2319
2454
  button.dataset.pending = "1";
2320
2455
  button.classList.add("pressed");
@@ -2335,6 +2470,65 @@ function bootAdmin(config) {
2335
2470
  delete button.dataset.pending;
2336
2471
  }
2337
2472
  };
2473
+ const wireJsonEditor = (textareaId, highlightId, statusId, shellId, allowEmpty) => {
2474
+ const textarea = el(textareaId, HTMLTextAreaElement);
2475
+ const highlight = el(highlightId, HTMLElement);
2476
+ const status = el(statusId, HTMLElement);
2477
+ const shell = el(shellId, HTMLElement);
2478
+ const sync = () => {
2479
+ const text2 = textarea.value;
2480
+ highlight.innerHTML = `${highlightJsonText(text2)}
2481
+ `;
2482
+ let valid = true;
2483
+ let message = "Valid JSON";
2484
+ if (allowEmpty && text2.trim() === "")
2485
+ message = "No body";
2486
+ else {
2487
+ try {
2488
+ readJson2(text2);
2489
+ } catch {
2490
+ valid = false;
2491
+ message = "Invalid JSON";
2492
+ }
2493
+ }
2494
+ shell.classList.toggle("invalid", !valid);
2495
+ status.classList.toggle("invalid", !valid);
2496
+ status.textContent = message;
2497
+ textarea.setAttribute("aria-invalid", valid ? "false" : "true");
2498
+ };
2499
+ const syncScroll = () => {
2500
+ const preview2 = highlight.parentElement;
2501
+ if (!(preview2 instanceof HTMLElement))
2502
+ return;
2503
+ preview2.scrollTop = textarea.scrollTop;
2504
+ preview2.scrollLeft = textarea.scrollLeft;
2505
+ };
2506
+ textarea.addEventListener("input", sync);
2507
+ textarea.addEventListener("scroll", syncScroll);
2508
+ sync();
2509
+ return sync;
2510
+ };
2511
+ const syncRouteEditor = wireJsonEditor("route-body", "route-highlight", "route-json-status", "route-editor", true);
2512
+ const syncRecordEditor = wireJsonEditor("editor-value", "editor-highlight", "editor-json-status", "record-editor", false);
2513
+ for (const button of document.querySelectorAll("[data-format]")) {
2514
+ if (!(button instanceof HTMLButtonElement))
2515
+ continue;
2516
+ button.addEventListener("click", () => {
2517
+ const target = button.dataset.format;
2518
+ if (target === void 0)
2519
+ return;
2520
+ const textarea = document.getElementById(target);
2521
+ if (!(textarea instanceof HTMLTextAreaElement) || textarea.value.trim() === "")
2522
+ return;
2523
+ try {
2524
+ textarea.value = JSON.stringify(readJson2(textarea.value), null, 2) ?? "";
2525
+ textarea.dispatchEvent(new Event("input"));
2526
+ textarea.focus();
2527
+ } catch {
2528
+ showError(new Error("Fix the JSON before formatting it"));
2529
+ }
2530
+ });
2531
+ }
2338
2532
  document.addEventListener("click", (event) => {
2339
2533
  const target = event.target;
2340
2534
  if (!(target instanceof Element))
@@ -2407,6 +2601,7 @@ function bootAdmin(config) {
2407
2601
  } else {
2408
2602
  editorValue.value = "{\n \n}";
2409
2603
  }
2604
+ syncRecordEditor();
2410
2605
  editor.showModal();
2411
2606
  }
2412
2607
  el("new-record", HTMLButtonElement).addEventListener("click", () => {
@@ -2436,10 +2631,10 @@ function bootAdmin(config) {
2436
2631
  const id = state.editing;
2437
2632
  if (!id)
2438
2633
  return;
2439
- api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2634
+ confirmAction(editorDelete, "Deleting...", "Deleted", () => api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2440
2635
  editor.close();
2441
2636
  return renderState();
2442
- }).catch(showError);
2637
+ }), "Confirm delete").catch(showError);
2443
2638
  });
2444
2639
  for (const button of document.querySelectorAll("[data-advance]")) {
2445
2640
  if (!(button instanceof HTMLButtonElement))
@@ -2485,10 +2680,10 @@ function bootAdmin(config) {
2485
2680
  }).then(() => renderFaults()).catch(showError);
2486
2681
  });
2487
2682
  el("fault-clear", HTMLButtonElement).addEventListener("click", () => {
2488
- confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults())).catch(showError);
2683
+ confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults()), "Confirm clear").catch(showError);
2489
2684
  });
2490
2685
  el("journal-clear", HTMLButtonElement).addEventListener("click", () => {
2491
- confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal())).catch(showError);
2686
+ confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal()), "Confirm clear").catch(showError);
2492
2687
  });
2493
2688
  el("route-list", HTMLElement).addEventListener("click", (event) => {
2494
2689
  const target = event.target;
@@ -2502,9 +2697,15 @@ function bootAdmin(config) {
2502
2697
  return;
2503
2698
  state.route = route;
2504
2699
  el("route-title", HTMLElement).textContent = route;
2700
+ el("route-send", HTMLButtonElement).disabled = false;
2505
2701
  const method = route.split(" ")[0] ?? "";
2506
- el("route-body", HTMLTextAreaElement).disabled = method === "GET" || method === "DELETE";
2702
+ const routeBody = el("route-body", HTMLTextAreaElement);
2703
+ routeBody.disabled = method === "GET" || method === "DELETE";
2704
+ el("route-editor", HTMLElement).hidden = routeBody.disabled;
2705
+ el("route-json-status", HTMLElement).parentElement?.toggleAttribute("hidden", routeBody.disabled);
2706
+ syncRouteEditor();
2507
2707
  });
2708
+ el("route-search", HTMLInputElement).addEventListener("input", paintRoutes);
2508
2709
  el("route-form", HTMLFormElement).addEventListener("submit", (event) => {
2509
2710
  event.preventDefault();
2510
2711
  const send = async () => {
@@ -2526,7 +2727,8 @@ function bootAdmin(config) {
2526
2727
  }
2527
2728
  }
2528
2729
  const data = await api(method, concrete, body);
2529
- el("route-result", HTMLElement).textContent = JSON.stringify(data, null, 2);
2730
+ const text2 = JSON.stringify(data, null, 2) ?? "null";
2731
+ el("route-result", HTMLElement).innerHTML = `<code>${highlightJsonText(text2)}</code>`;
2530
2732
  };
2531
2733
  send().catch(showError);
2532
2734
  });
@@ -2644,11 +2846,13 @@ function bootAdmin(config) {
2644
2846
  views.append(section);
2645
2847
  mountSqlExplorer(body, panelApi);
2646
2848
  }
2849
+ show(document.getElementById(`view-${state.view}`) === null ? "overview" : state.view);
2647
2850
  return refresh();
2648
2851
  }).catch(showError);
2649
2852
  }
2650
2853
  var clientPrelude = [
2651
2854
  isRecord2,
2855
+ highlightJsonText,
2652
2856
  mountAdminBrand,
2653
2857
  startAdminBrand,
2654
2858
  faultPresetList,
@@ -2671,6 +2875,7 @@ ${entry}(${payload});
2671
2875
  var defineMock = (runtime) => runtime;
2672
2876
  var STANDARD_ADMIN_ROUTES = [
2673
2877
  "GET /",
2878
+ "GET /health",
2674
2879
  "POST /reset",
2675
2880
  "GET /namespaces",
2676
2881
  "GET /clock",
@@ -2750,11 +2955,11 @@ var escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => {
2750
2955
  }
2751
2956
  });
2752
2957
  var ADMIN_BRANDS_URL = "https://mockingbird.chrisvouga.dev/brands.json";
2753
- var renderAdminDocument = (service) => {
2958
+ var renderAdminDocument = (service, adminPrefix = ADMIN_PREFIX) => {
2754
2959
  const name = escapeHtml(service);
2755
2960
  const script = adminClientSource({
2756
2961
  standardRoutes: STANDARD_ADMIN_ROUTES,
2757
- adminPrefix: ADMIN_PREFIX,
2962
+ adminPrefix,
2758
2963
  adminKeyHeader: ADMIN_KEY_HEADER,
2759
2964
  brandsUrl: ADMIN_BRANDS_URL,
2760
2965
  service
@@ -2771,75 +2976,99 @@ var renderAdminDocument = (service) => {
2771
2976
  ${CSS_RESET}
2772
2977
  :root {
2773
2978
  color-scheme: light;
2774
- --bg: #fafafa;
2979
+ --bg: #f7f8fb;
2775
2980
  --bg-elev: #ffffff;
2776
- --bg-muted: #f4f4f5;
2777
- --bg-inset: #f4f4f5;
2778
- --line: #dedee3;
2779
- --line-strong: #c4c4cc;
2780
- --ink: #18181b;
2781
- --muted: #62626b;
2782
- --faint: #71717a;
2783
- --accent: #27272a;
2981
+ --bg-muted: #f1f3f7;
2982
+ --bg-inset: #f8f9fc;
2983
+ --line: #e4e7ec;
2984
+ --line-strong: #cfd4dc;
2985
+ --ink: #101828;
2986
+ --muted: #475467;
2987
+ --faint: #667085;
2988
+ --accent: #6941c6;
2989
+ --accent-hover: #5933b4;
2784
2990
  --accent-ink: #ffffff;
2785
- --accent-soft: #f4f4f5;
2786
- --ok: #166534;
2787
- --ok-soft: #f0fdf4;
2788
- --warn: #a16207;
2789
- --warn-soft: #fefce8;
2790
- --err: #a82d32;
2791
- --err-soft: #fef2f2;
2792
- --shadow: none;
2991
+ --accent-soft: #f4f0ff;
2992
+ --ok: #067647;
2993
+ --ok-soft: #ecfdf3;
2994
+ --warn: #b54708;
2995
+ --warn-soft: #fffaeb;
2996
+ --err: #b42318;
2997
+ --err-soft: #fef3f2;
2998
+ --code-key: #6941c6;
2999
+ --code-string: #067647;
3000
+ --code-number: #175cd3;
3001
+ --code-boolean: #c11574;
3002
+ --code-null: #667085;
3003
+ --shadow-xs: 0 1px 2px rgb(16 24 40 / 0.05);
3004
+ --shadow-sm: 0 1px 3px rgb(16 24 40 / 0.08), 0 1px 2px rgb(16 24 40 / 0.04);
3005
+ --shadow-lg: 0 18px 48px rgb(16 24 40 / 0.16), 0 4px 12px rgb(16 24 40 / 0.08);
2793
3006
  --sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2794
3007
  --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
2795
- --radius: 10px;
2796
- --header: 56px;
2797
- --nav: 200px;
3008
+ --radius: 12px;
3009
+ --header: 72px;
3010
+ --nav: 224px;
2798
3011
  }
2799
3012
  :root[data-theme="dark"] {
2800
3013
  color-scheme: dark;
2801
- --bg: #111113;
2802
- --bg-elev: #19191c;
2803
- --bg-muted: #242428;
2804
- --bg-inset: #111113;
2805
- --line: #36363c;
2806
- --line-strong: #52525b;
2807
- --ink: #f4f4f5;
2808
- --muted: #a9a9b2;
2809
- --faint: #71717a;
2810
- --accent: #e4e4e7;
2811
- --accent-ink: #18181b;
2812
- --accent-soft: #242428;
2813
- --ok: #86efac;
2814
- --ok-soft: #14241b;
2815
- --warn: #fde68a;
2816
- --warn-soft: #2a2410;
2817
- --err: #ffaaaa;
2818
- --err-soft: #2c1516;
2819
- --shadow: none;
3014
+ --bg: #0c0e14;
3015
+ --bg-elev: #151821;
3016
+ --bg-muted: #20232e;
3017
+ --bg-inset: #11141c;
3018
+ --line: #2b303d;
3019
+ --line-strong: #414858;
3020
+ --ink: #f5f7fa;
3021
+ --muted: #b6bdc9;
3022
+ --faint: #8d96a7;
3023
+ --accent: #9e77ed;
3024
+ --accent-hover: #b692f6;
3025
+ --accent-ink: #ffffff;
3026
+ --accent-soft: #2b2142;
3027
+ --ok: #75e0a7;
3028
+ --ok-soft: #102a20;
3029
+ --warn: #fec84b;
3030
+ --warn-soft: #302611;
3031
+ --err: #fda29b;
3032
+ --err-soft: #321817;
3033
+ --code-key: #c3a6ff;
3034
+ --code-string: #75e0a7;
3035
+ --code-number: #84adff;
3036
+ --code-boolean: #f9a8d4;
3037
+ --code-null: #98a2b3;
3038
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
3039
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
3040
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
2820
3041
  }
2821
3042
  @media (prefers-color-scheme: dark) {
2822
3043
  :root:not([data-theme="light"]) {
2823
3044
  color-scheme: dark;
2824
- --bg: #111113;
2825
- --bg-elev: #19191c;
2826
- --bg-muted: #242428;
2827
- --bg-inset: #111113;
2828
- --line: #36363c;
2829
- --line-strong: #52525b;
2830
- --ink: #f4f4f5;
2831
- --muted: #a9a9b2;
2832
- --faint: #71717a;
2833
- --accent: #e4e4e7;
2834
- --accent-ink: #18181b;
2835
- --accent-soft: #242428;
2836
- --ok: #86efac;
2837
- --ok-soft: #14241b;
2838
- --warn: #fde68a;
2839
- --warn-soft: #2a2410;
2840
- --err: #ffaaaa;
2841
- --err-soft: #2c1516;
2842
- --shadow: none;
3045
+ --bg: #0c0e14;
3046
+ --bg-elev: #151821;
3047
+ --bg-muted: #20232e;
3048
+ --bg-inset: #11141c;
3049
+ --line: #2b303d;
3050
+ --line-strong: #414858;
3051
+ --ink: #f5f7fa;
3052
+ --muted: #b6bdc9;
3053
+ --faint: #8d96a7;
3054
+ --accent: #9e77ed;
3055
+ --accent-hover: #b692f6;
3056
+ --accent-ink: #ffffff;
3057
+ --accent-soft: #2b2142;
3058
+ --ok: #75e0a7;
3059
+ --ok-soft: #102a20;
3060
+ --warn: #fec84b;
3061
+ --warn-soft: #302611;
3062
+ --err: #fda29b;
3063
+ --err-soft: #321817;
3064
+ --code-key: #c3a6ff;
3065
+ --code-string: #75e0a7;
3066
+ --code-number: #84adff;
3067
+ --code-boolean: #f9a8d4;
3068
+ --code-null: #98a2b3;
3069
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
3070
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
3071
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
2843
3072
  }
2844
3073
  }
2845
3074
  /* Fill the viewport, then grow with the content so the background covers the scroll. */
@@ -2857,13 +3086,17 @@ var renderAdminDocument = (service) => {
2857
3086
  padding-right: env(safe-area-inset-right);
2858
3087
  -webkit-text-size-adjust: 100%;
2859
3088
  }
2860
- :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
3089
+ ::selection { background: color-mix(in srgb, var(--accent) 28%, transparent); }
3090
+ :focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 2px; }
3091
+ button, input, select, textarea { transition: border-color 140ms ease, box-shadow 140ms ease, transform 100ms ease; }
3092
+ button:disabled, input:disabled, select:disabled, textarea:disabled { cursor: not-allowed; opacity: 0.55; }
2861
3093
  .app { flex: 1 0 auto; display: flex; flex-direction: column; min-width: 0; width: 100%; }
2862
3094
  header.top {
2863
- position: sticky; top: 0; z-index: 5;
3095
+ position: sticky; top: 0; z-index: 20;
2864
3096
  display: flex; align-items: center; gap: 12px;
2865
- min-height: var(--header); padding: 10px 16px;
2866
- background: var(--bg-elev);
3097
+ min-height: var(--header); padding: 12px 20px;
3098
+ background: color-mix(in srgb, var(--bg-elev) 92%, transparent);
3099
+ backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
2867
3100
  border-bottom: 1px solid var(--line);
2868
3101
  min-width: 0; max-width: 100%;
2869
3102
  }
@@ -2871,12 +3104,21 @@ var renderAdminDocument = (service) => {
2871
3104
  display: flex; flex-flow: row wrap; align-items: center; gap: 14px;
2872
3105
  min-width: 0; max-width: 100%; margin-right: auto;
2873
3106
  }
2874
- .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; }
2875
- .brand-id strong { font-size: 15px; letter-spacing: -0.01em; }
3107
+ .brand-lockup { display: flex; align-items: center; gap: 10px; min-width: 0; }
3108
+ .brand-mark {
3109
+ width: 38px; height: 38px; display: grid; place-items: center; flex: none;
3110
+ border-radius: 11px; color: #fff;
3111
+ background: linear-gradient(145deg, #7f56d9 0%, #53389e 100%);
3112
+ box-shadow: 0 6px 16px rgb(105 65 198 / 0.24), inset 0 1px 0 rgb(255 255 255 / 0.25);
3113
+ }
3114
+ .brand-mark svg { width: 22px; height: 22px; }
3115
+ .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; line-height: 1.25; }
3116
+ .brand-id strong { font-size: 15px; letter-spacing: -0.015em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2876
3117
  .brand-id span { color: var(--faint); font-size: 12px; }
2877
3118
  .vendor {
2878
3119
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
2879
3120
  min-width: 0; max-width: 100%; font-size: 12px; color: var(--muted);
3121
+ padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--bg-inset);
2880
3122
  }
2881
3123
  .vendor[hidden] { display: none; }
2882
3124
  /* Logos are drawn for a light tile, same as the docs site, so the plate stays white. */
@@ -2888,12 +3130,17 @@ var renderAdminDocument = (service) => {
2888
3130
  .vendor-sep { color: var(--faint); }
2889
3131
  .vendor-link { color: inherit; text-decoration: none; }
2890
3132
  .vendor-link:hover { color: var(--ink); text-decoration: underline; }
2891
- .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
2892
- label.field { display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: var(--faint); }
3133
+ .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; }
3134
+ .sync-status { align-self: center; display: flex; align-items: center; gap: 6px; color: var(--faint); font-size: 12px; white-space: nowrap; }
3135
+ .sync-status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
3136
+ .sync-status.busy::before { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); animation: pulse 1.2s ease-in-out infinite; }
3137
+ label.field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; font-weight: 600; color: var(--faint); }
2893
3138
  label.field input, label.field select, .controls input[type="password"] {
2894
- background-color: var(--bg-inset); border: 1px solid var(--line); border-radius: 8px;
2895
- padding: 7px 8px; min-height: 36px; line-height: 1.2;
3139
+ background-color: var(--bg-elev); border: 1px solid var(--line-strong); border-radius: 9px;
3140
+ padding: 8px 10px; min-height: 38px; line-height: 1.2; box-shadow: var(--shadow-xs);
2896
3141
  }
3142
+ label.field input:hover, label.field select:hover, textarea:hover { border-color: color-mix(in srgb, var(--accent) 38%, var(--line-strong)); }
3143
+ 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; }
2897
3144
  /* A native menu ignores min-height, so Preset rendered about half as tall as
2898
3145
  the text fields in the same form. Paint it like those fields. */
2899
3146
  label.field select {
@@ -2910,12 +3157,20 @@ var renderAdminDocument = (service) => {
2910
3157
  dialog .field > textarea { width: 100%; }
2911
3158
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger, nav.side button { cursor: pointer; }
2912
3159
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger {
2913
- border-radius: 9px; min-height: 36px; padding: 0 12px; border: 1px solid var(--line);
2914
- background: var(--bg-elev);
2915
- }
2916
- .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
2917
- .btn-quiet { background: transparent; }
2918
- .btn-danger { color: var(--err); }
3160
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px;
3161
+ border-radius: 9px; min-height: 38px; padding: 0 13px; border: 1px solid var(--line-strong);
3162
+ background: var(--bg-elev); color: var(--ink); font-size: 13px; font-weight: 600; line-height: 1;
3163
+ box-shadow: var(--shadow-xs);
3164
+ }
3165
+ .icon-btn { width: 38px; padding: 0; }
3166
+ .icon-btn svg, .btn svg, .btn-primary svg { width: 16px; height: 16px; }
3167
+ .icon-btn:hover, .btn:hover { background: var(--bg-muted); border-color: var(--line-strong); }
3168
+ .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; }
3169
+ .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
3170
+ .btn-quiet { background: transparent; border-color: transparent; box-shadow: none; }
3171
+ .btn-quiet:hover { background: var(--bg-muted); }
3172
+ .btn-danger { color: var(--err); border-color: color-mix(in srgb, var(--err) 25%, var(--line)); }
3173
+ .btn-danger:hover, .btn-danger.confirming { background: var(--err-soft); border-color: color-mix(in srgb, var(--err) 45%, var(--line)); }
2919
3174
  /* Held after mouseup. Clearing an empty journal changes nothing on screen,
2920
3175
  so the press itself has to stay visible. */
2921
3176
  .icon-btn:active, .btn:active, .btn-quiet:active, .btn-danger:active, nav.side button:active,
@@ -2931,93 +3186,159 @@ var renderAdminDocument = (service) => {
2931
3186
  flex: 1 0 auto; min-width: 0;
2932
3187
  }
2933
3188
  nav.side {
2934
- border-right: 1px solid var(--line); padding: 12px;
3189
+ position: sticky; top: var(--header); align-self: start; height: calc(100vh - var(--header));
3190
+ border-right: 1px solid var(--line); padding: 18px 12px;
2935
3191
  display: flex; flex-direction: column; gap: 4px;
2936
- background: var(--bg-elev);
3192
+ background: var(--bg-elev); overflow-y: auto;
2937
3193
  }
3194
+ .nav-label { padding: 0 10px 8px; color: var(--faint); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; }
2938
3195
  nav.side button {
3196
+ position: relative; display: flex; align-items: center; gap: 10px; width: 100%;
2939
3197
  text-align: left; border: 0; background: transparent; border-radius: 9px;
2940
- padding: 9px 10px; min-height: 40px; color: var(--muted);
2941
- }
2942
- nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--ink); font-weight: 600; }
2943
- main { padding: 20px; max-width: 1100px; width: 100%; min-width: 0; }
2944
- .view h2 { margin: 0 0 4px; font-size: 22px; letter-spacing: -0.02em; }
2945
- .lede { margin: 0 0 16px; color: var(--muted); }
2946
- .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: 12px; }
3198
+ padding: 9px 10px; min-height: 42px; color: var(--muted); font-size: 13px; font-weight: 550;
3199
+ }
3200
+ nav.side button:hover { background: var(--bg-muted); color: var(--ink); }
3201
+ nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 650; }
3202
+ 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); }
3203
+ .nav-icon { width: 18px; height: 18px; display: grid; place-items: center; flex: none; }
3204
+ .nav-icon svg { width: 18px; height: 18px; }
3205
+ main { padding: 32px clamp(20px, 4vw, 48px) 64px; max-width: 1320px; width: 100%; min-width: 0; }
3206
+ main[aria-busy="true"] .view { opacity: 0.62; }
3207
+ .view { animation: view-in 180ms ease-out; }
3208
+ .view[hidden] { display: none; }
3209
+ .view-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 22px; }
3210
+ .view h2 { margin: 0 0 5px; font-size: clamp(24px, 3vw, 30px); line-height: 1.2; letter-spacing: -0.035em; }
3211
+ .view h3 { margin: 24px 0 10px; font-size: 15px; letter-spacing: -0.01em; }
3212
+ .lede { margin: 0; color: var(--muted); max-width: 72ch; font-size: 14px; }
3213
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 175px), 1fr)); gap: 14px; }
2947
3214
  .card {
2948
3215
  background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius);
2949
- padding: 14px 16px; box-shadow: var(--shadow);
3216
+ padding: 18px; box-shadow: var(--shadow-sm);
2950
3217
  }
2951
- .card .k { color: var(--faint); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
2952
- .card .v { display: flex; flex-direction: column; gap: 2px; font-size: 22px; margin-top: 4px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
3218
+ .stat-card { position: relative; overflow: hidden; }
3219
+ .stat-card::after { content: ""; position: absolute; inset: 0 0 auto; height: 2px; background: linear-gradient(90deg, var(--accent), transparent 78%); opacity: 0.7; }
3220
+ .card .k { color: var(--faint); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; }
3221
+ .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; }
2953
3222
  .card .v .clock-time { font-size: 14px; color: var(--muted); }
2954
- .split { display: grid; grid-template-columns: minmax(0, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
3223
+ .section-card { background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-xs); overflow: hidden; }
3224
+ .section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--line); }
3225
+ .section-title h3 { margin: 0; }
3226
+ .split { display: grid; grid-template-columns: minmax(0, 250px) minmax(0, 1fr); gap: 18px; align-items: start; }
3227
+ #collections { padding: 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); box-shadow: var(--shadow-xs); }
2955
3228
  .collection {
2956
3229
  width: 100%; text-align: left; border: 1px solid transparent; background: transparent;
2957
- border-radius: 10px; padding: 8px 10px; min-height: 44px;
3230
+ border-radius: 9px; padding: 10px 11px; min-height: 48px; color: var(--ink); cursor: pointer;
2958
3231
  }
2959
- .collection[aria-current="true"] { background: var(--bg-elev); border-color: var(--line); }
3232
+ .collection:hover { background: var(--bg-muted); }
3233
+ .collection[aria-current="true"] { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 18%, var(--line)); color: var(--accent); }
2960
3234
  .collection small { display: block; color: var(--faint); }
2961
3235
  .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 14px; }
2962
- .chip {
2963
- font-family: var(--mono); font-size: 12px; background: var(--bg-muted);
2964
- border-radius: 999px; padding: 4px 8px;
2965
- }
3236
+ .chip { font-family: var(--mono); font-size: 11px; background: var(--bg-muted); border: 1px solid var(--line); border-radius: 999px; padding: 4px 8px; }
2966
3237
  .chip i { color: var(--faint); font-style: normal; }
2967
3238
  .sql-layout {
2968
3239
  display: grid; grid-template-columns: minmax(10rem, 16rem) minmax(0, 1fr); gap: 16px;
2969
3240
  }
2970
- .sql-tables { display: flex; flex-direction: column; gap: 4px; max-height: 28rem; overflow: auto; }
2971
- .sql-tables button { text-align: left; }
3241
+ .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); }
3242
+ .sql-tables button { text-align: left; border: 0; border-radius: 8px; padding: 9px 10px; background: transparent; color: var(--muted); cursor: pointer; }
3243
+ .sql-tables button:hover, .sql-tables button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); }
2972
3244
  .sql-input { font-family: var(--mono); min-height: 7rem; width: 100%; }
2973
- table { width: 100%; border-collapse: collapse; font-size: 14px; }
2974
- th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
2975
- th { color: var(--faint); font-weight: 600; font-size: 12px; }
3245
+ .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); }
3246
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
3247
+ th, td { text-align: left; padding: 11px 13px; border-bottom: 1px solid var(--line); vertical-align: top; }
3248
+ th { color: var(--faint); background: var(--bg-inset); font-weight: 650; font-size: 11px; text-transform: uppercase; letter-spacing: 0.045em; }
3249
+ tbody tr:last-child td { border-bottom: 0; }
2976
3250
  tr.clickable { cursor: pointer; }
2977
- tr.clickable:hover { background: var(--bg-muted); }
3251
+ tr.clickable:hover { background: var(--accent-soft); }
2978
3252
  pre, code, .mono { font-family: var(--mono); overflow-wrap: anywhere; }
2979
3253
  pre {
2980
3254
  margin: 0; white-space: pre-wrap; word-break: break-word;
2981
- background: var(--bg-inset); border-radius: 10px; padding: 12px; font-size: 12px;
2982
- }
2983
- .row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
3255
+ background: var(--bg-inset); border: 1px solid var(--line); border-radius: 10px; padding: 14px; font-size: 12px; line-height: 1.65;
3256
+ }
3257
+ .json-key { color: var(--code-key); }
3258
+ .json-string { color: var(--code-string); }
3259
+ .json-number { color: var(--code-number); }
3260
+ .json-boolean { color: var(--code-boolean); }
3261
+ .json-null { color: var(--code-null); font-style: italic; }
3262
+ .json-inline { display: block; max-width: 44rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
3263
+ .json-view { position: relative; max-height: 26rem; overflow: auto; }
3264
+ .json-editor { position: relative; min-height: 190px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--bg-inset); overflow: hidden; }
3265
+ .json-editor:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
3266
+ .json-editor.invalid { border-color: var(--err); }
3267
+ .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; }
3268
+ .json-editor pre { position: absolute; inset: 0; pointer-events: none; background: transparent; }
3269
+ .json-editor textarea { position: relative; z-index: 1; resize: vertical; background: transparent; color: transparent; caret-color: var(--ink); -webkit-text-fill-color: transparent; }
3270
+ .json-editor textarea::selection { background: color-mix(in srgb, var(--accent) 32%, transparent); }
3271
+ .editor-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 7px; }
3272
+ .json-status { display: inline-flex; align-items: center; gap: 6px; color: var(--ok); font-size: 11px; }
3273
+ .json-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
3274
+ .json-status.invalid { color: var(--err); }
3275
+ .format-json { border: 0; padding: 3px 5px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 650; cursor: pointer; }
3276
+ .row-actions { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; margin: 14px 0; }
2984
3277
  .banner {
2985
- display: none; margin-bottom: 12px; padding: 10px 12px; border-radius: 10px;
2986
- background: var(--err-soft); color: var(--err);
2987
- }
2988
- .banner.show { display: block; }
2989
- .empty { color: var(--muted); padding: 24px 8px; }
3278
+ display: none; position: sticky; top: calc(var(--header) + 12px); z-index: 15;
3279
+ align-items: center; gap: 10px; margin: 0 0 16px; padding: 11px 12px 11px 14px;
3280
+ border: 1px solid color-mix(in srgb, var(--err) 30%, var(--line)); border-radius: 10px;
3281
+ background: var(--err-soft); color: var(--err); box-shadow: var(--shadow-sm); font-size: 13px;
3282
+ }
3283
+ .banner.show { display: flex; }
3284
+ .banner span { flex: 1; }
3285
+ .banner button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 7px; background: transparent; color: currentColor; cursor: pointer; }
3286
+ .banner button:hover { background: color-mix(in srgb, var(--err) 10%, transparent); }
3287
+ .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); }
2990
3288
  dialog {
2991
- border: 1px solid var(--line); border-radius: var(--radius); padding: 0; background: var(--bg-elev);
2992
- color: var(--ink); width: min(640px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: none;
2993
- }
2994
- dialog::backdrop { background: rgb(20 19 17 / 0.45); }
2995
- dialog form, .dialog-body { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
3289
+ border: 1px solid var(--line); border-radius: 16px; padding: 0; background: var(--bg-elev);
3290
+ color: var(--ink); width: min(720px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: var(--shadow-lg);
3291
+ }
3292
+ dialog[open] { animation: dialog-in 160ms ease-out; }
3293
+ dialog::backdrop { background: rgb(12 14 20 / 0.58); backdrop-filter: blur(3px); }
3294
+ dialog form, .dialog-body { padding: 22px; display: flex; flex-direction: column; gap: 14px; }
3295
+ .dialog-heading { display: flex; justify-content: space-between; gap: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
3296
+ .dialog-heading strong { font-size: 18px; letter-spacing: -0.02em; }
3297
+ .dialog-heading span { color: var(--muted); font-size: 12px; }
2996
3298
  textarea {
2997
3299
  width: 100%; min-height: 180px; resize: vertical; border-radius: 10px;
2998
3300
  border: 1px solid var(--line); background: var(--bg-inset); padding: 10px;
2999
3301
  font-family: var(--mono); font-size: 12px;
3000
3302
  }
3001
- .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
3002
- .badge { font-size: 11px; border-radius: 999px; padding: 2px 7px; background: var(--bg-muted); color: var(--muted); }
3303
+ .dialog-actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; padding-top: 4px; }
3304
+ .dialog-actions .btn-danger { margin-right: auto; }
3305
+ .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); }
3003
3306
  .badge.ok { background: var(--ok-soft); color: var(--ok); }
3004
3307
  .badge.warn { background: var(--warn-soft); color: var(--warn); }
3005
3308
  .status-2 { color: var(--ok); }
3006
3309
  .status-4, .status-5, .status-0 { color: var(--err); }
3007
3310
  .stack { display: flex; flex-direction: column; gap: 12px; }
3311
+ .form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
3312
+ .form-grid .wide { grid-column: span 2; }
3008
3313
  .route {
3009
- display: grid; grid-template-columns: 88px 1fr auto; gap: 8px; align-items: center;
3010
- padding: 8px 0; border-bottom: 1px solid var(--line);
3011
- }
3012
- .method { font-family: var(--mono); font-size: 12px; font-weight: 700; }
3314
+ display: grid; grid-template-columns: 84px minmax(0, 1fr) auto; gap: 10px; align-items: center;
3315
+ padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--bg-elev);
3316
+ }
3317
+ .route:first-child { border-radius: var(--radius) var(--radius) 0 0; }
3318
+ .route:last-child { border-bottom: 0; border-radius: 0 0 var(--radius) var(--radius); }
3319
+ .route-tools { display: flex; align-items: flex-end; gap: 12px; margin-bottom: 12px; }
3320
+ .route-tools .field { flex: 1; }
3321
+ .route-tools input { width: 100%; }
3322
+ .route-count { padding-bottom: 10px; color: var(--faint); font-size: 12px; white-space: nowrap; }
3323
+ #route-list { max-height: 34rem; border: 1px solid var(--line); border-radius: var(--radius); overflow: auto; box-shadow: var(--shadow-xs); }
3324
+ .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; }
3325
+ @keyframes view-in { from { transform: translateY(3px); } to { transform: translateY(0); } }
3326
+ @keyframes dialog-in { from { transform: translateY(8px) scale(0.99); } to { transform: translateY(0) scale(1); } }
3327
+ @keyframes pulse { 50% { opacity: 0.45; } }
3328
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
3329
+ @media (forced-colors: active) { .json-editor pre { display: none; } .json-editor textarea { color: CanvasText; -webkit-text-fill-color: CanvasText; } }
3013
3330
  @media (max-width: 800px) {
3331
+ :root { --header: 0px; }
3014
3332
  header.top {
3015
- align-items: stretch; flex-direction: column;
3016
- padding-top: max(10px, env(safe-area-inset-top));
3333
+ position: relative; align-items: stretch; flex-direction: column;
3334
+ padding: max(12px, env(safe-area-inset-top)) 14px 12px;
3017
3335
  }
3018
3336
  input, select, textarea { font-size: 16px; }
3019
3337
  .brand, .vendor, .controls { width: 100%; }
3338
+ .brand { gap: 10px; }
3339
+ .vendor { order: 2; border-radius: 9px; }
3020
3340
  label.field, label.field select, label.field input { flex: 1; min-width: 0; }
3341
+ .sync-status { display: none; }
3021
3342
  /* The layout fills the screen. A single-column grid stretches every auto
3022
3343
  row, so the tab bar grew with the leftover height and the buttons floated
3023
3344
  in the middle of it. The bar stays one row; the page below takes the rest. */
@@ -3027,14 +3348,23 @@ var renderAdminDocument = (service) => {
3027
3348
  align-content: start;
3028
3349
  }
3029
3350
  nav.side {
3030
- align-self: start; height: auto;
3351
+ position: sticky; top: 0; z-index: 12; align-self: start; height: auto;
3031
3352
  flex-direction: row; align-items: center; overflow-x: auto; min-width: 0;
3032
3353
  border-right: 0; border-bottom: 1px solid var(--line);
3033
- padding: 8px;
3354
+ padding: 8px; box-shadow: var(--shadow-xs);
3034
3355
  }
3035
- nav.side button { white-space: nowrap; flex: none; }
3356
+ .nav-label { display: none; }
3357
+ nav.side button { width: auto; white-space: nowrap; flex: none; }
3358
+ nav.side button[aria-current="true"]::before { inset: auto 9px -8px; width: auto; height: 3px; border-radius: 3px 3px 0 0; }
3036
3359
  .split, .sql-layout { grid-template-columns: minmax(0, 1fr); }
3037
- main { padding: 14px; }
3360
+ .form-grid { grid-template-columns: minmax(0, 1fr); }
3361
+ .form-grid .wide { grid-column: auto; }
3362
+ main { padding: 22px 14px 48px; }
3363
+ .view-head { margin-bottom: 18px; }
3364
+ .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
3365
+ .card { padding: 14px; }
3366
+ .card .v { font-size: 20px; }
3367
+ .table-shell { border: 0; background: transparent; box-shadow: none; overflow: visible; }
3038
3368
  table, thead, tbody, tr, th, td { display: block; }
3039
3369
  thead { display: none; }
3040
3370
  tr {
@@ -3049,50 +3379,63 @@ var renderAdminDocument = (service) => {
3049
3379
  .route { grid-template-columns: 72px 1fr; }
3050
3380
  .route button { grid-column: 1 / -1; }
3051
3381
  }
3382
+ @media (max-width: 460px) {
3383
+ .controls label.field { min-width: calc(50% - 4px); }
3384
+ .controls .icon-btn, .controls .btn { flex: 1; }
3385
+ .grid { grid-template-columns: minmax(0, 1fr); }
3386
+ .dialog-actions { flex-wrap: wrap; }
3387
+ .dialog-actions button { flex: 1; }
3388
+ .dialog-actions .btn-danger { flex-basis: 100%; margin-right: 0; }
3389
+ }
3052
3390
  </style>
3053
3391
  </head>
3054
3392
  <body>
3055
3393
  <div class="app" data-service="${name}" data-mockingbird-admin>
3056
3394
  <header class="top">
3057
3395
  <div class="brand">
3058
- <div class="brand-id">
3059
- <strong>${name}</strong>
3060
- <span>Admin</span>
3396
+ <div class="brand-lockup">
3397
+ <div class="brand-mark" aria-hidden="true">
3398
+ <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>
3399
+ </div>
3400
+ <div class="brand-id">
3401
+ <strong>${name}</strong>
3402
+ <span>Mock admin</span>
3403
+ </div>
3061
3404
  </div>
3062
3405
  <div class="vendor" id="vendor" hidden></div>
3063
3406
  </div>
3064
3407
  <div class="controls">
3408
+ <span class="sync-status" id="sync-status" role="status" aria-live="polite">Ready</span>
3065
3409
  <label class="field">Namespace
3066
3410
  <select id="namespace" aria-label="Namespace"></select>
3067
3411
  </label>
3068
3412
  <label class="field">Admin key
3069
3413
  <input id="admin-key" type="password" autocomplete="off" spellcheck="false" aria-label="Admin key">
3070
3414
  </label>
3071
- <button class="icon-btn" id="theme" type="button" aria-label="Toggle color theme">Theme</button>
3072
- <button class="btn" id="refresh" type="button">Refresh</button>
3415
+ <button class="icon-btn" id="theme" type="button" aria-label="Use dark theme" title="Use dark theme"></button>
3416
+ <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>
3073
3417
  </div>
3074
3418
  </header>
3075
3419
  <div class="layout">
3076
3420
  <nav class="side" id="nav" aria-label="Admin sections">
3077
- <button type="button" data-view="overview" aria-current="true">Overview</button>
3078
- <button type="button" data-view="state">State</button>
3079
- <button type="button" data-view="clock">Clock</button>
3080
- <button type="button" data-view="faults">Faults</button>
3081
- <button type="button" data-view="journal">Journal</button>
3082
- <button type="button" data-view="routes">Routes</button>
3421
+ <span class="nav-label">Workspace</span>
3422
+ <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>
3423
+ <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>
3424
+ <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>
3425
+ <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>
3426
+ <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>
3427
+ <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>
3083
3428
  </nav>
3084
3429
  <main id="views">
3085
- <p class="banner" id="banner" role="alert"></p>
3430
+ <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>
3086
3431
  <section class="view" id="view-overview">
3087
- <h2>Overview</h2>
3088
- <p class="lede">Health, clock, and the collections this namespace is holding.</p>
3432
+ <div class="view-head"><div><h2>Overview</h2><p class="lede">Health, clock, and the collections this namespace is holding.</p></div></div>
3089
3433
  <div class="grid" id="overview-cards"></div>
3090
3434
  <h3>Recent requests</h3>
3091
3435
  <div id="overview-requests"></div>
3092
3436
  </section>
3093
3437
  <section class="view" id="view-state" hidden>
3094
- <h2>State</h2>
3095
- <p class="lede">Every collection in this namespace. Declared fields stay visible before any row exists; stored rows add whatever else they contain.</p>
3438
+ <div class="view-head"><div><h2>State</h2><p class="lede">Inspect and edit every collection in this namespace.</p></div></div>
3096
3439
  <div class="split">
3097
3440
  <div id="collections"></div>
3098
3441
  <div>
@@ -3105,8 +3448,7 @@ var renderAdminDocument = (service) => {
3105
3448
  </div>
3106
3449
  </section>
3107
3450
  <section class="view" id="view-clock" hidden>
3108
- <h2>Clock</h2>
3109
- <p class="lede">The clock every timestamp in this mock reads.</p>
3451
+ <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>
3110
3452
  <div class="card" id="clock-readout"></div>
3111
3453
  <div class="row-actions">
3112
3454
  <button class="btn" type="button" data-advance="60000">+1m</button>
@@ -3125,21 +3467,22 @@ var renderAdminDocument = (service) => {
3125
3467
  </form>
3126
3468
  </section>
3127
3469
  <section class="view" id="view-faults" hidden>
3128
- <h2>Faults</h2>
3129
- <p class="lede">Inject a response, a delay, or a named preset. Rules apply to this namespace.</p>
3470
+ <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>
3130
3471
  <form id="fault-form" class="card stack">
3131
- <label class="field">Preset
3132
- <select id="fault-preset"><option value="">Custom rule</option></select>
3133
- </label>
3134
- <label class="field">Status
3135
- <input id="fault-status" inputmode="numeric" placeholder="503">
3136
- </label>
3137
- <label class="field">Path prefix
3138
- <input id="fault-path" placeholder="/v1">
3139
- </label>
3140
- <label class="field">Method
3141
- <input id="fault-method" placeholder="GET">
3142
- </label>
3472
+ <div class="form-grid">
3473
+ <label class="field wide">Preset
3474
+ <select id="fault-preset"><option value="">Custom rule</option></select>
3475
+ </label>
3476
+ <label class="field">Status
3477
+ <input id="fault-status" inputmode="numeric" placeholder="503">
3478
+ </label>
3479
+ <label class="field wide">Path prefix
3480
+ <input id="fault-path" placeholder="/v1">
3481
+ </label>
3482
+ <label class="field">Method
3483
+ <input id="fault-method" placeholder="GET">
3484
+ </label>
3485
+ </div>
3143
3486
  <div class="dialog-actions">
3144
3487
  <button class="btn-primary" type="submit">Add fault</button>
3145
3488
  <button class="btn-danger" id="fault-clear" type="button">Clear all</button>
@@ -3148,26 +3491,31 @@ var renderAdminDocument = (service) => {
3148
3491
  <div id="fault-list"></div>
3149
3492
  </section>
3150
3493
  <section class="view" id="view-journal" hidden>
3151
- <h2>Journal</h2>
3152
- <p class="lede">Requests this namespace has seen. Bodies are not stored.</p>
3153
- <div class="row-actions">
3154
- <button class="btn-danger" id="journal-clear" type="button">Clear journal</button>
3155
- </div>
3494
+ <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>
3156
3495
  <div id="journal"></div>
3157
3496
  </section>
3158
3497
  <section class="view" id="view-routes" hidden>
3159
- <h2>Routes</h2>
3160
- <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>
3498
+ <div class="view-head"><div><h2>Routes</h2><p class="lede">Explore shared controls and service-specific admin endpoints.</p></div></div>
3499
+ <div class="route-tools">
3500
+ <label class="field">Filter routes
3501
+ <input id="route-search" type="search" placeholder="Method, path, or extension" autocomplete="off">
3502
+ </label>
3503
+ <span class="route-count" id="route-count"></span>
3504
+ </div>
3161
3505
  <div id="route-list"></div>
3162
- <form id="route-form" class="card stack">
3506
+ <form id="route-form" class="card stack" style="margin-top: 18px">
3163
3507
  <strong id="route-title">Select a route</strong>
3164
3508
  <label class="field">JSON body
3165
- <textarea id="route-body" placeholder="{ }"></textarea>
3509
+ <div class="json-editor" id="route-editor" hidden>
3510
+ <pre aria-hidden="true"><code id="route-highlight"></code></pre>
3511
+ <textarea id="route-body" placeholder="{ }" spellcheck="false" aria-describedby="route-json-status" disabled></textarea>
3512
+ </div>
3513
+ <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>
3166
3514
  </label>
3167
3515
  <div class="dialog-actions">
3168
- <button class="btn-primary" type="submit">Send</button>
3516
+ <button class="btn-primary" id="route-send" type="submit" disabled>Send request</button>
3169
3517
  </div>
3170
- <pre id="route-result">Response will show here.</pre>
3518
+ <pre class="json-view" id="route-result"><code>Response will show here.</code></pre>
3171
3519
  </form>
3172
3520
  </section>
3173
3521
  </main>
@@ -3175,12 +3523,16 @@ var renderAdminDocument = (service) => {
3175
3523
  </div>
3176
3524
  <dialog id="editor">
3177
3525
  <form method="dialog" id="editor-form">
3178
- <strong id="editor-title">Record</strong>
3526
+ <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>
3179
3527
  <label class="field">Id
3180
3528
  <input id="editor-id" required>
3181
3529
  </label>
3182
3530
  <label class="field">JSON value
3183
- <textarea id="editor-value"></textarea>
3531
+ <div class="json-editor" id="record-editor">
3532
+ <pre aria-hidden="true"><code id="editor-highlight"></code></pre>
3533
+ <textarea id="editor-value" spellcheck="false" aria-describedby="editor-json-status"></textarea>
3534
+ </div>
3535
+ <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>
3184
3536
  </label>
3185
3537
  <div class="dialog-actions">
3186
3538
  <button class="btn-danger" id="editor-delete" type="button" value="delete">Delete</button>
@@ -3222,8 +3574,8 @@ var describeExtension = (extension) => {
3222
3574
  ...extension.script !== void 0 ? { script: extension.script } : {}
3223
3575
  };
3224
3576
  };
3225
- var adminUiRoutes = (service, ui) => {
3226
- const shell = () => renderAdminDocument(service);
3577
+ var adminUiRoutes = (service, ui, adminPrefix = ADMIN_PREFIX) => {
3578
+ const shell = () => renderAdminDocument(service, adminPrefix);
3227
3579
  const document3 = () => ui?.render ? ui.render({ service, defaultHtml: shell }) : shell();
3228
3580
  return {
3229
3581
  "GET /ui": () => htmlResponse(document3()),
@@ -3840,14 +4192,17 @@ var stateAdminRoutes = (routes) => {
3840
4192
  };
3841
4193
 
3842
4194
  // ../core/dist/version.js
3843
- var PACKAGE_VERSION = true ? "1.8.0" : UNRELEASED_VERSION;
4195
+ var PACKAGE_VERSION = true ? "2.0.0" : UNRELEASED_VERSION;
3844
4196
 
3845
4197
  // ../core/dist/signing.js
3846
4198
  var encoder = new TextEncoder();
3847
4199
  var toHex = (bytes) => [...bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
3848
4200
 
3849
4201
  // ../core/dist/webhooks.js
3850
- var json4 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
4202
+ var json4 = (status, body) => new Response(JSON.stringify(body), {
4203
+ status,
4204
+ headers: { "content-type": "application/json; charset=utf-8" }
4205
+ });
3851
4206
  var adminError3 = (status, message) => json4(status, { error: { type: "mockingbird_admin", message } });
3852
4207
  var isRecord5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3853
4208
  var parseEndpoint = (value) => {
@@ -3955,7 +4310,6 @@ var AT_HEADER = "x-mockingbird-at";
3955
4310
  var CHECKPOINT_HEADER = "x-mockingbird-checkpoint";
3956
4311
  var DEFAULT_NAMESPACE = "default";
3957
4312
  var NAMESPACE_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
3958
- var PATH_PREFIX = /^\/ns\/([^/]+)(\/.*)?$/;
3959
4313
  var BRANCH_PATTERN2 = /^[A-Za-z0-9_.-]{1,64}$/;
3960
4314
  var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
3961
4315
  var effects = /* @__PURE__ */ new WeakMap();
@@ -3996,6 +4350,22 @@ var operationMatcher = (document3) => {
3996
4350
  return (request, path) => matchers.find((m) => m.method === request.method && m.pattern.test(path))?.operationId;
3997
4351
  };
3998
4352
  var createRuntime = (options) => {
4353
+ const adminPrefix = resolveAdminPrefix(options.adminPrefix);
4354
+ const internalPaths = Object.entries(options.document?.paths ?? {}).filter(([, item]) => item["x-mockingbird-internal"] === true).map(([path]) => path);
4355
+ for (const path of internalPaths) {
4356
+ if (!path.startsWith(`${ADMIN_PREFIX}/blobs/`))
4357
+ throw new Error(`internal transport ${path} must live under ${ADMIN_PREFIX}/blobs/`);
4358
+ }
4359
+ assertAdminPrefixAvailable(adminPrefix, Object.keys(options.document?.paths ?? {}).filter((path) => !internalPaths.includes(path)));
4360
+ const internalMatch = (path) => {
4361
+ for (const pattern of internalPaths) {
4362
+ const actual = path.split("/");
4363
+ const expected = pattern.split("/");
4364
+ if (actual.length === expected.length && expected.every((part, i) => part.startsWith("{") || part === actual[i]))
4365
+ return pattern;
4366
+ }
4367
+ return void 0;
4368
+ };
3999
4369
  assertAdminUi(options.adminUi);
4000
4370
  const sqlite = bootSqlite(options.sqlite);
4001
4371
  const clock = options.clock ?? createClock();
@@ -4041,6 +4411,7 @@ var createRuntime = (options) => {
4041
4411
  const created = options.create({
4042
4412
  namespace: storageNamespace(key),
4043
4413
  publicNamespace,
4414
+ adminPrefix,
4044
4415
  sqlite,
4045
4416
  clock: namespaceClock(publicNamespace),
4046
4417
  rng: createdContextRng
@@ -4273,10 +4644,10 @@ var createRuntime = (options) => {
4273
4644
  namespaceClock,
4274
4645
  namespaceOf(request) {
4275
4646
  const url = new URL(request.url);
4276
- const prefixed = PATH_PREFIX.exec(url.pathname);
4647
+ const prefixed = matchNamespacePath(url.pathname, adminPrefix);
4277
4648
  const selected = request.headers.get(NAMESPACE_HEADER) ?? (prefixed ? decodeURIComponent(prefixed[1]) : void 0);
4278
4649
  const path = prefixed ? prefixed[2] ?? "/" : url.pathname;
4279
- if (path === "/__admin" || path.startsWith("/__admin/"))
4650
+ if (isAdminPath(path, adminPrefix))
4280
4651
  return url.searchParams.get("namespace") ?? selected ?? DEFAULT_NAMESPACE;
4281
4652
  if (selected !== void 0)
4282
4653
  return selected;
@@ -4342,7 +4713,7 @@ var createRuntime = (options) => {
4342
4713
  state: (namespace = DEFAULT_NAMESPACE) => inspectState(stateScope(namespace)),
4343
4714
  fetch: async (incoming) => {
4344
4715
  let request = incoming;
4345
- const prefixed = PATH_PREFIX.exec(new URL(request.url).pathname);
4716
+ const prefixed = matchNamespacePath(new URL(request.url).pathname, adminPrefix);
4346
4717
  if (prefixed) {
4347
4718
  const url2 = new URL(request.url);
4348
4719
  url2.pathname = prefixed[2] ?? "/";
@@ -4358,7 +4729,15 @@ var createRuntime = (options) => {
4358
4729
  signal: request.signal
4359
4730
  });
4360
4731
  }
4361
- let namespace = control.namespaceOf(request);
4732
+ const internalUrl = new URL(request.url);
4733
+ const internalPath = isAdminPath(internalUrl.pathname, adminPrefix) ? `${ADMIN_PREFIX}${internalUrl.pathname.slice(adminPrefix.length)}` : "";
4734
+ const internal = internalMatch(internalPath);
4735
+ const obsoleteInternalPath = adminPrefix !== ADMIN_PREFIX && internalMatch(internalUrl.pathname) && !internal;
4736
+ if (internal) {
4737
+ internalUrl.pathname = internalPath;
4738
+ request = new Request(internalUrl, request);
4739
+ }
4740
+ let namespace = internal ? internalUrl.searchParams.get("namespace") ?? control.namespaceOf(request) : control.namespaceOf(request);
4362
4741
  if (!request.headers.has(NAMESPACE_HEADER) && options.credential) {
4363
4742
  const credential = options.credential(request);
4364
4743
  const mapped = credential !== void 0 ? credentials.get(credential) : void 0;
@@ -4378,7 +4757,9 @@ var createRuntime = (options) => {
4378
4757
  return copy;
4379
4758
  }
4380
4759
  };
4381
- const handled = await control.handle(request);
4760
+ if (obsoleteInternalPath)
4761
+ return stamp(adminFail(404, "internal transport is only available under adminPrefix"));
4762
+ const handled = internal ? void 0 : await control.handle(request);
4382
4763
  if (handled)
4383
4764
  return stamp(handled);
4384
4765
  const started = monotonicNow();
@@ -4419,7 +4800,7 @@ var createRuntime = (options) => {
4419
4800
  type: "mockingbird_admin",
4420
4801
  message: `${NAMESPACE_HEADER} must match ${NAMESPACE_PATTERN}`
4421
4802
  }
4422
- }), { status: 400, headers: { "content-type": "application/json" } }));
4803
+ }), { status: 400, headers: { "content-type": "application/json; charset=utf-8" } }));
4423
4804
  }
4424
4805
  if (!BRANCH_PATTERN2.test(selectedBranch)) {
4425
4806
  log(400);
@@ -4536,15 +4917,16 @@ var createRuntime = (options) => {
4536
4917
  ...options.admin?.(runtime) ?? {},
4537
4918
  // Shell routes win a colliding key. Fault presets are one of them: the admin page
4538
4919
  // maps `presets`, and a service route must not replace that list with a record.
4539
- ...options.presets ? presetRoutes(options.presets, runtime) : {},
4920
+ ...options.presets ? presetRoutes(options.presets, runtime, adminPrefix) : {},
4540
4921
  ...stateAdminRoutes({
4541
4922
  open: stateScope,
4542
4923
  afterWrite: (namespace) => {
4543
4924
  checkpoint(namespace, "main");
4544
4925
  }
4545
4926
  }),
4546
- ...adminUiRoutes(options.name, options.adminUi)
4927
+ ...adminUiRoutes(options.name, options.adminUi, adminPrefix)
4547
4928
  },
4929
+ adminPrefix,
4548
4930
  adminKey: options.adminKey
4549
4931
  });
4550
4932
  return defineMock(runtime);
@@ -4558,7 +4940,10 @@ var mutableResponse = (response) => {
4558
4940
  return new Response(response.body, response);
4559
4941
  }
4560
4942
  };
4561
- var adminJson = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
4943
+ var adminJson = (status, body) => new Response(JSON.stringify(body), {
4944
+ status,
4945
+ headers: { "content-type": "application/json; charset=utf-8" }
4946
+ });
4562
4947
  var adminFail = (status, message) => adminJson(status, { error: { type: "mockingbird_admin", message } });
4563
4948
  var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4564
4949
  var credentialRoutes = (registry) => ({
@@ -4610,7 +4995,7 @@ var credentialRoutes = (registry) => ({
4610
4995
  return adminJson(200, { status: "ok" });
4611
4996
  }
4612
4997
  });
4613
- var presetRoutes = (presets, runtime) => ({
4998
+ var presetRoutes = (presets, runtime, adminPrefix) => ({
4614
4999
  "GET /faults/presets": () => {
4615
5000
  const body = {
4616
5001
  presets: Object.entries(presets).map(([name, preset]) => ({ name, ...preset }))
@@ -4620,7 +5005,7 @@ var presetRoutes = (presets, runtime) => ({
4620
5005
  "POST /faults/presets/:name": ({ params, body, namespace }) => {
4621
5006
  const name = params.name;
4622
5007
  if (!presets[name])
4623
- return adminFail(404, `no fault preset ${name}; GET /__admin/faults/presets`);
5008
+ return adminFail(404, `no fault preset ${name}; GET ${adminPrefix}/faults/presets`);
4624
5009
  const overrides = isObject(body) ? body : {};
4625
5010
  return adminJson(201, { preset: name, rules: runtime.applyPreset(name, namespace, overrides) });
4626
5011
  }
@@ -5266,6 +5651,7 @@ var createRuntime2 = (options = {}) => createRuntime({
5266
5651
  ...options.sqlite ? { sqlite: options.sqlite } : {},
5267
5652
  ...options.clock ? { clock: options.clock } : {},
5268
5653
  ...options.seed !== void 0 ? { seed: options.seed } : {},
5654
+ ...options.adminPrefix !== void 0 ? { adminPrefix: options.adminPrefix } : {},
5269
5655
  ...options.adminKey !== void 0 ? { adminKey: options.adminKey } : {},
5270
5656
  ...options.onLog ? { onLog: options.onLog } : {},
5271
5657
  credential: accessKeyCredential,
@@ -6061,6 +6447,7 @@ var SpeechAPI = class {
6061
6447
 
6062
6448
  export {
6063
6449
  createClock,
6450
+ resolveAdminPrefix,
6064
6451
  parseDuration,
6065
6452
  MS_PER_CHARACTER,
6066
6453
  durationFor,
@@ -6090,4 +6477,4 @@ export {
6090
6477
  accessKeyCredential,
6091
6478
  SpeechAPI
6092
6479
  };
6093
- //# sourceMappingURL=chunk-FLBEZUJF.js.map
6480
+ //# sourceMappingURL=chunk-RTZSXNQY.js.map