sql-dev-inspector 0.0.0-stage → 1.0.1

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.
@@ -0,0 +1,485 @@
1
+ /**
2
+ * Floating launcher for the SQL Inspector page, mounted in the host application during dev.
3
+ *
4
+ * It lives inside a shadow root and resets everything it uses, so it neither looks like nor
5
+ * is affected by the host's CSS (no inherited fonts, no styled `button` / `select` / `a`
6
+ * leaking in or out). No framework and no Nuxt imports: it must work in any frontend.
7
+ */
8
+ import { LOCALES, pickLocale, translate } from "../i18n/index.js";
9
+ export const STORAGE_KEY = "sqlDevInspector.widget";
10
+ export const HOTKEYS = { togglePanel: "Alt+Shift+S", toggleButton: "Alt+Shift+D" };
11
+ const DEFAULTS = {
12
+ enabled: true,
13
+ buttonVisible: true,
14
+ open: false,
15
+ dock: "bottom",
16
+ size: 420,
17
+ autoOpen: false,
18
+ offset: { right: 20, bottom: 20 },
19
+ lang: "auto"
20
+ };
21
+ const TRAILING_SLASHES = /\/+$/;
22
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
23
+ function loadSettings(win) {
24
+ try {
25
+ const raw = JSON.parse(win.localStorage.getItem(STORAGE_KEY) ?? "{}");
26
+ return {
27
+ enabled: raw.enabled !== false,
28
+ buttonVisible: raw.buttonVisible !== false,
29
+ open: raw.open === true,
30
+ dock: raw.dock === "right" ? "right" : "bottom",
31
+ size: Number.isFinite(raw.size) ? clamp(raw.size, 160, 4000) : DEFAULTS.size,
32
+ autoOpen: raw.autoOpen === true,
33
+ offset: {
34
+ right: Number.isFinite(raw.offset?.right) ? raw.offset.right : DEFAULTS.offset.right,
35
+ bottom: Number.isFinite(raw.offset?.bottom) ? raw.offset.bottom : DEFAULTS.offset.bottom
36
+ },
37
+ lang: LOCALES.some(locale => locale.code === raw.lang) ? raw.lang : "auto"
38
+ };
39
+ }
40
+ catch {
41
+ return { ...DEFAULTS, offset: { ...DEFAULTS.offset } };
42
+ }
43
+ }
44
+ const TOKENS_LIGHT = `--surface:#fff;--surface-2:#f1f4f8;--border:#dfe5ec;--text:#121821;--text-2:#4a5666;--text-3:#6e7a8a;--accent:#1f6feb;--err:#cf222e;--bar:#101820;--bar-text:#e9eef4;--shadow:0 10px 30px rgba(15,23,42,.28);`;
45
+ const TOKENS_DARK = `--surface:#151b23;--surface-2:#1d2631;--border:#2b3544;--text:#e6edf3;--text-2:#aab5c3;--text-3:#7d8896;--accent:#5aa2ff;--err:#ff6b6b;--bar:#0b1016;--bar-text:#e6edf3;--shadow:0 10px 30px rgba(0,0,0,.6);`;
46
+ const STYLE = `
47
+ :host { all: initial; position: fixed; top: 0; left: 0; width: 0; height: 0; display: block; z-index: 2147483000; }
48
+ .root { all: initial; display: block; ${TOKENS_LIGHT} font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Hiragino Sans", "Yu Gothic UI", sans-serif; color: var(--text); -webkit-font-smoothing: antialiased; pointer-events: none; }
49
+ @media (prefers-color-scheme: dark) { .root { ${TOKENS_DARK} } }
50
+ :where(.root) *, :where(.root) *::before, :where(.root) *::after { box-sizing: border-box; margin: 0; letter-spacing: normal; text-transform: none; }
51
+ /* :where() keeps the reset at zero specificity, so the component rules below can restyle these controls. */
52
+ :where(.root) :where(button, select) { all: unset; box-sizing: border-box; cursor: pointer; font: inherit; color: inherit; }
53
+ .root button:focus-visible, .root select:focus-visible, .root [role="switch"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
54
+ .root svg { width: 16px; height: 16px; flex: none; stroke: currentColor; fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
55
+ .root [hidden] { display: none !important; }
56
+
57
+ .fab { position: fixed; display: inline-flex; align-items: center; gap: 8px; height: 38px; padding: 0 14px 0 12px; border-radius: 999px; background: var(--bar); color: var(--bar-text); border: 1px solid rgba(255,255,255,.12); box-shadow: var(--shadow); font-weight: 650; font-size: 12.5px; user-select: none; touch-action: none; pointer-events: auto; transition: transform .12s ease, box-shadow .12s ease; }
58
+ .fab:hover { transform: translateY(-1px); }
59
+ .fab:active { transform: translateY(0); }
60
+ .fab svg { color: #7fb3ff; }
61
+ .badge { min-width: 20px; height: 20px; padding: 0 6px; border-radius: 10px; background: rgba(255,255,255,.16); font-size: 11px; font-weight: 700; text-align: center; line-height: 20px; font-variant-numeric: tabular-nums; }
62
+ .badge:empty { display: none; }
63
+ .badge.warn { background: #d99a06; color: #fff; } .badge.err { background: #e5484d; color: #fff; }
64
+
65
+ .panel { position: fixed; display: none; flex-direction: column; background: var(--surface); box-shadow: var(--shadow); pointer-events: auto; overflow: hidden; }
66
+ .panel.open { display: flex; }
67
+ .panel.bottom { left: 0; right: 0; bottom: 0; border-top: 1px solid var(--border); border-radius: 12px 12px 0 0; }
68
+ .panel.right { top: 0; bottom: 0; right: 0; border-left: 1px solid var(--border); border-radius: 12px 0 0 12px; }
69
+ .grip { position: absolute; z-index: 2; }
70
+ .bottom .grip { left: 0; right: 0; top: 0; height: 8px; cursor: ns-resize; }
71
+ .right .grip { top: 0; bottom: 0; left: 0; width: 8px; cursor: ew-resize; }
72
+ .grip:hover { background: color-mix(in srgb, var(--accent) 40%, transparent); }
73
+ .bar { display: flex; align-items: center; gap: 4px; height: 40px; padding: 0 8px 0 12px; background: var(--bar); color: var(--bar-text); flex: none; }
74
+ .bar .title { font-weight: 650; font-size: 13px; margin-left: 2px; }
75
+ .bar .logo { color: #7fb3ff; display: inline-flex; }
76
+ .bar .spacer { flex: 1; }
77
+ .tool { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 6px; color: #b9c4d0; }
78
+ .tool:hover { background: rgba(255,255,255,.12); color: #fff; }
79
+ .tool[aria-pressed="true"] { background: rgba(255,255,255,.18); color: #fff; }
80
+ .divider { width: 1px; height: 18px; margin: 0 4px; background: rgba(255,255,255,.18); }
81
+ iframe { flex: 1; width: 100%; border: 0; background: var(--surface); color-scheme: normal; }
82
+ .resizing iframe { pointer-events: none; }
83
+
84
+ .menu { position: absolute; z-index: 3; top: 46px; right: 8px; width: 320px; max-width: calc(100% - 16px); max-height: calc(100% - 56px); overflow: auto; padding: 6px; border-radius: 10px; background: var(--surface); border: 1px solid var(--border); box-shadow: var(--shadow); }
85
+ .menu .row { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 36px; padding: 4px 8px; border-radius: 6px; color: var(--text); }
86
+ .menu .row.clickable { cursor: pointer; }
87
+ .menu .row.clickable:hover { background: var(--surface-2); }
88
+ .menu .label { font-size: 12.5px; }
89
+ .menu .section { padding: 8px 8px 4px; color: var(--text-3); font-size: 11px; font-weight: 650; text-transform: uppercase !important; letter-spacing: .06em !important; }
90
+ .switch { position: relative; width: 30px; height: 18px; border-radius: 999px; background: var(--border); flex: none; transition: background .15s; }
91
+ .switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.35); transition: transform .15s; }
92
+ .row[aria-checked="true"] .switch { background: var(--accent); }
93
+ .row[aria-checked="true"] .switch::after { transform: translateX(12px); }
94
+ .value { display: inline-flex; align-items: center; gap: 6px; color: var(--text-2); font-size: 12.5px; font-weight: 600; }
95
+ .value svg { width: 12px; height: 12px; color: var(--text-3); transition: transform .15s; }
96
+ .row[aria-expanded="true"] .value svg { transform: rotate(180deg); }
97
+ .options { padding: 2px 0 4px; }
98
+ .opt { display: flex; align-items: center; gap: 8px; min-height: 32px; margin: 0 4px; padding: 0 8px 0 10px; border-radius: 6px; font-size: 12.5px; cursor: pointer; }
99
+ .opt:hover, .opt:focus-visible { background: var(--surface-2); outline: none; }
100
+ .opt .check { width: 14px; height: 14px; color: var(--accent); visibility: hidden; }
101
+ .opt[aria-selected="true"] { font-weight: 650; }
102
+ .opt[aria-selected="true"] .check { visibility: visible; }
103
+ .opt .code { margin-left: auto; color: var(--text-3); font: 600 10.5px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; text-transform: uppercase !important; }
104
+ .keys { display: flex; align-items: center; gap: 3px; }
105
+ kbd { font: 600 10.5px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; padding: 3px 5px; border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 4px; background: var(--surface-2); color: var(--text-2); }
106
+ .menu .sep { height: 1px; margin: 6px 0; background: var(--border); }
107
+ .menu .action { display: flex; align-items: center; gap: 8px; width: 100%; height: 34px; padding: 0 8px; border-radius: 6px; font-size: 12.5px; }
108
+ .menu .action:hover { background: var(--surface-2); }
109
+ .menu .action.danger { color: var(--err); }
110
+ .menu .hint { padding: 0 8px 6px; color: var(--text-3); font-size: 11.5px; }
111
+ @media (prefers-reduced-motion: reduce) { .root * { transition: none !important; } }
112
+ `;
113
+ const ICON = (paths) => `<svg viewBox="0 0 24 24" aria-hidden="true">${paths}</svg>`;
114
+ const ICONS = {
115
+ db: ICON(`<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"/>`),
116
+ gear: ICON(`<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.9.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.9 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.9-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.9V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/>`),
117
+ external: ICON(`<path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5"/>`),
118
+ close: ICON(`<path d="m6 6 12 12M18 6 6 18"/>`),
119
+ dockBottom: ICON(`<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 14h18"/>`),
120
+ dockRight: ICON(`<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M14 4v16"/>`),
121
+ trash: ICON(`<path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/>`),
122
+ power: ICON(`<path d="M12 3v9M6.3 6.8a8 8 0 1 0 11.4 0"/>`),
123
+ down: ICON(`<path d="m6 9 6 6 6-6"/>`),
124
+ check: ICON(`<path d="m5 12.5 4.5 4.5L19 7.5"/>`)
125
+ };
126
+ const keyChips = (combo) => combo.split("+").map(key => `<kbd>${key}</kbd>`).join("");
127
+ export function mountSqlInspectorWidget(options) {
128
+ const win = options.win ?? globalThis;
129
+ const doc = win.document;
130
+ const inspectorUrl = `${options.baseURL.replace(TRAILING_SLASHES, "")}/__sql-inspector/`;
131
+ const settings = loadSettings(win);
132
+ const save = () => {
133
+ try {
134
+ win.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
135
+ }
136
+ catch {
137
+ // storage can be blocked; settings then last for this page only
138
+ }
139
+ };
140
+ const locale = () => settings.lang === "auto"
141
+ ? pickLocale(win.navigator?.languages?.length ? win.navigator.languages : [win.navigator?.language])
142
+ : settings.lang;
143
+ const t = (key, params) => translate(locale(), key, params);
144
+ // A custom-element name, not a <div>: host stylesheets that style `div` / `body > *` cannot match it by tag.
145
+ const host = doc.createElement("sql-dev-inspector");
146
+ host.setAttribute("data-sql-inspector", "");
147
+ const root = host.attachShadow({ mode: "open" });
148
+ root.innerHTML = `
149
+ <style>${STYLE}</style>
150
+ <div class="root">
151
+ <button class="fab" type="button" data-act="toggle">${ICONS.db}<span>SQL</span><span class="badge"></span></button>
152
+ <section class="panel" role="dialog">
153
+ <div class="grip" role="separator"></div>
154
+ <header class="bar">
155
+ <span class="logo">${ICONS.db}</span><b class="title"></b><span class="spacer"></span>
156
+ <button class="tool" type="button" data-act="dock-bottom">${ICONS.dockBottom}</button>
157
+ <button class="tool" type="button" data-act="dock-right">${ICONS.dockRight}</button>
158
+ <span class="divider"></span>
159
+ <button class="tool" type="button" data-act="menu" aria-haspopup="true" aria-expanded="false">${ICONS.gear}</button>
160
+ <button class="tool" type="button" data-act="tab">${ICONS.external}</button>
161
+ <button class="tool" type="button" data-act="close">${ICONS.close}</button>
162
+ </header>
163
+ <div class="menu" hidden>
164
+ <div class="row clickable" role="switch" tabindex="0" data-opt="autoOpen"><span class="label" data-t="widget.autoOpen"></span><span class="switch"></span></div>
165
+ <div class="row clickable" role="switch" tabindex="0" data-opt="buttonVisible"><span class="label" data-t="widget.showButton"></span><span class="switch"></span></div>
166
+ <div class="row clickable" role="button" tabindex="0" aria-haspopup="listbox" aria-expanded="false" data-lang-toggle><span class="label" data-t="widget.language"></span><span class="value"><span data-lang-current></span>${ICONS.down}</span></div>
167
+ <div class="options" role="listbox" data-lang-options hidden></div>
168
+ <div class="sep"></div>
169
+ <div class="section" data-t="widget.shortcuts"></div>
170
+ <div class="row"><span class="label" data-t="widget.shortcut.toggle"></span><span class="keys">${keyChips(HOTKEYS.togglePanel)}</span></div>
171
+ <div class="row"><span class="label" data-t="widget.shortcut.button"></span><span class="keys">${keyChips(HOTKEYS.toggleButton)}</span></div>
172
+ <div class="row"><span class="label" data-t="widget.shortcut.close"></span><span class="keys"><kbd>Esc</kbd></span></div>
173
+ <div class="sep"></div>
174
+ <button class="action" type="button" data-act="clear">${ICONS.trash}<span data-t="widget.clear"></span></button>
175
+ <button class="action danger" type="button" data-act="disable">${ICONS.power}<span data-t="widget.disable"></span></button>
176
+ <div class="hint" data-disable-hint></div>
177
+ </div>
178
+ <iframe></iframe>
179
+ </section>
180
+ </div>`;
181
+ const $ = (selector) => root.querySelector(selector);
182
+ const button = $(".fab");
183
+ const badge = $(".badge");
184
+ const panel = $(".panel");
185
+ const menu = $(".menu");
186
+ const grip = $(".grip");
187
+ const frame = $("iframe");
188
+ const langToggle = $("[data-lang-toggle]");
189
+ const langOptions = $("[data-lang-options]");
190
+ // A custom listbox, not a <select>: the browser/OS draws a native select's popup, which no CSS can restyle.
191
+ const langChoices = [{ value: "auto", code: "" }, ...LOCALES.map(l => ({ value: l.code, code: l.code }))];
192
+ langOptions.innerHTML = langChoices.map(choice => `<div class="opt" role="option" tabindex="-1" data-lang-opt="${choice.value}">${ICONS.check}<span data-lang-name></span><span class="code">${choice.code}</span></div>`).join("");
193
+ const langName = (value) => value === "auto" ? t("widget.lang.auto") : LOCALES.find(l => l.code === value).name;
194
+ const query = () => settings.lang === "auto" ? "" : `?lang=${settings.lang}`;
195
+ const frameUrl = () => `${inspectorUrl}${query()}#latest`;
196
+ const applyTexts = () => {
197
+ for (const el of Array.from(root.querySelectorAll("[data-t]"))) {
198
+ el.textContent = t(el.dataset.t);
199
+ }
200
+ const openLabel = t("widget.open", { keys: HOTKEYS.togglePanel });
201
+ button.setAttribute("title", openLabel);
202
+ button.setAttribute("aria-label", openLabel);
203
+ $(".title").textContent = t("widget.title");
204
+ panel.setAttribute("aria-label", t("widget.title"));
205
+ const titles = {
206
+ "dock-bottom": t("widget.dock.bottom"),
207
+ "dock-right": t("widget.dock.right"),
208
+ "menu": t("widget.settings"),
209
+ "tab": t("widget.newTab"),
210
+ "close": t("widget.close", { key: "Esc" })
211
+ };
212
+ for (const [act, title] of Object.entries(titles)) {
213
+ const el = $(`[data-act="${act}"]`);
214
+ el.setAttribute("title", title);
215
+ el.setAttribute("aria-label", title);
216
+ }
217
+ grip.setAttribute("aria-label", t("widget.resize"));
218
+ $("[data-disable-hint]").textContent = t("widget.disable.hint", { keys: HOTKEYS.togglePanel });
219
+ for (const opt of Array.from(langOptions.querySelectorAll("[data-lang-opt]"))) {
220
+ const value = opt.dataset.langOpt;
221
+ opt.querySelector("[data-lang-name]").textContent = langName(value);
222
+ opt.setAttribute("aria-selected", String(value === settings.lang));
223
+ opt.querySelector("svg").setAttribute("class", "check");
224
+ }
225
+ $("[data-lang-current]").textContent = langName(settings.lang);
226
+ };
227
+ const setLangList = (open) => {
228
+ langOptions.hidden = !open;
229
+ langToggle.setAttribute("aria-expanded", String(open));
230
+ };
231
+ const chooseLang = (value) => {
232
+ settings.lang = value;
233
+ applyTexts();
234
+ setLangList(false);
235
+ if (frame.getAttribute("src")) {
236
+ frame.setAttribute("src", frameUrl());
237
+ }
238
+ render();
239
+ langToggle.focus();
240
+ };
241
+ let stream = null;
242
+ let lastAnnounced = "";
243
+ let timer = null;
244
+ const render = () => {
245
+ host.style.display = settings.enabled ? "" : "none";
246
+ button.style.display = settings.buttonVisible ? "" : "none";
247
+ button.style.right = `${settings.offset.right}px`;
248
+ button.style.bottom = `${settings.offset.bottom}px`;
249
+ panel.classList.toggle("open", settings.open);
250
+ panel.classList.toggle("bottom", settings.dock === "bottom");
251
+ panel.classList.toggle("right", settings.dock === "right");
252
+ $(`[data-act="dock-bottom"]`).setAttribute("aria-pressed", String(settings.dock === "bottom"));
253
+ $(`[data-act="dock-right"]`).setAttribute("aria-pressed", String(settings.dock === "right"));
254
+ const max = (settings.dock === "bottom" ? win.innerHeight : win.innerWidth) * 0.9;
255
+ const size = `${clamp(settings.size, 160, Math.max(160, max))}px`;
256
+ panel.style.height = settings.dock === "bottom" ? size : "";
257
+ panel.style.width = settings.dock === "right" ? size : "";
258
+ for (const row of Array.from(root.querySelectorAll("[data-opt]"))) {
259
+ row.setAttribute("aria-checked", String(Boolean(settings[row.dataset.opt])));
260
+ }
261
+ // Loaded on first open only: a closed panel costs the app nothing.
262
+ if (settings.open && !frame.getAttribute("src")) {
263
+ frame.setAttribute("src", frameUrl());
264
+ frame.addEventListener("load", () => frame.contentWindow?.addEventListener("keydown", onKeydown), { once: true });
265
+ }
266
+ save();
267
+ if (settings.enabled) {
268
+ ensureConnected();
269
+ }
270
+ };
271
+ const setMenu = (open) => {
272
+ menu.hidden = !open;
273
+ setLangList(false);
274
+ const gear = $(`[data-act="menu"]`);
275
+ gear.setAttribute("aria-expanded", String(open));
276
+ gear.setAttribute("aria-pressed", String(open));
277
+ };
278
+ const setOpen = (open) => {
279
+ settings.open = open;
280
+ if (open) {
281
+ settings.enabled = true;
282
+ }
283
+ else {
284
+ setMenu(false);
285
+ }
286
+ render();
287
+ };
288
+ function onKeydown(event) {
289
+ if (event.altKey && event.shiftKey && event.code === "KeyS") {
290
+ event.preventDefault();
291
+ setOpen(!settings.open || !settings.enabled);
292
+ }
293
+ else if (event.altKey && event.shiftKey && event.code === "KeyD") {
294
+ event.preventDefault();
295
+ settings.buttonVisible = !settings.buttonVisible;
296
+ render();
297
+ }
298
+ else if (event.key === "Escape" && settings.open && event.composedPath().some(node => node === host || node === frame.contentWindow)) {
299
+ if (menu.hidden) {
300
+ setOpen(false);
301
+ }
302
+ else {
303
+ setMenu(false);
304
+ }
305
+ }
306
+ }
307
+ win.addEventListener("keydown", onKeydown);
308
+ // Key events from inside the shadow root are composed, so the window listener already sees them.
309
+ // Drag the button; a move under 4px is a plain click. A real `click` event does the toggling (so
310
+ // keyboard activation, assistive tech and programmatic clicks all work); a drag swallows its own click.
311
+ let suppressClick = false;
312
+ button.addEventListener("pointerdown", (down) => {
313
+ const start = { x: down.clientX, y: down.clientY, ...settings.offset };
314
+ let moved = false;
315
+ const move = (event) => {
316
+ const dx = event.clientX - start.x;
317
+ const dy = event.clientY - start.y;
318
+ moved ||= Math.hypot(dx, dy) > 4;
319
+ if (moved) {
320
+ settings.offset = {
321
+ right: clamp(start.right - dx, 4, win.innerWidth - 60),
322
+ bottom: clamp(start.bottom - dy, 4, win.innerHeight - 40)
323
+ };
324
+ button.style.right = `${settings.offset.right}px`;
325
+ button.style.bottom = `${settings.offset.bottom}px`;
326
+ }
327
+ };
328
+ const up = () => {
329
+ win.removeEventListener("pointermove", move);
330
+ win.removeEventListener("pointerup", up);
331
+ if (moved) {
332
+ suppressClick = true;
333
+ setTimeout(() => {
334
+ suppressClick = false;
335
+ }, 0);
336
+ save();
337
+ }
338
+ };
339
+ win.addEventListener("pointermove", move);
340
+ win.addEventListener("pointerup", up);
341
+ });
342
+ button.addEventListener("click", () => {
343
+ if (!suppressClick) {
344
+ setOpen(!settings.open);
345
+ }
346
+ });
347
+ grip.addEventListener("pointerdown", (down) => {
348
+ const start = { x: down.clientX, y: down.clientY, size: settings.size };
349
+ panel.classList.add("resizing");
350
+ const move = (event) => {
351
+ settings.size = settings.dock === "bottom" ? start.size + (start.y - event.clientY) : start.size + (start.x - event.clientX);
352
+ render();
353
+ };
354
+ const up = () => {
355
+ panel.classList.remove("resizing");
356
+ win.removeEventListener("pointermove", move);
357
+ win.removeEventListener("pointerup", up);
358
+ };
359
+ win.addEventListener("pointermove", move);
360
+ win.addEventListener("pointerup", up);
361
+ });
362
+ const toggleOption = (row) => {
363
+ const name = row.dataset.opt;
364
+ settings[name] = !settings[name];
365
+ render();
366
+ };
367
+ root.addEventListener("click", (event) => {
368
+ const target = event.target;
369
+ if (target.closest("[data-lang-toggle]")) {
370
+ setLangList(langOptions.hidden);
371
+ if (!langOptions.hidden) {
372
+ langOptions.querySelector(`[aria-selected="true"]`)?.focus();
373
+ }
374
+ return;
375
+ }
376
+ const row = target.closest("[data-opt]");
377
+ if (row) {
378
+ toggleOption(row);
379
+ return;
380
+ }
381
+ const action = target.closest("[data-act]")?.dataset.act;
382
+ if (action === "close") {
383
+ setOpen(false);
384
+ }
385
+ else if (action === "menu") {
386
+ setMenu(menu.hidden);
387
+ }
388
+ else if (action === "dock-bottom" || action === "dock-right") {
389
+ settings.dock = action === "dock-bottom" ? "bottom" : "right";
390
+ render();
391
+ }
392
+ else if (action === "tab") {
393
+ win.open(frameUrl(), "_blank", "noopener");
394
+ }
395
+ else if (action === "clear") {
396
+ void win.fetch(`${inspectorUrl}api/clear`, { method: "POST" });
397
+ }
398
+ else if (action === "disable") {
399
+ settings.enabled = false;
400
+ settings.open = false;
401
+ stream?.close();
402
+ stream = null;
403
+ render();
404
+ }
405
+ });
406
+ root.addEventListener("keydown", (event) => {
407
+ const e = event;
408
+ const row = e.target.closest("[data-opt]");
409
+ if (row && (e.key === "Enter" || e.key === " ")) {
410
+ e.preventDefault();
411
+ toggleOption(row);
412
+ }
413
+ else if (e.target.closest("[data-lang-toggle]") && (e.key === "Enter" || e.key === " ")) {
414
+ e.preventDefault();
415
+ e.target.click();
416
+ }
417
+ });
418
+ langOptions.addEventListener("click", (event) => {
419
+ const opt = event.target.closest("[data-lang-opt]");
420
+ if (opt) {
421
+ chooseLang(opt.dataset.langOpt);
422
+ }
423
+ });
424
+ langOptions.addEventListener("keydown", (event) => {
425
+ const options = Array.from(langOptions.querySelectorAll("[data-lang-opt]"));
426
+ const index = options.indexOf(event.target);
427
+ if (event.key === "ArrowDown") {
428
+ event.preventDefault();
429
+ options[Math.min(options.length - 1, index + 1)]?.focus();
430
+ }
431
+ else if (event.key === "ArrowUp") {
432
+ event.preventDefault();
433
+ options[Math.max(0, index - 1)]?.focus();
434
+ }
435
+ else if (event.key === "Enter" || event.key === " ") {
436
+ event.preventDefault();
437
+ chooseLang(event.target.dataset.langOpt);
438
+ }
439
+ else if (event.key === "Escape") {
440
+ event.stopPropagation();
441
+ setLangList(false);
442
+ langToggle.focus();
443
+ }
444
+ });
445
+ // Badge: query count of the newest request, amber / red when it has warnings / errors.
446
+ const refreshBadge = async () => {
447
+ try {
448
+ const list = await (await win.fetch(`${inspectorUrl}api/requests`)).json();
449
+ const latest = list[0];
450
+ badge.textContent = latest ? String(latest.queryCount) : "";
451
+ badge.className = `badge${latest?.errors ? " err" : latest?.warnings ? " warn" : ""}`;
452
+ if (latest && (latest.errors || latest.warnings) && latest.id !== lastAnnounced && settings.autoOpen && !settings.open) {
453
+ setOpen(true);
454
+ }
455
+ lastAnnounced = latest && (latest.errors || latest.warnings) ? latest.id : lastAnnounced;
456
+ }
457
+ catch {
458
+ // inspector not reachable (server restarting): keep the last badge
459
+ }
460
+ };
461
+ function ensureConnected() {
462
+ if (stream || !settings.enabled || !win.EventSource) {
463
+ return;
464
+ }
465
+ void refreshBadge();
466
+ stream = new win.EventSource(`${inspectorUrl}api/stream`);
467
+ stream.onmessage = () => {
468
+ timer ??= setTimeout(() => {
469
+ timer = null;
470
+ void refreshBadge();
471
+ }, 150);
472
+ };
473
+ }
474
+ doc.body.append(host);
475
+ applyTexts();
476
+ render();
477
+ return {
478
+ settings: () => ({ ...settings }),
479
+ destroy: () => {
480
+ win.removeEventListener("keydown", onKeydown);
481
+ stream?.close();
482
+ host.remove();
483
+ }
484
+ };
485
+ }
@@ -0,0 +1,26 @@
1
+ import { type Locale, type MessageKey, MESSAGES } from "./messages.js";
2
+ export type { Locale, MessageKey };
3
+ export { MESSAGES };
4
+ export interface LocaleInfo {
5
+ code: Locale;
6
+ /** Name of the language in that language, for the language picker. */
7
+ name: string;
8
+ }
9
+ export declare const LOCALES: LocaleInfo[];
10
+ export declare const DEFAULT_LOCALE: Locale;
11
+ /**
12
+ * Maps a BCP 47 tag ("vi-VN"), a POSIX locale ("ja_JP.UTF-8") or a bare code to a supported
13
+ * locale, or null when it names none.
14
+ */
15
+ export declare function matchLocale(input: string | null | undefined): Locale | null;
16
+ /** First supported locale in a preference list (e.g. `navigator.languages`), else English. */
17
+ export declare function pickLocale(preferences: readonly (string | null | undefined)[]): Locale;
18
+ /** `{name}` placeholders are replaced; an unknown placeholder is left visible rather than dropped. */
19
+ export declare function translate(locale: Locale, key: MessageKey, params?: Record<string, string | number>): string;
20
+ export declare function isMessageKey(key: string): key is MessageKey;
21
+ /**
22
+ * Locale for text printed by the server (terminal, API `message` fields). Only an explicit
23
+ * `SQL_INSPECTOR_LANG` counts: a machine's `LANG` says nothing about what the developer
24
+ * wants a dev tool to speak. Returns null when unset, so callers can tell "chosen" from "default".
25
+ */
26
+ export declare function chosenLocale(env: Record<string, string | undefined>): Locale | null;
@@ -0,0 +1,48 @@
1
+ import { MESSAGES } from "./messages.js";
2
+ export { MESSAGES };
3
+ export const LOCALES = [
4
+ { code: "en", name: "English" },
5
+ { code: "vi", name: "Tiếng Việt" },
6
+ { code: "ja", name: "日本語" }
7
+ ];
8
+ export const DEFAULT_LOCALE = "en";
9
+ const LOCALE_TAG = /^([a-z]{2,3})(?:[_\-.]|$)/i;
10
+ /**
11
+ * Maps a BCP 47 tag ("vi-VN"), a POSIX locale ("ja_JP.UTF-8") or a bare code to a supported
12
+ * locale, or null when it names none.
13
+ */
14
+ export function matchLocale(input) {
15
+ const code = LOCALE_TAG.exec(input?.trim() ?? "")?.[1]?.toLowerCase();
16
+ return LOCALES.find(locale => locale.code === code)?.code ?? null;
17
+ }
18
+ /** First supported locale in a preference list (e.g. `navigator.languages`), else English. */
19
+ export function pickLocale(preferences) {
20
+ for (const preference of preferences) {
21
+ const match = matchLocale(preference);
22
+ if (match) {
23
+ return match;
24
+ }
25
+ }
26
+ return DEFAULT_LOCALE;
27
+ }
28
+ const PLACEHOLDER = /\{(\w+)\}/g;
29
+ /** `{name}` placeholders are replaced; an unknown placeholder is left visible rather than dropped. */
30
+ export function translate(locale, key, params = {}) {
31
+ const messages = MESSAGES[locale];
32
+ const english = MESSAGES.en;
33
+ // A `<key>.one` variant, when it exists, is used for a count of exactly 1 ("1 query", not "1 queries").
34
+ const one = String(params.count) === "1" ? (messages[`${key}.one`] ?? english[`${key}.one`]) : undefined;
35
+ const template = one ?? messages[key] ?? english[key];
36
+ return template.replace(PLACEHOLDER, (whole, name) => name in params ? String(params[name]) : whole);
37
+ }
38
+ export function isMessageKey(key) {
39
+ return key in MESSAGES.en;
40
+ }
41
+ /**
42
+ * Locale for text printed by the server (terminal, API `message` fields). Only an explicit
43
+ * `SQL_INSPECTOR_LANG` counts: a machine's `LANG` says nothing about what the developer
44
+ * wants a dev tool to speak. Returns null when unset, so callers can tell "chosen" from "default".
45
+ */
46
+ export function chosenLocale(env) {
47
+ return matchLocale(env.SQL_INSPECTOR_LANG);
48
+ }