@tailor-platform/sdk 1.56.0 → 1.57.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +0 -23
  3. package/dist/{application-YHZIkjdy.mjs → application-CdkoGX27.mjs} +37 -4
  4. package/dist/application-CdkoGX27.mjs.map +1 -0
  5. package/dist/application-x_mURdR0.mjs +4 -0
  6. package/dist/cli/erd-viewer-assets/app.js +1181 -0
  7. package/dist/cli/erd-viewer-assets/index.html +73 -0
  8. package/dist/cli/erd-viewer-assets/serve.json +13 -0
  9. package/dist/cli/erd-viewer-assets/styles.css +789 -0
  10. package/dist/cli/index.mjs +686 -345
  11. package/dist/cli/index.mjs.map +1 -1
  12. package/dist/cli/lib.d.mts +7 -2
  13. package/dist/cli/lib.mjs +2 -2
  14. package/dist/client-DLPEPJ_s.mjs.map +1 -1
  15. package/dist/configure/index.d.mts +2 -2
  16. package/dist/configure/index.mjs +1 -1
  17. package/dist/configure/index.mjs.map +1 -1
  18. package/dist/{index-BW3v5XYC.d.mts → index-B61gFI9a.d.mts} +7 -2
  19. package/dist/{runtime-B8F1nklz.mjs → runtime-1YuaoNr8.mjs} +57 -63
  20. package/dist/runtime-1YuaoNr8.mjs.map +1 -0
  21. package/dist/{types-BinLwXM9.mjs → types-BwGth3a1.mjs} +57 -28
  22. package/dist/types-BwGth3a1.mjs.map +1 -0
  23. package/dist/{types-UeXbHFXW.mjs → types-Ccwchyj5.mjs} +1 -1
  24. package/dist/utils/test/index.d.mts +2 -2
  25. package/dist/{workflow.generated-BHdBzgx6.d.mts → workflow.generated-Kz-nQrTf.d.mts} +10 -1
  26. package/docs/cli/tailordb.md +31 -26
  27. package/docs/cli-reference.md +2 -2
  28. package/package.json +1 -3
  29. package/dist/application-C9-t0qQb.mjs +0 -4
  30. package/dist/application-YHZIkjdy.mjs.map +0 -1
  31. package/dist/runtime-B8F1nklz.mjs.map +0 -1
  32. package/dist/types-BinLwXM9.mjs.map +0 -1
@@ -0,0 +1,1181 @@
1
+ const TABLE_WIDTH = 260;
2
+ const TABLE_HEIGHT = 62;
3
+ const FIELD_ROW_HEIGHT = 28;
4
+ const FIELD_SECTION_BORDER_HEIGHT = 1;
5
+ const X_GAP = 240;
6
+ const Y_GAP = 56;
7
+ const CARDINALITY_MARKER_WIDTH = 50;
8
+ const CROW_FOOT_TIP_OFFSET = 0;
9
+ const CROW_FOOT_JOIN_OFFSET = 18;
10
+ const CARDINALITY_OUTER_OFFSET = 32;
11
+ const DRAG_THRESHOLD = 4;
12
+ const FIT_PADDING = 80;
13
+ const MIN_ZOOM = 0.25;
14
+ const MAX_ZOOM = 2.2;
15
+ const DEFAULT_SHOW_MODE = "TABLE_NAME";
16
+ const SHOW_MODE_OPTIONS = [
17
+ { value: "ALL_FIELDS", label: "All Fields" },
18
+ { value: "TABLE_NAME", label: "Table Name" },
19
+ { value: "KEY_ONLY", label: "Key Only" },
20
+ ];
21
+
22
+ const elements = {
23
+ main: document.querySelector(".main"),
24
+ namespace: document.getElementById("namespace"),
25
+ revision: document.getElementById("revision"),
26
+ search: document.getElementById("search"),
27
+ tableSummary: document.getElementById("table-count-summary"),
28
+ toggleAllTables: document.getElementById("toggle-all-tables"),
29
+ tableList: document.getElementById("table-list"),
30
+ canvas: document.getElementById("canvas"),
31
+ world: document.getElementById("world"),
32
+ edges: document.getElementById("edges"),
33
+ nodes: document.getElementById("nodes"),
34
+ details: document.getElementById("details"),
35
+ emptyState: document.getElementById("empty-state"),
36
+ status: document.getElementById("status"),
37
+ zoomIn: document.getElementById("zoom-in"),
38
+ zoomOut: document.getElementById("zoom-out"),
39
+ zoomLabel: document.getElementById("zoom-label"),
40
+ fitView: document.getElementById("fit-view"),
41
+ showMode: document.getElementById("show-mode"),
42
+ showModeMenu: document.getElementById("show-mode-menu"),
43
+ copyLink: document.getElementById("copy-link"),
44
+ };
45
+
46
+ let schema;
47
+ let layout;
48
+ let selectedTable;
49
+ let searchText = "";
50
+ let viewport = { x: 32, y: 32, z: 1 };
51
+ let hasZoomFromHash = false;
52
+ let userAdjustedViewport = false;
53
+ let hashUpdatesDisabled = false;
54
+ let showMode = DEFAULT_SHOW_MODE;
55
+ let activeCardDrag;
56
+ let activeCanvasPan;
57
+ let activeViewportAnimation;
58
+ let suppressNextCanvasClick = false;
59
+ const manualNodePositions = new Map();
60
+ const hiddenTableNames = new Set();
61
+
62
+ function escapeHtml(value) {
63
+ return String(value ?? "")
64
+ .replaceAll("&", "&")
65
+ .replaceAll("<", "&lt;")
66
+ .replaceAll(">", "&gt;")
67
+ .replaceAll('"', "&quot;");
68
+ }
69
+
70
+ function clamp(value, min, max) {
71
+ return Math.min(max, Math.max(min, value));
72
+ }
73
+
74
+ function tableByName(name) {
75
+ return schema?.tables.find((table) => table.name === name);
76
+ }
77
+
78
+ function showModeOption(value) {
79
+ return SHOW_MODE_OPTIONS.find((option) => option.value === value);
80
+ }
81
+
82
+ function isTableHidden(tableName) {
83
+ return hiddenTableNames.has(tableName);
84
+ }
85
+
86
+ function visibleTables() {
87
+ return schema.tables.filter((table) => !isTableHidden(table.name));
88
+ }
89
+
90
+ function visibleTableNames() {
91
+ return visibleTables().map((table) => table.name);
92
+ }
93
+
94
+ function readHashState() {
95
+ const params = new URLSearchParams(location.hash.slice(1));
96
+ selectedTable = params.get("table") || undefined;
97
+ const nextShowMode = params.get("show");
98
+ if (showModeOption(nextShowMode)) {
99
+ showMode = nextShowMode;
100
+ }
101
+ hiddenTableNames.clear();
102
+ for (const tableName of (params.get("hidden") || "").split(",")) {
103
+ if (tableName) hiddenTableNames.add(tableName);
104
+ }
105
+ const z = Number(params.get("z"));
106
+ if (params.has("z") && Number.isFinite(z)) {
107
+ viewport = { ...viewport, z: clamp(z, MIN_ZOOM, MAX_ZOOM) };
108
+ hasZoomFromHash = true;
109
+ }
110
+ }
111
+
112
+ function writeHashState() {
113
+ if (hashUpdatesDisabled) return;
114
+ const params = new URLSearchParams();
115
+ if (selectedTable) params.set("table", selectedTable);
116
+ if (showMode !== DEFAULT_SHOW_MODE) params.set("show", showMode);
117
+ if (hiddenTableNames.size > 0) {
118
+ params.set("hidden", [...hiddenTableNames].sort((a, b) => a.localeCompare(b)).join(","));
119
+ }
120
+ params.set("z", viewport.z.toFixed(3));
121
+ try {
122
+ history.replaceState(null, "", `#${params.toString()}`);
123
+ } catch {
124
+ // Disable further hash writes once they fail (e.g. sandboxed artifact
125
+ // preview) so panning/zooming does not throw on every frame.
126
+ hashUpdatesDisabled = true;
127
+ }
128
+ }
129
+
130
+ const SCHEMA_BLOCK_PATTERN =
131
+ /<script type="application\/json" id="erd-schema">([\s\S]*?)<\/script>/;
132
+
133
+ // Read the schema from the embedded `#erd-schema` data block in the current
134
+ // document (the viewer is always a self-contained HTML).
135
+ function embeddedSchema() {
136
+ if (typeof document === "undefined") return undefined;
137
+ const element = document.getElementById("erd-schema");
138
+ if (!element) return undefined;
139
+ try {
140
+ return JSON.parse(element.textContent);
141
+ } catch {
142
+ return undefined;
143
+ }
144
+ }
145
+
146
+ // Re-fetch the served HTML and extract its embedded schema. Used by watch mode
147
+ // to pick up rebuilds without a separate schema.json.
148
+ async function fetchServedSchema() {
149
+ const response = await fetch(`./?t=${Date.now()}`, { cache: "no-store" });
150
+ if (!response.ok) {
151
+ throw new Error(`Failed to load ERD (${response.status})`);
152
+ }
153
+ const match = SCHEMA_BLOCK_PATTERN.exec(await response.text());
154
+ if (!match) {
155
+ throw new Error("ERD schema block not found.");
156
+ }
157
+ return JSON.parse(match[1]);
158
+ }
159
+
160
+ function isKeyColumn(column) {
161
+ return Boolean(column.primaryKey || column.unique || column.index || column.relation);
162
+ }
163
+
164
+ function columnsForShowMode(table) {
165
+ if (showMode === "ALL_FIELDS") return table.columns;
166
+ if (showMode === "KEY_ONLY") return table.columns.filter(isKeyColumn);
167
+ return [];
168
+ }
169
+
170
+ function cardHeight(table) {
171
+ const fieldCount = columnsForShowMode(table).length;
172
+ return (
173
+ TABLE_HEIGHT +
174
+ (fieldCount > 0 ? FIELD_SECTION_BORDER_HEIGHT : 0) +
175
+ fieldCount * FIELD_ROW_HEIGHT
176
+ );
177
+ }
178
+
179
+ function computeRanks(tables, relations) {
180
+ const ranks = new Map(tables.map((table) => [table.name, 0]));
181
+ for (let i = 0; i < tables.length; i += 1) {
182
+ let changed = false;
183
+ for (const relation of relations) {
184
+ const targetRank = ranks.get(relation.targetTable) ?? 0;
185
+ const sourceRank = ranks.get(relation.sourceTable) ?? 0;
186
+ if (sourceRank <= targetRank && relation.sourceTable !== relation.targetTable) {
187
+ ranks.set(relation.sourceTable, targetRank + 1);
188
+ changed = true;
189
+ }
190
+ }
191
+ if (!changed) break;
192
+ }
193
+ return ranks;
194
+ }
195
+
196
+ function computeLayout(nextSchema) {
197
+ const tables = [...nextSchema.tables].sort((a, b) => a.name.localeCompare(b.name));
198
+ const ranks = computeRanks(tables, nextSchema.relations);
199
+ const layers = new Map();
200
+ for (const table of tables) {
201
+ const rank = ranks.get(table.name) ?? 0;
202
+ if (!layers.has(rank)) layers.set(rank, []);
203
+ layers.get(rank).push(table);
204
+ }
205
+
206
+ const nodes = new Map();
207
+ for (const rank of [...layers.keys()].sort((a, b) => a - b)) {
208
+ const layerTables = layers.get(rank).sort((a, b) => a.name.localeCompare(b.name));
209
+ let y = 0;
210
+ for (const table of layerTables) {
211
+ const height = cardHeight(table);
212
+ const x = rank * (TABLE_WIDTH + X_GAP);
213
+ nodes.set(table.name, { x, y, width: TABLE_WIDTH, height });
214
+ y += height + Y_GAP;
215
+ }
216
+ }
217
+
218
+ return {
219
+ nodes,
220
+ ...layoutBounds(nodes),
221
+ };
222
+ }
223
+
224
+ function layoutBounds(nodes, tableNames) {
225
+ const includedNames = tableNames ? new Set(tableNames) : undefined;
226
+ let minX = Infinity;
227
+ let minY = Infinity;
228
+ let maxX = -Infinity;
229
+ let maxY = -Infinity;
230
+ for (const [tableName, node] of nodes) {
231
+ if (includedNames && !includedNames.has(tableName)) continue;
232
+ minX = Math.min(minX, node.x);
233
+ minY = Math.min(minY, node.y);
234
+ maxX = Math.max(maxX, node.x + node.width);
235
+ maxY = Math.max(maxY, node.y + node.height);
236
+ }
237
+
238
+ if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
239
+ return { x: 0, y: 0, width: TABLE_WIDTH, height: TABLE_HEIGHT };
240
+ }
241
+
242
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
243
+ }
244
+
245
+ function visibleLayoutBounds() {
246
+ return layoutBounds(layout.nodes, visibleTableNames());
247
+ }
248
+
249
+ function applyManualNodePositions() {
250
+ for (const [tableName, position] of manualNodePositions) {
251
+ const node = layout.nodes.get(tableName);
252
+ if (node) {
253
+ node.x = position.x;
254
+ node.y = position.y;
255
+ }
256
+ }
257
+ Object.assign(layout, layoutBounds(layout.nodes));
258
+ }
259
+
260
+ function isTableRelatedToSelection(tableName) {
261
+ if (!selectedTable) return true;
262
+ if (tableName === selectedTable) return true;
263
+ return schema.relations.some(
264
+ (relation) =>
265
+ (relation.sourceTable === selectedTable && relation.targetTable === tableName) ||
266
+ (relation.targetTable === selectedTable && relation.sourceTable === tableName),
267
+ );
268
+ }
269
+
270
+ function matchesSearch(table) {
271
+ if (!searchText) return true;
272
+ const needle = searchText.toLowerCase();
273
+ return (
274
+ table.name.toLowerCase().includes(needle) ||
275
+ table.columns.some((column) => column.name.toLowerCase().includes(needle))
276
+ );
277
+ }
278
+
279
+ function eyeIcon(hidden) {
280
+ if (hidden) {
281
+ return `
282
+ <svg class="eye-icon" viewBox="0 0 24 24" aria-hidden="true">
283
+ <path d="M3 3l18 18"></path>
284
+ <path d="M10.6 10.6a2 2 0 0 0 2.8 2.8"></path>
285
+ <path d="M8.4 5.6A9.3 9.3 0 0 1 12 5c5 0 8.4 4.1 9.5 6.2a2 2 0 0 1 0 1.6 14.3 14.3 0 0 1-2.7 3.5"></path>
286
+ <path d="M6.1 6.9a14.7 14.7 0 0 0-3.6 4.3 2 2 0 0 0 0 1.6C3.6 14.9 7 19 12 19a9.8 9.8 0 0 0 4-.8"></path>
287
+ </svg>
288
+ `;
289
+ }
290
+
291
+ return `
292
+ <svg class="eye-icon" viewBox="0 0 24 24" aria-hidden="true">
293
+ <path d="M2.5 11.2C3.6 9.1 7 5 12 5s8.4 4.1 9.5 6.2a2 2 0 0 1 0 1.6C20.4 14.9 17 19 12 19s-8.4-4.1-9.5-6.2a2 2 0 0 1 0-1.6Z"></path>
294
+ <circle cx="12" cy="12" r="3"></circle>
295
+ </svg>
296
+ `;
297
+ }
298
+
299
+ function renderHeader() {
300
+ elements.namespace.textContent = schema.namespace;
301
+ elements.revision.textContent = `${schema.tables.length} tables / ${schema.relations.length} relations / ${schema.revision}`;
302
+ const visibleCount = visibleTables().length;
303
+ elements.tableSummary.textContent =
304
+ visibleCount === schema.tables.length
305
+ ? String(schema.tables.length)
306
+ : `${visibleCount}/${schema.tables.length}`;
307
+ const allTablesHidden = schema.tables.length > 0 && visibleCount === 0;
308
+ const allTableVisibilityLabel = allTablesHidden ? "Show all tables" : "Hide all tables";
309
+ elements.toggleAllTables.disabled = schema.tables.length === 0;
310
+ elements.toggleAllTables.innerHTML = eyeIcon(allTablesHidden);
311
+ elements.toggleAllTables.setAttribute("aria-label", allTableVisibilityLabel);
312
+ elements.toggleAllTables.title = allTableVisibilityLabel;
313
+ }
314
+
315
+ function renderTableList() {
316
+ const tables = schema.tables.filter(matchesSearch);
317
+ elements.tableList.innerHTML = tables
318
+ .map((table) => {
319
+ const hidden = isTableHidden(table.name);
320
+ const visibilityLabel = hidden ? "Show" : "Hide";
321
+ return `
322
+ <div class="table-list-row ${hidden ? "is-hidden" : ""}">
323
+ <button
324
+ type="button"
325
+ class="table-select"
326
+ data-table="${escapeHtml(table.name)}"
327
+ aria-current="${table.name === selectedTable}"
328
+ >
329
+ <span class="table-list-icon" aria-hidden="true"></span>
330
+ <span>${escapeHtml(table.name)}</span>
331
+ </button>
332
+ <button
333
+ type="button"
334
+ class="table-visibility-toggle"
335
+ data-table="${escapeHtml(table.name)}"
336
+ aria-label="${visibilityLabel} table ${escapeHtml(table.name)}"
337
+ title="${visibilityLabel}"
338
+ >
339
+ ${eyeIcon(hidden)}
340
+ </button>
341
+ </div>
342
+ `;
343
+ })
344
+ .join("");
345
+
346
+ elements.tableList.querySelectorAll(".table-select").forEach((button) => {
347
+ button.addEventListener("click", () => {
348
+ selectTable(button.dataset.table);
349
+ });
350
+ });
351
+ elements.tableList.querySelectorAll(".table-visibility-toggle").forEach((button) => {
352
+ button.addEventListener("click", (event) => {
353
+ event.stopPropagation();
354
+ toggleTableVisibility(button.dataset.table);
355
+ });
356
+ });
357
+ }
358
+
359
+ function tableFieldRows(table) {
360
+ const columns = columnsForShowMode(table);
361
+ if (columns.length === 0) return "";
362
+
363
+ return `
364
+ <div class="table-fields">
365
+ ${columns
366
+ .map(
367
+ (column) => `
368
+ <div class="table-field ${isKeyColumn(column) ? "is-key" : ""}">
369
+ <span class="field-name">${escapeHtml(column.name)}</span>
370
+ <span class="field-type">${escapeHtml(column.type)}${column.array ? "[]" : ""}</span>
371
+ </div>
372
+ `,
373
+ )
374
+ .join("")}
375
+ </div>
376
+ `;
377
+ }
378
+
379
+ function renderNodes() {
380
+ elements.nodes.innerHTML = schema.tables
381
+ .filter((table) => !isTableHidden(table.name))
382
+ .map((table) => {
383
+ const node = layout.nodes.get(table.name);
384
+ const related = isTableRelatedToSelection(table.name);
385
+ const muted = !matchesSearch(table) || !related;
386
+ return `
387
+ <button
388
+ type="button"
389
+ class="table-card ${table.name === selectedTable ? "is-selected" : ""} ${related ? "is-related" : ""} ${muted ? "is-muted" : ""}"
390
+ data-table="${escapeHtml(table.name)}"
391
+ aria-label="Focus table ${escapeHtml(table.name)}"
392
+ aria-pressed="${table.name === selectedTable}"
393
+ style="left: ${node.x}px; top: ${node.y}px; height: ${node.height}px"
394
+ >
395
+ <div class="table-head">
396
+ <span class="table-icon" aria-hidden="true"></span>
397
+ <div class="table-name">${escapeHtml(table.name)}</div>
398
+ </div>
399
+ ${tableFieldRows(table)}
400
+ </button>
401
+ `;
402
+ })
403
+ .join("");
404
+
405
+ elements.nodes.querySelectorAll(".table-card").forEach(wireTableCard);
406
+ }
407
+
408
+ function wireTableCard(card) {
409
+ card.addEventListener("click", (event) => {
410
+ event.stopPropagation();
411
+ suppressNextCanvasClick = false;
412
+ if (card.dataset.dragged === "true") {
413
+ event.preventDefault();
414
+ card.dataset.dragged = "false";
415
+ return;
416
+ }
417
+ selectTable(card.dataset.table);
418
+ });
419
+
420
+ card.addEventListener("pointerdown", (event) => {
421
+ if (event.button !== 0) return;
422
+ cancelViewportAnimation();
423
+ const tableName = card.dataset.table;
424
+ const node = layout.nodes.get(tableName);
425
+ if (!node) return;
426
+
427
+ activeCardDrag = {
428
+ card,
429
+ moved: false,
430
+ pointerId: event.pointerId,
431
+ startClientX: event.clientX,
432
+ startClientY: event.clientY,
433
+ startX: node.x,
434
+ startY: node.y,
435
+ tableName,
436
+ };
437
+ card.setPointerCapture(event.pointerId);
438
+ });
439
+
440
+ card.addEventListener("pointermove", (event) => {
441
+ if (!activeCardDrag || activeCardDrag.pointerId !== event.pointerId) return;
442
+
443
+ const screenDeltaX = event.clientX - activeCardDrag.startClientX;
444
+ const screenDeltaY = event.clientY - activeCardDrag.startClientY;
445
+ if (!activeCardDrag.moved && Math.hypot(screenDeltaX, screenDeltaY) < DRAG_THRESHOLD) {
446
+ return;
447
+ }
448
+
449
+ event.preventDefault();
450
+ activeCardDrag.moved = true;
451
+ activeCardDrag.card.classList.add("is-dragging");
452
+ moveTableCard(
453
+ activeCardDrag.tableName,
454
+ activeCardDrag.card,
455
+ activeCardDrag.startX + screenDeltaX / viewport.z,
456
+ activeCardDrag.startY + screenDeltaY / viewport.z,
457
+ );
458
+ });
459
+
460
+ card.addEventListener("pointerup", finishCardDrag);
461
+ card.addEventListener("pointercancel", finishCardDrag);
462
+ }
463
+
464
+ function moveTableCard(tableName, card, x, y) {
465
+ const node = layout.nodes.get(tableName);
466
+ if (!node) return;
467
+
468
+ userAdjustedViewport = true;
469
+ node.x = Math.round(x);
470
+ node.y = Math.round(y);
471
+ manualNodePositions.set(tableName, { x: node.x, y: node.y });
472
+ Object.assign(layout, layoutBounds(layout.nodes));
473
+ card.style.left = `${node.x}px`;
474
+ card.style.top = `${node.y}px`;
475
+ renderEdges();
476
+ }
477
+
478
+ function finishCardDrag(event) {
479
+ if (!activeCardDrag || activeCardDrag.pointerId !== event.pointerId) return;
480
+
481
+ if (activeCardDrag.card.hasPointerCapture(event.pointerId)) {
482
+ activeCardDrag.card.releasePointerCapture(event.pointerId);
483
+ }
484
+ activeCardDrag.card.classList.remove("is-dragging");
485
+ if (activeCardDrag.moved) {
486
+ event.preventDefault();
487
+ activeCardDrag.card.dataset.dragged = "true";
488
+ selectedTable = activeCardDrag.tableName;
489
+ activeCardDrag = undefined;
490
+ renderAll({ center: false });
491
+ return;
492
+ }
493
+
494
+ activeCardDrag = undefined;
495
+ }
496
+
497
+ function edgeGeometry(source, target) {
498
+ const sourceRight = source.x < target.x;
499
+ const direction = sourceRight ? 1 : -1;
500
+ const sourceNodeX = sourceRight ? source.x + source.width : source.x;
501
+ const targetNodeX = sourceRight ? target.x : target.x + target.width;
502
+ const sy = source.y + source.height / 2;
503
+ const ty = target.y + target.height / 2;
504
+ const sx = sourceNodeX + direction * CARDINALITY_MARKER_WIDTH;
505
+ const tx = targetNodeX - direction * CARDINALITY_MARKER_WIDTH;
506
+ const bend = Math.max(80, Math.abs(tx - sx) * 0.45);
507
+ const c1x = sx + (sourceRight ? bend : -bend);
508
+ const c2x = tx + (sourceRight ? -bend : bend);
509
+ return {
510
+ d: `M ${sx} ${sy} C ${c1x} ${sy}, ${c2x} ${ty}, ${tx} ${ty}`,
511
+ sourceNodePoint: { x: sourceNodeX, y: sy },
512
+ targetNodePoint: { x: targetNodeX, y: ty },
513
+ sourceRight,
514
+ };
515
+ }
516
+
517
+ function sourceColumnForRelation(relation) {
518
+ const table = tableByName(relation.sourceTable);
519
+ const sourceColumn = relation.sourceColumns[0];
520
+ return table?.columns.find((column) => column.name === sourceColumn);
521
+ }
522
+
523
+ function normalizedRelationType(relation) {
524
+ if (["1-1", "oneToOne"].includes(relation.relationType)) return "1-1";
525
+ if (["n-1", "N-1", "manyToOne"].includes(relation.relationType)) return "n-1";
526
+ return relation.relationType || "foreignKey";
527
+ }
528
+
529
+ function relationCardinality(relation) {
530
+ const sourceColumn = sourceColumnForRelation(relation);
531
+ const relationType = normalizedRelationType(relation);
532
+ const sourceMultiple =
533
+ relationType === "n-1" ||
534
+ relationType === "keyOnly" ||
535
+ (relationType === "foreignKey" && sourceColumn?.unique !== true);
536
+ const targetMultiple = relationType === "keyOnly" && sourceColumn?.array === true;
537
+
538
+ return {
539
+ source: { min: 0, max: sourceMultiple ? "n" : 1 },
540
+ target: { min: relation.required ? 1 : 0, max: targetMultiple ? "n" : 1 },
541
+ };
542
+ }
543
+
544
+ function oneMarker(x, y) {
545
+ return `
546
+ <line x1="${x}" y1="${y - 10}" x2="${x}" y2="${y + 10}"></line>
547
+ `;
548
+ }
549
+
550
+ function cardinalityMarker(cardinality, point, sideSign, selected) {
551
+ const crowFootTip = CROW_FOOT_TIP_OFFSET;
552
+ const crowFootJoin = CROW_FOOT_JOIN_OFFSET;
553
+ const outer = CARDINALITY_OUTER_OFFSET;
554
+ const markerEndX = point.x + sideSign * CARDINALITY_MARKER_WIDTH;
555
+ const parts = [
556
+ `<line class="edge-cardinality-line" x1="${point.x}" y1="${point.y}" x2="${markerEndX}" y2="${point.y}"></line>`,
557
+ ];
558
+
559
+ if (cardinality.max === "n") {
560
+ const baseX = point.x + sideSign * crowFootJoin;
561
+ const endX = point.x + sideSign * crowFootTip;
562
+ parts.push(`
563
+ <line x1="${baseX}" y1="${point.y}" x2="${endX}" y2="${point.y - 11}"></line>
564
+ <line x1="${baseX}" y1="${point.y}" x2="${endX}" y2="${point.y + 11}"></line>
565
+ `);
566
+ if (cardinality.min === 0) {
567
+ parts.push(`<circle cx="${point.x + sideSign * outer}" cy="${point.y}" r="6"></circle>`);
568
+ } else {
569
+ parts.push(oneMarker(point.x + sideSign * outer, point.y));
570
+ }
571
+ } else {
572
+ parts.push(oneMarker(point.x + sideSign * crowFootJoin, point.y));
573
+ if (cardinality.min === 0) {
574
+ parts.push(`<circle cx="${point.x + sideSign * outer}" cy="${point.y}" r="6"></circle>`);
575
+ } else {
576
+ parts.push(oneMarker(point.x + sideSign * outer, point.y));
577
+ }
578
+ }
579
+
580
+ return `
581
+ <g class="edge-cardinality ${selected ? "is-selected" : ""}">
582
+ ${parts.join("")}
583
+ </g>
584
+ `;
585
+ }
586
+
587
+ function renderEdges() {
588
+ const bounds = visibleLayoutBounds();
589
+ elements.edges.setAttribute("width", String(bounds.width + 400));
590
+ elements.edges.setAttribute("height", String(bounds.height + 400));
591
+ elements.edges.innerHTML = schema.relations
592
+ .map((relation) => {
593
+ if (isTableHidden(relation.sourceTable) || isTableHidden(relation.targetTable)) return "";
594
+ const source = layout.nodes.get(relation.sourceTable);
595
+ const target = layout.nodes.get(relation.targetTable);
596
+ if (!source || !target) return "";
597
+ const selected =
598
+ relation.sourceTable === selectedTable || relation.targetTable === selectedTable;
599
+ const geometry = edgeGeometry(source, target);
600
+ const cardinality = relationCardinality(relation);
601
+ const direction = geometry.sourceRight ? 1 : -1;
602
+ return `
603
+ <path class="edge ${selected ? "is-selected" : ""}" d="${geometry.d}"></path>
604
+ ${cardinalityMarker(cardinality.source, geometry.sourceNodePoint, direction, selected)}
605
+ ${cardinalityMarker(cardinality.target, geometry.targetNodePoint, -direction, selected)}
606
+ `;
607
+ })
608
+ .join("");
609
+ }
610
+
611
+ function relationRows(table, direction) {
612
+ const relations = schema.relations.filter((relation) =>
613
+ direction === "out" ? relation.sourceTable === table.name : relation.targetTable === table.name,
614
+ );
615
+ if (relations.length === 0) return `<p>None</p>`;
616
+ return `
617
+ <div class="detail-list">
618
+ ${relations
619
+ .map((relation) => {
620
+ const label =
621
+ direction === "out"
622
+ ? `${relation.sourceColumns.join(", ")} -> ${relation.targetTable}.${relation.targetColumns.join(", ")}`
623
+ : `${relation.sourceTable}.${relation.sourceColumns.join(", ")} -> ${relation.targetColumns.join(", ")}`;
624
+ return `<div class="detail-row"><strong>${escapeHtml(label)}</strong><code>${escapeHtml(relation.kind)}</code></div>`;
625
+ })
626
+ .join("")}
627
+ </div>
628
+ `;
629
+ }
630
+
631
+ function columnPills(column) {
632
+ const pills = [];
633
+ if (column.primaryKey) pills.push("primary");
634
+ if (column.required) pills.push("required");
635
+ if (column.array) pills.push("array");
636
+ if (column.unique) pills.push("unique");
637
+ if (column.index) pills.push("index");
638
+ if (column.relation) pills.push(`${column.relation.targetTable}.${column.relation.targetColumn}`);
639
+ if (column.enumValues?.length) pills.push(...column.enumValues);
640
+ if (column.validations) pills.push(`${column.validations} validations`);
641
+ if (column.hooks?.create) pills.push("create hook");
642
+ if (column.hooks?.update) pills.push("update hook");
643
+ return pills.length
644
+ ? `<div class="pill-wrap">${pills.map((pill) => `<span class="pill">${escapeHtml(pill)}</span>`).join("")}</div>`
645
+ : "";
646
+ }
647
+
648
+ function renderDetails() {
649
+ const table = selectedTable ? tableByName(selectedTable) : undefined;
650
+ if (!table) {
651
+ elements.details.hidden = true;
652
+ elements.details.innerHTML = "";
653
+ elements.main.classList.add("is-details-collapsed");
654
+ return;
655
+ }
656
+
657
+ elements.details.hidden = false;
658
+ elements.main.classList.remove("is-details-collapsed");
659
+ elements.details.innerHTML = `
660
+ <div class="details-inner">
661
+ <section>
662
+ <h2><span class="table-icon" aria-hidden="true"></span>${escapeHtml(table.name)}</h2>
663
+ <p>${escapeHtml(table.description || table.pluralForm)}</p>
664
+ </section>
665
+ <section class="details-section">
666
+ <h3>Outgoing Relations</h3>
667
+ ${relationRows(table, "out")}
668
+ </section>
669
+ <section class="details-section">
670
+ <h3>Incoming Relations</h3>
671
+ ${relationRows(table, "in")}
672
+ </section>
673
+ <section class="details-section">
674
+ <h3>Columns</h3>
675
+ <div class="detail-list">
676
+ ${table.columns
677
+ .map(
678
+ (column) => `
679
+ <div class="detail-row">
680
+ <div>
681
+ <strong>${escapeHtml(column.name)}</strong>
682
+ ${column.description ? `<p>${escapeHtml(column.description)}</p>` : ""}
683
+ ${columnPills(column)}
684
+ </div>
685
+ <code>${escapeHtml(column.type)}${column.array ? "[]" : ""}</code>
686
+ </div>
687
+ `,
688
+ )
689
+ .join("")}
690
+ </div>
691
+ </section>
692
+ ${
693
+ table.indexes.length
694
+ ? `<section class="details-section">
695
+ <h3>Indexes</h3>
696
+ <div class="detail-list">
697
+ ${table.indexes
698
+ .map(
699
+ (index) => `
700
+ <div class="detail-row">
701
+ <strong>${escapeHtml(index.name)}</strong>
702
+ <code>${escapeHtml(index.fields.join(", "))}${index.unique ? " unique" : ""}</code>
703
+ </div>
704
+ `,
705
+ )
706
+ .join("")}
707
+ </div>
708
+ </section>`
709
+ : ""
710
+ }
711
+ ${
712
+ table.source?.kind === "plugin"
713
+ ? `<section class="details-section">
714
+ <h3>Source</h3>
715
+ <div class="detail-row">
716
+ <strong>${escapeHtml(table.source.pluginId)}</strong>
717
+ <code>${escapeHtml(table.source.generatedTypeKind || "plugin")}</code>
718
+ </div>
719
+ </section>`
720
+ : ""
721
+ }
722
+ </div>
723
+ `;
724
+ }
725
+
726
+ function checkIcon() {
727
+ return `
728
+ <svg class="check-icon" viewBox="0 0 24 24" aria-hidden="true">
729
+ <path d="M20 6 9 17l-5-5"></path>
730
+ </svg>
731
+ `;
732
+ }
733
+
734
+ function renderShowModeControls() {
735
+ const selectedOption = showModeOption(showMode) ?? showModeOption(DEFAULT_SHOW_MODE);
736
+ elements.showMode.innerHTML = `
737
+ <span>${escapeHtml(selectedOption.label)}</span>
738
+ <span class="show-mode-caret" aria-hidden="true"></span>
739
+ `;
740
+ elements.showMode.setAttribute("aria-label", `Show mode: ${selectedOption.label}`);
741
+ elements.showModeMenu.innerHTML = SHOW_MODE_OPTIONS.map(
742
+ (option) => `
743
+ <button
744
+ type="button"
745
+ class="show-mode-option"
746
+ role="menuitemradio"
747
+ aria-checked="${option.value === showMode}"
748
+ data-show-mode="${option.value}"
749
+ >
750
+ <span>${escapeHtml(option.label)}</span>
751
+ <span class="show-mode-check">${option.value === showMode ? checkIcon() : ""}</span>
752
+ </button>
753
+ `,
754
+ ).join("");
755
+
756
+ elements.showModeMenu.querySelectorAll(".show-mode-option").forEach((button) => {
757
+ button.addEventListener("click", () => {
758
+ setShowMode(button.dataset.showMode);
759
+ });
760
+ });
761
+ }
762
+
763
+ function setShowModeMenuOpen(open) {
764
+ elements.showMode.setAttribute("aria-expanded", String(open));
765
+ elements.showModeMenu.hidden = !open;
766
+ }
767
+
768
+ function setShowMode(nextShowMode) {
769
+ if (!showModeOption(nextShowMode)) return;
770
+ setShowModeMenuOpen(false);
771
+ if (showMode === nextShowMode) return;
772
+
773
+ showMode = nextShowMode;
774
+ renderAll({ center: false });
775
+ }
776
+
777
+ function toggleTableVisibility(tableName) {
778
+ if (!tableName) return;
779
+ if (hiddenTableNames.has(tableName)) {
780
+ hiddenTableNames.delete(tableName);
781
+ } else {
782
+ hiddenTableNames.add(tableName);
783
+ }
784
+ clearSelectionIfHidden();
785
+ renderAll({ center: false });
786
+ }
787
+
788
+ function setAllTableVisibility(hidden) {
789
+ if (!schema) return;
790
+ hiddenTableNames.clear();
791
+ if (hidden) {
792
+ for (const table of schema.tables) {
793
+ hiddenTableNames.add(table.name);
794
+ }
795
+ }
796
+ clearSelectionIfHidden();
797
+ renderAll({ center: false });
798
+ }
799
+
800
+ function clearSelectionIfHidden() {
801
+ if (selectedTable && isTableHidden(selectedTable)) {
802
+ selectedTable = undefined;
803
+ }
804
+ }
805
+
806
+ function toggleAllTableVisibility() {
807
+ if (!schema) return;
808
+ setAllTableVisibility(visibleTables().length > 0);
809
+ }
810
+
811
+ function cancelViewportAnimation() {
812
+ if (!activeViewportAnimation) return;
813
+ cancelAnimationFrame(activeViewportAnimation.frame);
814
+ activeViewportAnimation = undefined;
815
+ }
816
+
817
+ function easeOutCubic(value) {
818
+ return 1 - (1 - value) ** 3;
819
+ }
820
+
821
+ function animateViewportTo(targetViewport) {
822
+ cancelViewportAnimation();
823
+ const startViewport = { ...viewport };
824
+ const duration = 360;
825
+ let startedAt;
826
+
827
+ activeViewportAnimation = { frame: undefined };
828
+ const step = () => {
829
+ const now = Date.now();
830
+ startedAt ??= now;
831
+ const progress = clamp((now - startedAt) / duration, 0, 1);
832
+ const eased = easeOutCubic(progress);
833
+ viewport = {
834
+ x: startViewport.x + (targetViewport.x - startViewport.x) * eased,
835
+ y: startViewport.y + (targetViewport.y - startViewport.y) * eased,
836
+ z: startViewport.z + (targetViewport.z - startViewport.z) * eased,
837
+ };
838
+ applyTransform();
839
+
840
+ if (progress < 1) {
841
+ activeViewportAnimation.frame = requestAnimationFrame(step);
842
+ return;
843
+ }
844
+
845
+ viewport = targetViewport;
846
+ activeViewportAnimation = undefined;
847
+ applyTransform();
848
+ };
849
+
850
+ activeViewportAnimation.frame = requestAnimationFrame(step);
851
+ }
852
+
853
+ function applyTransform() {
854
+ elements.world.style.transform = `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.z})`;
855
+ elements.canvas.style.backgroundPosition = `${viewport.x}px ${viewport.y}px`;
856
+ elements.canvas.style.backgroundSize = `${Math.max(4, 22 * viewport.z)}px ${Math.max(4, 22 * viewport.z)}px`;
857
+ elements.zoomLabel.textContent = `${Math.round(viewport.z * 100)}%`;
858
+ writeHashState();
859
+ }
860
+
861
+ function centeredViewportForTable(tableName) {
862
+ const node = layout?.nodes.get(tableName);
863
+ if (!node || isTableHidden(tableName)) return;
864
+
865
+ const rect = elements.canvas.getBoundingClientRect();
866
+ return {
867
+ ...viewport,
868
+ x: Math.round(rect.width / 2 - (node.x + node.width / 2) * viewport.z),
869
+ y: Math.round(rect.height / 2 - (node.y + node.height / 2) * viewport.z),
870
+ };
871
+ }
872
+
873
+ function fitView() {
874
+ cancelViewportAnimation();
875
+ if (!layout || visibleTables().length === 0) return;
876
+ const bounds = visibleLayoutBounds();
877
+ const rect = elements.canvas.getBoundingClientRect();
878
+ const scale = clamp(
879
+ Math.min(
880
+ (rect.width - FIT_PADDING) / Math.max(bounds.width, 1),
881
+ (rect.height - FIT_PADDING) / Math.max(bounds.height, 1),
882
+ ),
883
+ MIN_ZOOM,
884
+ 1.2,
885
+ );
886
+ viewport = {
887
+ z: scale,
888
+ x: Math.round((rect.width - bounds.width * scale) / 2 - bounds.x * scale),
889
+ y: Math.round((rect.height - bounds.height * scale) / 2 - bounds.y * scale),
890
+ };
891
+ applyTransform();
892
+ }
893
+
894
+ function centerVisibleBounds() {
895
+ if (!layout || visibleTables().length === 0) return;
896
+ const bounds = visibleLayoutBounds();
897
+ const rect = elements.canvas.getBoundingClientRect();
898
+ viewport = {
899
+ z: viewport.z,
900
+ x: Math.round((rect.width - bounds.width * viewport.z) / 2 - bounds.x * viewport.z),
901
+ y: Math.round((rect.height - bounds.height * viewport.z) / 2 - bounds.y * viewport.z),
902
+ };
903
+ applyTransform();
904
+ }
905
+
906
+ // Determine the on-load viewport from URL state: focus the selected table, fall
907
+ // back to the persisted zoom, and otherwise fit the whole diagram. Pan offsets
908
+ // are intentionally not persisted in the URL.
909
+ function applyInitialView() {
910
+ if (selectedTable && !isTableHidden(selectedTable)) {
911
+ const nextViewport = centeredViewportForTable(selectedTable);
912
+ if (nextViewport) {
913
+ cancelViewportAnimation();
914
+ viewport = nextViewport;
915
+ applyTransform();
916
+ return;
917
+ }
918
+ }
919
+ if (hasZoomFromHash) {
920
+ centerVisibleBounds();
921
+ return;
922
+ }
923
+ fitView();
924
+ }
925
+
926
+ function renderAll(options = {}) {
927
+ layout = computeLayout(schema);
928
+ applyManualNodePositions();
929
+ elements.emptyState.textContent =
930
+ schema.tables.length === 0 ? "No TailorDB types found." : "No visible tables.";
931
+ elements.emptyState.hidden = visibleTables().length > 0;
932
+ renderHeader();
933
+ renderTableList();
934
+ renderShowModeControls();
935
+ renderEdges();
936
+ renderNodes();
937
+ renderDetails();
938
+ if (options.fit) {
939
+ fitView();
940
+ return;
941
+ }
942
+ if (options.center && selectedTable) {
943
+ const nextViewport = centeredViewportForTable(selectedTable);
944
+ if (nextViewport) {
945
+ if (options.smooth) {
946
+ animateViewportTo(nextViewport);
947
+ } else {
948
+ cancelViewportAnimation();
949
+ viewport = nextViewport;
950
+ applyTransform();
951
+ }
952
+ return;
953
+ }
954
+ }
955
+ applyTransform();
956
+ }
957
+
958
+ function selectTable(tableName, options = {}) {
959
+ if (!tableName) return;
960
+ if (selectedTable === tableName) {
961
+ selectedTable = undefined;
962
+ renderAll({ center: false });
963
+ return;
964
+ }
965
+
966
+ selectedTable = tableName;
967
+ userAdjustedViewport = true;
968
+ renderAll({ center: options.center !== false, smooth: options.smooth !== false });
969
+ }
970
+
971
+ function clearTableSelection() {
972
+ if (!selectedTable) return;
973
+ selectedTable = undefined;
974
+ renderAll({ center: false });
975
+ }
976
+
977
+ function zoomAt(nextZoom, clientX, clientY) {
978
+ cancelViewportAnimation();
979
+ userAdjustedViewport = true;
980
+ const rect = elements.canvas.getBoundingClientRect();
981
+ const worldX = (clientX - rect.left - viewport.x) / viewport.z;
982
+ const worldY = (clientY - rect.top - viewport.y) / viewport.z;
983
+ const z = clamp(nextZoom, MIN_ZOOM, MAX_ZOOM);
984
+ viewport = {
985
+ z,
986
+ x: clientX - rect.left - worldX * z,
987
+ y: clientY - rect.top - worldY * z,
988
+ };
989
+ applyTransform();
990
+ }
991
+
992
+ function panBy(deltaX, deltaY) {
993
+ cancelViewportAnimation();
994
+ userAdjustedViewport = true;
995
+ viewport = {
996
+ ...viewport,
997
+ x: viewport.x - deltaX,
998
+ y: viewport.y - deltaY,
999
+ };
1000
+ applyTransform();
1001
+ }
1002
+
1003
+ function startCanvasPan(event) {
1004
+ if (event.button !== 0 || event.target.closest("button, input, .canvas-toolbar")) return;
1005
+ cancelViewportAnimation();
1006
+
1007
+ activeCanvasPan = {
1008
+ moved: false,
1009
+ pointerId: event.pointerId,
1010
+ startClientX: event.clientX,
1011
+ startClientY: event.clientY,
1012
+ startX: viewport.x,
1013
+ startY: viewport.y,
1014
+ };
1015
+ elements.canvas.setPointerCapture(event.pointerId);
1016
+ }
1017
+
1018
+ function moveCanvasPan(event) {
1019
+ if (!activeCanvasPan || activeCanvasPan.pointerId !== event.pointerId) return;
1020
+ if ((event.buttons & 1) === 0) {
1021
+ finishCanvasPan(event);
1022
+ return;
1023
+ }
1024
+
1025
+ const deltaX = event.clientX - activeCanvasPan.startClientX;
1026
+ const deltaY = event.clientY - activeCanvasPan.startClientY;
1027
+ if (!activeCanvasPan.moved && Math.hypot(deltaX, deltaY) < DRAG_THRESHOLD) return;
1028
+
1029
+ event.preventDefault();
1030
+ activeCanvasPan.moved = true;
1031
+ userAdjustedViewport = true;
1032
+ elements.canvas.classList.add("is-panning");
1033
+ viewport = {
1034
+ ...viewport,
1035
+ x: activeCanvasPan.startX + deltaX,
1036
+ y: activeCanvasPan.startY + deltaY,
1037
+ };
1038
+ applyTransform();
1039
+ }
1040
+
1041
+ function finishCanvasPan(event) {
1042
+ if (!activeCanvasPan || activeCanvasPan.pointerId !== event.pointerId) return;
1043
+
1044
+ if (elements.canvas.hasPointerCapture(event.pointerId)) {
1045
+ elements.canvas.releasePointerCapture(event.pointerId);
1046
+ }
1047
+ if (activeCanvasPan.moved) {
1048
+ event.preventDefault();
1049
+ suppressNextCanvasClick = true;
1050
+ }
1051
+ activeCanvasPan = undefined;
1052
+ elements.canvas.classList.remove("is-panning");
1053
+ }
1054
+
1055
+ function wireInteractions() {
1056
+ elements.search.addEventListener("input", () => {
1057
+ searchText = elements.search.value.trim();
1058
+ renderAll();
1059
+ });
1060
+
1061
+ elements.zoomIn.addEventListener("click", () => {
1062
+ const rect = elements.canvas.getBoundingClientRect();
1063
+ zoomAt(viewport.z * 1.2, rect.left + rect.width / 2, rect.top + rect.height / 2);
1064
+ });
1065
+ elements.zoomOut.addEventListener("click", () => {
1066
+ const rect = elements.canvas.getBoundingClientRect();
1067
+ zoomAt(viewport.z / 1.2, rect.left + rect.width / 2, rect.top + rect.height / 2);
1068
+ });
1069
+ elements.fitView.addEventListener("click", fitView);
1070
+ elements.toggleAllTables.addEventListener("click", toggleAllTableVisibility);
1071
+ elements.showMode.addEventListener("click", () => {
1072
+ setShowModeMenuOpen(elements.showModeMenu.hidden);
1073
+ });
1074
+ elements.copyLink.addEventListener("click", async () => {
1075
+ try {
1076
+ await navigator.clipboard.writeText(location.href);
1077
+ showStatus("Link copied");
1078
+ } catch {
1079
+ showStatus("Copy failed", true);
1080
+ }
1081
+ });
1082
+
1083
+ elements.canvas.addEventListener("click", (event) => {
1084
+ if (suppressNextCanvasClick) {
1085
+ suppressNextCanvasClick = false;
1086
+ return;
1087
+ }
1088
+ if (event.target.closest("button, input, .canvas-toolbar")) return;
1089
+ clearTableSelection();
1090
+ });
1091
+ elements.canvas.addEventListener(
1092
+ "wheel",
1093
+ (event) => {
1094
+ event.preventDefault();
1095
+ if (event.ctrlKey || event.metaKey) {
1096
+ zoomAt(viewport.z * Math.exp(-event.deltaY * 0.001), event.clientX, event.clientY);
1097
+ return;
1098
+ }
1099
+
1100
+ if (event.shiftKey) {
1101
+ const deltaX = event.deltaX || event.deltaY;
1102
+ panBy(deltaX, 0);
1103
+ return;
1104
+ }
1105
+
1106
+ // Touchpads report horizontal swipes via deltaX without a modifier key.
1107
+ panBy(event.deltaX, event.deltaY);
1108
+ },
1109
+ { passive: false },
1110
+ );
1111
+ elements.canvas.addEventListener("pointerdown", startCanvasPan);
1112
+ elements.canvas.addEventListener("pointermove", moveCanvasPan);
1113
+ elements.canvas.addEventListener("pointerup", finishCanvasPan);
1114
+ elements.canvas.addEventListener("pointercancel", finishCanvasPan);
1115
+ elements.canvas.addEventListener("lostpointercapture", finishCanvasPan);
1116
+
1117
+ document.addEventListener("pointerdown", (event) => {
1118
+ if (!event.target.closest(".show-mode-control")) {
1119
+ setShowModeMenuOpen(false);
1120
+ }
1121
+ });
1122
+ window.addEventListener("keydown", (event) => {
1123
+ if (event.key === "Escape") setShowModeMenuOpen(false);
1124
+ });
1125
+ window.addEventListener("resize", () => {
1126
+ if (!userAdjustedViewport) applyInitialView();
1127
+ });
1128
+ }
1129
+
1130
+ function showStatus(message, danger = false) {
1131
+ elements.status.hidden = false;
1132
+ elements.status.textContent = message;
1133
+ elements.status.style.color = danger ? "var(--danger)" : "var(--accent)";
1134
+ clearTimeout(showStatus.timer);
1135
+ showStatus.timer = setTimeout(() => {
1136
+ elements.status.hidden = true;
1137
+ }, 3000);
1138
+ }
1139
+
1140
+ function startPolling() {
1141
+ const params = new URLSearchParams(location.search);
1142
+ if (params.get("watch") !== "1") return;
1143
+
1144
+ setInterval(async () => {
1145
+ try {
1146
+ const nextSchema = await fetchServedSchema();
1147
+ if (nextSchema.revision !== schema.revision) {
1148
+ schema = nextSchema;
1149
+ if (selectedTable && !tableByName(selectedTable)) {
1150
+ selectedTable = undefined;
1151
+ }
1152
+ clearSelectionIfHidden();
1153
+ renderAll();
1154
+ showStatus("Schema updated");
1155
+ }
1156
+ } catch (error) {
1157
+ showStatus(String(error), true);
1158
+ }
1159
+ }, 1500);
1160
+ }
1161
+
1162
+ async function main() {
1163
+ readHashState();
1164
+ wireInteractions();
1165
+ try {
1166
+ schema = embeddedSchema() ?? (await fetchServedSchema());
1167
+ if (selectedTable && !tableByName(selectedTable)) {
1168
+ selectedTable = undefined;
1169
+ }
1170
+ clearSelectionIfHidden();
1171
+ renderAll();
1172
+ applyInitialView();
1173
+ startPolling();
1174
+ } catch (error) {
1175
+ elements.namespace.textContent = "TailorDB ERD";
1176
+ elements.revision.textContent = "Schema load failed";
1177
+ showStatus(String(error), true);
1178
+ }
1179
+ }
1180
+
1181
+ main();