@crvouga/mockingbird-service-posthog 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
  };
@@ -1581,6 +1620,34 @@ dialog:not([open]) {
1581
1620
  function isRecord2(value) {
1582
1621
  return typeof value === "object" && value !== null && !Array.isArray(value);
1583
1622
  }
1623
+ function highlightJsonText(text) {
1624
+ const escapeToken = (value) => value.replace(/[&<>"']/g, (char) => {
1625
+ switch (char) {
1626
+ case "&":
1627
+ return "&amp;";
1628
+ case "<":
1629
+ return "&lt;";
1630
+ case ">":
1631
+ return "&gt;";
1632
+ case '"':
1633
+ return "&quot;";
1634
+ default:
1635
+ return "&#39;";
1636
+ }
1637
+ });
1638
+ 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;
1639
+ let cursor = 0;
1640
+ let html = "";
1641
+ for (const match of text.matchAll(token)) {
1642
+ const index = match.index ?? 0;
1643
+ const value = match[0];
1644
+ html += escapeToken(text.slice(cursor, index));
1645
+ const kind = value.startsWith('"') ? value.trimEnd().endsWith(":") ? "key" : "string" : value === "true" || value === "false" ? "boolean" : value === "null" ? "null" : "number";
1646
+ html += `<span class="json-${kind}">${escapeToken(value)}</span>`;
1647
+ cursor = index + value.length;
1648
+ }
1649
+ return html + escapeToken(text.slice(cursor));
1650
+ }
1584
1651
  function mountAdminBrand(doc, host, service, catalog) {
1585
1652
  const clip = (value, max) => {
1586
1653
  if (typeof value !== "string")
@@ -1942,14 +2009,16 @@ function bootAdmin(config) {
1942
2009
  const state = {
1943
2010
  namespace: "default",
1944
2011
  key: sessionStorage.getItem("mockingbird-admin-key") || "",
1945
- view: "overview",
2012
+ view: ["overview", "state", "clock", "faults", "journal", "routes"].includes(location.hash.slice(1)) ? location.hash.slice(1) : "overview",
1946
2013
  collection: "",
1947
2014
  shape: null,
1948
2015
  route: null,
2016
+ routes: [],
1949
2017
  editing: null
1950
2018
  };
1951
2019
  const standard = new Set(config.standardRoutes);
1952
2020
  const banner = el("banner", HTMLElement);
2021
+ const bannerMessage = el("banner-message", HTMLElement);
1953
2022
  const keyInput = el("admin-key", HTMLInputElement);
1954
2023
  keyInput.value = state.key;
1955
2024
  const params = new URLSearchParams(location.search);
@@ -1960,18 +2029,21 @@ function bootAdmin(config) {
1960
2029
  sessionStorage.setItem("mockingbird-admin-key", state.key);
1961
2030
  params.delete("key");
1962
2031
  const next = params.toString();
1963
- history.replaceState(null, "", location.pathname + (next ? `?${next}` : ""));
2032
+ history.replaceState(null, "", location.pathname + (next ? `?${next}` : "") + location.hash);
1964
2033
  }
1965
2034
  const queryNamespace = params.get("namespace");
1966
2035
  if (queryNamespace)
1967
2036
  state.namespace = queryNamespace;
1968
2037
  const showError = (error2) => {
1969
- banner.textContent = error2 instanceof Error ? error2.message : String(error2);
2038
+ bannerMessage.textContent = error2 instanceof Error ? error2.message : String(error2);
1970
2039
  banner.classList.add("show");
2040
+ banner.scrollIntoView({ behavior: "smooth", block: "nearest" });
1971
2041
  };
1972
2042
  const clearError = () => {
1973
2043
  banner.classList.remove("show");
2044
+ bannerMessage.textContent = "";
1974
2045
  };
2046
+ el("banner-dismiss", HTMLButtonElement).addEventListener("click", clearError);
1975
2047
  const api = async (method, path, body) => {
1976
2048
  const headers = { accept: "application/json" };
1977
2049
  if (state.key !== "")
@@ -2015,16 +2087,21 @@ function bootAdmin(config) {
2015
2087
  namespace: () => state.namespace
2016
2088
  };
2017
2089
  const isPanelScript = (value) => typeof value === "function";
2090
+ const themeButton = el("theme", HTMLButtonElement);
2018
2091
  const setTheme = (theme) => {
2019
2092
  if (theme)
2020
2093
  document.documentElement.dataset.theme = theme;
2021
2094
  else
2022
2095
  delete document.documentElement.dataset.theme;
2096
+ const dark = theme ? theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
2097
+ 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>';
2098
+ const label = dark ? "Use light theme" : "Use dark theme";
2099
+ themeButton.setAttribute("aria-label", label);
2100
+ themeButton.title = label;
2023
2101
  };
2024
2102
  const storedTheme = localStorage.getItem("mockingbird-admin-theme");
2025
- if (storedTheme)
2026
- setTheme(storedTheme);
2027
- el("theme", HTMLButtonElement).addEventListener("click", () => {
2103
+ setTheme(storedTheme === "dark" || storedTheme === "light" ? storedTheme : null);
2104
+ themeButton.addEventListener("click", () => {
2028
2105
  const current = document.documentElement.dataset.theme;
2029
2106
  const dark = current ? current === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
2030
2107
  const next = dark ? "light" : "dark";
@@ -2043,6 +2120,7 @@ function bootAdmin(config) {
2043
2120
  continue;
2044
2121
  section.hidden = section.id !== `view-${view}`;
2045
2122
  }
2123
+ history.replaceState(null, "", `${location.pathname}${location.search}#${encodeURIComponent(view)}`);
2046
2124
  };
2047
2125
  el("nav", HTMLElement).addEventListener("click", (event) => {
2048
2126
  const target = event.target;
@@ -2057,7 +2135,7 @@ function bootAdmin(config) {
2057
2135
  show(view);
2058
2136
  refresh().catch(showError);
2059
2137
  });
2060
- 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>`;
2138
+ 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>`;
2061
2139
  const table = (columns, rows) => {
2062
2140
  if (rows.length === 0)
2063
2141
  return '<p class="empty">Nothing here yet.</p>';
@@ -2067,16 +2145,22 @@ function bootAdmin(config) {
2067
2145
  const attrs = "attrs" in row && typeof row.attrs === "string" ? row.attrs : "";
2068
2146
  return `<tr${attrs}>${cells}</tr>`;
2069
2147
  }).join("");
2070
- return `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
2148
+ return `<div class="table-shell"><table><thead>${head}</thead><tbody>${body}</tbody></table></div>`;
2071
2149
  };
2072
2150
  const preview = (value) => {
2073
2151
  const text = JSON.stringify(value) ?? "null";
2074
- return esc(text.length > 180 ? `${text.slice(0, 180)}\u2026` : text);
2152
+ const clipped = text.length > 180 ? `${text.slice(0, 180)}\u2026` : text;
2153
+ return `<code class="json-inline">${highlightJsonText(clipped)}</code>`;
2075
2154
  };
2076
2155
  const when = (epoch) => {
2077
2156
  const date = new Date(epoch);
2078
2157
  return Number.isNaN(date.getTime()) ? String(epoch) : date.toISOString();
2079
2158
  };
2159
+ const journalWhen = (value) => {
2160
+ const numeric = value.trim() === "" ? Number.NaN : Number(value);
2161
+ const epoch = Number.isFinite(numeric) ? numeric : Date.parse(value);
2162
+ return Number.isFinite(epoch) ? when(epoch) : value;
2163
+ };
2080
2164
  const clockCard = (epoch) => {
2081
2165
  const date = new Date(epoch);
2082
2166
  if (Number.isNaN(date.getTime()))
@@ -2196,7 +2280,7 @@ function bootAdmin(config) {
2196
2280
  };
2197
2281
  const renderOverview = async () => {
2198
2282
  const [health, shape, clock, journal] = await Promise.all([
2199
- originGet("/health"),
2283
+ originGet(`${config.adminPrefix}/health`),
2200
2284
  api("GET", "/state"),
2201
2285
  api("GET", "/clock"),
2202
2286
  api("GET", "/requests?limit=8")
@@ -2213,7 +2297,7 @@ function bootAdmin(config) {
2213
2297
  },
2214
2298
  {
2215
2299
  k: "Status",
2216
- v: esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")
2300
+ v: `<span class="badge ok">${esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")}</span>`
2217
2301
  },
2218
2302
  { k: "Namespace", v: esc(state.namespace) },
2219
2303
  { k: "Collections", v: String(decoded2.collections.length) },
@@ -2300,26 +2384,46 @@ function bootAdmin(config) {
2300
2384
  const renderJournal = async () => {
2301
2385
  const journal = await api("GET", "/requests?limit=100");
2302
2386
  el("journal", HTMLElement).innerHTML = table([
2303
- { label: "When", cell: (row) => esc(row.at) },
2304
- { label: "Method", cell: (row) => esc(row.method) },
2387
+ { label: "When", cell: (row) => esc(journalWhen(row.at)) },
2388
+ {
2389
+ label: "Method",
2390
+ cell: (row) => `<span class="method">${esc(row.method)}</span>`
2391
+ },
2305
2392
  { label: "Path", cell: (row) => esc(row.path) },
2306
- { label: "Status", cell: (row) => esc(row.status) },
2393
+ {
2394
+ label: "Status",
2395
+ cell: (row) => `<span class="badge ${row.status.startsWith("2") ? "ok" : row.status.startsWith("4") || row.status.startsWith("5") ? "warn" : ""}">${esc(row.status)}</span>`
2396
+ },
2307
2397
  { label: "ms", cell: (row) => esc(row.durationMs) }
2308
2398
  ], decodeJournal(journal));
2309
2399
  };
2310
- const renderRoutes = async () => {
2311
- const data = await api("GET", "/");
2312
- if (!isRecord2(data))
2313
- throw new Error("Route list was not an object");
2314
- const routes = requireList(data.routes, "Routes").map((route) => String(route));
2400
+ const paintRoutes = () => {
2401
+ const query = el("route-search", HTMLInputElement).value.trim().toLocaleLowerCase();
2402
+ const routes = state.routes.filter((route) => route.toLocaleLowerCase().includes(query));
2403
+ el("route-count", HTMLElement).textContent = `${routes.length} of ${state.routes.length}`;
2404
+ if (state.routes.length === 0) {
2405
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No admin routes are available.</p>';
2406
+ return;
2407
+ }
2408
+ if (routes.length === 0) {
2409
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No routes match this filter.</p>';
2410
+ return;
2411
+ }
2315
2412
  el("route-list", HTMLElement).innerHTML = routes.map((route) => {
2316
2413
  const extra = standard.has(route) ? "" : " <span class='badge'>extension</span>";
2317
2414
  const parts = route.split(" ");
2318
2415
  const method = parts[0] ?? "";
2319
2416
  const path = parts.slice(1).join(" ");
2320
- 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>`;
2417
+ 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>`;
2321
2418
  }).join("");
2322
2419
  };
2420
+ const renderRoutes = async () => {
2421
+ const data = await api("GET", "/");
2422
+ if (!isRecord2(data))
2423
+ throw new Error("Route list was not an object");
2424
+ state.routes = requireList(data.routes, "Routes").map((route) => String(route));
2425
+ paintRoutes();
2426
+ };
2323
2427
  const renderers = {
2324
2428
  overview: renderOverview,
2325
2429
  state: renderState,
@@ -2328,19 +2432,50 @@ function bootAdmin(config) {
2328
2432
  journal: renderJournal,
2329
2433
  routes: renderRoutes
2330
2434
  };
2435
+ let refreshToken = 0;
2331
2436
  async function refresh() {
2437
+ const token = ++refreshToken;
2332
2438
  clearError();
2439
+ const main = el("views", HTMLElement);
2440
+ const sync = el("sync-status", HTMLElement);
2441
+ main.setAttribute("aria-busy", "true");
2442
+ sync.classList.add("busy");
2443
+ sync.textContent = "Updating";
2333
2444
  const render = renderers[state.view];
2334
- if (render)
2335
- await render();
2445
+ try {
2446
+ if (render)
2447
+ await render();
2448
+ if (token === refreshToken)
2449
+ sync.textContent = "Up to date";
2450
+ } finally {
2451
+ if (token === refreshToken) {
2452
+ main.removeAttribute("aria-busy");
2453
+ sync.classList.remove("busy");
2454
+ }
2455
+ }
2336
2456
  }
2337
2457
  const delay = (ms) => new Promise((resolve) => {
2338
2458
  window.setTimeout(resolve, ms);
2339
2459
  });
2340
- const confirmAction = async (button, pending, done, work) => {
2460
+ const confirmAction = async (button, pending, done, work, confirmLabel) => {
2341
2461
  if (button.dataset.pending === "1")
2342
2462
  return;
2343
2463
  const label = button.textContent ?? "";
2464
+ if (confirmLabel !== void 0 && button.dataset.confirming !== "1") {
2465
+ button.dataset.confirming = "1";
2466
+ button.classList.add("confirming");
2467
+ button.textContent = confirmLabel;
2468
+ window.setTimeout(() => {
2469
+ if (button.dataset.confirming !== "1")
2470
+ return;
2471
+ button.textContent = label;
2472
+ button.classList.remove("confirming");
2473
+ delete button.dataset.confirming;
2474
+ }, 3e3);
2475
+ return;
2476
+ }
2477
+ button.classList.remove("confirming");
2478
+ delete button.dataset.confirming;
2344
2479
  const width = button.offsetWidth;
2345
2480
  button.dataset.pending = "1";
2346
2481
  button.classList.add("pressed");
@@ -2361,6 +2496,65 @@ function bootAdmin(config) {
2361
2496
  delete button.dataset.pending;
2362
2497
  }
2363
2498
  };
2499
+ const wireJsonEditor = (textareaId, highlightId, statusId, shellId, allowEmpty) => {
2500
+ const textarea = el(textareaId, HTMLTextAreaElement);
2501
+ const highlight = el(highlightId, HTMLElement);
2502
+ const status = el(statusId, HTMLElement);
2503
+ const shell = el(shellId, HTMLElement);
2504
+ const sync = () => {
2505
+ const text = textarea.value;
2506
+ highlight.innerHTML = `${highlightJsonText(text)}
2507
+ `;
2508
+ let valid = true;
2509
+ let message = "Valid JSON";
2510
+ if (allowEmpty && text.trim() === "")
2511
+ message = "No body";
2512
+ else {
2513
+ try {
2514
+ readJson2(text);
2515
+ } catch {
2516
+ valid = false;
2517
+ message = "Invalid JSON";
2518
+ }
2519
+ }
2520
+ shell.classList.toggle("invalid", !valid);
2521
+ status.classList.toggle("invalid", !valid);
2522
+ status.textContent = message;
2523
+ textarea.setAttribute("aria-invalid", valid ? "false" : "true");
2524
+ };
2525
+ const syncScroll = () => {
2526
+ const preview2 = highlight.parentElement;
2527
+ if (!(preview2 instanceof HTMLElement))
2528
+ return;
2529
+ preview2.scrollTop = textarea.scrollTop;
2530
+ preview2.scrollLeft = textarea.scrollLeft;
2531
+ };
2532
+ textarea.addEventListener("input", sync);
2533
+ textarea.addEventListener("scroll", syncScroll);
2534
+ sync();
2535
+ return sync;
2536
+ };
2537
+ const syncRouteEditor = wireJsonEditor("route-body", "route-highlight", "route-json-status", "route-editor", true);
2538
+ const syncRecordEditor = wireJsonEditor("editor-value", "editor-highlight", "editor-json-status", "record-editor", false);
2539
+ for (const button of document.querySelectorAll("[data-format]")) {
2540
+ if (!(button instanceof HTMLButtonElement))
2541
+ continue;
2542
+ button.addEventListener("click", () => {
2543
+ const target = button.dataset.format;
2544
+ if (target === void 0)
2545
+ return;
2546
+ const textarea = document.getElementById(target);
2547
+ if (!(textarea instanceof HTMLTextAreaElement) || textarea.value.trim() === "")
2548
+ return;
2549
+ try {
2550
+ textarea.value = JSON.stringify(readJson2(textarea.value), null, 2) ?? "";
2551
+ textarea.dispatchEvent(new Event("input"));
2552
+ textarea.focus();
2553
+ } catch {
2554
+ showError(new Error("Fix the JSON before formatting it"));
2555
+ }
2556
+ });
2557
+ }
2364
2558
  document.addEventListener("click", (event) => {
2365
2559
  const target = event.target;
2366
2560
  if (!(target instanceof Element))
@@ -2433,6 +2627,7 @@ function bootAdmin(config) {
2433
2627
  } else {
2434
2628
  editorValue.value = "{\n \n}";
2435
2629
  }
2630
+ syncRecordEditor();
2436
2631
  editor.showModal();
2437
2632
  }
2438
2633
  el("new-record", HTMLButtonElement).addEventListener("click", () => {
@@ -2462,10 +2657,10 @@ function bootAdmin(config) {
2462
2657
  const id = state.editing;
2463
2658
  if (!id)
2464
2659
  return;
2465
- api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2660
+ confirmAction(editorDelete, "Deleting...", "Deleted", () => api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2466
2661
  editor.close();
2467
2662
  return renderState();
2468
- }).catch(showError);
2663
+ }), "Confirm delete").catch(showError);
2469
2664
  });
2470
2665
  for (const button of document.querySelectorAll("[data-advance]")) {
2471
2666
  if (!(button instanceof HTMLButtonElement))
@@ -2511,10 +2706,10 @@ function bootAdmin(config) {
2511
2706
  }).then(() => renderFaults()).catch(showError);
2512
2707
  });
2513
2708
  el("fault-clear", HTMLButtonElement).addEventListener("click", () => {
2514
- confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults())).catch(showError);
2709
+ confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults()), "Confirm clear").catch(showError);
2515
2710
  });
2516
2711
  el("journal-clear", HTMLButtonElement).addEventListener("click", () => {
2517
- confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal())).catch(showError);
2712
+ confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal()), "Confirm clear").catch(showError);
2518
2713
  });
2519
2714
  el("route-list", HTMLElement).addEventListener("click", (event) => {
2520
2715
  const target = event.target;
@@ -2528,9 +2723,15 @@ function bootAdmin(config) {
2528
2723
  return;
2529
2724
  state.route = route;
2530
2725
  el("route-title", HTMLElement).textContent = route;
2726
+ el("route-send", HTMLButtonElement).disabled = false;
2531
2727
  const method = route.split(" ")[0] ?? "";
2532
- el("route-body", HTMLTextAreaElement).disabled = method === "GET" || method === "DELETE";
2728
+ const routeBody = el("route-body", HTMLTextAreaElement);
2729
+ routeBody.disabled = method === "GET" || method === "DELETE";
2730
+ el("route-editor", HTMLElement).hidden = routeBody.disabled;
2731
+ el("route-json-status", HTMLElement).parentElement?.toggleAttribute("hidden", routeBody.disabled);
2732
+ syncRouteEditor();
2533
2733
  });
2734
+ el("route-search", HTMLInputElement).addEventListener("input", paintRoutes);
2534
2735
  el("route-form", HTMLFormElement).addEventListener("submit", (event) => {
2535
2736
  event.preventDefault();
2536
2737
  const send = async () => {
@@ -2552,7 +2753,8 @@ function bootAdmin(config) {
2552
2753
  }
2553
2754
  }
2554
2755
  const data = await api(method, concrete, body);
2555
- el("route-result", HTMLElement).textContent = JSON.stringify(data, null, 2);
2756
+ const text = JSON.stringify(data, null, 2) ?? "null";
2757
+ el("route-result", HTMLElement).innerHTML = `<code>${highlightJsonText(text)}</code>`;
2556
2758
  };
2557
2759
  send().catch(showError);
2558
2760
  });
@@ -2670,11 +2872,13 @@ function bootAdmin(config) {
2670
2872
  views.append(section);
2671
2873
  mountSqlExplorer(body, panelApi);
2672
2874
  }
2875
+ show(document.getElementById(`view-${state.view}`) === null ? "overview" : state.view);
2673
2876
  return refresh();
2674
2877
  }).catch(showError);
2675
2878
  }
2676
2879
  var clientPrelude = [
2677
2880
  isRecord2,
2881
+ highlightJsonText,
2678
2882
  mountAdminBrand,
2679
2883
  startAdminBrand,
2680
2884
  faultPresetList,
@@ -2697,6 +2901,7 @@ ${entry}(${payload});
2697
2901
  var defineMock = (runtime) => runtime;
2698
2902
  var STANDARD_ADMIN_ROUTES = [
2699
2903
  "GET /",
2904
+ "GET /health",
2700
2905
  "POST /reset",
2701
2906
  "GET /namespaces",
2702
2907
  "GET /clock",
@@ -2776,11 +2981,11 @@ var escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => {
2776
2981
  }
2777
2982
  });
2778
2983
  var ADMIN_BRANDS_URL = "https://mockingbird.chrisvouga.dev/brands.json";
2779
- var renderAdminDocument = (service) => {
2984
+ var renderAdminDocument = (service, adminPrefix = ADMIN_PREFIX) => {
2780
2985
  const name = escapeHtml(service);
2781
2986
  const script = adminClientSource({
2782
2987
  standardRoutes: STANDARD_ADMIN_ROUTES,
2783
- adminPrefix: ADMIN_PREFIX,
2988
+ adminPrefix,
2784
2989
  adminKeyHeader: ADMIN_KEY_HEADER,
2785
2990
  brandsUrl: ADMIN_BRANDS_URL,
2786
2991
  service
@@ -2797,75 +3002,99 @@ var renderAdminDocument = (service) => {
2797
3002
  ${CSS_RESET}
2798
3003
  :root {
2799
3004
  color-scheme: light;
2800
- --bg: #fafafa;
3005
+ --bg: #f7f8fb;
2801
3006
  --bg-elev: #ffffff;
2802
- --bg-muted: #f4f4f5;
2803
- --bg-inset: #f4f4f5;
2804
- --line: #dedee3;
2805
- --line-strong: #c4c4cc;
2806
- --ink: #18181b;
2807
- --muted: #62626b;
2808
- --faint: #71717a;
2809
- --accent: #27272a;
3007
+ --bg-muted: #f1f3f7;
3008
+ --bg-inset: #f8f9fc;
3009
+ --line: #e4e7ec;
3010
+ --line-strong: #cfd4dc;
3011
+ --ink: #101828;
3012
+ --muted: #475467;
3013
+ --faint: #667085;
3014
+ --accent: #6941c6;
3015
+ --accent-hover: #5933b4;
2810
3016
  --accent-ink: #ffffff;
2811
- --accent-soft: #f4f4f5;
2812
- --ok: #166534;
2813
- --ok-soft: #f0fdf4;
2814
- --warn: #a16207;
2815
- --warn-soft: #fefce8;
2816
- --err: #a82d32;
2817
- --err-soft: #fef2f2;
2818
- --shadow: none;
3017
+ --accent-soft: #f4f0ff;
3018
+ --ok: #067647;
3019
+ --ok-soft: #ecfdf3;
3020
+ --warn: #b54708;
3021
+ --warn-soft: #fffaeb;
3022
+ --err: #b42318;
3023
+ --err-soft: #fef3f2;
3024
+ --code-key: #6941c6;
3025
+ --code-string: #067647;
3026
+ --code-number: #175cd3;
3027
+ --code-boolean: #c11574;
3028
+ --code-null: #667085;
3029
+ --shadow-xs: 0 1px 2px rgb(16 24 40 / 0.05);
3030
+ --shadow-sm: 0 1px 3px rgb(16 24 40 / 0.08), 0 1px 2px rgb(16 24 40 / 0.04);
3031
+ --shadow-lg: 0 18px 48px rgb(16 24 40 / 0.16), 0 4px 12px rgb(16 24 40 / 0.08);
2819
3032
  --sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2820
3033
  --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
2821
- --radius: 10px;
2822
- --header: 56px;
2823
- --nav: 200px;
3034
+ --radius: 12px;
3035
+ --header: 72px;
3036
+ --nav: 224px;
2824
3037
  }
2825
3038
  :root[data-theme="dark"] {
2826
3039
  color-scheme: dark;
2827
- --bg: #111113;
2828
- --bg-elev: #19191c;
2829
- --bg-muted: #242428;
2830
- --bg-inset: #111113;
2831
- --line: #36363c;
2832
- --line-strong: #52525b;
2833
- --ink: #f4f4f5;
2834
- --muted: #a9a9b2;
2835
- --faint: #71717a;
2836
- --accent: #e4e4e7;
2837
- --accent-ink: #18181b;
2838
- --accent-soft: #242428;
2839
- --ok: #86efac;
2840
- --ok-soft: #14241b;
2841
- --warn: #fde68a;
2842
- --warn-soft: #2a2410;
2843
- --err: #ffaaaa;
2844
- --err-soft: #2c1516;
2845
- --shadow: none;
3040
+ --bg: #0c0e14;
3041
+ --bg-elev: #151821;
3042
+ --bg-muted: #20232e;
3043
+ --bg-inset: #11141c;
3044
+ --line: #2b303d;
3045
+ --line-strong: #414858;
3046
+ --ink: #f5f7fa;
3047
+ --muted: #b6bdc9;
3048
+ --faint: #8d96a7;
3049
+ --accent: #9e77ed;
3050
+ --accent-hover: #b692f6;
3051
+ --accent-ink: #ffffff;
3052
+ --accent-soft: #2b2142;
3053
+ --ok: #75e0a7;
3054
+ --ok-soft: #102a20;
3055
+ --warn: #fec84b;
3056
+ --warn-soft: #302611;
3057
+ --err: #fda29b;
3058
+ --err-soft: #321817;
3059
+ --code-key: #c3a6ff;
3060
+ --code-string: #75e0a7;
3061
+ --code-number: #84adff;
3062
+ --code-boolean: #f9a8d4;
3063
+ --code-null: #98a2b3;
3064
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
3065
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
3066
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
2846
3067
  }
2847
3068
  @media (prefers-color-scheme: dark) {
2848
3069
  :root:not([data-theme="light"]) {
2849
3070
  color-scheme: dark;
2850
- --bg: #111113;
2851
- --bg-elev: #19191c;
2852
- --bg-muted: #242428;
2853
- --bg-inset: #111113;
2854
- --line: #36363c;
2855
- --line-strong: #52525b;
2856
- --ink: #f4f4f5;
2857
- --muted: #a9a9b2;
2858
- --faint: #71717a;
2859
- --accent: #e4e4e7;
2860
- --accent-ink: #18181b;
2861
- --accent-soft: #242428;
2862
- --ok: #86efac;
2863
- --ok-soft: #14241b;
2864
- --warn: #fde68a;
2865
- --warn-soft: #2a2410;
2866
- --err: #ffaaaa;
2867
- --err-soft: #2c1516;
2868
- --shadow: none;
3071
+ --bg: #0c0e14;
3072
+ --bg-elev: #151821;
3073
+ --bg-muted: #20232e;
3074
+ --bg-inset: #11141c;
3075
+ --line: #2b303d;
3076
+ --line-strong: #414858;
3077
+ --ink: #f5f7fa;
3078
+ --muted: #b6bdc9;
3079
+ --faint: #8d96a7;
3080
+ --accent: #9e77ed;
3081
+ --accent-hover: #b692f6;
3082
+ --accent-ink: #ffffff;
3083
+ --accent-soft: #2b2142;
3084
+ --ok: #75e0a7;
3085
+ --ok-soft: #102a20;
3086
+ --warn: #fec84b;
3087
+ --warn-soft: #302611;
3088
+ --err: #fda29b;
3089
+ --err-soft: #321817;
3090
+ --code-key: #c3a6ff;
3091
+ --code-string: #75e0a7;
3092
+ --code-number: #84adff;
3093
+ --code-boolean: #f9a8d4;
3094
+ --code-null: #98a2b3;
3095
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
3096
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
3097
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
2869
3098
  }
2870
3099
  }
2871
3100
  /* Fill the viewport, then grow with the content so the background covers the scroll. */
@@ -2883,13 +3112,17 @@ var renderAdminDocument = (service) => {
2883
3112
  padding-right: env(safe-area-inset-right);
2884
3113
  -webkit-text-size-adjust: 100%;
2885
3114
  }
2886
- :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
3115
+ ::selection { background: color-mix(in srgb, var(--accent) 28%, transparent); }
3116
+ :focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 2px; }
3117
+ button, input, select, textarea { transition: border-color 140ms ease, box-shadow 140ms ease, transform 100ms ease; }
3118
+ button:disabled, input:disabled, select:disabled, textarea:disabled { cursor: not-allowed; opacity: 0.55; }
2887
3119
  .app { flex: 1 0 auto; display: flex; flex-direction: column; min-width: 0; width: 100%; }
2888
3120
  header.top {
2889
- position: sticky; top: 0; z-index: 5;
3121
+ position: sticky; top: 0; z-index: 20;
2890
3122
  display: flex; align-items: center; gap: 12px;
2891
- min-height: var(--header); padding: 10px 16px;
2892
- background: var(--bg-elev);
3123
+ min-height: var(--header); padding: 12px 20px;
3124
+ background: color-mix(in srgb, var(--bg-elev) 92%, transparent);
3125
+ backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
2893
3126
  border-bottom: 1px solid var(--line);
2894
3127
  min-width: 0; max-width: 100%;
2895
3128
  }
@@ -2897,12 +3130,21 @@ var renderAdminDocument = (service) => {
2897
3130
  display: flex; flex-flow: row wrap; align-items: center; gap: 14px;
2898
3131
  min-width: 0; max-width: 100%; margin-right: auto;
2899
3132
  }
2900
- .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; }
2901
- .brand-id strong { font-size: 15px; letter-spacing: -0.01em; }
3133
+ .brand-lockup { display: flex; align-items: center; gap: 10px; min-width: 0; }
3134
+ .brand-mark {
3135
+ width: 38px; height: 38px; display: grid; place-items: center; flex: none;
3136
+ border-radius: 11px; color: #fff;
3137
+ background: linear-gradient(145deg, #7f56d9 0%, #53389e 100%);
3138
+ box-shadow: 0 6px 16px rgb(105 65 198 / 0.24), inset 0 1px 0 rgb(255 255 255 / 0.25);
3139
+ }
3140
+ .brand-mark svg { width: 22px; height: 22px; }
3141
+ .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; line-height: 1.25; }
3142
+ .brand-id strong { font-size: 15px; letter-spacing: -0.015em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2902
3143
  .brand-id span { color: var(--faint); font-size: 12px; }
2903
3144
  .vendor {
2904
3145
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
2905
3146
  min-width: 0; max-width: 100%; font-size: 12px; color: var(--muted);
3147
+ padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--bg-inset);
2906
3148
  }
2907
3149
  .vendor[hidden] { display: none; }
2908
3150
  /* Logos are drawn for a light tile, same as the docs site, so the plate stays white. */
@@ -2914,12 +3156,17 @@ var renderAdminDocument = (service) => {
2914
3156
  .vendor-sep { color: var(--faint); }
2915
3157
  .vendor-link { color: inherit; text-decoration: none; }
2916
3158
  .vendor-link:hover { color: var(--ink); text-decoration: underline; }
2917
- .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
2918
- label.field { display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: var(--faint); }
3159
+ .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; }
3160
+ .sync-status { align-self: center; display: flex; align-items: center; gap: 6px; color: var(--faint); font-size: 12px; white-space: nowrap; }
3161
+ .sync-status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
3162
+ .sync-status.busy::before { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); animation: pulse 1.2s ease-in-out infinite; }
3163
+ label.field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; font-weight: 600; color: var(--faint); }
2919
3164
  label.field input, label.field select, .controls input[type="password"] {
2920
- background-color: var(--bg-inset); border: 1px solid var(--line); border-radius: 8px;
2921
- padding: 7px 8px; min-height: 36px; line-height: 1.2;
3165
+ background-color: var(--bg-elev); border: 1px solid var(--line-strong); border-radius: 9px;
3166
+ padding: 8px 10px; min-height: 38px; line-height: 1.2; box-shadow: var(--shadow-xs);
2922
3167
  }
3168
+ label.field input:hover, label.field select:hover, textarea:hover { border-color: color-mix(in srgb, var(--accent) 38%, var(--line-strong)); }
3169
+ 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; }
2923
3170
  /* A native menu ignores min-height, so Preset rendered about half as tall as
2924
3171
  the text fields in the same form. Paint it like those fields. */
2925
3172
  label.field select {
@@ -2936,12 +3183,20 @@ var renderAdminDocument = (service) => {
2936
3183
  dialog .field > textarea { width: 100%; }
2937
3184
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger, nav.side button { cursor: pointer; }
2938
3185
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger {
2939
- border-radius: 9px; min-height: 36px; padding: 0 12px; border: 1px solid var(--line);
2940
- background: var(--bg-elev);
2941
- }
2942
- .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
2943
- .btn-quiet { background: transparent; }
2944
- .btn-danger { color: var(--err); }
3186
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px;
3187
+ border-radius: 9px; min-height: 38px; padding: 0 13px; border: 1px solid var(--line-strong);
3188
+ background: var(--bg-elev); color: var(--ink); font-size: 13px; font-weight: 600; line-height: 1;
3189
+ box-shadow: var(--shadow-xs);
3190
+ }
3191
+ .icon-btn { width: 38px; padding: 0; }
3192
+ .icon-btn svg, .btn svg, .btn-primary svg { width: 16px; height: 16px; }
3193
+ .icon-btn:hover, .btn:hover { background: var(--bg-muted); border-color: var(--line-strong); }
3194
+ .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; }
3195
+ .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
3196
+ .btn-quiet { background: transparent; border-color: transparent; box-shadow: none; }
3197
+ .btn-quiet:hover { background: var(--bg-muted); }
3198
+ .btn-danger { color: var(--err); border-color: color-mix(in srgb, var(--err) 25%, var(--line)); }
3199
+ .btn-danger:hover, .btn-danger.confirming { background: var(--err-soft); border-color: color-mix(in srgb, var(--err) 45%, var(--line)); }
2945
3200
  /* Held after mouseup. Clearing an empty journal changes nothing on screen,
2946
3201
  so the press itself has to stay visible. */
2947
3202
  .icon-btn:active, .btn:active, .btn-quiet:active, .btn-danger:active, nav.side button:active,
@@ -2957,93 +3212,159 @@ var renderAdminDocument = (service) => {
2957
3212
  flex: 1 0 auto; min-width: 0;
2958
3213
  }
2959
3214
  nav.side {
2960
- border-right: 1px solid var(--line); padding: 12px;
3215
+ position: sticky; top: var(--header); align-self: start; height: calc(100vh - var(--header));
3216
+ border-right: 1px solid var(--line); padding: 18px 12px;
2961
3217
  display: flex; flex-direction: column; gap: 4px;
2962
- background: var(--bg-elev);
3218
+ background: var(--bg-elev); overflow-y: auto;
2963
3219
  }
3220
+ .nav-label { padding: 0 10px 8px; color: var(--faint); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; }
2964
3221
  nav.side button {
3222
+ position: relative; display: flex; align-items: center; gap: 10px; width: 100%;
2965
3223
  text-align: left; border: 0; background: transparent; border-radius: 9px;
2966
- padding: 9px 10px; min-height: 40px; color: var(--muted);
2967
- }
2968
- nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--ink); font-weight: 600; }
2969
- main { padding: 20px; max-width: 1100px; width: 100%; min-width: 0; }
2970
- .view h2 { margin: 0 0 4px; font-size: 22px; letter-spacing: -0.02em; }
2971
- .lede { margin: 0 0 16px; color: var(--muted); }
2972
- .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: 12px; }
3224
+ padding: 9px 10px; min-height: 42px; color: var(--muted); font-size: 13px; font-weight: 550;
3225
+ }
3226
+ nav.side button:hover { background: var(--bg-muted); color: var(--ink); }
3227
+ nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 650; }
3228
+ 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); }
3229
+ .nav-icon { width: 18px; height: 18px; display: grid; place-items: center; flex: none; }
3230
+ .nav-icon svg { width: 18px; height: 18px; }
3231
+ main { padding: 32px clamp(20px, 4vw, 48px) 64px; max-width: 1320px; width: 100%; min-width: 0; }
3232
+ main[aria-busy="true"] .view { opacity: 0.62; }
3233
+ .view { animation: view-in 180ms ease-out; }
3234
+ .view[hidden] { display: none; }
3235
+ .view-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 22px; }
3236
+ .view h2 { margin: 0 0 5px; font-size: clamp(24px, 3vw, 30px); line-height: 1.2; letter-spacing: -0.035em; }
3237
+ .view h3 { margin: 24px 0 10px; font-size: 15px; letter-spacing: -0.01em; }
3238
+ .lede { margin: 0; color: var(--muted); max-width: 72ch; font-size: 14px; }
3239
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 175px), 1fr)); gap: 14px; }
2973
3240
  .card {
2974
3241
  background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius);
2975
- padding: 14px 16px; box-shadow: var(--shadow);
3242
+ padding: 18px; box-shadow: var(--shadow-sm);
2976
3243
  }
2977
- .card .k { color: var(--faint); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
2978
- .card .v { display: flex; flex-direction: column; gap: 2px; font-size: 22px; margin-top: 4px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
3244
+ .stat-card { position: relative; overflow: hidden; }
3245
+ .stat-card::after { content: ""; position: absolute; inset: 0 0 auto; height: 2px; background: linear-gradient(90deg, var(--accent), transparent 78%); opacity: 0.7; }
3246
+ .card .k { color: var(--faint); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; }
3247
+ .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; }
2979
3248
  .card .v .clock-time { font-size: 14px; color: var(--muted); }
2980
- .split { display: grid; grid-template-columns: minmax(0, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
3249
+ .section-card { background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-xs); overflow: hidden; }
3250
+ .section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--line); }
3251
+ .section-title h3 { margin: 0; }
3252
+ .split { display: grid; grid-template-columns: minmax(0, 250px) minmax(0, 1fr); gap: 18px; align-items: start; }
3253
+ #collections { padding: 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); box-shadow: var(--shadow-xs); }
2981
3254
  .collection {
2982
3255
  width: 100%; text-align: left; border: 1px solid transparent; background: transparent;
2983
- border-radius: 10px; padding: 8px 10px; min-height: 44px;
3256
+ border-radius: 9px; padding: 10px 11px; min-height: 48px; color: var(--ink); cursor: pointer;
2984
3257
  }
2985
- .collection[aria-current="true"] { background: var(--bg-elev); border-color: var(--line); }
3258
+ .collection:hover { background: var(--bg-muted); }
3259
+ .collection[aria-current="true"] { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 18%, var(--line)); color: var(--accent); }
2986
3260
  .collection small { display: block; color: var(--faint); }
2987
3261
  .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 14px; }
2988
- .chip {
2989
- font-family: var(--mono); font-size: 12px; background: var(--bg-muted);
2990
- border-radius: 999px; padding: 4px 8px;
2991
- }
3262
+ .chip { font-family: var(--mono); font-size: 11px; background: var(--bg-muted); border: 1px solid var(--line); border-radius: 999px; padding: 4px 8px; }
2992
3263
  .chip i { color: var(--faint); font-style: normal; }
2993
3264
  .sql-layout {
2994
3265
  display: grid; grid-template-columns: minmax(10rem, 16rem) minmax(0, 1fr); gap: 16px;
2995
3266
  }
2996
- .sql-tables { display: flex; flex-direction: column; gap: 4px; max-height: 28rem; overflow: auto; }
2997
- .sql-tables button { text-align: left; }
3267
+ .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); }
3268
+ .sql-tables button { text-align: left; border: 0; border-radius: 8px; padding: 9px 10px; background: transparent; color: var(--muted); cursor: pointer; }
3269
+ .sql-tables button:hover, .sql-tables button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); }
2998
3270
  .sql-input { font-family: var(--mono); min-height: 7rem; width: 100%; }
2999
- table { width: 100%; border-collapse: collapse; font-size: 14px; }
3000
- th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
3001
- th { color: var(--faint); font-weight: 600; font-size: 12px; }
3271
+ .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); }
3272
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
3273
+ th, td { text-align: left; padding: 11px 13px; border-bottom: 1px solid var(--line); vertical-align: top; }
3274
+ th { color: var(--faint); background: var(--bg-inset); font-weight: 650; font-size: 11px; text-transform: uppercase; letter-spacing: 0.045em; }
3275
+ tbody tr:last-child td { border-bottom: 0; }
3002
3276
  tr.clickable { cursor: pointer; }
3003
- tr.clickable:hover { background: var(--bg-muted); }
3277
+ tr.clickable:hover { background: var(--accent-soft); }
3004
3278
  pre, code, .mono { font-family: var(--mono); overflow-wrap: anywhere; }
3005
3279
  pre {
3006
3280
  margin: 0; white-space: pre-wrap; word-break: break-word;
3007
- background: var(--bg-inset); border-radius: 10px; padding: 12px; font-size: 12px;
3008
- }
3009
- .row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
3281
+ background: var(--bg-inset); border: 1px solid var(--line); border-radius: 10px; padding: 14px; font-size: 12px; line-height: 1.65;
3282
+ }
3283
+ .json-key { color: var(--code-key); }
3284
+ .json-string { color: var(--code-string); }
3285
+ .json-number { color: var(--code-number); }
3286
+ .json-boolean { color: var(--code-boolean); }
3287
+ .json-null { color: var(--code-null); font-style: italic; }
3288
+ .json-inline { display: block; max-width: 44rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
3289
+ .json-view { position: relative; max-height: 26rem; overflow: auto; }
3290
+ .json-editor { position: relative; min-height: 190px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--bg-inset); overflow: hidden; }
3291
+ .json-editor:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
3292
+ .json-editor.invalid { border-color: var(--err); }
3293
+ .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; }
3294
+ .json-editor pre { position: absolute; inset: 0; pointer-events: none; background: transparent; }
3295
+ .json-editor textarea { position: relative; z-index: 1; resize: vertical; background: transparent; color: transparent; caret-color: var(--ink); -webkit-text-fill-color: transparent; }
3296
+ .json-editor textarea::selection { background: color-mix(in srgb, var(--accent) 32%, transparent); }
3297
+ .editor-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 7px; }
3298
+ .json-status { display: inline-flex; align-items: center; gap: 6px; color: var(--ok); font-size: 11px; }
3299
+ .json-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
3300
+ .json-status.invalid { color: var(--err); }
3301
+ .format-json { border: 0; padding: 3px 5px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 650; cursor: pointer; }
3302
+ .row-actions { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; margin: 14px 0; }
3010
3303
  .banner {
3011
- display: none; margin-bottom: 12px; padding: 10px 12px; border-radius: 10px;
3012
- background: var(--err-soft); color: var(--err);
3013
- }
3014
- .banner.show { display: block; }
3015
- .empty { color: var(--muted); padding: 24px 8px; }
3304
+ display: none; position: sticky; top: calc(var(--header) + 12px); z-index: 15;
3305
+ align-items: center; gap: 10px; margin: 0 0 16px; padding: 11px 12px 11px 14px;
3306
+ border: 1px solid color-mix(in srgb, var(--err) 30%, var(--line)); border-radius: 10px;
3307
+ background: var(--err-soft); color: var(--err); box-shadow: var(--shadow-sm); font-size: 13px;
3308
+ }
3309
+ .banner.show { display: flex; }
3310
+ .banner span { flex: 1; }
3311
+ .banner button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 7px; background: transparent; color: currentColor; cursor: pointer; }
3312
+ .banner button:hover { background: color-mix(in srgb, var(--err) 10%, transparent); }
3313
+ .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); }
3016
3314
  dialog {
3017
- border: 1px solid var(--line); border-radius: var(--radius); padding: 0; background: var(--bg-elev);
3018
- color: var(--ink); width: min(640px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: none;
3019
- }
3020
- dialog::backdrop { background: rgb(20 19 17 / 0.45); }
3021
- dialog form, .dialog-body { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
3315
+ border: 1px solid var(--line); border-radius: 16px; padding: 0; background: var(--bg-elev);
3316
+ color: var(--ink); width: min(720px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: var(--shadow-lg);
3317
+ }
3318
+ dialog[open] { animation: dialog-in 160ms ease-out; }
3319
+ dialog::backdrop { background: rgb(12 14 20 / 0.58); backdrop-filter: blur(3px); }
3320
+ dialog form, .dialog-body { padding: 22px; display: flex; flex-direction: column; gap: 14px; }
3321
+ .dialog-heading { display: flex; justify-content: space-between; gap: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
3322
+ .dialog-heading strong { font-size: 18px; letter-spacing: -0.02em; }
3323
+ .dialog-heading span { color: var(--muted); font-size: 12px; }
3022
3324
  textarea {
3023
3325
  width: 100%; min-height: 180px; resize: vertical; border-radius: 10px;
3024
3326
  border: 1px solid var(--line); background: var(--bg-inset); padding: 10px;
3025
3327
  font-family: var(--mono); font-size: 12px;
3026
3328
  }
3027
- .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
3028
- .badge { font-size: 11px; border-radius: 999px; padding: 2px 7px; background: var(--bg-muted); color: var(--muted); }
3329
+ .dialog-actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; padding-top: 4px; }
3330
+ .dialog-actions .btn-danger { margin-right: auto; }
3331
+ .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); }
3029
3332
  .badge.ok { background: var(--ok-soft); color: var(--ok); }
3030
3333
  .badge.warn { background: var(--warn-soft); color: var(--warn); }
3031
3334
  .status-2 { color: var(--ok); }
3032
3335
  .status-4, .status-5, .status-0 { color: var(--err); }
3033
3336
  .stack { display: flex; flex-direction: column; gap: 12px; }
3337
+ .form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
3338
+ .form-grid .wide { grid-column: span 2; }
3034
3339
  .route {
3035
- display: grid; grid-template-columns: 88px 1fr auto; gap: 8px; align-items: center;
3036
- padding: 8px 0; border-bottom: 1px solid var(--line);
3037
- }
3038
- .method { font-family: var(--mono); font-size: 12px; font-weight: 700; }
3340
+ display: grid; grid-template-columns: 84px minmax(0, 1fr) auto; gap: 10px; align-items: center;
3341
+ padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--bg-elev);
3342
+ }
3343
+ .route:first-child { border-radius: var(--radius) var(--radius) 0 0; }
3344
+ .route:last-child { border-bottom: 0; border-radius: 0 0 var(--radius) var(--radius); }
3345
+ .route-tools { display: flex; align-items: flex-end; gap: 12px; margin-bottom: 12px; }
3346
+ .route-tools .field { flex: 1; }
3347
+ .route-tools input { width: 100%; }
3348
+ .route-count { padding-bottom: 10px; color: var(--faint); font-size: 12px; white-space: nowrap; }
3349
+ #route-list { max-height: 34rem; border: 1px solid var(--line); border-radius: var(--radius); overflow: auto; box-shadow: var(--shadow-xs); }
3350
+ .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; }
3351
+ @keyframes view-in { from { transform: translateY(3px); } to { transform: translateY(0); } }
3352
+ @keyframes dialog-in { from { transform: translateY(8px) scale(0.99); } to { transform: translateY(0) scale(1); } }
3353
+ @keyframes pulse { 50% { opacity: 0.45; } }
3354
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
3355
+ @media (forced-colors: active) { .json-editor pre { display: none; } .json-editor textarea { color: CanvasText; -webkit-text-fill-color: CanvasText; } }
3039
3356
  @media (max-width: 800px) {
3357
+ :root { --header: 0px; }
3040
3358
  header.top {
3041
- align-items: stretch; flex-direction: column;
3042
- padding-top: max(10px, env(safe-area-inset-top));
3359
+ position: relative; align-items: stretch; flex-direction: column;
3360
+ padding: max(12px, env(safe-area-inset-top)) 14px 12px;
3043
3361
  }
3044
3362
  input, select, textarea { font-size: 16px; }
3045
3363
  .brand, .vendor, .controls { width: 100%; }
3364
+ .brand { gap: 10px; }
3365
+ .vendor { order: 2; border-radius: 9px; }
3046
3366
  label.field, label.field select, label.field input { flex: 1; min-width: 0; }
3367
+ .sync-status { display: none; }
3047
3368
  /* The layout fills the screen. A single-column grid stretches every auto
3048
3369
  row, so the tab bar grew with the leftover height and the buttons floated
3049
3370
  in the middle of it. The bar stays one row; the page below takes the rest. */
@@ -3053,14 +3374,23 @@ var renderAdminDocument = (service) => {
3053
3374
  align-content: start;
3054
3375
  }
3055
3376
  nav.side {
3056
- align-self: start; height: auto;
3377
+ position: sticky; top: 0; z-index: 12; align-self: start; height: auto;
3057
3378
  flex-direction: row; align-items: center; overflow-x: auto; min-width: 0;
3058
3379
  border-right: 0; border-bottom: 1px solid var(--line);
3059
- padding: 8px;
3380
+ padding: 8px; box-shadow: var(--shadow-xs);
3060
3381
  }
3061
- nav.side button { white-space: nowrap; flex: none; }
3382
+ .nav-label { display: none; }
3383
+ nav.side button { width: auto; white-space: nowrap; flex: none; }
3384
+ nav.side button[aria-current="true"]::before { inset: auto 9px -8px; width: auto; height: 3px; border-radius: 3px 3px 0 0; }
3062
3385
  .split, .sql-layout { grid-template-columns: minmax(0, 1fr); }
3063
- main { padding: 14px; }
3386
+ .form-grid { grid-template-columns: minmax(0, 1fr); }
3387
+ .form-grid .wide { grid-column: auto; }
3388
+ main { padding: 22px 14px 48px; }
3389
+ .view-head { margin-bottom: 18px; }
3390
+ .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
3391
+ .card { padding: 14px; }
3392
+ .card .v { font-size: 20px; }
3393
+ .table-shell { border: 0; background: transparent; box-shadow: none; overflow: visible; }
3064
3394
  table, thead, tbody, tr, th, td { display: block; }
3065
3395
  thead { display: none; }
3066
3396
  tr {
@@ -3075,50 +3405,63 @@ var renderAdminDocument = (service) => {
3075
3405
  .route { grid-template-columns: 72px 1fr; }
3076
3406
  .route button { grid-column: 1 / -1; }
3077
3407
  }
3408
+ @media (max-width: 460px) {
3409
+ .controls label.field { min-width: calc(50% - 4px); }
3410
+ .controls .icon-btn, .controls .btn { flex: 1; }
3411
+ .grid { grid-template-columns: minmax(0, 1fr); }
3412
+ .dialog-actions { flex-wrap: wrap; }
3413
+ .dialog-actions button { flex: 1; }
3414
+ .dialog-actions .btn-danger { flex-basis: 100%; margin-right: 0; }
3415
+ }
3078
3416
  </style>
3079
3417
  </head>
3080
3418
  <body>
3081
3419
  <div class="app" data-service="${name}" data-mockingbird-admin>
3082
3420
  <header class="top">
3083
3421
  <div class="brand">
3084
- <div class="brand-id">
3085
- <strong>${name}</strong>
3086
- <span>Admin</span>
3422
+ <div class="brand-lockup">
3423
+ <div class="brand-mark" aria-hidden="true">
3424
+ <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>
3425
+ </div>
3426
+ <div class="brand-id">
3427
+ <strong>${name}</strong>
3428
+ <span>Mock admin</span>
3429
+ </div>
3087
3430
  </div>
3088
3431
  <div class="vendor" id="vendor" hidden></div>
3089
3432
  </div>
3090
3433
  <div class="controls">
3434
+ <span class="sync-status" id="sync-status" role="status" aria-live="polite">Ready</span>
3091
3435
  <label class="field">Namespace
3092
3436
  <select id="namespace" aria-label="Namespace"></select>
3093
3437
  </label>
3094
3438
  <label class="field">Admin key
3095
3439
  <input id="admin-key" type="password" autocomplete="off" spellcheck="false" aria-label="Admin key">
3096
3440
  </label>
3097
- <button class="icon-btn" id="theme" type="button" aria-label="Toggle color theme">Theme</button>
3098
- <button class="btn" id="refresh" type="button">Refresh</button>
3441
+ <button class="icon-btn" id="theme" type="button" aria-label="Use dark theme" title="Use dark theme"></button>
3442
+ <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>
3099
3443
  </div>
3100
3444
  </header>
3101
3445
  <div class="layout">
3102
3446
  <nav class="side" id="nav" aria-label="Admin sections">
3103
- <button type="button" data-view="overview" aria-current="true">Overview</button>
3104
- <button type="button" data-view="state">State</button>
3105
- <button type="button" data-view="clock">Clock</button>
3106
- <button type="button" data-view="faults">Faults</button>
3107
- <button type="button" data-view="journal">Journal</button>
3108
- <button type="button" data-view="routes">Routes</button>
3447
+ <span class="nav-label">Workspace</span>
3448
+ <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>
3449
+ <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>
3450
+ <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>
3451
+ <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>
3452
+ <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>
3453
+ <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>
3109
3454
  </nav>
3110
3455
  <main id="views">
3111
- <p class="banner" id="banner" role="alert"></p>
3456
+ <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>
3112
3457
  <section class="view" id="view-overview">
3113
- <h2>Overview</h2>
3114
- <p class="lede">Health, clock, and the collections this namespace is holding.</p>
3458
+ <div class="view-head"><div><h2>Overview</h2><p class="lede">Health, clock, and the collections this namespace is holding.</p></div></div>
3115
3459
  <div class="grid" id="overview-cards"></div>
3116
3460
  <h3>Recent requests</h3>
3117
3461
  <div id="overview-requests"></div>
3118
3462
  </section>
3119
3463
  <section class="view" id="view-state" hidden>
3120
- <h2>State</h2>
3121
- <p class="lede">Every collection in this namespace. Declared fields stay visible before any row exists; stored rows add whatever else they contain.</p>
3464
+ <div class="view-head"><div><h2>State</h2><p class="lede">Inspect and edit every collection in this namespace.</p></div></div>
3122
3465
  <div class="split">
3123
3466
  <div id="collections"></div>
3124
3467
  <div>
@@ -3131,8 +3474,7 @@ var renderAdminDocument = (service) => {
3131
3474
  </div>
3132
3475
  </section>
3133
3476
  <section class="view" id="view-clock" hidden>
3134
- <h2>Clock</h2>
3135
- <p class="lede">The clock every timestamp in this mock reads.</p>
3477
+ <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>
3136
3478
  <div class="card" id="clock-readout"></div>
3137
3479
  <div class="row-actions">
3138
3480
  <button class="btn" type="button" data-advance="60000">+1m</button>
@@ -3151,21 +3493,22 @@ var renderAdminDocument = (service) => {
3151
3493
  </form>
3152
3494
  </section>
3153
3495
  <section class="view" id="view-faults" hidden>
3154
- <h2>Faults</h2>
3155
- <p class="lede">Inject a response, a delay, or a named preset. Rules apply to this namespace.</p>
3496
+ <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>
3156
3497
  <form id="fault-form" class="card stack">
3157
- <label class="field">Preset
3158
- <select id="fault-preset"><option value="">Custom rule</option></select>
3159
- </label>
3160
- <label class="field">Status
3161
- <input id="fault-status" inputmode="numeric" placeholder="503">
3162
- </label>
3163
- <label class="field">Path prefix
3164
- <input id="fault-path" placeholder="/v1">
3165
- </label>
3166
- <label class="field">Method
3167
- <input id="fault-method" placeholder="GET">
3168
- </label>
3498
+ <div class="form-grid">
3499
+ <label class="field wide">Preset
3500
+ <select id="fault-preset"><option value="">Custom rule</option></select>
3501
+ </label>
3502
+ <label class="field">Status
3503
+ <input id="fault-status" inputmode="numeric" placeholder="503">
3504
+ </label>
3505
+ <label class="field wide">Path prefix
3506
+ <input id="fault-path" placeholder="/v1">
3507
+ </label>
3508
+ <label class="field">Method
3509
+ <input id="fault-method" placeholder="GET">
3510
+ </label>
3511
+ </div>
3169
3512
  <div class="dialog-actions">
3170
3513
  <button class="btn-primary" type="submit">Add fault</button>
3171
3514
  <button class="btn-danger" id="fault-clear" type="button">Clear all</button>
@@ -3174,26 +3517,31 @@ var renderAdminDocument = (service) => {
3174
3517
  <div id="fault-list"></div>
3175
3518
  </section>
3176
3519
  <section class="view" id="view-journal" hidden>
3177
- <h2>Journal</h2>
3178
- <p class="lede">Requests this namespace has seen. Bodies are not stored.</p>
3179
- <div class="row-actions">
3180
- <button class="btn-danger" id="journal-clear" type="button">Clear journal</button>
3181
- </div>
3520
+ <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>
3182
3521
  <div id="journal"></div>
3183
3522
  </section>
3184
3523
  <section class="view" id="view-routes" hidden>
3185
- <h2>Routes</h2>
3186
- <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>
3524
+ <div class="view-head"><div><h2>Routes</h2><p class="lede">Explore shared controls and service-specific admin endpoints.</p></div></div>
3525
+ <div class="route-tools">
3526
+ <label class="field">Filter routes
3527
+ <input id="route-search" type="search" placeholder="Method, path, or extension" autocomplete="off">
3528
+ </label>
3529
+ <span class="route-count" id="route-count"></span>
3530
+ </div>
3187
3531
  <div id="route-list"></div>
3188
- <form id="route-form" class="card stack">
3532
+ <form id="route-form" class="card stack" style="margin-top: 18px">
3189
3533
  <strong id="route-title">Select a route</strong>
3190
3534
  <label class="field">JSON body
3191
- <textarea id="route-body" placeholder="{ }"></textarea>
3535
+ <div class="json-editor" id="route-editor" hidden>
3536
+ <pre aria-hidden="true"><code id="route-highlight"></code></pre>
3537
+ <textarea id="route-body" placeholder="{ }" spellcheck="false" aria-describedby="route-json-status" disabled></textarea>
3538
+ </div>
3539
+ <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>
3192
3540
  </label>
3193
3541
  <div class="dialog-actions">
3194
- <button class="btn-primary" type="submit">Send</button>
3542
+ <button class="btn-primary" id="route-send" type="submit" disabled>Send request</button>
3195
3543
  </div>
3196
- <pre id="route-result">Response will show here.</pre>
3544
+ <pre class="json-view" id="route-result"><code>Response will show here.</code></pre>
3197
3545
  </form>
3198
3546
  </section>
3199
3547
  </main>
@@ -3201,12 +3549,16 @@ var renderAdminDocument = (service) => {
3201
3549
  </div>
3202
3550
  <dialog id="editor">
3203
3551
  <form method="dialog" id="editor-form">
3204
- <strong id="editor-title">Record</strong>
3552
+ <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>
3205
3553
  <label class="field">Id
3206
3554
  <input id="editor-id" required>
3207
3555
  </label>
3208
3556
  <label class="field">JSON value
3209
- <textarea id="editor-value"></textarea>
3557
+ <div class="json-editor" id="record-editor">
3558
+ <pre aria-hidden="true"><code id="editor-highlight"></code></pre>
3559
+ <textarea id="editor-value" spellcheck="false" aria-describedby="editor-json-status"></textarea>
3560
+ </div>
3561
+ <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>
3210
3562
  </label>
3211
3563
  <div class="dialog-actions">
3212
3564
  <button class="btn-danger" id="editor-delete" type="button" value="delete">Delete</button>
@@ -3248,8 +3600,8 @@ var describeExtension = (extension) => {
3248
3600
  ...extension.script !== void 0 ? { script: extension.script } : {}
3249
3601
  };
3250
3602
  };
3251
- var adminUiRoutes = (service, ui) => {
3252
- const shell = () => renderAdminDocument(service);
3603
+ var adminUiRoutes = (service, ui, adminPrefix = ADMIN_PREFIX) => {
3604
+ const shell = () => renderAdminDocument(service, adminPrefix);
3253
3605
  const document3 = () => ui?.render ? ui.render({ service, defaultHtml: shell }) : shell();
3254
3606
  return {
3255
3607
  "GET /ui": () => htmlResponse(document3()),
@@ -3866,14 +4218,17 @@ var stateAdminRoutes = (routes) => {
3866
4218
  };
3867
4219
 
3868
4220
  // ../core/dist/version.js
3869
- var PACKAGE_VERSION = true ? "1.8.0" : UNRELEASED_VERSION;
4221
+ var PACKAGE_VERSION = true ? "2.0.0" : UNRELEASED_VERSION;
3870
4222
 
3871
4223
  // ../core/dist/signing.js
3872
4224
  var encoder = new TextEncoder();
3873
4225
  var fromBase64 = (value) => Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
3874
4226
 
3875
4227
  // ../core/dist/webhooks.js
3876
- var json4 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
4228
+ var json4 = (status, body) => new Response(JSON.stringify(body), {
4229
+ status,
4230
+ headers: { "content-type": "application/json; charset=utf-8" }
4231
+ });
3877
4232
  var adminError3 = (status, message) => json4(status, { error: { type: "mockingbird_admin", message } });
3878
4233
  var isRecord5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3879
4234
  var parseEndpoint = (value) => {
@@ -3981,7 +4336,6 @@ var AT_HEADER = "x-mockingbird-at";
3981
4336
  var CHECKPOINT_HEADER = "x-mockingbird-checkpoint";
3982
4337
  var DEFAULT_NAMESPACE = "default";
3983
4338
  var NAMESPACE_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
3984
- var PATH_PREFIX = /^\/ns\/([^/]+)(\/.*)?$/;
3985
4339
  var BRANCH_PATTERN2 = /^[A-Za-z0-9_.-]{1,64}$/;
3986
4340
  var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
3987
4341
  var effects = /* @__PURE__ */ new WeakMap();
@@ -4021,6 +4375,22 @@ var operationMatcher = (document3) => {
4021
4375
  return (request, path) => matchers.find((m) => m.method === request.method && m.pattern.test(path))?.operationId;
4022
4376
  };
4023
4377
  var createRuntime = (options) => {
4378
+ const adminPrefix = resolveAdminPrefix(options.adminPrefix);
4379
+ const internalPaths = Object.entries(options.document?.paths ?? {}).filter(([, item]) => item["x-mockingbird-internal"] === true).map(([path]) => path);
4380
+ for (const path of internalPaths) {
4381
+ if (!path.startsWith(`${ADMIN_PREFIX}/blobs/`))
4382
+ throw new Error(`internal transport ${path} must live under ${ADMIN_PREFIX}/blobs/`);
4383
+ }
4384
+ assertAdminPrefixAvailable(adminPrefix, Object.keys(options.document?.paths ?? {}).filter((path) => !internalPaths.includes(path)));
4385
+ const internalMatch = (path) => {
4386
+ for (const pattern of internalPaths) {
4387
+ const actual = path.split("/");
4388
+ const expected = pattern.split("/");
4389
+ if (actual.length === expected.length && expected.every((part, i) => part.startsWith("{") || part === actual[i]))
4390
+ return pattern;
4391
+ }
4392
+ return void 0;
4393
+ };
4024
4394
  assertAdminUi(options.adminUi);
4025
4395
  const sqlite = bootSqlite(options.sqlite);
4026
4396
  const clock = options.clock ?? createClock();
@@ -4066,6 +4436,7 @@ var createRuntime = (options) => {
4066
4436
  const created = options.create({
4067
4437
  namespace: storageNamespace(key),
4068
4438
  publicNamespace,
4439
+ adminPrefix,
4069
4440
  sqlite,
4070
4441
  clock: namespaceClock(publicNamespace),
4071
4442
  rng: createdContextRng
@@ -4298,10 +4669,10 @@ var createRuntime = (options) => {
4298
4669
  namespaceClock,
4299
4670
  namespaceOf(request) {
4300
4671
  const url = new URL(request.url);
4301
- const prefixed = PATH_PREFIX.exec(url.pathname);
4672
+ const prefixed = matchNamespacePath(url.pathname, adminPrefix);
4302
4673
  const selected = request.headers.get(NAMESPACE_HEADER) ?? (prefixed ? decodeURIComponent(prefixed[1]) : void 0);
4303
4674
  const path = prefixed ? prefixed[2] ?? "/" : url.pathname;
4304
- if (path === "/__admin" || path.startsWith("/__admin/"))
4675
+ if (isAdminPath(path, adminPrefix))
4305
4676
  return url.searchParams.get("namespace") ?? selected ?? DEFAULT_NAMESPACE;
4306
4677
  if (selected !== void 0)
4307
4678
  return selected;
@@ -4367,7 +4738,7 @@ var createRuntime = (options) => {
4367
4738
  state: (namespace = DEFAULT_NAMESPACE) => inspectState(stateScope(namespace)),
4368
4739
  fetch: async (incoming) => {
4369
4740
  let request = incoming;
4370
- const prefixed = PATH_PREFIX.exec(new URL(request.url).pathname);
4741
+ const prefixed = matchNamespacePath(new URL(request.url).pathname, adminPrefix);
4371
4742
  if (prefixed) {
4372
4743
  const url2 = new URL(request.url);
4373
4744
  url2.pathname = prefixed[2] ?? "/";
@@ -4383,7 +4754,15 @@ var createRuntime = (options) => {
4383
4754
  signal: request.signal
4384
4755
  });
4385
4756
  }
4386
- let namespace = control.namespaceOf(request);
4757
+ const internalUrl = new URL(request.url);
4758
+ const internalPath = isAdminPath(internalUrl.pathname, adminPrefix) ? `${ADMIN_PREFIX}${internalUrl.pathname.slice(adminPrefix.length)}` : "";
4759
+ const internal = internalMatch(internalPath);
4760
+ const obsoleteInternalPath = adminPrefix !== ADMIN_PREFIX && internalMatch(internalUrl.pathname) && !internal;
4761
+ if (internal) {
4762
+ internalUrl.pathname = internalPath;
4763
+ request = new Request(internalUrl, request);
4764
+ }
4765
+ let namespace = internal ? internalUrl.searchParams.get("namespace") ?? control.namespaceOf(request) : control.namespaceOf(request);
4387
4766
  if (!request.headers.has(NAMESPACE_HEADER) && options.credential) {
4388
4767
  const credential = options.credential(request);
4389
4768
  const mapped = credential !== void 0 ? credentials.get(credential) : void 0;
@@ -4403,7 +4782,9 @@ var createRuntime = (options) => {
4403
4782
  return copy;
4404
4783
  }
4405
4784
  };
4406
- const handled = await control.handle(request);
4785
+ if (obsoleteInternalPath)
4786
+ return stamp(adminFail(404, "internal transport is only available under adminPrefix"));
4787
+ const handled = internal ? void 0 : await control.handle(request);
4407
4788
  if (handled)
4408
4789
  return stamp(handled);
4409
4790
  const started = monotonicNow();
@@ -4444,7 +4825,7 @@ var createRuntime = (options) => {
4444
4825
  type: "mockingbird_admin",
4445
4826
  message: `${NAMESPACE_HEADER} must match ${NAMESPACE_PATTERN}`
4446
4827
  }
4447
- }), { status: 400, headers: { "content-type": "application/json" } }));
4828
+ }), { status: 400, headers: { "content-type": "application/json; charset=utf-8" } }));
4448
4829
  }
4449
4830
  if (!BRANCH_PATTERN2.test(selectedBranch)) {
4450
4831
  log(400);
@@ -4561,15 +4942,16 @@ var createRuntime = (options) => {
4561
4942
  ...options.admin?.(runtime) ?? {},
4562
4943
  // Shell routes win a colliding key. Fault presets are one of them: the admin page
4563
4944
  // maps `presets`, and a service route must not replace that list with a record.
4564
- ...options.presets ? presetRoutes(options.presets, runtime) : {},
4945
+ ...options.presets ? presetRoutes(options.presets, runtime, adminPrefix) : {},
4565
4946
  ...stateAdminRoutes({
4566
4947
  open: stateScope,
4567
4948
  afterWrite: (namespace) => {
4568
4949
  checkpoint(namespace, "main");
4569
4950
  }
4570
4951
  }),
4571
- ...adminUiRoutes(options.name, options.adminUi)
4952
+ ...adminUiRoutes(options.name, options.adminUi, adminPrefix)
4572
4953
  },
4954
+ adminPrefix,
4573
4955
  adminKey: options.adminKey
4574
4956
  });
4575
4957
  return defineMock(runtime);
@@ -4583,7 +4965,10 @@ var mutableResponse = (response) => {
4583
4965
  return new Response(response.body, response);
4584
4966
  }
4585
4967
  };
4586
- var adminJson = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
4968
+ var adminJson = (status, body) => new Response(JSON.stringify(body), {
4969
+ status,
4970
+ headers: { "content-type": "application/json; charset=utf-8" }
4971
+ });
4587
4972
  var adminFail = (status, message) => adminJson(status, { error: { type: "mockingbird_admin", message } });
4588
4973
  var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4589
4974
  var credentialRoutes = (registry) => ({
@@ -4635,7 +5020,7 @@ var credentialRoutes = (registry) => ({
4635
5020
  return adminJson(200, { status: "ok" });
4636
5021
  }
4637
5022
  });
4638
- var presetRoutes = (presets, runtime) => ({
5023
+ var presetRoutes = (presets, runtime, adminPrefix) => ({
4639
5024
  "GET /faults/presets": () => {
4640
5025
  const body = {
4641
5026
  presets: Object.entries(presets).map(([name, preset]) => ({ name, ...preset }))
@@ -4645,7 +5030,7 @@ var presetRoutes = (presets, runtime) => ({
4645
5030
  "POST /faults/presets/:name": ({ params, body, namespace }) => {
4646
5031
  const name = params.name;
4647
5032
  if (!presets[name])
4648
- return adminFail(404, `no fault preset ${name}; GET /__admin/faults/presets`);
5033
+ return adminFail(404, `no fault preset ${name}; GET ${adminPrefix}/faults/presets`);
4649
5034
  const overrides = isObject(body) ? body : {};
4650
5035
  return adminJson(201, { preset: name, rules: runtime.applyPreset(name, namespace, overrides) });
4651
5036
  }
@@ -5258,6 +5643,7 @@ var createRuntime2 = (options = {}) => {
5258
5643
  ...options.sqlite ? { sqlite: options.sqlite } : {},
5259
5644
  ...options.clock ? { clock: options.clock } : {},
5260
5645
  ...options.seed !== void 0 ? { seed: options.seed } : {},
5646
+ ...options.adminPrefix !== void 0 ? { adminPrefix: options.adminPrefix } : {},
5261
5647
  ...options.adminKey !== void 0 ? { adminKey: options.adminKey } : {},
5262
5648
  ...options.onLog ? { onLog: options.onLog } : {},
5263
5649
  credential: (request) => tokens.get(request),
@@ -5275,7 +5661,7 @@ var createRuntime2 = (options = {}) => {
5275
5661
  const inner = runtime.fetch;
5276
5662
  const fetch2 = async (request) => {
5277
5663
  const path = new URL(request.url).pathname;
5278
- if (!request.headers.has(NAMESPACE_HEADER) && !path.startsWith("/ns/") && !path.startsWith("/__admin") && path !== "/health") {
5664
+ if (!request.headers.has(NAMESPACE_HEADER) && !(path === (options.adminPrefix ?? "/__admin") || path.startsWith(`${options.adminPrefix ?? "/__admin"}/`))) {
5279
5665
  const token = await requestToken(request, path) ?? bearerToken(request);
5280
5666
  if (token !== void 0) tokens.set(request, token);
5281
5667
  }
@@ -5607,6 +5993,7 @@ var malformedBody = () => ({
5607
5993
 
5608
5994
  export {
5609
5995
  createClock,
5996
+ resolveAdminPrefix,
5610
5997
  parseDuration,
5611
5998
  decodePostHogBody,
5612
5999
  tokenFromBody,
@@ -5625,4 +6012,4 @@ export {
5625
6012
  scrubProperties,
5626
6013
  PostHogAPI
5627
6014
  };
5628
- //# sourceMappingURL=chunk-Y3KAEWE2.js.map
6015
+ //# sourceMappingURL=chunk-NRFKITRM.js.map