@crvouga/mockingbird-service-formbricks 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
  };
@@ -1566,6 +1605,34 @@ dialog:not([open]) {
1566
1605
  function isRecord2(value) {
1567
1606
  return typeof value === "object" && value !== null && !Array.isArray(value);
1568
1607
  }
1608
+ function highlightJsonText(text) {
1609
+ const escapeToken = (value) => value.replace(/[&<>"']/g, (char) => {
1610
+ switch (char) {
1611
+ case "&":
1612
+ return "&amp;";
1613
+ case "<":
1614
+ return "&lt;";
1615
+ case ">":
1616
+ return "&gt;";
1617
+ case '"':
1618
+ return "&quot;";
1619
+ default:
1620
+ return "&#39;";
1621
+ }
1622
+ });
1623
+ 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;
1624
+ let cursor = 0;
1625
+ let html = "";
1626
+ for (const match of text.matchAll(token)) {
1627
+ const index = match.index ?? 0;
1628
+ const value = match[0];
1629
+ html += escapeToken(text.slice(cursor, index));
1630
+ const kind = value.startsWith('"') ? value.trimEnd().endsWith(":") ? "key" : "string" : value === "true" || value === "false" ? "boolean" : value === "null" ? "null" : "number";
1631
+ html += `<span class="json-${kind}">${escapeToken(value)}</span>`;
1632
+ cursor = index + value.length;
1633
+ }
1634
+ return html + escapeToken(text.slice(cursor));
1635
+ }
1569
1636
  function mountAdminBrand(doc, host, service, catalog) {
1570
1637
  const clip = (value, max) => {
1571
1638
  if (typeof value !== "string")
@@ -1927,14 +1994,16 @@ function bootAdmin(config) {
1927
1994
  const state = {
1928
1995
  namespace: "default",
1929
1996
  key: sessionStorage.getItem("mockingbird-admin-key") || "",
1930
- view: "overview",
1997
+ view: ["overview", "state", "clock", "faults", "journal", "routes"].includes(location.hash.slice(1)) ? location.hash.slice(1) : "overview",
1931
1998
  collection: "",
1932
1999
  shape: null,
1933
2000
  route: null,
2001
+ routes: [],
1934
2002
  editing: null
1935
2003
  };
1936
2004
  const standard = new Set(config.standardRoutes);
1937
2005
  const banner = el("banner", HTMLElement);
2006
+ const bannerMessage = el("banner-message", HTMLElement);
1938
2007
  const keyInput = el("admin-key", HTMLInputElement);
1939
2008
  keyInput.value = state.key;
1940
2009
  const params = new URLSearchParams(location.search);
@@ -1945,18 +2014,21 @@ function bootAdmin(config) {
1945
2014
  sessionStorage.setItem("mockingbird-admin-key", state.key);
1946
2015
  params.delete("key");
1947
2016
  const next = params.toString();
1948
- history.replaceState(null, "", location.pathname + (next ? `?${next}` : ""));
2017
+ history.replaceState(null, "", location.pathname + (next ? `?${next}` : "") + location.hash);
1949
2018
  }
1950
2019
  const queryNamespace = params.get("namespace");
1951
2020
  if (queryNamespace)
1952
2021
  state.namespace = queryNamespace;
1953
2022
  const showError = (error2) => {
1954
- banner.textContent = error2 instanceof Error ? error2.message : String(error2);
2023
+ bannerMessage.textContent = error2 instanceof Error ? error2.message : String(error2);
1955
2024
  banner.classList.add("show");
2025
+ banner.scrollIntoView({ behavior: "smooth", block: "nearest" });
1956
2026
  };
1957
2027
  const clearError = () => {
1958
2028
  banner.classList.remove("show");
2029
+ bannerMessage.textContent = "";
1959
2030
  };
2031
+ el("banner-dismiss", HTMLButtonElement).addEventListener("click", clearError);
1960
2032
  const api = async (method, path, body) => {
1961
2033
  const headers = { accept: "application/json" };
1962
2034
  if (state.key !== "")
@@ -2000,16 +2072,21 @@ function bootAdmin(config) {
2000
2072
  namespace: () => state.namespace
2001
2073
  };
2002
2074
  const isPanelScript = (value) => typeof value === "function";
2075
+ const themeButton = el("theme", HTMLButtonElement);
2003
2076
  const setTheme = (theme) => {
2004
2077
  if (theme)
2005
2078
  document.documentElement.dataset.theme = theme;
2006
2079
  else
2007
2080
  delete document.documentElement.dataset.theme;
2081
+ const dark = theme ? theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
2082
+ 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>';
2083
+ const label = dark ? "Use light theme" : "Use dark theme";
2084
+ themeButton.setAttribute("aria-label", label);
2085
+ themeButton.title = label;
2008
2086
  };
2009
2087
  const storedTheme = localStorage.getItem("mockingbird-admin-theme");
2010
- if (storedTheme)
2011
- setTheme(storedTheme);
2012
- el("theme", HTMLButtonElement).addEventListener("click", () => {
2088
+ setTheme(storedTheme === "dark" || storedTheme === "light" ? storedTheme : null);
2089
+ themeButton.addEventListener("click", () => {
2013
2090
  const current = document.documentElement.dataset.theme;
2014
2091
  const dark = current ? current === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
2015
2092
  const next = dark ? "light" : "dark";
@@ -2028,6 +2105,7 @@ function bootAdmin(config) {
2028
2105
  continue;
2029
2106
  section.hidden = section.id !== `view-${view}`;
2030
2107
  }
2108
+ history.replaceState(null, "", `${location.pathname}${location.search}#${encodeURIComponent(view)}`);
2031
2109
  };
2032
2110
  el("nav", HTMLElement).addEventListener("click", (event) => {
2033
2111
  const target = event.target;
@@ -2042,7 +2120,7 @@ function bootAdmin(config) {
2042
2120
  show(view);
2043
2121
  refresh().catch(showError);
2044
2122
  });
2045
- 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>`;
2123
+ 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>`;
2046
2124
  const table = (columns, rows) => {
2047
2125
  if (rows.length === 0)
2048
2126
  return '<p class="empty">Nothing here yet.</p>';
@@ -2052,16 +2130,22 @@ function bootAdmin(config) {
2052
2130
  const attrs = "attrs" in row && typeof row.attrs === "string" ? row.attrs : "";
2053
2131
  return `<tr${attrs}>${cells}</tr>`;
2054
2132
  }).join("");
2055
- return `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
2133
+ return `<div class="table-shell"><table><thead>${head}</thead><tbody>${body}</tbody></table></div>`;
2056
2134
  };
2057
2135
  const preview = (value) => {
2058
2136
  const text = JSON.stringify(value) ?? "null";
2059
- return esc(text.length > 180 ? `${text.slice(0, 180)}\u2026` : text);
2137
+ const clipped = text.length > 180 ? `${text.slice(0, 180)}\u2026` : text;
2138
+ return `<code class="json-inline">${highlightJsonText(clipped)}</code>`;
2060
2139
  };
2061
2140
  const when = (epoch) => {
2062
2141
  const date = new Date(epoch);
2063
2142
  return Number.isNaN(date.getTime()) ? String(epoch) : date.toISOString();
2064
2143
  };
2144
+ const journalWhen = (value) => {
2145
+ const numeric = value.trim() === "" ? Number.NaN : Number(value);
2146
+ const epoch = Number.isFinite(numeric) ? numeric : Date.parse(value);
2147
+ return Number.isFinite(epoch) ? when(epoch) : value;
2148
+ };
2065
2149
  const clockCard = (epoch) => {
2066
2150
  const date = new Date(epoch);
2067
2151
  if (Number.isNaN(date.getTime()))
@@ -2181,7 +2265,7 @@ function bootAdmin(config) {
2181
2265
  };
2182
2266
  const renderOverview = async () => {
2183
2267
  const [health, shape, clock, journal] = await Promise.all([
2184
- originGet("/health"),
2268
+ originGet(`${config.adminPrefix}/health`),
2185
2269
  api("GET", "/state"),
2186
2270
  api("GET", "/clock"),
2187
2271
  api("GET", "/requests?limit=8")
@@ -2198,7 +2282,7 @@ function bootAdmin(config) {
2198
2282
  },
2199
2283
  {
2200
2284
  k: "Status",
2201
- v: esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")
2285
+ v: `<span class="badge ok">${esc(typeof health.status === "string" && health.status !== "" ? health.status : "ok")}</span>`
2202
2286
  },
2203
2287
  { k: "Namespace", v: esc(state.namespace) },
2204
2288
  { k: "Collections", v: String(decoded.collections.length) },
@@ -2285,26 +2369,46 @@ function bootAdmin(config) {
2285
2369
  const renderJournal = async () => {
2286
2370
  const journal = await api("GET", "/requests?limit=100");
2287
2371
  el("journal", HTMLElement).innerHTML = table([
2288
- { label: "When", cell: (row) => esc(row.at) },
2289
- { label: "Method", cell: (row) => esc(row.method) },
2372
+ { label: "When", cell: (row) => esc(journalWhen(row.at)) },
2373
+ {
2374
+ label: "Method",
2375
+ cell: (row) => `<span class="method">${esc(row.method)}</span>`
2376
+ },
2290
2377
  { label: "Path", cell: (row) => esc(row.path) },
2291
- { label: "Status", cell: (row) => esc(row.status) },
2378
+ {
2379
+ label: "Status",
2380
+ cell: (row) => `<span class="badge ${row.status.startsWith("2") ? "ok" : row.status.startsWith("4") || row.status.startsWith("5") ? "warn" : ""}">${esc(row.status)}</span>`
2381
+ },
2292
2382
  { label: "ms", cell: (row) => esc(row.durationMs) }
2293
2383
  ], decodeJournal(journal));
2294
2384
  };
2295
- const renderRoutes = async () => {
2296
- const data = await api("GET", "/");
2297
- if (!isRecord2(data))
2298
- throw new Error("Route list was not an object");
2299
- const routes = requireList(data.routes, "Routes").map((route) => String(route));
2385
+ const paintRoutes = () => {
2386
+ const query = el("route-search", HTMLInputElement).value.trim().toLocaleLowerCase();
2387
+ const routes = state.routes.filter((route) => route.toLocaleLowerCase().includes(query));
2388
+ el("route-count", HTMLElement).textContent = `${routes.length} of ${state.routes.length}`;
2389
+ if (state.routes.length === 0) {
2390
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No admin routes are available.</p>';
2391
+ return;
2392
+ }
2393
+ if (routes.length === 0) {
2394
+ el("route-list", HTMLElement).innerHTML = '<p class="empty">No routes match this filter.</p>';
2395
+ return;
2396
+ }
2300
2397
  el("route-list", HTMLElement).innerHTML = routes.map((route) => {
2301
2398
  const extra = standard.has(route) ? "" : " <span class='badge'>extension</span>";
2302
2399
  const parts = route.split(" ");
2303
2400
  const method = parts[0] ?? "";
2304
2401
  const path = parts.slice(1).join(" ");
2305
- 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>`;
2402
+ 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>`;
2306
2403
  }).join("");
2307
2404
  };
2405
+ const renderRoutes = async () => {
2406
+ const data = await api("GET", "/");
2407
+ if (!isRecord2(data))
2408
+ throw new Error("Route list was not an object");
2409
+ state.routes = requireList(data.routes, "Routes").map((route) => String(route));
2410
+ paintRoutes();
2411
+ };
2308
2412
  const renderers = {
2309
2413
  overview: renderOverview,
2310
2414
  state: renderState,
@@ -2313,19 +2417,50 @@ function bootAdmin(config) {
2313
2417
  journal: renderJournal,
2314
2418
  routes: renderRoutes
2315
2419
  };
2420
+ let refreshToken = 0;
2316
2421
  async function refresh() {
2422
+ const token = ++refreshToken;
2317
2423
  clearError();
2424
+ const main = el("views", HTMLElement);
2425
+ const sync = el("sync-status", HTMLElement);
2426
+ main.setAttribute("aria-busy", "true");
2427
+ sync.classList.add("busy");
2428
+ sync.textContent = "Updating";
2318
2429
  const render = renderers[state.view];
2319
- if (render)
2320
- await render();
2430
+ try {
2431
+ if (render)
2432
+ await render();
2433
+ if (token === refreshToken)
2434
+ sync.textContent = "Up to date";
2435
+ } finally {
2436
+ if (token === refreshToken) {
2437
+ main.removeAttribute("aria-busy");
2438
+ sync.classList.remove("busy");
2439
+ }
2440
+ }
2321
2441
  }
2322
2442
  const delay = (ms) => new Promise((resolve) => {
2323
2443
  window.setTimeout(resolve, ms);
2324
2444
  });
2325
- const confirmAction = async (button, pending, done, work) => {
2445
+ const confirmAction = async (button, pending, done, work, confirmLabel) => {
2326
2446
  if (button.dataset.pending === "1")
2327
2447
  return;
2328
2448
  const label = button.textContent ?? "";
2449
+ if (confirmLabel !== void 0 && button.dataset.confirming !== "1") {
2450
+ button.dataset.confirming = "1";
2451
+ button.classList.add("confirming");
2452
+ button.textContent = confirmLabel;
2453
+ window.setTimeout(() => {
2454
+ if (button.dataset.confirming !== "1")
2455
+ return;
2456
+ button.textContent = label;
2457
+ button.classList.remove("confirming");
2458
+ delete button.dataset.confirming;
2459
+ }, 3e3);
2460
+ return;
2461
+ }
2462
+ button.classList.remove("confirming");
2463
+ delete button.dataset.confirming;
2329
2464
  const width = button.offsetWidth;
2330
2465
  button.dataset.pending = "1";
2331
2466
  button.classList.add("pressed");
@@ -2346,6 +2481,65 @@ function bootAdmin(config) {
2346
2481
  delete button.dataset.pending;
2347
2482
  }
2348
2483
  };
2484
+ const wireJsonEditor = (textareaId, highlightId, statusId, shellId, allowEmpty) => {
2485
+ const textarea = el(textareaId, HTMLTextAreaElement);
2486
+ const highlight = el(highlightId, HTMLElement);
2487
+ const status = el(statusId, HTMLElement);
2488
+ const shell = el(shellId, HTMLElement);
2489
+ const sync = () => {
2490
+ const text = textarea.value;
2491
+ highlight.innerHTML = `${highlightJsonText(text)}
2492
+ `;
2493
+ let valid = true;
2494
+ let message = "Valid JSON";
2495
+ if (allowEmpty && text.trim() === "")
2496
+ message = "No body";
2497
+ else {
2498
+ try {
2499
+ readJson2(text);
2500
+ } catch {
2501
+ valid = false;
2502
+ message = "Invalid JSON";
2503
+ }
2504
+ }
2505
+ shell.classList.toggle("invalid", !valid);
2506
+ status.classList.toggle("invalid", !valid);
2507
+ status.textContent = message;
2508
+ textarea.setAttribute("aria-invalid", valid ? "false" : "true");
2509
+ };
2510
+ const syncScroll = () => {
2511
+ const preview2 = highlight.parentElement;
2512
+ if (!(preview2 instanceof HTMLElement))
2513
+ return;
2514
+ preview2.scrollTop = textarea.scrollTop;
2515
+ preview2.scrollLeft = textarea.scrollLeft;
2516
+ };
2517
+ textarea.addEventListener("input", sync);
2518
+ textarea.addEventListener("scroll", syncScroll);
2519
+ sync();
2520
+ return sync;
2521
+ };
2522
+ const syncRouteEditor = wireJsonEditor("route-body", "route-highlight", "route-json-status", "route-editor", true);
2523
+ const syncRecordEditor = wireJsonEditor("editor-value", "editor-highlight", "editor-json-status", "record-editor", false);
2524
+ for (const button of document.querySelectorAll("[data-format]")) {
2525
+ if (!(button instanceof HTMLButtonElement))
2526
+ continue;
2527
+ button.addEventListener("click", () => {
2528
+ const target = button.dataset.format;
2529
+ if (target === void 0)
2530
+ return;
2531
+ const textarea = document.getElementById(target);
2532
+ if (!(textarea instanceof HTMLTextAreaElement) || textarea.value.trim() === "")
2533
+ return;
2534
+ try {
2535
+ textarea.value = JSON.stringify(readJson2(textarea.value), null, 2) ?? "";
2536
+ textarea.dispatchEvent(new Event("input"));
2537
+ textarea.focus();
2538
+ } catch {
2539
+ showError(new Error("Fix the JSON before formatting it"));
2540
+ }
2541
+ });
2542
+ }
2349
2543
  document.addEventListener("click", (event) => {
2350
2544
  const target = event.target;
2351
2545
  if (!(target instanceof Element))
@@ -2418,6 +2612,7 @@ function bootAdmin(config) {
2418
2612
  } else {
2419
2613
  editorValue.value = "{\n \n}";
2420
2614
  }
2615
+ syncRecordEditor();
2421
2616
  editor.showModal();
2422
2617
  }
2423
2618
  el("new-record", HTMLButtonElement).addEventListener("click", () => {
@@ -2447,10 +2642,10 @@ function bootAdmin(config) {
2447
2642
  const id = state.editing;
2448
2643
  if (!id)
2449
2644
  return;
2450
- api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2645
+ confirmAction(editorDelete, "Deleting...", "Deleted", () => api("DELETE", `/state/${encodeURIComponent(state.collection)}/${encodeURIComponent(id)}`).then(() => {
2451
2646
  editor.close();
2452
2647
  return renderState();
2453
- }).catch(showError);
2648
+ }), "Confirm delete").catch(showError);
2454
2649
  });
2455
2650
  for (const button of document.querySelectorAll("[data-advance]")) {
2456
2651
  if (!(button instanceof HTMLButtonElement))
@@ -2496,10 +2691,10 @@ function bootAdmin(config) {
2496
2691
  }).then(() => renderFaults()).catch(showError);
2497
2692
  });
2498
2693
  el("fault-clear", HTMLButtonElement).addEventListener("click", () => {
2499
- confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults())).catch(showError);
2694
+ confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults()), "Confirm clear").catch(showError);
2500
2695
  });
2501
2696
  el("journal-clear", HTMLButtonElement).addEventListener("click", () => {
2502
- confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal())).catch(showError);
2697
+ confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal()), "Confirm clear").catch(showError);
2503
2698
  });
2504
2699
  el("route-list", HTMLElement).addEventListener("click", (event) => {
2505
2700
  const target = event.target;
@@ -2513,9 +2708,15 @@ function bootAdmin(config) {
2513
2708
  return;
2514
2709
  state.route = route;
2515
2710
  el("route-title", HTMLElement).textContent = route;
2711
+ el("route-send", HTMLButtonElement).disabled = false;
2516
2712
  const method = route.split(" ")[0] ?? "";
2517
- el("route-body", HTMLTextAreaElement).disabled = method === "GET" || method === "DELETE";
2713
+ const routeBody = el("route-body", HTMLTextAreaElement);
2714
+ routeBody.disabled = method === "GET" || method === "DELETE";
2715
+ el("route-editor", HTMLElement).hidden = routeBody.disabled;
2716
+ el("route-json-status", HTMLElement).parentElement?.toggleAttribute("hidden", routeBody.disabled);
2717
+ syncRouteEditor();
2518
2718
  });
2719
+ el("route-search", HTMLInputElement).addEventListener("input", paintRoutes);
2519
2720
  el("route-form", HTMLFormElement).addEventListener("submit", (event) => {
2520
2721
  event.preventDefault();
2521
2722
  const send = async () => {
@@ -2537,7 +2738,8 @@ function bootAdmin(config) {
2537
2738
  }
2538
2739
  }
2539
2740
  const data = await api(method, concrete, body);
2540
- el("route-result", HTMLElement).textContent = JSON.stringify(data, null, 2);
2741
+ const text = JSON.stringify(data, null, 2) ?? "null";
2742
+ el("route-result", HTMLElement).innerHTML = `<code>${highlightJsonText(text)}</code>`;
2541
2743
  };
2542
2744
  send().catch(showError);
2543
2745
  });
@@ -2655,11 +2857,13 @@ function bootAdmin(config) {
2655
2857
  views.append(section);
2656
2858
  mountSqlExplorer(body, panelApi);
2657
2859
  }
2860
+ show(document.getElementById(`view-${state.view}`) === null ? "overview" : state.view);
2658
2861
  return refresh();
2659
2862
  }).catch(showError);
2660
2863
  }
2661
2864
  var clientPrelude = [
2662
2865
  isRecord2,
2866
+ highlightJsonText,
2663
2867
  mountAdminBrand,
2664
2868
  startAdminBrand,
2665
2869
  faultPresetList,
@@ -2682,6 +2886,7 @@ ${entry}(${payload});
2682
2886
  var defineMock = (runtime) => runtime;
2683
2887
  var STANDARD_ADMIN_ROUTES = [
2684
2888
  "GET /",
2889
+ "GET /health",
2685
2890
  "POST /reset",
2686
2891
  "GET /namespaces",
2687
2892
  "GET /clock",
@@ -2761,11 +2966,11 @@ var escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => {
2761
2966
  }
2762
2967
  });
2763
2968
  var ADMIN_BRANDS_URL = "https://mockingbird.chrisvouga.dev/brands.json";
2764
- var renderAdminDocument = (service) => {
2969
+ var renderAdminDocument = (service, adminPrefix = ADMIN_PREFIX) => {
2765
2970
  const name = escapeHtml(service);
2766
2971
  const script = adminClientSource({
2767
2972
  standardRoutes: STANDARD_ADMIN_ROUTES,
2768
- adminPrefix: ADMIN_PREFIX,
2973
+ adminPrefix,
2769
2974
  adminKeyHeader: ADMIN_KEY_HEADER,
2770
2975
  brandsUrl: ADMIN_BRANDS_URL,
2771
2976
  service
@@ -2782,75 +2987,99 @@ var renderAdminDocument = (service) => {
2782
2987
  ${CSS_RESET}
2783
2988
  :root {
2784
2989
  color-scheme: light;
2785
- --bg: #fafafa;
2990
+ --bg: #f7f8fb;
2786
2991
  --bg-elev: #ffffff;
2787
- --bg-muted: #f4f4f5;
2788
- --bg-inset: #f4f4f5;
2789
- --line: #dedee3;
2790
- --line-strong: #c4c4cc;
2791
- --ink: #18181b;
2792
- --muted: #62626b;
2793
- --faint: #71717a;
2794
- --accent: #27272a;
2992
+ --bg-muted: #f1f3f7;
2993
+ --bg-inset: #f8f9fc;
2994
+ --line: #e4e7ec;
2995
+ --line-strong: #cfd4dc;
2996
+ --ink: #101828;
2997
+ --muted: #475467;
2998
+ --faint: #667085;
2999
+ --accent: #6941c6;
3000
+ --accent-hover: #5933b4;
2795
3001
  --accent-ink: #ffffff;
2796
- --accent-soft: #f4f4f5;
2797
- --ok: #166534;
2798
- --ok-soft: #f0fdf4;
2799
- --warn: #a16207;
2800
- --warn-soft: #fefce8;
2801
- --err: #a82d32;
2802
- --err-soft: #fef2f2;
2803
- --shadow: none;
3002
+ --accent-soft: #f4f0ff;
3003
+ --ok: #067647;
3004
+ --ok-soft: #ecfdf3;
3005
+ --warn: #b54708;
3006
+ --warn-soft: #fffaeb;
3007
+ --err: #b42318;
3008
+ --err-soft: #fef3f2;
3009
+ --code-key: #6941c6;
3010
+ --code-string: #067647;
3011
+ --code-number: #175cd3;
3012
+ --code-boolean: #c11574;
3013
+ --code-null: #667085;
3014
+ --shadow-xs: 0 1px 2px rgb(16 24 40 / 0.05);
3015
+ --shadow-sm: 0 1px 3px rgb(16 24 40 / 0.08), 0 1px 2px rgb(16 24 40 / 0.04);
3016
+ --shadow-lg: 0 18px 48px rgb(16 24 40 / 0.16), 0 4px 12px rgb(16 24 40 / 0.08);
2804
3017
  --sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2805
3018
  --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
2806
- --radius: 10px;
2807
- --header: 56px;
2808
- --nav: 200px;
3019
+ --radius: 12px;
3020
+ --header: 72px;
3021
+ --nav: 224px;
2809
3022
  }
2810
3023
  :root[data-theme="dark"] {
2811
3024
  color-scheme: dark;
2812
- --bg: #111113;
2813
- --bg-elev: #19191c;
2814
- --bg-muted: #242428;
2815
- --bg-inset: #111113;
2816
- --line: #36363c;
2817
- --line-strong: #52525b;
2818
- --ink: #f4f4f5;
2819
- --muted: #a9a9b2;
2820
- --faint: #71717a;
2821
- --accent: #e4e4e7;
2822
- --accent-ink: #18181b;
2823
- --accent-soft: #242428;
2824
- --ok: #86efac;
2825
- --ok-soft: #14241b;
2826
- --warn: #fde68a;
2827
- --warn-soft: #2a2410;
2828
- --err: #ffaaaa;
2829
- --err-soft: #2c1516;
2830
- --shadow: none;
3025
+ --bg: #0c0e14;
3026
+ --bg-elev: #151821;
3027
+ --bg-muted: #20232e;
3028
+ --bg-inset: #11141c;
3029
+ --line: #2b303d;
3030
+ --line-strong: #414858;
3031
+ --ink: #f5f7fa;
3032
+ --muted: #b6bdc9;
3033
+ --faint: #8d96a7;
3034
+ --accent: #9e77ed;
3035
+ --accent-hover: #b692f6;
3036
+ --accent-ink: #ffffff;
3037
+ --accent-soft: #2b2142;
3038
+ --ok: #75e0a7;
3039
+ --ok-soft: #102a20;
3040
+ --warn: #fec84b;
3041
+ --warn-soft: #302611;
3042
+ --err: #fda29b;
3043
+ --err-soft: #321817;
3044
+ --code-key: #c3a6ff;
3045
+ --code-string: #75e0a7;
3046
+ --code-number: #84adff;
3047
+ --code-boolean: #f9a8d4;
3048
+ --code-null: #98a2b3;
3049
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
3050
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
3051
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
2831
3052
  }
2832
3053
  @media (prefers-color-scheme: dark) {
2833
3054
  :root:not([data-theme="light"]) {
2834
3055
  color-scheme: dark;
2835
- --bg: #111113;
2836
- --bg-elev: #19191c;
2837
- --bg-muted: #242428;
2838
- --bg-inset: #111113;
2839
- --line: #36363c;
2840
- --line-strong: #52525b;
2841
- --ink: #f4f4f5;
2842
- --muted: #a9a9b2;
2843
- --faint: #71717a;
2844
- --accent: #e4e4e7;
2845
- --accent-ink: #18181b;
2846
- --accent-soft: #242428;
2847
- --ok: #86efac;
2848
- --ok-soft: #14241b;
2849
- --warn: #fde68a;
2850
- --warn-soft: #2a2410;
2851
- --err: #ffaaaa;
2852
- --err-soft: #2c1516;
2853
- --shadow: none;
3056
+ --bg: #0c0e14;
3057
+ --bg-elev: #151821;
3058
+ --bg-muted: #20232e;
3059
+ --bg-inset: #11141c;
3060
+ --line: #2b303d;
3061
+ --line-strong: #414858;
3062
+ --ink: #f5f7fa;
3063
+ --muted: #b6bdc9;
3064
+ --faint: #8d96a7;
3065
+ --accent: #9e77ed;
3066
+ --accent-hover: #b692f6;
3067
+ --accent-ink: #ffffff;
3068
+ --accent-soft: #2b2142;
3069
+ --ok: #75e0a7;
3070
+ --ok-soft: #102a20;
3071
+ --warn: #fec84b;
3072
+ --warn-soft: #302611;
3073
+ --err: #fda29b;
3074
+ --err-soft: #321817;
3075
+ --code-key: #c3a6ff;
3076
+ --code-string: #75e0a7;
3077
+ --code-number: #84adff;
3078
+ --code-boolean: #f9a8d4;
3079
+ --code-null: #98a2b3;
3080
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.24);
3081
+ --shadow-sm: 0 4px 12px rgb(0 0 0 / 0.2);
3082
+ --shadow-lg: 0 20px 56px rgb(0 0 0 / 0.45);
2854
3083
  }
2855
3084
  }
2856
3085
  /* Fill the viewport, then grow with the content so the background covers the scroll. */
@@ -2868,13 +3097,17 @@ var renderAdminDocument = (service) => {
2868
3097
  padding-right: env(safe-area-inset-right);
2869
3098
  -webkit-text-size-adjust: 100%;
2870
3099
  }
2871
- :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
3100
+ ::selection { background: color-mix(in srgb, var(--accent) 28%, transparent); }
3101
+ :focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 2px; }
3102
+ button, input, select, textarea { transition: border-color 140ms ease, box-shadow 140ms ease, transform 100ms ease; }
3103
+ button:disabled, input:disabled, select:disabled, textarea:disabled { cursor: not-allowed; opacity: 0.55; }
2872
3104
  .app { flex: 1 0 auto; display: flex; flex-direction: column; min-width: 0; width: 100%; }
2873
3105
  header.top {
2874
- position: sticky; top: 0; z-index: 5;
3106
+ position: sticky; top: 0; z-index: 20;
2875
3107
  display: flex; align-items: center; gap: 12px;
2876
- min-height: var(--header); padding: 10px 16px;
2877
- background: var(--bg-elev);
3108
+ min-height: var(--header); padding: 12px 20px;
3109
+ background: color-mix(in srgb, var(--bg-elev) 92%, transparent);
3110
+ backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
2878
3111
  border-bottom: 1px solid var(--line);
2879
3112
  min-width: 0; max-width: 100%;
2880
3113
  }
@@ -2882,12 +3115,21 @@ var renderAdminDocument = (service) => {
2882
3115
  display: flex; flex-flow: row wrap; align-items: center; gap: 14px;
2883
3116
  min-width: 0; max-width: 100%; margin-right: auto;
2884
3117
  }
2885
- .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; }
2886
- .brand-id strong { font-size: 15px; letter-spacing: -0.01em; }
3118
+ .brand-lockup { display: flex; align-items: center; gap: 10px; min-width: 0; }
3119
+ .brand-mark {
3120
+ width: 38px; height: 38px; display: grid; place-items: center; flex: none;
3121
+ border-radius: 11px; color: #fff;
3122
+ background: linear-gradient(145deg, #7f56d9 0%, #53389e 100%);
3123
+ box-shadow: 0 6px 16px rgb(105 65 198 / 0.24), inset 0 1px 0 rgb(255 255 255 / 0.25);
3124
+ }
3125
+ .brand-mark svg { width: 22px; height: 22px; }
3126
+ .brand-id { display: flex; flex-direction: column; min-width: 0; flex: none; line-height: 1.25; }
3127
+ .brand-id strong { font-size: 15px; letter-spacing: -0.015em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2887
3128
  .brand-id span { color: var(--faint); font-size: 12px; }
2888
3129
  .vendor {
2889
3130
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
2890
3131
  min-width: 0; max-width: 100%; font-size: 12px; color: var(--muted);
3132
+ padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--bg-inset);
2891
3133
  }
2892
3134
  .vendor[hidden] { display: none; }
2893
3135
  /* Logos are drawn for a light tile, same as the docs site, so the plate stays white. */
@@ -2899,12 +3141,17 @@ var renderAdminDocument = (service) => {
2899
3141
  .vendor-sep { color: var(--faint); }
2900
3142
  .vendor-link { color: inherit; text-decoration: none; }
2901
3143
  .vendor-link:hover { color: var(--ink); text-decoration: underline; }
2902
- .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
2903
- label.field { display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: var(--faint); }
3144
+ .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; }
3145
+ .sync-status { align-self: center; display: flex; align-items: center; gap: 6px; color: var(--faint); font-size: 12px; white-space: nowrap; }
3146
+ .sync-status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
3147
+ .sync-status.busy::before { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); animation: pulse 1.2s ease-in-out infinite; }
3148
+ label.field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; font-weight: 600; color: var(--faint); }
2904
3149
  label.field input, label.field select, .controls input[type="password"] {
2905
- background-color: var(--bg-inset); border: 1px solid var(--line); border-radius: 8px;
2906
- padding: 7px 8px; min-height: 36px; line-height: 1.2;
3150
+ background-color: var(--bg-elev); border: 1px solid var(--line-strong); border-radius: 9px;
3151
+ padding: 8px 10px; min-height: 38px; line-height: 1.2; box-shadow: var(--shadow-xs);
2907
3152
  }
3153
+ label.field input:hover, label.field select:hover, textarea:hover { border-color: color-mix(in srgb, var(--accent) 38%, var(--line-strong)); }
3154
+ 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; }
2908
3155
  /* A native menu ignores min-height, so Preset rendered about half as tall as
2909
3156
  the text fields in the same form. Paint it like those fields. */
2910
3157
  label.field select {
@@ -2921,12 +3168,20 @@ var renderAdminDocument = (service) => {
2921
3168
  dialog .field > textarea { width: 100%; }
2922
3169
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger, nav.side button { cursor: pointer; }
2923
3170
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger {
2924
- border-radius: 9px; min-height: 36px; padding: 0 12px; border: 1px solid var(--line);
2925
- background: var(--bg-elev);
2926
- }
2927
- .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
2928
- .btn-quiet { background: transparent; }
2929
- .btn-danger { color: var(--err); }
3171
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px;
3172
+ border-radius: 9px; min-height: 38px; padding: 0 13px; border: 1px solid var(--line-strong);
3173
+ background: var(--bg-elev); color: var(--ink); font-size: 13px; font-weight: 600; line-height: 1;
3174
+ box-shadow: var(--shadow-xs);
3175
+ }
3176
+ .icon-btn { width: 38px; padding: 0; }
3177
+ .icon-btn svg, .btn svg, .btn-primary svg { width: 16px; height: 16px; }
3178
+ .icon-btn:hover, .btn:hover { background: var(--bg-muted); border-color: var(--line-strong); }
3179
+ .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; }
3180
+ .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
3181
+ .btn-quiet { background: transparent; border-color: transparent; box-shadow: none; }
3182
+ .btn-quiet:hover { background: var(--bg-muted); }
3183
+ .btn-danger { color: var(--err); border-color: color-mix(in srgb, var(--err) 25%, var(--line)); }
3184
+ .btn-danger:hover, .btn-danger.confirming { background: var(--err-soft); border-color: color-mix(in srgb, var(--err) 45%, var(--line)); }
2930
3185
  /* Held after mouseup. Clearing an empty journal changes nothing on screen,
2931
3186
  so the press itself has to stay visible. */
2932
3187
  .icon-btn:active, .btn:active, .btn-quiet:active, .btn-danger:active, nav.side button:active,
@@ -2942,93 +3197,159 @@ var renderAdminDocument = (service) => {
2942
3197
  flex: 1 0 auto; min-width: 0;
2943
3198
  }
2944
3199
  nav.side {
2945
- border-right: 1px solid var(--line); padding: 12px;
3200
+ position: sticky; top: var(--header); align-self: start; height: calc(100vh - var(--header));
3201
+ border-right: 1px solid var(--line); padding: 18px 12px;
2946
3202
  display: flex; flex-direction: column; gap: 4px;
2947
- background: var(--bg-elev);
3203
+ background: var(--bg-elev); overflow-y: auto;
2948
3204
  }
3205
+ .nav-label { padding: 0 10px 8px; color: var(--faint); font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; }
2949
3206
  nav.side button {
3207
+ position: relative; display: flex; align-items: center; gap: 10px; width: 100%;
2950
3208
  text-align: left; border: 0; background: transparent; border-radius: 9px;
2951
- padding: 9px 10px; min-height: 40px; color: var(--muted);
2952
- }
2953
- nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--ink); font-weight: 600; }
2954
- main { padding: 20px; max-width: 1100px; width: 100%; min-width: 0; }
2955
- .view h2 { margin: 0 0 4px; font-size: 22px; letter-spacing: -0.02em; }
2956
- .lede { margin: 0 0 16px; color: var(--muted); }
2957
- .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: 12px; }
3209
+ padding: 9px 10px; min-height: 42px; color: var(--muted); font-size: 13px; font-weight: 550;
3210
+ }
3211
+ nav.side button:hover { background: var(--bg-muted); color: var(--ink); }
3212
+ nav.side button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 650; }
3213
+ 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); }
3214
+ .nav-icon { width: 18px; height: 18px; display: grid; place-items: center; flex: none; }
3215
+ .nav-icon svg { width: 18px; height: 18px; }
3216
+ main { padding: 32px clamp(20px, 4vw, 48px) 64px; max-width: 1320px; width: 100%; min-width: 0; }
3217
+ main[aria-busy="true"] .view { opacity: 0.62; }
3218
+ .view { animation: view-in 180ms ease-out; }
3219
+ .view[hidden] { display: none; }
3220
+ .view-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 22px; }
3221
+ .view h2 { margin: 0 0 5px; font-size: clamp(24px, 3vw, 30px); line-height: 1.2; letter-spacing: -0.035em; }
3222
+ .view h3 { margin: 24px 0 10px; font-size: 15px; letter-spacing: -0.01em; }
3223
+ .lede { margin: 0; color: var(--muted); max-width: 72ch; font-size: 14px; }
3224
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 175px), 1fr)); gap: 14px; }
2958
3225
  .card {
2959
3226
  background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius);
2960
- padding: 14px 16px; box-shadow: var(--shadow);
3227
+ padding: 18px; box-shadow: var(--shadow-sm);
2961
3228
  }
2962
- .card .k { color: var(--faint); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
2963
- .card .v { display: flex; flex-direction: column; gap: 2px; font-size: 22px; margin-top: 4px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
3229
+ .stat-card { position: relative; overflow: hidden; }
3230
+ .stat-card::after { content: ""; position: absolute; inset: 0 0 auto; height: 2px; background: linear-gradient(90deg, var(--accent), transparent 78%); opacity: 0.7; }
3231
+ .card .k { color: var(--faint); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; }
3232
+ .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; }
2964
3233
  .card .v .clock-time { font-size: 14px; color: var(--muted); }
2965
- .split { display: grid; grid-template-columns: minmax(0, 240px) minmax(0, 1fr); gap: 16px; align-items: start; }
3234
+ .section-card { background: var(--bg-elev); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-xs); overflow: hidden; }
3235
+ .section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--line); }
3236
+ .section-title h3 { margin: 0; }
3237
+ .split { display: grid; grid-template-columns: minmax(0, 250px) minmax(0, 1fr); gap: 18px; align-items: start; }
3238
+ #collections { padding: 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-elev); box-shadow: var(--shadow-xs); }
2966
3239
  .collection {
2967
3240
  width: 100%; text-align: left; border: 1px solid transparent; background: transparent;
2968
- border-radius: 10px; padding: 8px 10px; min-height: 44px;
3241
+ border-radius: 9px; padding: 10px 11px; min-height: 48px; color: var(--ink); cursor: pointer;
2969
3242
  }
2970
- .collection[aria-current="true"] { background: var(--bg-elev); border-color: var(--line); }
3243
+ .collection:hover { background: var(--bg-muted); }
3244
+ .collection[aria-current="true"] { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 18%, var(--line)); color: var(--accent); }
2971
3245
  .collection small { display: block; color: var(--faint); }
2972
3246
  .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 14px; }
2973
- .chip {
2974
- font-family: var(--mono); font-size: 12px; background: var(--bg-muted);
2975
- border-radius: 999px; padding: 4px 8px;
2976
- }
3247
+ .chip { font-family: var(--mono); font-size: 11px; background: var(--bg-muted); border: 1px solid var(--line); border-radius: 999px; padding: 4px 8px; }
2977
3248
  .chip i { color: var(--faint); font-style: normal; }
2978
3249
  .sql-layout {
2979
3250
  display: grid; grid-template-columns: minmax(10rem, 16rem) minmax(0, 1fr); gap: 16px;
2980
3251
  }
2981
- .sql-tables { display: flex; flex-direction: column; gap: 4px; max-height: 28rem; overflow: auto; }
2982
- .sql-tables button { text-align: left; }
3252
+ .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); }
3253
+ .sql-tables button { text-align: left; border: 0; border-radius: 8px; padding: 9px 10px; background: transparent; color: var(--muted); cursor: pointer; }
3254
+ .sql-tables button:hover, .sql-tables button[aria-current="true"] { background: var(--accent-soft); color: var(--accent); }
2983
3255
  .sql-input { font-family: var(--mono); min-height: 7rem; width: 100%; }
2984
- table { width: 100%; border-collapse: collapse; font-size: 14px; }
2985
- th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
2986
- th { color: var(--faint); font-weight: 600; font-size: 12px; }
3256
+ .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); }
3257
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
3258
+ th, td { text-align: left; padding: 11px 13px; border-bottom: 1px solid var(--line); vertical-align: top; }
3259
+ th { color: var(--faint); background: var(--bg-inset); font-weight: 650; font-size: 11px; text-transform: uppercase; letter-spacing: 0.045em; }
3260
+ tbody tr:last-child td { border-bottom: 0; }
2987
3261
  tr.clickable { cursor: pointer; }
2988
- tr.clickable:hover { background: var(--bg-muted); }
3262
+ tr.clickable:hover { background: var(--accent-soft); }
2989
3263
  pre, code, .mono { font-family: var(--mono); overflow-wrap: anywhere; }
2990
3264
  pre {
2991
3265
  margin: 0; white-space: pre-wrap; word-break: break-word;
2992
- background: var(--bg-inset); border-radius: 10px; padding: 12px; font-size: 12px;
2993
- }
2994
- .row-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
3266
+ background: var(--bg-inset); border: 1px solid var(--line); border-radius: 10px; padding: 14px; font-size: 12px; line-height: 1.65;
3267
+ }
3268
+ .json-key { color: var(--code-key); }
3269
+ .json-string { color: var(--code-string); }
3270
+ .json-number { color: var(--code-number); }
3271
+ .json-boolean { color: var(--code-boolean); }
3272
+ .json-null { color: var(--code-null); font-style: italic; }
3273
+ .json-inline { display: block; max-width: 44rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
3274
+ .json-view { position: relative; max-height: 26rem; overflow: auto; }
3275
+ .json-editor { position: relative; min-height: 190px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--bg-inset); overflow: hidden; }
3276
+ .json-editor:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
3277
+ .json-editor.invalid { border-color: var(--err); }
3278
+ .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; }
3279
+ .json-editor pre { position: absolute; inset: 0; pointer-events: none; background: transparent; }
3280
+ .json-editor textarea { position: relative; z-index: 1; resize: vertical; background: transparent; color: transparent; caret-color: var(--ink); -webkit-text-fill-color: transparent; }
3281
+ .json-editor textarea::selection { background: color-mix(in srgb, var(--accent) 32%, transparent); }
3282
+ .editor-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 7px; }
3283
+ .json-status { display: inline-flex; align-items: center; gap: 6px; color: var(--ok); font-size: 11px; }
3284
+ .json-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
3285
+ .json-status.invalid { color: var(--err); }
3286
+ .format-json { border: 0; padding: 3px 5px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 650; cursor: pointer; }
3287
+ .row-actions { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; margin: 14px 0; }
2995
3288
  .banner {
2996
- display: none; margin-bottom: 12px; padding: 10px 12px; border-radius: 10px;
2997
- background: var(--err-soft); color: var(--err);
2998
- }
2999
- .banner.show { display: block; }
3000
- .empty { color: var(--muted); padding: 24px 8px; }
3289
+ display: none; position: sticky; top: calc(var(--header) + 12px); z-index: 15;
3290
+ align-items: center; gap: 10px; margin: 0 0 16px; padding: 11px 12px 11px 14px;
3291
+ border: 1px solid color-mix(in srgb, var(--err) 30%, var(--line)); border-radius: 10px;
3292
+ background: var(--err-soft); color: var(--err); box-shadow: var(--shadow-sm); font-size: 13px;
3293
+ }
3294
+ .banner.show { display: flex; }
3295
+ .banner span { flex: 1; }
3296
+ .banner button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 7px; background: transparent; color: currentColor; cursor: pointer; }
3297
+ .banner button:hover { background: color-mix(in srgb, var(--err) 10%, transparent); }
3298
+ .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); }
3001
3299
  dialog {
3002
- border: 1px solid var(--line); border-radius: var(--radius); padding: 0; background: var(--bg-elev);
3003
- color: var(--ink); width: min(640px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: none;
3004
- }
3005
- dialog::backdrop { background: rgb(20 19 17 / 0.45); }
3006
- dialog form, .dialog-body { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
3300
+ border: 1px solid var(--line); border-radius: 16px; padding: 0; background: var(--bg-elev);
3301
+ color: var(--ink); width: min(720px, calc(100% - 24px)); max-width: calc(100vw - 24px); box-shadow: var(--shadow-lg);
3302
+ }
3303
+ dialog[open] { animation: dialog-in 160ms ease-out; }
3304
+ dialog::backdrop { background: rgb(12 14 20 / 0.58); backdrop-filter: blur(3px); }
3305
+ dialog form, .dialog-body { padding: 22px; display: flex; flex-direction: column; gap: 14px; }
3306
+ .dialog-heading { display: flex; justify-content: space-between; gap: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
3307
+ .dialog-heading strong { font-size: 18px; letter-spacing: -0.02em; }
3308
+ .dialog-heading span { color: var(--muted); font-size: 12px; }
3007
3309
  textarea {
3008
3310
  width: 100%; min-height: 180px; resize: vertical; border-radius: 10px;
3009
3311
  border: 1px solid var(--line); background: var(--bg-inset); padding: 10px;
3010
3312
  font-family: var(--mono); font-size: 12px;
3011
3313
  }
3012
- .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
3013
- .badge { font-size: 11px; border-radius: 999px; padding: 2px 7px; background: var(--bg-muted); color: var(--muted); }
3314
+ .dialog-actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; padding-top: 4px; }
3315
+ .dialog-actions .btn-danger { margin-right: auto; }
3316
+ .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); }
3014
3317
  .badge.ok { background: var(--ok-soft); color: var(--ok); }
3015
3318
  .badge.warn { background: var(--warn-soft); color: var(--warn); }
3016
3319
  .status-2 { color: var(--ok); }
3017
3320
  .status-4, .status-5, .status-0 { color: var(--err); }
3018
3321
  .stack { display: flex; flex-direction: column; gap: 12px; }
3322
+ .form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
3323
+ .form-grid .wide { grid-column: span 2; }
3019
3324
  .route {
3020
- display: grid; grid-template-columns: 88px 1fr auto; gap: 8px; align-items: center;
3021
- padding: 8px 0; border-bottom: 1px solid var(--line);
3022
- }
3023
- .method { font-family: var(--mono); font-size: 12px; font-weight: 700; }
3325
+ display: grid; grid-template-columns: 84px minmax(0, 1fr) auto; gap: 10px; align-items: center;
3326
+ padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--bg-elev);
3327
+ }
3328
+ .route:first-child { border-radius: var(--radius) var(--radius) 0 0; }
3329
+ .route:last-child { border-bottom: 0; border-radius: 0 0 var(--radius) var(--radius); }
3330
+ .route-tools { display: flex; align-items: flex-end; gap: 12px; margin-bottom: 12px; }
3331
+ .route-tools .field { flex: 1; }
3332
+ .route-tools input { width: 100%; }
3333
+ .route-count { padding-bottom: 10px; color: var(--faint); font-size: 12px; white-space: nowrap; }
3334
+ #route-list { max-height: 34rem; border: 1px solid var(--line); border-radius: var(--radius); overflow: auto; box-shadow: var(--shadow-xs); }
3335
+ .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; }
3336
+ @keyframes view-in { from { transform: translateY(3px); } to { transform: translateY(0); } }
3337
+ @keyframes dialog-in { from { transform: translateY(8px) scale(0.99); } to { transform: translateY(0) scale(1); } }
3338
+ @keyframes pulse { 50% { opacity: 0.45; } }
3339
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
3340
+ @media (forced-colors: active) { .json-editor pre { display: none; } .json-editor textarea { color: CanvasText; -webkit-text-fill-color: CanvasText; } }
3024
3341
  @media (max-width: 800px) {
3342
+ :root { --header: 0px; }
3025
3343
  header.top {
3026
- align-items: stretch; flex-direction: column;
3027
- padding-top: max(10px, env(safe-area-inset-top));
3344
+ position: relative; align-items: stretch; flex-direction: column;
3345
+ padding: max(12px, env(safe-area-inset-top)) 14px 12px;
3028
3346
  }
3029
3347
  input, select, textarea { font-size: 16px; }
3030
3348
  .brand, .vendor, .controls { width: 100%; }
3349
+ .brand { gap: 10px; }
3350
+ .vendor { order: 2; border-radius: 9px; }
3031
3351
  label.field, label.field select, label.field input { flex: 1; min-width: 0; }
3352
+ .sync-status { display: none; }
3032
3353
  /* The layout fills the screen. A single-column grid stretches every auto
3033
3354
  row, so the tab bar grew with the leftover height and the buttons floated
3034
3355
  in the middle of it. The bar stays one row; the page below takes the rest. */
@@ -3038,14 +3359,23 @@ var renderAdminDocument = (service) => {
3038
3359
  align-content: start;
3039
3360
  }
3040
3361
  nav.side {
3041
- align-self: start; height: auto;
3362
+ position: sticky; top: 0; z-index: 12; align-self: start; height: auto;
3042
3363
  flex-direction: row; align-items: center; overflow-x: auto; min-width: 0;
3043
3364
  border-right: 0; border-bottom: 1px solid var(--line);
3044
- padding: 8px;
3365
+ padding: 8px; box-shadow: var(--shadow-xs);
3045
3366
  }
3046
- nav.side button { white-space: nowrap; flex: none; }
3367
+ .nav-label { display: none; }
3368
+ nav.side button { width: auto; white-space: nowrap; flex: none; }
3369
+ nav.side button[aria-current="true"]::before { inset: auto 9px -8px; width: auto; height: 3px; border-radius: 3px 3px 0 0; }
3047
3370
  .split, .sql-layout { grid-template-columns: minmax(0, 1fr); }
3048
- main { padding: 14px; }
3371
+ .form-grid { grid-template-columns: minmax(0, 1fr); }
3372
+ .form-grid .wide { grid-column: auto; }
3373
+ main { padding: 22px 14px 48px; }
3374
+ .view-head { margin-bottom: 18px; }
3375
+ .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
3376
+ .card { padding: 14px; }
3377
+ .card .v { font-size: 20px; }
3378
+ .table-shell { border: 0; background: transparent; box-shadow: none; overflow: visible; }
3049
3379
  table, thead, tbody, tr, th, td { display: block; }
3050
3380
  thead { display: none; }
3051
3381
  tr {
@@ -3060,50 +3390,63 @@ var renderAdminDocument = (service) => {
3060
3390
  .route { grid-template-columns: 72px 1fr; }
3061
3391
  .route button { grid-column: 1 / -1; }
3062
3392
  }
3393
+ @media (max-width: 460px) {
3394
+ .controls label.field { min-width: calc(50% - 4px); }
3395
+ .controls .icon-btn, .controls .btn { flex: 1; }
3396
+ .grid { grid-template-columns: minmax(0, 1fr); }
3397
+ .dialog-actions { flex-wrap: wrap; }
3398
+ .dialog-actions button { flex: 1; }
3399
+ .dialog-actions .btn-danger { flex-basis: 100%; margin-right: 0; }
3400
+ }
3063
3401
  </style>
3064
3402
  </head>
3065
3403
  <body>
3066
3404
  <div class="app" data-service="${name}" data-mockingbird-admin>
3067
3405
  <header class="top">
3068
3406
  <div class="brand">
3069
- <div class="brand-id">
3070
- <strong>${name}</strong>
3071
- <span>Admin</span>
3407
+ <div class="brand-lockup">
3408
+ <div class="brand-mark" aria-hidden="true">
3409
+ <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>
3410
+ </div>
3411
+ <div class="brand-id">
3412
+ <strong>${name}</strong>
3413
+ <span>Mock admin</span>
3414
+ </div>
3072
3415
  </div>
3073
3416
  <div class="vendor" id="vendor" hidden></div>
3074
3417
  </div>
3075
3418
  <div class="controls">
3419
+ <span class="sync-status" id="sync-status" role="status" aria-live="polite">Ready</span>
3076
3420
  <label class="field">Namespace
3077
3421
  <select id="namespace" aria-label="Namespace"></select>
3078
3422
  </label>
3079
3423
  <label class="field">Admin key
3080
3424
  <input id="admin-key" type="password" autocomplete="off" spellcheck="false" aria-label="Admin key">
3081
3425
  </label>
3082
- <button class="icon-btn" id="theme" type="button" aria-label="Toggle color theme">Theme</button>
3083
- <button class="btn" id="refresh" type="button">Refresh</button>
3426
+ <button class="icon-btn" id="theme" type="button" aria-label="Use dark theme" title="Use dark theme"></button>
3427
+ <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>
3084
3428
  </div>
3085
3429
  </header>
3086
3430
  <div class="layout">
3087
3431
  <nav class="side" id="nav" aria-label="Admin sections">
3088
- <button type="button" data-view="overview" aria-current="true">Overview</button>
3089
- <button type="button" data-view="state">State</button>
3090
- <button type="button" data-view="clock">Clock</button>
3091
- <button type="button" data-view="faults">Faults</button>
3092
- <button type="button" data-view="journal">Journal</button>
3093
- <button type="button" data-view="routes">Routes</button>
3432
+ <span class="nav-label">Workspace</span>
3433
+ <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>
3434
+ <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>
3435
+ <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>
3436
+ <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>
3437
+ <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>
3438
+ <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>
3094
3439
  </nav>
3095
3440
  <main id="views">
3096
- <p class="banner" id="banner" role="alert"></p>
3441
+ <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>
3097
3442
  <section class="view" id="view-overview">
3098
- <h2>Overview</h2>
3099
- <p class="lede">Health, clock, and the collections this namespace is holding.</p>
3443
+ <div class="view-head"><div><h2>Overview</h2><p class="lede">Health, clock, and the collections this namespace is holding.</p></div></div>
3100
3444
  <div class="grid" id="overview-cards"></div>
3101
3445
  <h3>Recent requests</h3>
3102
3446
  <div id="overview-requests"></div>
3103
3447
  </section>
3104
3448
  <section class="view" id="view-state" hidden>
3105
- <h2>State</h2>
3106
- <p class="lede">Every collection in this namespace. Declared fields stay visible before any row exists; stored rows add whatever else they contain.</p>
3449
+ <div class="view-head"><div><h2>State</h2><p class="lede">Inspect and edit every collection in this namespace.</p></div></div>
3107
3450
  <div class="split">
3108
3451
  <div id="collections"></div>
3109
3452
  <div>
@@ -3116,8 +3459,7 @@ var renderAdminDocument = (service) => {
3116
3459
  </div>
3117
3460
  </section>
3118
3461
  <section class="view" id="view-clock" hidden>
3119
- <h2>Clock</h2>
3120
- <p class="lede">The clock every timestamp in this mock reads.</p>
3462
+ <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>
3121
3463
  <div class="card" id="clock-readout"></div>
3122
3464
  <div class="row-actions">
3123
3465
  <button class="btn" type="button" data-advance="60000">+1m</button>
@@ -3136,21 +3478,22 @@ var renderAdminDocument = (service) => {
3136
3478
  </form>
3137
3479
  </section>
3138
3480
  <section class="view" id="view-faults" hidden>
3139
- <h2>Faults</h2>
3140
- <p class="lede">Inject a response, a delay, or a named preset. Rules apply to this namespace.</p>
3481
+ <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>
3141
3482
  <form id="fault-form" class="card stack">
3142
- <label class="field">Preset
3143
- <select id="fault-preset"><option value="">Custom rule</option></select>
3144
- </label>
3145
- <label class="field">Status
3146
- <input id="fault-status" inputmode="numeric" placeholder="503">
3147
- </label>
3148
- <label class="field">Path prefix
3149
- <input id="fault-path" placeholder="/v1">
3150
- </label>
3151
- <label class="field">Method
3152
- <input id="fault-method" placeholder="GET">
3153
- </label>
3483
+ <div class="form-grid">
3484
+ <label class="field wide">Preset
3485
+ <select id="fault-preset"><option value="">Custom rule</option></select>
3486
+ </label>
3487
+ <label class="field">Status
3488
+ <input id="fault-status" inputmode="numeric" placeholder="503">
3489
+ </label>
3490
+ <label class="field wide">Path prefix
3491
+ <input id="fault-path" placeholder="/v1">
3492
+ </label>
3493
+ <label class="field">Method
3494
+ <input id="fault-method" placeholder="GET">
3495
+ </label>
3496
+ </div>
3154
3497
  <div class="dialog-actions">
3155
3498
  <button class="btn-primary" type="submit">Add fault</button>
3156
3499
  <button class="btn-danger" id="fault-clear" type="button">Clear all</button>
@@ -3159,26 +3502,31 @@ var renderAdminDocument = (service) => {
3159
3502
  <div id="fault-list"></div>
3160
3503
  </section>
3161
3504
  <section class="view" id="view-journal" hidden>
3162
- <h2>Journal</h2>
3163
- <p class="lede">Requests this namespace has seen. Bodies are not stored.</p>
3164
- <div class="row-actions">
3165
- <button class="btn-danger" id="journal-clear" type="button">Clear journal</button>
3166
- </div>
3505
+ <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>
3167
3506
  <div id="journal"></div>
3168
3507
  </section>
3169
3508
  <section class="view" id="view-routes" hidden>
3170
- <h2>Routes</h2>
3171
- <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>
3509
+ <div class="view-head"><div><h2>Routes</h2><p class="lede">Explore shared controls and service-specific admin endpoints.</p></div></div>
3510
+ <div class="route-tools">
3511
+ <label class="field">Filter routes
3512
+ <input id="route-search" type="search" placeholder="Method, path, or extension" autocomplete="off">
3513
+ </label>
3514
+ <span class="route-count" id="route-count"></span>
3515
+ </div>
3172
3516
  <div id="route-list"></div>
3173
- <form id="route-form" class="card stack">
3517
+ <form id="route-form" class="card stack" style="margin-top: 18px">
3174
3518
  <strong id="route-title">Select a route</strong>
3175
3519
  <label class="field">JSON body
3176
- <textarea id="route-body" placeholder="{ }"></textarea>
3520
+ <div class="json-editor" id="route-editor" hidden>
3521
+ <pre aria-hidden="true"><code id="route-highlight"></code></pre>
3522
+ <textarea id="route-body" placeholder="{ }" spellcheck="false" aria-describedby="route-json-status" disabled></textarea>
3523
+ </div>
3524
+ <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>
3177
3525
  </label>
3178
3526
  <div class="dialog-actions">
3179
- <button class="btn-primary" type="submit">Send</button>
3527
+ <button class="btn-primary" id="route-send" type="submit" disabled>Send request</button>
3180
3528
  </div>
3181
- <pre id="route-result">Response will show here.</pre>
3529
+ <pre class="json-view" id="route-result"><code>Response will show here.</code></pre>
3182
3530
  </form>
3183
3531
  </section>
3184
3532
  </main>
@@ -3186,12 +3534,16 @@ var renderAdminDocument = (service) => {
3186
3534
  </div>
3187
3535
  <dialog id="editor">
3188
3536
  <form method="dialog" id="editor-form">
3189
- <strong id="editor-title">Record</strong>
3537
+ <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>
3190
3538
  <label class="field">Id
3191
3539
  <input id="editor-id" required>
3192
3540
  </label>
3193
3541
  <label class="field">JSON value
3194
- <textarea id="editor-value"></textarea>
3542
+ <div class="json-editor" id="record-editor">
3543
+ <pre aria-hidden="true"><code id="editor-highlight"></code></pre>
3544
+ <textarea id="editor-value" spellcheck="false" aria-describedby="editor-json-status"></textarea>
3545
+ </div>
3546
+ <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>
3195
3547
  </label>
3196
3548
  <div class="dialog-actions">
3197
3549
  <button class="btn-danger" id="editor-delete" type="button" value="delete">Delete</button>
@@ -3233,8 +3585,8 @@ var describeExtension = (extension) => {
3233
3585
  ...extension.script !== void 0 ? { script: extension.script } : {}
3234
3586
  };
3235
3587
  };
3236
- var adminUiRoutes = (service, ui) => {
3237
- const shell = () => renderAdminDocument(service);
3588
+ var adminUiRoutes = (service, ui, adminPrefix = ADMIN_PREFIX) => {
3589
+ const shell = () => renderAdminDocument(service, adminPrefix);
3238
3590
  const document3 = () => ui?.render ? ui.render({ service, defaultHtml: shell }) : shell();
3239
3591
  return {
3240
3592
  "GET /ui": () => htmlResponse(document3()),
@@ -3851,7 +4203,7 @@ var stateAdminRoutes = (routes) => {
3851
4203
  };
3852
4204
 
3853
4205
  // ../core/dist/version.js
3854
- var PACKAGE_VERSION = true ? "1.8.0" : UNRELEASED_VERSION;
4206
+ var PACKAGE_VERSION = true ? "2.0.0" : UNRELEASED_VERSION;
3855
4207
 
3856
4208
  // ../core/dist/signing.js
3857
4209
  var encoder = new TextEncoder();
@@ -4224,7 +4576,10 @@ var createWebhookHub = (options) => {
4224
4576
  };
4225
4577
  return hub;
4226
4578
  };
4227
- var json4 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
4579
+ var json4 = (status, body) => new Response(JSON.stringify(body), {
4580
+ status,
4581
+ headers: { "content-type": "application/json; charset=utf-8" }
4582
+ });
4228
4583
  var adminError3 = (status, message) => json4(status, { error: { type: "mockingbird_admin", message } });
4229
4584
  var isRecord5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4230
4585
  var parseEndpoint = (value) => {
@@ -4332,7 +4687,6 @@ var AT_HEADER = "x-mockingbird-at";
4332
4687
  var CHECKPOINT_HEADER = "x-mockingbird-checkpoint";
4333
4688
  var DEFAULT_NAMESPACE = "default";
4334
4689
  var NAMESPACE_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/;
4335
- var PATH_PREFIX = /^\/ns\/([^/]+)(\/.*)?$/;
4336
4690
  var BRANCH_PATTERN2 = /^[A-Za-z0-9_.-]{1,64}$/;
4337
4691
  var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
4338
4692
  var effects = /* @__PURE__ */ new WeakMap();
@@ -4373,6 +4727,22 @@ var operationMatcher = (document3) => {
4373
4727
  return (request, path) => matchers.find((m) => m.method === request.method && m.pattern.test(path))?.operationId;
4374
4728
  };
4375
4729
  var createRuntime = (options) => {
4730
+ const adminPrefix = resolveAdminPrefix(options.adminPrefix);
4731
+ const internalPaths = Object.entries(options.document?.paths ?? {}).filter(([, item]) => item["x-mockingbird-internal"] === true).map(([path]) => path);
4732
+ for (const path of internalPaths) {
4733
+ if (!path.startsWith(`${ADMIN_PREFIX}/blobs/`))
4734
+ throw new Error(`internal transport ${path} must live under ${ADMIN_PREFIX}/blobs/`);
4735
+ }
4736
+ assertAdminPrefixAvailable(adminPrefix, Object.keys(options.document?.paths ?? {}).filter((path) => !internalPaths.includes(path)));
4737
+ const internalMatch = (path) => {
4738
+ for (const pattern of internalPaths) {
4739
+ const actual = path.split("/");
4740
+ const expected = pattern.split("/");
4741
+ if (actual.length === expected.length && expected.every((part, i) => part.startsWith("{") || part === actual[i]))
4742
+ return pattern;
4743
+ }
4744
+ return void 0;
4745
+ };
4376
4746
  assertAdminUi(options.adminUi);
4377
4747
  const sqlite = bootSqlite(options.sqlite);
4378
4748
  const clock = options.clock ?? createClock();
@@ -4418,6 +4788,7 @@ var createRuntime = (options) => {
4418
4788
  const created = options.create({
4419
4789
  namespace: storageNamespace(key),
4420
4790
  publicNamespace,
4791
+ adminPrefix,
4421
4792
  sqlite,
4422
4793
  clock: namespaceClock(publicNamespace),
4423
4794
  rng: createdContextRng
@@ -4650,10 +5021,10 @@ var createRuntime = (options) => {
4650
5021
  namespaceClock,
4651
5022
  namespaceOf(request) {
4652
5023
  const url = new URL(request.url);
4653
- const prefixed = PATH_PREFIX.exec(url.pathname);
5024
+ const prefixed = matchNamespacePath(url.pathname, adminPrefix);
4654
5025
  const selected = request.headers.get(NAMESPACE_HEADER) ?? (prefixed ? decodeURIComponent(prefixed[1]) : void 0);
4655
5026
  const path = prefixed ? prefixed[2] ?? "/" : url.pathname;
4656
- if (path === "/__admin" || path.startsWith("/__admin/"))
5027
+ if (isAdminPath(path, adminPrefix))
4657
5028
  return url.searchParams.get("namespace") ?? selected ?? DEFAULT_NAMESPACE;
4658
5029
  if (selected !== void 0)
4659
5030
  return selected;
@@ -4719,7 +5090,7 @@ var createRuntime = (options) => {
4719
5090
  state: (namespace = DEFAULT_NAMESPACE) => inspectState(stateScope(namespace)),
4720
5091
  fetch: async (incoming) => {
4721
5092
  let request = incoming;
4722
- const prefixed = PATH_PREFIX.exec(new URL(request.url).pathname);
5093
+ const prefixed = matchNamespacePath(new URL(request.url).pathname, adminPrefix);
4723
5094
  if (prefixed) {
4724
5095
  const url2 = new URL(request.url);
4725
5096
  url2.pathname = prefixed[2] ?? "/";
@@ -4735,7 +5106,15 @@ var createRuntime = (options) => {
4735
5106
  signal: request.signal
4736
5107
  });
4737
5108
  }
4738
- let namespace = control.namespaceOf(request);
5109
+ const internalUrl = new URL(request.url);
5110
+ const internalPath = isAdminPath(internalUrl.pathname, adminPrefix) ? `${ADMIN_PREFIX}${internalUrl.pathname.slice(adminPrefix.length)}` : "";
5111
+ const internal = internalMatch(internalPath);
5112
+ const obsoleteInternalPath = adminPrefix !== ADMIN_PREFIX && internalMatch(internalUrl.pathname) && !internal;
5113
+ if (internal) {
5114
+ internalUrl.pathname = internalPath;
5115
+ request = new Request(internalUrl, request);
5116
+ }
5117
+ let namespace = internal ? internalUrl.searchParams.get("namespace") ?? control.namespaceOf(request) : control.namespaceOf(request);
4739
5118
  if (!request.headers.has(NAMESPACE_HEADER) && options.credential) {
4740
5119
  const credential = options.credential(request);
4741
5120
  const mapped = credential !== void 0 ? credentials.get(credential) : void 0;
@@ -4755,7 +5134,9 @@ var createRuntime = (options) => {
4755
5134
  return copy;
4756
5135
  }
4757
5136
  };
4758
- const handled = await control.handle(request);
5137
+ if (obsoleteInternalPath)
5138
+ return stamp(adminFail(404, "internal transport is only available under adminPrefix"));
5139
+ const handled = internal ? void 0 : await control.handle(request);
4759
5140
  if (handled)
4760
5141
  return stamp(handled);
4761
5142
  const started = monotonicNow();
@@ -4796,7 +5177,7 @@ var createRuntime = (options) => {
4796
5177
  type: "mockingbird_admin",
4797
5178
  message: `${NAMESPACE_HEADER} must match ${NAMESPACE_PATTERN}`
4798
5179
  }
4799
- }), { status: 400, headers: { "content-type": "application/json" } }));
5180
+ }), { status: 400, headers: { "content-type": "application/json; charset=utf-8" } }));
4800
5181
  }
4801
5182
  if (!BRANCH_PATTERN2.test(selectedBranch)) {
4802
5183
  log(400);
@@ -4913,15 +5294,16 @@ var createRuntime = (options) => {
4913
5294
  ...options.admin?.(runtime) ?? {},
4914
5295
  // Shell routes win a colliding key. Fault presets are one of them: the admin page
4915
5296
  // maps `presets`, and a service route must not replace that list with a record.
4916
- ...options.presets ? presetRoutes(options.presets, runtime) : {},
5297
+ ...options.presets ? presetRoutes(options.presets, runtime, adminPrefix) : {},
4917
5298
  ...stateAdminRoutes({
4918
5299
  open: stateScope,
4919
5300
  afterWrite: (namespace) => {
4920
5301
  checkpoint(namespace, "main");
4921
5302
  }
4922
5303
  }),
4923
- ...adminUiRoutes(options.name, options.adminUi)
5304
+ ...adminUiRoutes(options.name, options.adminUi, adminPrefix)
4924
5305
  },
5306
+ adminPrefix,
4925
5307
  adminKey: options.adminKey
4926
5308
  });
4927
5309
  return defineMock(runtime);
@@ -4935,7 +5317,10 @@ var mutableResponse = (response) => {
4935
5317
  return new Response(response.body, response);
4936
5318
  }
4937
5319
  };
4938
- var adminJson = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
5320
+ var adminJson = (status, body) => new Response(JSON.stringify(body), {
5321
+ status,
5322
+ headers: { "content-type": "application/json; charset=utf-8" }
5323
+ });
4939
5324
  var adminFail = (status, message) => adminJson(status, { error: { type: "mockingbird_admin", message } });
4940
5325
  var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4941
5326
  var credentialRoutes = (registry) => ({
@@ -4987,7 +5372,7 @@ var credentialRoutes = (registry) => ({
4987
5372
  return adminJson(200, { status: "ok" });
4988
5373
  }
4989
5374
  });
4990
- var presetRoutes = (presets, runtime) => ({
5375
+ var presetRoutes = (presets, runtime, adminPrefix) => ({
4991
5376
  "GET /faults/presets": () => {
4992
5377
  const body = {
4993
5378
  presets: Object.entries(presets).map(([name, preset]) => ({ name, ...preset }))
@@ -4997,7 +5382,7 @@ var presetRoutes = (presets, runtime) => ({
4997
5382
  "POST /faults/presets/:name": ({ params, body, namespace }) => {
4998
5383
  const name = params.name;
4999
5384
  if (!presets[name])
5000
- return adminFail(404, `no fault preset ${name}; GET /__admin/faults/presets`);
5385
+ return adminFail(404, `no fault preset ${name}; GET ${adminPrefix}/faults/presets`);
5001
5386
  const overrides = isObject(body) ? body : {};
5002
5387
  return adminJson(201, { preset: name, rules: runtime.applyPreset(name, namespace, overrides) });
5003
5388
  }
@@ -6106,6 +6491,7 @@ var createRuntime2 = (options = {}) => {
6106
6491
  ...options.sqlite ? { sqlite: options.sqlite } : {},
6107
6492
  ...options.clock ? { clock: options.clock } : {},
6108
6493
  ...options.seed !== void 0 ? { seed: options.seed } : {},
6494
+ ...options.adminPrefix !== void 0 ? { adminPrefix: options.adminPrefix } : {},
6109
6495
  ...options.adminKey !== void 0 ? { adminKey: options.adminKey } : {},
6110
6496
  ...options.onLog ? { onLog: options.onLog } : {},
6111
6497
  credential: formbricksCredential,
@@ -6736,6 +7122,7 @@ var FormbricksAPI = class {
6736
7122
 
6737
7123
  export {
6738
7124
  createClock,
7125
+ resolveAdminPrefix,
6739
7126
  parseDuration,
6740
7127
  document2 as document,
6741
7128
  operationIds,
@@ -6753,4 +7140,4 @@ export {
6753
7140
  formbricksError,
6754
7141
  FormbricksAPI
6755
7142
  };
6756
- //# sourceMappingURL=chunk-JEV2BAL7.js.map
7143
+ //# sourceMappingURL=chunk-2YLOIK2J.js.map