jskelet 0.1.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.
Files changed (72) hide show
  1. package/AGENTS.md +127 -0
  2. package/CHANGELOG.md +40 -0
  3. package/LICENSE +21 -0
  4. package/README.md +342 -0
  5. package/bin/jskelet.mjs +104 -0
  6. package/docs/01-baslangic.md +285 -0
  7. package/docs/02-mimari.md +287 -0
  8. package/docs/03-routing.md +437 -0
  9. package/docs/04-render-ve-sablonlar.md +490 -0
  10. package/docs/05-islands.md +429 -0
  11. package/docs/06-cache.md +409 -0
  12. package/docs/07-yapilandirma.md +673 -0
  13. package/docs/08-build.md +366 -0
  14. package/docs/09-dev-araclari.md +302 -0
  15. package/docs/10-dagitim.md +329 -0
  16. package/docs/11-tasima.md +352 -0
  17. package/docs/README.md +82 -0
  18. package/package.json +97 -0
  19. package/src/build/build.mjs +138 -0
  20. package/src/build/ensure-build.mjs +15 -0
  21. package/src/build/paths.mjs +118 -0
  22. package/src/build/resolve-peer.mjs +36 -0
  23. package/src/build/tasks/client.mjs +268 -0
  24. package/src/build/tasks/css.mjs +124 -0
  25. package/src/build/tasks/fonts.mjs +146 -0
  26. package/src/build/tasks/icons.mjs +224 -0
  27. package/src/build/tasks/images.mjs +244 -0
  28. package/src/build/tasks/precompress.mjs +78 -0
  29. package/src/client/devtools/overlay.js +1763 -0
  30. package/src/client/devtools/report.html +185 -0
  31. package/src/client/devtools/report.js +712 -0
  32. package/src/client/dom.js +95 -0
  33. package/src/client/index.js +26 -0
  34. package/src/client/registry.js +223 -0
  35. package/src/client/safe-image.js +91 -0
  36. package/src/client/store.js +36 -0
  37. package/src/config/defaults.js +102 -0
  38. package/src/config/index.js +433 -0
  39. package/src/config/pattern.js +107 -0
  40. package/src/dev-server.mjs +383 -0
  41. package/src/http/control-flow.js +56 -0
  42. package/src/http/request-cache.js +46 -0
  43. package/src/index.js +35 -0
  44. package/src/init.mjs +220 -0
  45. package/src/log.mjs +332 -0
  46. package/src/logo.png +0 -0
  47. package/src/runtime/alias-hooks.mjs +119 -0
  48. package/src/runtime/register.mjs +4 -0
  49. package/src/server/assets.js +119 -0
  50. package/src/server/create-app.js +167 -0
  51. package/src/server/dev/devtools.js +383 -0
  52. package/src/server/dev/report.js +351 -0
  53. package/src/server/head-hints.js +132 -0
  54. package/src/server/html-cache.js +166 -0
  55. package/src/server/metadata.js +102 -0
  56. package/src/server/middleware/compression.js +205 -0
  57. package/src/server/middleware/dev-gate.js +62 -0
  58. package/src/server/middleware/headers.js +37 -0
  59. package/src/server/middleware/redirects.js +32 -0
  60. package/src/server/middleware/static-precompressed.js +100 -0
  61. package/src/server/middleware/upstream-proxy.js +141 -0
  62. package/src/server/prewarm.js +283 -0
  63. package/src/server/render.js +356 -0
  64. package/src/server/router.js +121 -0
  65. package/src/server/status-page.js +164 -0
  66. package/src/server/upstream-tracking.js +51 -0
  67. package/src/start.mjs +7 -0
  68. package/src/templates/layout.ejs +44 -0
  69. package/src/version.mjs +17 -0
  70. package/src/views/components/loader.js +85 -0
  71. package/src/views/helpers/html.js +102 -0
  72. package/src/views/helpers/tags.js +193 -0
@@ -0,0 +1,1763 @@
1
+ /**
2
+ * Dev overlay: sağ altta yüzen baloncuk; hataları ve performans
3
+ * istatistiklerini gösterir.
4
+ *
5
+ * Bu dosya **build'e dahil değildir**. `npm run dev` sırasında sunucu onu
6
+ * `/__jskelet/dev/overlay.js` altından ham olarak servis eder, layout da
7
+ * script etiketini yalnızca development'ta basar. Bu yüzden burada bundler
8
+ * yok: import yapmadan, tek dosya olarak çalışır.
9
+ *
10
+ * Tüm arayüz shadow DOM içinde durur; sayfanın CSS'i ile karışmaz.
11
+ */
12
+
13
+ const BASE = "/__jskelet/dev";
14
+ const POLL_MS = 2000;
15
+ const MAX_ERRORS = 100;
16
+
17
+ /** @type {{ id: number, level: string, message: string, stack: string | null, source: string, at: number }[]} */
18
+ const clientErrors = [];
19
+ let nextId = 1;
20
+
21
+ /** @type {{ requests: any[], errors: any[], memory?: { rss: number, heapUsed: number }, uptime?: number, node?: string } | null} */
22
+ let serverStats = null;
23
+
24
+ const metrics = {
25
+ ttfb: null,
26
+ fcp: null,
27
+ lcp: null,
28
+ cls: 0,
29
+ inp: null,
30
+ dcl: null,
31
+ load: null,
32
+ longTasks: 0,
33
+ blocking: 0,
34
+ };
35
+
36
+ let activeTab = "errors";
37
+ let open = false;
38
+
39
+ /** Açık bırakılan yığın izleri; panel her poll'da yeniden çizildiği için gerekir. */
40
+ let expanded = new Set();
41
+
42
+ /**
43
+ * Sunucu `node --watch` ile yeniden başladığında ya da sayfa tazelendiğinde
44
+ * panelin kapanmaması için görünüm durumu ve tarayıcı hata günlüğü sekme
45
+ * belleğinde saklanır.
46
+ */
47
+ const STATE_KEY = "jskelet-devtools";
48
+ const BOOT_KEY = "jskelet-devtools-boot";
49
+
50
+ /** Son görülen sunucu süreci; değişirse yeniden başlatılmış demektir. */
51
+ let bootId = null;
52
+ let offline = false;
53
+
54
+ function saveState() {
55
+ try {
56
+ sessionStorage.setItem(
57
+ STATE_KEY,
58
+ JSON.stringify({
59
+ open,
60
+ activeTab,
61
+ aboutSection,
62
+ expanded: [...expanded],
63
+ errors: clientErrors.slice(0, 30),
64
+ }),
65
+ );
66
+ } catch {
67
+ // Kota dolu olabilir; durum kaybı dev akışını engellemez.
68
+ }
69
+ }
70
+
71
+ function loadState() {
72
+ try {
73
+ const saved = JSON.parse(sessionStorage.getItem(STATE_KEY) ?? "null");
74
+ if (!saved) return;
75
+
76
+ open = Boolean(saved.open);
77
+ activeTab = saved.activeTab ?? "errors";
78
+ aboutSection = saved.aboutSection ?? "framework";
79
+ expanded = new Set(saved.expanded ?? []);
80
+ clientErrors.push(...(saved.errors ?? []));
81
+ nextId = Math.max(nextId, ...clientErrors.map((item) => item.id + 1));
82
+ } catch {
83
+ // Bozuk kayıt: temiz başla.
84
+ }
85
+ }
86
+
87
+ /* ---------------------------------------------------------------- hatalar */
88
+
89
+ /**
90
+ * @param {string} level
91
+ * @param {string} message
92
+ * @param {{ stack?: string | null, source?: string }} [extra]
93
+ */
94
+ function pushError(level, message, extra = {}) {
95
+ clientErrors.unshift({
96
+ id: nextId++,
97
+ level,
98
+ message,
99
+ stack: extra.stack ?? null,
100
+ source: extra.source ?? "client",
101
+ at: Date.now(),
102
+ });
103
+ if (clientErrors.length > MAX_ERRORS) clientErrors.length = MAX_ERRORS;
104
+ saveState();
105
+ render();
106
+ }
107
+
108
+ function captureErrors() {
109
+ window.addEventListener("error", (event) => {
110
+ if (event.error instanceof Error) {
111
+ pushError("error", `${event.error.name}: ${event.error.message}`, {
112
+ stack: event.error.stack,
113
+ });
114
+ return;
115
+ }
116
+ // Kaynak yükleme hataları (img/script/link) Error taşımaz.
117
+ const target = /** @type {HTMLElement | null} */ (event.target);
118
+ if (target && target !== /** @type {any} */ (window)) {
119
+ const url = target.getAttribute?.("src") ?? target.getAttribute?.("href");
120
+ pushError("error", `Kaynak yüklenemedi: ${url ?? target.tagName}`, {
121
+ source: "resource",
122
+ });
123
+ return;
124
+ }
125
+ pushError("error", event.message);
126
+ }, true);
127
+
128
+ window.addEventListener("unhandledrejection", (event) => {
129
+ const reason = event.reason;
130
+ pushError(
131
+ "error",
132
+ reason instanceof Error
133
+ ? `${reason.name}: ${reason.message}`
134
+ : `Unhandled rejection: ${String(reason)}`,
135
+ { stack: reason instanceof Error ? reason.stack : null },
136
+ );
137
+ });
138
+
139
+ for (const level of ["error", "warn"]) {
140
+ const original = console[level].bind(console);
141
+ console[level] = (...args) => {
142
+ const error = args.find((arg) => arg instanceof Error);
143
+ pushError(
144
+ level,
145
+ args
146
+ .map((arg) =>
147
+ typeof arg === "string"
148
+ ? arg
149
+ : arg instanceof Error
150
+ ? `${arg.name}: ${arg.message}`
151
+ : safeJson(arg),
152
+ )
153
+ .join(" "),
154
+ { stack: error?.stack ?? null, source: "console" },
155
+ );
156
+ original(...args);
157
+ };
158
+ }
159
+ }
160
+
161
+ /**
162
+ * @param {unknown} value
163
+ * @returns {string}
164
+ */
165
+ function safeJson(value) {
166
+ try {
167
+ return JSON.stringify(value);
168
+ } catch {
169
+ return String(value);
170
+ }
171
+ }
172
+
173
+ /* ------------------------------------------------------------- performans */
174
+
175
+ /**
176
+ * @param {string} type
177
+ * @param {(entries: PerformanceEntry[]) => void} handler
178
+ * @param {PerformanceObserverInit} [options]
179
+ */
180
+ function observe(type, handler, options = {}) {
181
+ try {
182
+ new PerformanceObserver((list) => handler(list.getEntries())).observe({
183
+ type,
184
+ buffered: true,
185
+ ...options,
186
+ });
187
+ } catch {
188
+ // Tarayıcı bu metriği desteklemiyor; sessizce atla.
189
+ }
190
+ }
191
+
192
+ function captureMetrics() {
193
+ const nav = performance.getEntriesByType("navigation")[0];
194
+ if (nav) {
195
+ metrics.ttfb = nav.responseStart;
196
+ metrics.dcl = nav.domContentLoadedEventEnd || null;
197
+ metrics.load = nav.loadEventEnd || null;
198
+ }
199
+
200
+ observe("paint", (entries) => {
201
+ for (const entry of entries) {
202
+ if (entry.name === "first-contentful-paint") metrics.fcp = entry.startTime;
203
+ }
204
+ render();
205
+ });
206
+
207
+ observe("largest-contentful-paint", (entries) => {
208
+ const last = entries.at(-1);
209
+ if (last) metrics.lcp = last.startTime;
210
+ render();
211
+ });
212
+
213
+ observe("layout-shift", (entries) => {
214
+ for (const entry of entries) {
215
+ if (!entry.hadRecentInput) metrics.cls += entry.value;
216
+ }
217
+ render();
218
+ });
219
+
220
+ observe("longtask", (entries) => {
221
+ for (const entry of entries) {
222
+ metrics.longTasks += 1;
223
+ metrics.blocking += Math.max(0, entry.duration - 50);
224
+ }
225
+ render();
226
+ });
227
+
228
+ observe("event", (entries) => {
229
+ for (const entry of entries) {
230
+ const value = entry.duration;
231
+ if (metrics.inp == null || value > metrics.inp) metrics.inp = value;
232
+ }
233
+ render();
234
+ }, { durationThreshold: 40 });
235
+
236
+ window.addEventListener("load", () => {
237
+ const entry = performance.getEntriesByType("navigation")[0];
238
+ if (entry) {
239
+ metrics.dcl = entry.domContentLoadedEventEnd;
240
+ metrics.load = entry.loadEventEnd;
241
+ }
242
+ render();
243
+ });
244
+ }
245
+
246
+ /**
247
+ * Ölçüm okumaları saniyede birden fazla yapılmaz. Kaynak listesi yüzlerce
248
+ * kayda çıkabiliyor ve her yeniden çizimde baştan taranması sekme geçişini
249
+ * hissedilir şekilde yavaşlatıyordu.
250
+ *
251
+ * @template T
252
+ * @param {() => T} compute
253
+ * @param {number} [ttl]
254
+ * @returns {() => T}
255
+ */
256
+ function cached(compute, ttl = 1000) {
257
+ let value = null;
258
+ let at = 0;
259
+
260
+ return () => {
261
+ const now = performance.now();
262
+ if (value === null || now - at > ttl) {
263
+ value = compute();
264
+ at = now;
265
+ }
266
+ return value;
267
+ };
268
+ }
269
+
270
+ const resourceSummary = cached(() => {
271
+ const resources = performance.getEntriesByType("resource");
272
+ let bytes = 0;
273
+ /** @type {Record<string, { count: number, bytes: number }>} */
274
+ const byType = {};
275
+
276
+ for (const entry of resources) {
277
+ const size = entry.transferSize || entry.encodedBodySize || 0;
278
+ bytes += size;
279
+ const kind = entry.initiatorType || "other";
280
+ byType[kind] ??= { count: 0, bytes: 0 };
281
+ byType[kind].count += 1;
282
+ byType[kind].bytes += size;
283
+ }
284
+
285
+ return { count: resources.length, bytes, byType };
286
+ });
287
+
288
+ const islandSummary = cached(() => ({
289
+ total: document.querySelectorAll("[data-island]").length,
290
+ ready: document.querySelectorAll("[data-island-ready]").length,
291
+ }));
292
+
293
+ /* --------------------------------------------------------- API çağrıları */
294
+
295
+ /** @type {{ url: string, ms: number, status: number, bytes: number, initiator: string }[]} */
296
+ const clientApi = [];
297
+
298
+ /**
299
+ * Tarayıcıdaki veri çağrıları. Panelin kendi uçları ve statik varlıklar
300
+ * sayılmaz; amaç sayfanın hangi API'lere kaç ms harcadığını görmek.
301
+ */
302
+ function captureFetch() {
303
+ const original = window.fetch.bind(window);
304
+
305
+ window.fetch = async (input, init) => {
306
+ const url = typeof input === "string" ? input : (input?.url ?? String(input));
307
+ if (url.includes(BASE)) return original(input, init);
308
+
309
+ const started = performance.now();
310
+ try {
311
+ const response = await original(input, init);
312
+ record(url, performance.now() - started, response.status, response);
313
+ return response;
314
+ } catch (error) {
315
+ record(url, performance.now() - started, 0, null);
316
+ throw error;
317
+ }
318
+ };
319
+
320
+ /**
321
+ * @param {string} url
322
+ * @param {number} ms
323
+ * @param {number} status
324
+ * @param {Response | null} response
325
+ */
326
+ function record(url, ms, status, response) {
327
+ clientApi.push({
328
+ url,
329
+ ms: Math.round(ms),
330
+ status,
331
+ bytes: Number(response?.headers.get("content-length") ?? 0),
332
+ initiator: "fetch",
333
+ });
334
+ if (clientApi.length > 100) clientApi.shift();
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Sayfa ölçümlerini rapora gönderir. Sekme kapanırken de gitmesi gerektiği
340
+ * için mümkünse `sendBeacon` kullanılır.
341
+ *
342
+ * @param {boolean} [leaving]
343
+ */
344
+ function sendPageReport(leaving = false) {
345
+ const resources = resourceSummary();
346
+ const payload = JSON.stringify({
347
+ url: location.href,
348
+ title: document.title,
349
+ metrics: { ...metrics },
350
+ resources,
351
+ islands: {
352
+ ...islandSummary(),
353
+ names: [
354
+ ...new Set(
355
+ [...document.querySelectorAll("[data-island]")].map(
356
+ (node) => node.dataset.island,
357
+ ),
358
+ ),
359
+ ],
360
+ },
361
+ api: clientApi,
362
+ });
363
+
364
+ if (leaving && navigator.sendBeacon) {
365
+ navigator.sendBeacon(
366
+ `${BASE}/vitals`,
367
+ new Blob([payload], { type: "application/json" }),
368
+ );
369
+ return;
370
+ }
371
+
372
+ fetch(`${BASE}/vitals`, {
373
+ method: "POST",
374
+ headers: { "content-type": "application/json" },
375
+ body: payload,
376
+ keepalive: true,
377
+ }).catch(() => {
378
+ // Rapor kaydı en iyi çaba.
379
+ });
380
+ }
381
+
382
+ /* ------------------------------------------------------------------ sunucu */
383
+
384
+ let restarts = 0;
385
+
386
+ /* -------------------------------------------------------- canlı yenileme */
387
+
388
+ /**
389
+ * Sunucu olay akışı. Amaç titremeyi bitirmek: CSS değiştiğinde sayfa
390
+ * yenilenmez, yalnızca stylesheet yeni sürümle takas edilir. Sunucu yeniden
391
+ * başladığında (boot kimliği değişince) tek sefer tam yenileme yapılır;
392
+ * overlay durumu sekme belleğinde durduğu için panel açık kalmaya devam eder.
393
+ */
394
+ function connectEvents() {
395
+ const source = new EventSource(`${BASE}/events`);
396
+
397
+ source.addEventListener("message", (event) => {
398
+ /** @type {{ type: string, boot?: string, href?: string }} */
399
+ const payload = JSON.parse(event.data);
400
+
401
+ if (payload.type === "hello") {
402
+ offline = false;
403
+ const previous = sessionStorage.getItem(BOOT_KEY);
404
+ sessionStorage.setItem(BOOT_KEY, payload.boot);
405
+
406
+ if (previous && previous !== payload.boot) {
407
+ restarts += 1;
408
+ location.reload();
409
+ return;
410
+ }
411
+
412
+ bootId = payload.boot;
413
+ render();
414
+ return;
415
+ }
416
+
417
+ if (payload.type === "css") {
418
+ swapStylesheet(payload.href);
419
+ return;
420
+ }
421
+
422
+ if (payload.type === "reload") location.reload();
423
+ });
424
+
425
+ source.addEventListener("error", () => {
426
+ // Sunucu yeniden başlarken bağlantı düşer; EventSource kendi kendine
427
+ // yeniden bağlanır, biz yalnızca göstergeyi güncelleriz.
428
+ if (!offline) {
429
+ offline = true;
430
+ render();
431
+ }
432
+ });
433
+ }
434
+
435
+ /**
436
+ * Yeni sheet yüklenmeden eskisi kaldırılmaz; böylece stilsiz bir kare oluşmaz.
437
+ * @param {string} href
438
+ */
439
+ function swapStylesheet(href) {
440
+ const current = document.querySelector('link[rel="stylesheet"]');
441
+ if (!current || current.getAttribute("href") === href) return;
442
+
443
+ const next = current.cloneNode();
444
+ next.href = href;
445
+ next.addEventListener("load", () => current.remove(), { once: true });
446
+ current.after(next);
447
+ }
448
+
449
+ async function pollServer() {
450
+ try {
451
+ const response = await fetch(`${BASE}/stats`, { cache: "no-store" });
452
+ if (!response.ok) return;
453
+
454
+ const stats = await response.json();
455
+
456
+ // Süreç kimliği değiştiyse sunucu yeniden başlamıştır. Overlay kapanmaz,
457
+ // yalnızca sayacı artar; günlükler sunucuda kalıcı olduğu için de silinmez.
458
+ if (bootId && stats.boot !== bootId) restarts += 1;
459
+ bootId = stats.boot ?? bootId;
460
+
461
+ offline = false;
462
+ serverStats = stats;
463
+ render();
464
+
465
+ // Isıtma sürerken sayaç akıcı görünsün diye yoklama sıklaşır.
466
+ if (stats.prewarm?.active) setTimeout(pollServer, 600);
467
+ } catch {
468
+ // Yeniden başlatma penceresi: eldeki veriler korunur, yalnızca durum
469
+ // göstergesi "bağlantı yok"a döner.
470
+ if (!offline) {
471
+ offline = true;
472
+ render();
473
+ }
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Isıtmayı panelden tetikler. Yol listesi verilirse yalnızca onlar denenir.
479
+ * @param {string[]} [paths]
480
+ */
481
+ async function startPrewarm(paths) {
482
+ try {
483
+ await fetch(`${BASE}/prewarm`, {
484
+ method: "POST",
485
+ headers: { "content-type": "application/json" },
486
+ body: JSON.stringify({ paths: paths ?? [] }),
487
+ });
488
+ } catch {
489
+ // Sunucu yeniden başlıyor olabilir; sonraki yoklama durumu getirir.
490
+ }
491
+ pollServer();
492
+ }
493
+
494
+ async function clearAll() {
495
+ clientErrors.length = 0;
496
+ try {
497
+ await fetch(`${BASE}/clear`, { method: "POST" });
498
+ } catch {
499
+ // yoksay
500
+ }
501
+ serverStats = null;
502
+ saveState();
503
+ render();
504
+ pollServer();
505
+ }
506
+
507
+ /* ---------------------------------------------------------------- arayüz */
508
+
509
+ const CSS = `
510
+ :host { all: initial; }
511
+ * { box-sizing: border-box; font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; }
512
+
513
+ .root {
514
+ --bg: #0c0e12;
515
+ --surface: rgba(255,255,255,.045);
516
+ --surface-hover: rgba(255,255,255,.08);
517
+ --line: rgba(255,255,255,.09);
518
+ --text: #eef1f5;
519
+ --muted: #8b95a3;
520
+ --good: #34d399;
521
+ --mid: #fbbf24;
522
+ --bad: #fb7185;
523
+ position: fixed; inset-block-end: 20px; inset-inline-end: 20px;
524
+ z-index: 2147483647; display: flex; flex-direction: column;
525
+ align-items: flex-end; gap: 10px; color: var(--text);
526
+ }
527
+
528
+ .bubble {
529
+ position: relative; display: grid; place-items: center; cursor: pointer;
530
+ width: 46px; height: 46px; padding: 0; border-radius: 50%;
531
+ border: 1px solid var(--line);
532
+ background: radial-gradient(120% 120% at 30% 0%, #1b2029 0%, #0c0e12 70%);
533
+ box-shadow: 0 10px 30px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.06);
534
+ transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease;
535
+ }
536
+ .bubble:hover { transform: translateY(-2px); border-color: rgba(255,255,255,.28); }
537
+ .bubble:active { transform: translateY(0); }
538
+ .bubble.bad { border-color: rgba(251,113,133,.55); box-shadow: 0 10px 30px rgba(251,113,133,.22); }
539
+ .bubble.warn { border-color: rgba(251,191,36,.5); box-shadow: 0 10px 30px rgba(251,191,36,.18); }
540
+ .bubble img { width: 26px; height: 26px; object-fit: contain; display: block; }
541
+ .badge {
542
+ position: absolute; inset-block-start: -5px; inset-inline-end: -5px;
543
+ min-width: 19px; height: 19px; padding: 0 5px; border-radius: 999px;
544
+ display: grid; place-items: center; font-size: 11px; font-weight: 700;
545
+ font-variant-numeric: tabular-nums; background: var(--bad); color: #2a0710;
546
+ border: 2px solid var(--bg);
547
+ }
548
+ .badge.warn { background: var(--mid); color: #2a1d00; }
549
+
550
+ /* Baloncuğun solunda ısıtma ilerlemesi. */
551
+ /* Karartma katmanı tam ekranı kapladığı için ısıtma rozeti onun altında
552
+ kalabiliyordu; dock açıkça üstte tutulur. */
553
+ .dock { position: relative; z-index: 1; display: flex; align-items: center; gap: 8px; }
554
+ .warm {
555
+ display: inline-flex; align-items: center; gap: 7px; white-space: nowrap;
556
+ padding: 5px 11px 5px 9px; border-radius: 999px;
557
+ border: 1px solid var(--line); color: #cfd6de;
558
+ background: rgba(12,14,18,.92);
559
+ box-shadow: 0 8px 24px rgba(0,0,0,.4), inset 0 1px 0 rgba(255,255,255,.05);
560
+ font-size: 11.5px; font-weight: 600; font-variant-numeric: tabular-nums;
561
+ animation: slide .2s ease;
562
+ }
563
+ @keyframes slide { from { opacity: 0; transform: translateX(8px); } }
564
+ .warm .ring {
565
+ width: 11px; height: 11px; border-radius: 50%; flex: none;
566
+ border: 2px solid rgba(255,255,255,.18); border-top-color: var(--good);
567
+ animation: spin .7s linear infinite;
568
+ }
569
+ @keyframes spin { to { transform: rotate(360deg); } }
570
+ .warm.done { color: #b9f2dc; border-color: rgba(52,211,153,.35); }
571
+ .warm.done .ring { border: 0; background: var(--good); animation: none; }
572
+ @media (prefers-reduced-motion: reduce) {
573
+ .warm, .warm .ring { animation: none; }
574
+ }
575
+
576
+ /* hidden özniteliği, display tanımlayan sınıflar tarafından eziliyordu:
577
+ panel kapanmıyor, rozet hep görünüyordu. */
578
+ [hidden] { display: none !important; }
579
+
580
+ .backdrop {
581
+ position: fixed; inset: 0; display: grid; place-items: center; padding: 24px;
582
+ background: rgba(6,8,11,.55); backdrop-filter: blur(3px);
583
+ animation: fade .15s ease;
584
+ }
585
+ @keyframes fade { from { opacity: 0; } }
586
+
587
+ .panel {
588
+ width: min(1040px, 94vw); height: min(760px, 86vh);
589
+ display: grid; grid-template-columns: 208px 1fr; grid-template-rows: auto 1fr;
590
+ overflow: hidden;
591
+ background: linear-gradient(180deg, rgba(23,27,34,.98) 0%, rgba(12,14,18,.98) 30%);
592
+ border: 1px solid var(--line); border-radius: 18px;
593
+ box-shadow: 0 32px 80px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.06);
594
+ animation: rise .18s cubic-bezier(.2,.8,.3,1);
595
+ }
596
+ @keyframes rise { from { opacity: 0; transform: translateY(10px) scale(.99); } }
597
+ @media (prefers-reduced-motion: reduce) {
598
+ .panel, .backdrop { animation: none; }
599
+ .bubble { transition: none; }
600
+ }
601
+ @media (max-width: 720px) {
602
+ .panel { grid-template-columns: 1fr; grid-template-rows: auto auto 1fr; }
603
+ .rail { flex-direction: row; overflow: auto; }
604
+ }
605
+
606
+ .head {
607
+ grid-column: 1 / -1; display: flex; align-items: center; gap: 12px;
608
+ padding: 14px 18px; border-bottom: 1px solid var(--line);
609
+ }
610
+ .head img { width: 22px; height: 22px; object-fit: contain; }
611
+ .title { font-size: 14px; font-weight: 700; letter-spacing: -.01em; }
612
+ .kbd {
613
+ font-size: 10px; color: var(--muted); border: 1px solid var(--line);
614
+ border-radius: 6px; padding: 1px 6px; font-family: ui-monospace, monospace;
615
+ }
616
+
617
+ .rail {
618
+ display: flex; flex-direction: column; gap: 4px; padding: 14px 10px;
619
+ border-inline-end: 1px solid var(--line); background: rgba(0,0,0,.22);
620
+ }
621
+ .pill {
622
+ display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px;
623
+ border-radius: 999px; font-size: 11px; font-weight: 600; color: var(--muted);
624
+ background: var(--surface); border: 1px solid var(--line);
625
+ }
626
+ .pill .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--good); }
627
+ .pill.bad { color: #ffd7de; } .pill.bad .dot { background: var(--bad); }
628
+ .pill.warn { color: #ffeec2; } .pill.warn .dot { background: var(--mid); }
629
+ .spacer { flex: 1; }
630
+
631
+ .tab {
632
+ display: flex; align-items: center; gap: 8px; width: 100%; text-align: start;
633
+ background: transparent; border: 0; color: var(--muted); font-size: 12.5px;
634
+ padding: 9px 11px; border-radius: 10px; cursor: pointer; font-weight: 600;
635
+ transition: color .12s ease, background .12s ease;
636
+ }
637
+ .tab:hover { color: var(--text); background: var(--surface); }
638
+ .tab[aria-selected="true"] { background: var(--surface-hover); color: #fff; }
639
+ .tab svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
640
+ .tab .chip { margin-inline-start: auto; background: var(--bad); color: #2a0710; border-radius: 999px; padding: 0 6px; font-size: 10px; font-weight: 700; font-variant-numeric: tabular-nums; }
641
+ .tab .chip.neutral { background: rgba(255,255,255,.1); color: #dbe2ea; }
642
+ .rail-foot { margin-block-start: auto; display: flex; flex-direction: column; gap: 6px; padding: 8px 4px 0; }
643
+ .divider { height: 1px; margin: 4px 2px; background: var(--line); }
644
+
645
+ /* Hakkında sekmesi: iç sekmeler + anlatı metni. */
646
+ .subtabs { display: flex; flex-wrap: wrap; gap: 6px; margin: 14px 0 4px; }
647
+ .subtab {
648
+ background: var(--surface); border: 1px solid var(--line); color: var(--muted);
649
+ border-radius: 999px; padding: 5px 13px; font-size: 11.5px; font-weight: 600; cursor: pointer;
650
+ transition: color .12s ease, background .12s ease, border-color .12s ease;
651
+ }
652
+ .subtab:hover { color: var(--text); background: var(--surface-hover); }
653
+ .subtab[aria-selected="true"] { color: #fff; background: var(--surface-hover); border-color: rgba(255,255,255,.24); }
654
+
655
+ .prose { width: 100%; }
656
+ .prose p { margin: 0 0 12px; color: #c6ced9; }
657
+ .prose > :last-child { margin-bottom: 0; }
658
+ .prose .rows { margin: 0 0 12px; }
659
+ .prose .row { grid-template-columns: 110px 1fr; align-items: start; padding: 9px 11px; }
660
+ .prose .row .tag { text-transform: none; letter-spacing: 0; font-family: ui-monospace, SFMono-Regular, monospace; text-align: center; }
661
+ .prose .row .path { white-space: normal; overflow: visible; text-overflow: clip; font-family: inherit; font-size: 12px; color: #c6ced9; }
662
+
663
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 10px; margin: 0 0 12px; }
664
+ .card {
665
+ background: var(--surface); border: 1px solid var(--line);
666
+ border-radius: 12px; padding: 11px 13px;
667
+ }
668
+ .card b { display: block; color: #fff; font-size: 12px; margin-bottom: 3px; }
669
+ .card span { color: var(--muted); }
670
+ .prose p strong { color: #fff; font-weight: 700; }
671
+ .prose code { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 11.5px; background: rgba(0,0,0,.4); border: 1px solid var(--line); border-radius: 6px; padding: 1px 5px; color: #dbe2ea; }
672
+ .steps { display: flex; flex-direction: column; gap: 8px; margin: 0 0 12px; }
673
+ .step {
674
+ display: grid; grid-template-columns: 26px 1fr; gap: 12px; align-items: start;
675
+ background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 11px 13px;
676
+ }
677
+ .step .n {
678
+ display: grid; place-items: center; width: 24px; height: 24px; border-radius: 8px;
679
+ background: rgba(255,255,255,.06); border: 1px solid var(--line);
680
+ font-size: 11px; font-weight: 700; color: #fff; font-variant-numeric: tabular-nums;
681
+ }
682
+ .step b { display: block; color: #fff; font-size: 12.5px; }
683
+ .step span { color: var(--muted); }
684
+
685
+ .body { overflow: auto; padding: 16px 20px 20px; font-size: 12.5px; line-height: 1.6; scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.18) transparent; }
686
+ .body::-webkit-scrollbar { width: 10px; }
687
+ .body::-webkit-scrollbar-thumb { background: rgba(255,255,255,.16); border-radius: 8px; }
688
+
689
+ h4 { margin: 22px 0 10px; font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
690
+ h4:first-child { margin-top: 0; }
691
+
692
+ /* Sekmenin başındaki tek cümlelik durum özeti. */
693
+ .lede {
694
+ display: flex; gap: 12px; align-items: flex-start;
695
+ background: var(--surface); border: 1px solid var(--line);
696
+ border-radius: 14px; padding: 14px 16px; font-size: 13px; line-height: 1.55;
697
+ }
698
+ .lede .mark { font-size: 18px; line-height: 1.2; }
699
+ .lede strong { color: #fff; font-weight: 700; }
700
+ .lede.good .mark { color: var(--good); }
701
+ .lede.mid .mark { color: var(--mid); }
702
+ .lede.bad .mark { color: var(--bad); }
703
+
704
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
705
+ .metric {
706
+ position: relative; overflow: hidden; background: var(--surface);
707
+ border: 1px solid var(--line); border-radius: 14px; padding: 12px 14px 14px;
708
+ }
709
+ .metric .label { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .06em; font-weight: 600; }
710
+ .metric .value { font-size: 22px; font-weight: 700; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
711
+ .value.good { color: var(--good); } .value.mid { color: var(--mid); } .value.bad { color: var(--bad); }
712
+ .metric .bar { margin-top: 7px; height: 3px; border-radius: 999px; background: rgba(255,255,255,.08); overflow: hidden; }
713
+ .metric .bar span { display: block; height: 100%; border-radius: 999px; background: var(--muted); }
714
+ .metric .bar span.good { background: var(--good); }
715
+ .metric .bar span.mid { background: var(--mid); }
716
+ .metric .bar span.bad { background: var(--bad); }
717
+
718
+ .item {
719
+ background: var(--surface); border: 1px solid var(--line);
720
+ border-inline-start: 2px solid var(--bad); border-radius: 4px 12px 12px 4px;
721
+ padding: 9px 11px; margin-bottom: 8px;
722
+ }
723
+ .item.warn { border-inline-start-color: var(--mid); }
724
+ .item .meta { color: var(--muted); font-size: 10.5px; display: flex; align-items: center; gap: 8px; margin-bottom: 3px; }
725
+ .tag { border: 1px solid var(--line); border-radius: 999px; padding: 0 7px; text-transform: uppercase; letter-spacing: .05em; font-weight: 600; font-size: 9.5px; }
726
+ .msg { word-break: break-word; color: var(--text); }
727
+ .item pre { margin: 8px 0 0; padding: 8px 10px; background: rgba(0,0,0,.4); border-radius: 8px; white-space: pre-wrap; word-break: break-word; color: #b8c0cb; font-size: 11px; font-family: ui-monospace, SFMono-Regular, monospace; max-height: 150px; overflow: auto; }
728
+ .link { background: none; border: 0; padding: 0; margin-top: 6px; color: var(--muted); font-size: 11px; font-weight: 600; cursor: pointer; }
729
+ .link:hover { color: var(--text); }
730
+
731
+ .copy {
732
+ display: inline-flex; align-items: center; gap: 5px; cursor: pointer;
733
+ background: transparent; border: 1px solid transparent; border-radius: 7px;
734
+ padding: 2px 5px; color: var(--muted); font-size: 10.5px; font-weight: 600;
735
+ transition: color .15s ease, background .15s ease, border-color .15s ease;
736
+ }
737
+ .copy:hover { color: var(--text); background: var(--surface-hover); border-color: var(--line); }
738
+ .copy.done { color: var(--good); }
739
+ .copy svg { width: 13px; height: 13px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
740
+ h4 .head-action { float: inline-end; text-transform: none; letter-spacing: 0; }
741
+
742
+ .empty { color: #5f6874; padding: 14px; text-align: center; background: var(--surface); border: 1px dashed var(--line); border-radius: 12px; }
743
+
744
+ .rows { display: flex; flex-direction: column; gap: 4px; }
745
+ .row {
746
+ display: grid; grid-template-columns: auto 1fr auto auto; align-items: center; gap: 8px;
747
+ padding: 7px 10px; border-radius: 10px; background: var(--surface);
748
+ border: 1px solid transparent; font-variant-numeric: tabular-nums;
749
+ }
750
+ .row:hover { border-color: var(--line); }
751
+ .row .path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #cfd6de; font-family: ui-monospace, SFMono-Regular, monospace; font-size: 11px; }
752
+ .path a { color: inherit; text-decoration: none; border-bottom: 1px solid transparent; }
753
+ .path a:hover { color: #fff; border-bottom-color: rgba(255,255,255,.35); }
754
+ .status { font-size: 10px; font-weight: 700; border-radius: 6px; padding: 1px 6px; background: rgba(52,211,153,.14); color: var(--good); }
755
+ .status.warn { background: rgba(251,191,36,.14); color: var(--mid); }
756
+ .status.bad { background: rgba(251,113,133,.14); color: var(--bad); }
757
+ .hint { color: var(--muted); font-size: 10.5px; }
758
+ .row .good { color: var(--good); } .row .mid { color: var(--mid); } .row .bad { color: var(--bad); }
759
+
760
+ .btn {
761
+ background: var(--surface); border: 1px solid var(--line); color: var(--text);
762
+ border-radius: 9px; padding: 5px 10px; font-size: 11px; font-weight: 600; cursor: pointer;
763
+ transition: background .15s ease;
764
+ }
765
+ .btn:hover { background: var(--surface-hover); }
766
+ .btn.icon { padding: 5px 8px; line-height: 1; }
767
+ .btn.wide {
768
+ display: flex; align-items: center; gap: 8px; width: 100%;
769
+ padding: 8px 10px; font-size: 12px; text-decoration: none;
770
+ }
771
+ .btn svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
772
+ .btn.mini { display: inline-flex; align-items: center; gap: 6px; padding: 4px 9px; font-size: 11px; }
773
+ .btn.mini svg { width: 12px; height: 12px; }
774
+ .btn[disabled] { opacity: .5; cursor: default; }
775
+ .toolbar { display: flex; justify-content: flex-end; margin-bottom: 10px; }
776
+ .btn.danger { color: #ffc9d2; border-color: rgba(251,113,133,.28); background: rgba(251,113,133,.1); }
777
+ .btn.danger:hover { color: #fff; border-color: rgba(251,113,133,.55); background: rgba(251,113,133,.22); }
778
+ `;
779
+
780
+ let shadow = null;
781
+
782
+ /**
783
+ * İskelet bir kez kurulur. Her yoklamada tüm ağacı yeniden yazmak logoyu ve
784
+ * paneli titretiyordu; artık yalnızca değişen metin/sınıf güncelleniyor,
785
+ * gövde HTML'i de içerik gerçekten değiştiyse yenileniyor.
786
+ */
787
+ const TAB_ICONS = {
788
+ errors: `<svg viewBox="0 0 16 16"><path d="M8 5.5v4M8 11.5h.01M8 1.8 1.8 13.2h12.4L8 1.8Z"/></svg>`,
789
+ perf: `<svg viewBox="0 0 16 16"><path d="M2 13V8m4 5V3m4 10V6m4 7v-3"/></svg>`,
790
+ server: `<svg viewBox="0 0 16 16"><path d="M2.5 3.5h11v3h-11zM2.5 9.5h11v3h-11zM5 5h.01M5 11h.01"/></svg>`,
791
+ warm: `<svg viewBox="0 0 16 16"><path d="M8 14c2.5 0 4.2-1.7 4.2-3.9C12.2 6.5 8 2 8 2S3.8 6.5 3.8 10.1C3.8 12.3 5.5 14 8 14Z"/></svg>`,
792
+ about: `<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="6.2"/><path d="M8 7.2v4M8 4.9h.01"/></svg>`,
793
+ };
794
+
795
+ const REFRESH_ICON = `<svg viewBox="0 0 16 16"><path d="M13.5 8a5.5 5.5 0 1 1-1.9-4.16M13.5 2v3.5H10"/></svg>`;
796
+ const SCOPE_ICON = `<svg viewBox="0 0 16 16"><circle cx="7" cy="7" r="4.6"/><path d="M10.4 10.4 14 14M7 4.9v4.2M4.9 7h4.2"/></svg>`;
797
+ const TRASH_ICON = `<svg viewBox="0 0 16 16"><path d="M2.5 4.5h11M6 4.5V3h4v1.5M4 4.5l.7 8.2a1 1 0 0 0 1 .8h4.6a1 1 0 0 0 1-.8l.7-8.2M6.5 7v4M9.5 7v4"/></svg>`;
798
+
799
+ const SKELETON = `
800
+ <div class="root">
801
+ <div class="backdrop" data-action="backdrop" hidden>
802
+ <div class="panel" role="dialog" aria-label="JSkelet dev araçları">
803
+ <div class="head">
804
+ <img src="${BASE}/logo.png" alt="">
805
+ <span class="title">JSkelet Dev</span>
806
+ <span class="pill"><span class="dot"></span><span data-part="pill"></span></span>
807
+ <span class="spacer"></span>
808
+ <span class="kbd">Alt+D</span>
809
+ <button class="btn icon" data-action="toggle" title="Kapat (Esc)">✕</button>
810
+ </div>
811
+ <div class="rail">
812
+ <button class="tab" data-tab="errors">${TAB_ICONS.errors} Hatalar<span class="chip" data-part="chip" hidden></span></button>
813
+ <button class="tab" data-tab="perf">${TAB_ICONS.perf} Performans</button>
814
+ <button class="tab" data-tab="server">${TAB_ICONS.server} Sunucu</button>
815
+ <button class="tab" data-tab="warm">${TAB_ICONS.warm} Prewarming<span class="chip neutral" data-part="warm-chip" hidden></span></button>
816
+ <div class="rail-foot">
817
+ <button class="tab" data-tab="about">${TAB_ICONS.about} Hakkında</button>
818
+ <div class="divider"></div>
819
+ <button class="btn wide" data-action="reload">${REFRESH_ICON} Sayfayı yenile</button>
820
+ <a class="btn wide" href="${BASE}/report" target="_blank" rel="noreferrer">${SCOPE_ICON} Detaylı İncele</a>
821
+ <button class="btn wide danger" data-action="clear">${TRASH_ICON} Kayıtları temizle</button>
822
+ </div>
823
+ </div>
824
+ <div class="body"></div>
825
+ </div>
826
+ </div>
827
+ <div class="dock">
828
+ <span class="warm" data-part="warm" hidden>
829
+ <span class="ring"></span>
830
+ <span data-part="warm-text"></span>
831
+ </span>
832
+ <button class="bubble" data-action="toggle">
833
+ <img src="${BASE}/logo.png" alt="JSkelet dev araçları">
834
+ <span class="badge" hidden></span>
835
+ </button>
836
+ </div>
837
+ </div>
838
+ `;
839
+
840
+ function ensureRoot() {
841
+ if (shadow) return shadow;
842
+
843
+ const host = document.createElement("div");
844
+ host.id = "jskelet-devtools";
845
+ document.body.append(host);
846
+
847
+ shadow = host.attachShadow({ mode: "open" });
848
+ const style = document.createElement("style");
849
+ style.textContent = CSS;
850
+ const root = document.createElement("div");
851
+ root.innerHTML = SKELETON;
852
+ shadow.append(style, root);
853
+
854
+ return shadow;
855
+ }
856
+
857
+ /**
858
+ * @param {number | null} value
859
+ * @param {[number, number]} thresholds iyi/orta sınırları
860
+ */
861
+ function grade(value, [good, mid]) {
862
+ if (value == null) return "";
863
+ if (value <= good) return "good";
864
+ if (value <= mid) return "mid";
865
+ return "bad";
866
+ }
867
+
868
+ /**
869
+ * @param {number | null} value
870
+ * @param {string} [unit]
871
+ */
872
+ function ms(value, unit = "ms") {
873
+ return value == null ? "—" : `${Math.round(value)} ${unit}`;
874
+ }
875
+
876
+ /** @param {number} bytes */
877
+ function kb(bytes) {
878
+ return bytes >= 1024 * 1024
879
+ ? `${(bytes / 1024 / 1024).toFixed(2)} MB`
880
+ : `${Math.round(bytes / 1024)} KB`;
881
+ }
882
+
883
+ /**
884
+ * Panel içindeki yolları tıklanabilir yapar; sayfa yeni sekmede açılır ki
885
+ * incelenen oturum ve panel durumu bozulmasın. Yol gerçek bir URL değilse
886
+ * (ör. boş) düz metin döner.
887
+ *
888
+ * @param {string} value
889
+ * @returns {string}
890
+ */
891
+ function pathLink(value) {
892
+ const text = escapeHtml(value);
893
+ if (!value?.startsWith("/")) return text;
894
+
895
+ return `<a href="${escapeHtml(value)}" target="_blank" rel="noreferrer" title="Yeni sekmede aç: ${text}">${text}</a>`;
896
+ }
897
+
898
+ /** @param {string} value */
899
+ function escapeHtml(value) {
900
+ return String(value).replace(/[&<>"]/g, (char) =>
901
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[char],
902
+ );
903
+ }
904
+
905
+ /**
906
+ * Ölçüm kartı. `ratio` verilirse eşiklere göre dolan ince bir çubuk çizilir.
907
+ * @param {{ label: string, value: string, tone?: string, ratio?: number | null }[]} items
908
+ */
909
+ function metricGrid(items) {
910
+ return `<div class="grid">${items
911
+ .map((item) => {
912
+ const width = Math.round(Math.min(1, Math.max(0.04, item.ratio ?? 0)) * 100);
913
+ return `<div class="metric">
914
+ <div class="label">${escapeHtml(item.label)}</div>
915
+ <div class="value ${item.tone ?? ""}">${escapeHtml(item.value)}</div>
916
+ ${
917
+ item.ratio == null
918
+ ? ""
919
+ : `<div class="bar"><span class="${item.tone ?? ""}" style="width:${width}%"></span></div>`
920
+ }
921
+ </div>`;
922
+ })
923
+ .join("")}</div>`;
924
+ }
925
+
926
+ /**
927
+ * @param {number | null} value
928
+ * @param {number} ceiling
929
+ * @returns {number | null}
930
+ */
931
+ function ratio(value, ceiling) {
932
+ return value == null ? null : value / ceiling;
933
+ }
934
+
935
+ /**
936
+ * Kopyalanacak metinler HTML'e gömülmez; anahtarla burada tutulur.
937
+ * @type {Map<string, string>}
938
+ */
939
+ const clipboard = new Map();
940
+
941
+ /** Kısa süreli "kopyalandı" geri bildirimi için. */
942
+ let copiedKey = null;
943
+
944
+ /**
945
+ * @param {{ level: string, message: string, stack?: string | null, source?: string, at: number }} item
946
+ * @returns {string}
947
+ */
948
+ function asText(item) {
949
+ const time = new Date(item.at).toISOString();
950
+ return [
951
+ `[${item.level}] ${item.source ?? "server"} · ${time}`,
952
+ item.message,
953
+ item.stack ?? "",
954
+ ]
955
+ .filter(Boolean)
956
+ .join("\n");
957
+ }
958
+
959
+ /** @param {{ id?: number, level: string, message: string, stack?: string | null, source?: string, at: number }[]} list */
960
+ function errorList(list, scope) {
961
+ if (!list.length) return `<div class="empty">Kayıt yok.</div>`;
962
+ return list
963
+ .map((item) => {
964
+ const key = `${scope}:${item.id ?? item.at}`;
965
+ const shown = expanded.has(key);
966
+ clipboard.set(key, asText(item));
967
+
968
+ return `<div class="item ${item.level === "warn" ? "warn" : ""}">
969
+ <div class="meta">
970
+ <span class="tag">${escapeHtml(item.source ?? "server")}</span>
971
+ <span>${new Date(item.at).toLocaleTimeString("tr-TR")}</span>
972
+ <span class="spacer"></span>
973
+ ${copyButton(key)}
974
+ </div>
975
+ <div class="msg">${escapeHtml(item.message)}</div>
976
+ ${
977
+ item.stack
978
+ ? `<button class="link" data-action="stack" data-key="${escapeHtml(key)}">${shown ? "▾ yığını gizle" : "▸ yığını göster"}</button>
979
+ ${shown ? `<pre>${escapeHtml(item.stack)}</pre>` : ""}`
980
+ : ""
981
+ }
982
+ </div>`;
983
+ })
984
+ .join("");
985
+ }
986
+
987
+ const COPY_ICON = `<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.5 2.5h6a1 1 0 0 1 1 1v6M3.5 5.5h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1h-6a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z"/></svg>`;
988
+ const CHECK_ICON = `<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8.5l3.5 3.5L13 5"/></svg>`;
989
+
990
+ /**
991
+ * @param {string} key
992
+ * @param {string} [label]
993
+ */
994
+ function copyButton(key, label = "") {
995
+ const done = copiedKey === key;
996
+ return `<button class="copy ${done ? "done" : ""}" data-action="copy" data-key="${escapeHtml(key)}"
997
+ title="${done ? "Kopyalandı" : "Kopyala"}">${done ? CHECK_ICON : COPY_ICON}${
998
+ label ? `<span>${escapeHtml(label)}</span>` : ""
999
+ }</button>`;
1000
+ }
1001
+
1002
+ /**
1003
+ * Sekmenin başındaki tek cümlelik anlatı.
1004
+ * @param {"good" | "mid" | "bad"} tone
1005
+ * @param {string} html
1006
+ */
1007
+ function lede(tone, html) {
1008
+ const mark = tone === "good" ? "✓" : tone === "mid" ? "⚠" : "✕";
1009
+ return `<div class="lede ${tone}"><span class="mark">${mark}</span><span>${html}</span></div>`;
1010
+ }
1011
+
1012
+ function errorsTab() {
1013
+ const server = serverStats?.errors ?? [];
1014
+
1015
+ clipboard.set(
1016
+ "all",
1017
+ [...clientErrors, ...server.map((item) => ({ ...item, source: "server" }))]
1018
+ .map(asText)
1019
+ .join("\n\n"),
1020
+ );
1021
+
1022
+ const total = clientErrors.length + server.length;
1023
+ const errorCount = [...clientErrors, ...server].filter(
1024
+ (item) => item.level === "error",
1025
+ ).length;
1026
+
1027
+ return `
1028
+ ${lede(
1029
+ errorCount ? "bad" : total ? "mid" : "good",
1030
+ errorCount
1031
+ ? `<strong>${errorCount} hata</strong> yakalandı${total - errorCount ? `, yanında ${total - errorCount} uyarı var` : ""}. En yenisi üstte; yığın izini açıp kaydı tek tıkla kopyalayabilirsin.`
1032
+ : total
1033
+ ? `Hata yok, <strong>${total} uyarı</strong> var. Uyarılar genelde eksik veri ya da port edilmemiş island'lardan gelir.`
1034
+ : "Bu oturumda tarayıcı ya da sunucu tarafında hiç hata görülmedi.",
1035
+ )}
1036
+ <h4>Tarayıcı · ${clientErrors.length}${
1037
+ total ? `<span class="head-action">${copyButton("all", "tümünü kopyala")}</span>` : ""
1038
+ }</h4>
1039
+ ${errorList(clientErrors, "client")}
1040
+ <h4>Sunucu · ${server.length}</h4>
1041
+ ${errorList(
1042
+ server.map((item) => ({ ...item, source: "server" })),
1043
+ "server",
1044
+ )}
1045
+ `;
1046
+ }
1047
+
1048
+ function perfTab() {
1049
+ const resources = resourceSummary();
1050
+ const islands = islandSummary();
1051
+ const memory = performance.memory?.usedJSHeapSize;
1052
+
1053
+ const types = Object.entries(resources.byType)
1054
+ .sort((a, b) => b[1].bytes - a[1].bytes)
1055
+ .slice(0, 6);
1056
+
1057
+ const heaviest = types[0]?.[1].bytes ?? 1;
1058
+
1059
+ clipboard.set(
1060
+ "perf",
1061
+ [
1062
+ `URL: ${location.href}`,
1063
+ `LCP: ${ms(metrics.lcp)} CLS: ${metrics.cls.toFixed(3)} INP: ${ms(metrics.inp)}`,
1064
+ `FCP: ${ms(metrics.fcp)} TTFB: ${ms(metrics.ttfb)}`,
1065
+ `Long task: ${metrics.longTasks} Blocking: ${ms(metrics.blocking)}`,
1066
+ `İstek: ${resources.count} Transfer: ${kb(resources.bytes)}`,
1067
+ `Island: ${islands.ready}/${islands.total}`,
1068
+ ].join("\n"),
1069
+ );
1070
+
1071
+ const vitals = [
1072
+ grade(metrics.lcp, [2500, 4000]),
1073
+ grade(metrics.cls * 1000, [100, 250]),
1074
+ grade(metrics.inp, [200, 500]),
1075
+ ];
1076
+ const tone = vitals.includes("bad") ? "bad" : vitals.includes("mid") ? "mid" : "good";
1077
+
1078
+ return `
1079
+ ${lede(
1080
+ tone,
1081
+ `Sayfa <strong>${ms(metrics.lcp)}</strong>'de en büyük içeriği boyadı, düzen kayması <strong>${metrics.cls.toFixed(3)}</strong>. ` +
1082
+ `Ana iş parçacığı ${metrics.longTasks} uzun görevle <strong>${ms(metrics.blocking)}</strong> bloke oldu; ` +
1083
+ `${resources.count} istekte <strong>${kb(resources.bytes)}</strong> indirildi ve ${islands.total} island'ın ${islands.ready}'i bağlandı.`,
1084
+ )}
1085
+ <h4>Core Web Vitals<span class="head-action">${copyButton("perf", "özeti kopyala")}</span></h4>
1086
+ ${metricGrid([
1087
+ { label: "LCP", value: ms(metrics.lcp), tone: grade(metrics.lcp, [2500, 4000]), ratio: ratio(metrics.lcp, 4000) },
1088
+ { label: "CLS", value: metrics.cls.toFixed(3), tone: grade(metrics.cls * 1000, [100, 250]), ratio: metrics.cls / 0.25 },
1089
+ { label: "INP", value: ms(metrics.inp), tone: grade(metrics.inp, [200, 500]), ratio: ratio(metrics.inp, 500) },
1090
+ { label: "FCP", value: ms(metrics.fcp), tone: grade(metrics.fcp, [1800, 3000]), ratio: ratio(metrics.fcp, 3000) },
1091
+ { label: "TTFB", value: ms(metrics.ttfb), tone: grade(metrics.ttfb, [800, 1800]), ratio: ratio(metrics.ttfb, 1800) },
1092
+ ])}
1093
+ <h4>Yükleme</h4>
1094
+ ${metricGrid([
1095
+ { label: "DOMContentLoaded", value: ms(metrics.dcl) },
1096
+ { label: "Load", value: ms(metrics.load) },
1097
+ { label: "Long task", value: String(metrics.longTasks), tone: grade(metrics.longTasks, [2, 6]), ratio: metrics.longTasks / 6 },
1098
+ { label: "Blocking", value: ms(metrics.blocking), tone: grade(metrics.blocking, [200, 600]), ratio: metrics.blocking / 600 },
1099
+ ...(memory ? [{ label: "JS heap", value: kb(memory) }] : []),
1100
+ ])}
1101
+ <h4>Kaynaklar</h4>
1102
+ ${metricGrid([
1103
+ { label: "İstek", value: String(resources.count) },
1104
+ { label: "Transfer", value: kb(resources.bytes) },
1105
+ { label: "Island", value: `${islands.ready}/${islands.total}`, ratio: islands.total ? islands.ready / islands.total : null, tone: "good" },
1106
+ ])}
1107
+ <div class="rows" style="margin-top:8px">
1108
+ ${types
1109
+ .map(
1110
+ ([kind, info]) => `<div class="row">
1111
+ <span class="tag">${escapeHtml(kind)}</span>
1112
+ <span class="path">${info.count} istek</span>
1113
+ <span class="hint">${Math.round((info.bytes / heaviest) * 100)}%</span>
1114
+ <span>${kb(info.bytes)}</span>
1115
+ </div>`,
1116
+ )
1117
+ .join("")}
1118
+ </div>
1119
+ `;
1120
+ }
1121
+
1122
+ function serverTab() {
1123
+ if (!serverStats) return `<div class="empty">Sunucu istatistikleri bekleniyor…</div>`;
1124
+
1125
+ const requests = serverStats.requests ?? [];
1126
+ const slowest = requests.reduce((max, item) => Math.max(max, item.ms), 0);
1127
+ const average = requests.length
1128
+ ? requests.reduce((sum, item) => sum + item.ms, 0) / requests.length
1129
+ : null;
1130
+
1131
+ clipboard.set(
1132
+ "requests",
1133
+ requests
1134
+ .map(
1135
+ (item) =>
1136
+ `${new Date(item.at).toISOString()} ${item.method} ${item.url} ${item.status} ${Math.round(item.ms)}ms${
1137
+ item.cache ? ` ${item.cache}` : ""
1138
+ }`,
1139
+ )
1140
+ .join("\n"),
1141
+ );
1142
+
1143
+ return `
1144
+ ${lede(
1145
+ offline ? "mid" : grade(average, [150, 500]) || "good",
1146
+ offline
1147
+ ? "Sunucuya şu an ulaşılamıyor; yeniden başlatılmayı bekliyoruz. Eldeki kayıtlar korunuyor."
1148
+ : `Sunucu <strong>${Math.round(serverStats.uptime ?? 0)} sn</strong> ayakta, ` +
1149
+ `son ${requests.length} isteği ortalama <strong>${ms(average)}</strong> ile yanıtladı ` +
1150
+ `(en yavaş ${ms(slowest)}). Süreç ${kb(serverStats.memory?.rss ?? 0)} bellek kullanıyor` +
1151
+ `${restarts ? ` ve bu oturumda ${restarts} kez yeniden başladı` : ""}.`,
1152
+ )}
1153
+ <h4>Süreç${
1154
+ requests.length
1155
+ ? `<span class="head-action">${copyButton("requests", "istekleri kopyala")}</span>`
1156
+ : ""
1157
+ }</h4>
1158
+ ${metricGrid([
1159
+ { label: "Node", value: serverStats.node ?? "—" },
1160
+ { label: "Uptime", value: `${Math.round(serverStats.uptime ?? 0)} sn` },
1161
+ { label: "RSS", value: kb(serverStats.memory?.rss ?? 0) },
1162
+ { label: "Heap", value: kb(serverStats.memory?.heapUsed ?? 0) },
1163
+ { label: "Ort. render", value: ms(average), tone: grade(average, [150, 500]) },
1164
+ { label: "En yavaş", value: ms(slowest), tone: grade(slowest, [300, 1000]) },
1165
+ { label: "Restart", value: String(restarts), tone: offline ? "mid" : "" },
1166
+ ...(serverStats.prewarm?.total
1167
+ ? [
1168
+ {
1169
+ label: "Prewarm",
1170
+ value: `${serverStats.prewarm.ok}/${serverStats.prewarm.total}`,
1171
+ tone: serverStats.prewarm.failed ? "mid" : "good",
1172
+ ratio: serverStats.prewarm.done / serverStats.prewarm.total,
1173
+ },
1174
+ ]
1175
+ : []),
1176
+ ])}
1177
+ <h4>Son istekler</h4>
1178
+ ${
1179
+ requests.length
1180
+ ? `<div class="rows">${requests
1181
+ .map(
1182
+ (item) => `<div class="row">
1183
+ <span class="status ${item.status >= 500 ? "bad" : item.status >= 400 ? "warn" : ""}">${item.status}</span>
1184
+ <span class="path">${pathLink(item.url)}</span>
1185
+ <span class="hint">${escapeHtml(item.cache ?? "—")}</span>
1186
+ <span class="${grade(item.ms, [150, 500])}">${Math.round(item.ms)} ms</span>
1187
+ </div>`,
1188
+ )
1189
+ .join("")}</div>`
1190
+ : `<div class="empty">Henüz istek yok.</div>`
1191
+ }
1192
+ `;
1193
+ }
1194
+
1195
+ function warmTab() {
1196
+ const warm = serverStats?.prewarm;
1197
+
1198
+ if (!warm?.total) {
1199
+ return `
1200
+ ${warmToolbar(false)}
1201
+ <div class="empty">Önbellek ısıtması henüz başlamadı. Dev'de sunucu bir süre sakin kalınca tetiklenir; <code>PREWARM=0</code> ile kapatılabilir.</div>
1202
+ `;
1203
+ }
1204
+
1205
+ const entries = warm.entries ?? [];
1206
+ const failures = entries.filter((item) => item.error);
1207
+ const bytes = entries.reduce((sum, item) => sum + item.bytes, 0);
1208
+ const slowest = entries.reduce(
1209
+ (max, item) => (item.ms > (max?.ms ?? -1) ? item : max),
1210
+ null,
1211
+ );
1212
+ const average = entries.length
1213
+ ? entries.reduce((sum, item) => sum + item.ms, 0) / entries.length
1214
+ : null;
1215
+ const elapsed = warm.finishedAt
1216
+ ? warm.finishedAt - warm.startedAt
1217
+ : Date.now() - warm.startedAt;
1218
+ const cached = entries.filter((item) => item.cache && item.cache !== "MISS").length;
1219
+
1220
+ clipboard.set(
1221
+ "warm",
1222
+ [
1223
+ `Prewarm ${warm.ok}/${warm.total} · ${warm.failed} hata · ${(elapsed / 1000).toFixed(1)}s · ${kb(bytes)}`,
1224
+ ...entries.map(
1225
+ (item) =>
1226
+ `${String(item.status).padStart(3)} ${String(item.ms).padStart(5)}ms ${String(
1227
+ Math.round(item.bytes / 1024),
1228
+ ).padStart(5)}KB ${item.path}${item.error ? ` — ${item.error}` : ""}`,
1229
+ ),
1230
+ ].join("\n"),
1231
+ );
1232
+
1233
+ return `
1234
+ ${warmToolbar(warm.active)}
1235
+ ${lede(
1236
+ warm.active ? "mid" : warm.failed ? "bad" : "good",
1237
+ warm.active
1238
+ ? `Isıtma sürüyor: <strong>${warm.done}/${warm.total}</strong> sayfa denendi, şu ana kadar ${kb(bytes)} render edildi.`
1239
+ : warm.failed
1240
+ ? `<strong>${warm.ok}/${warm.total}</strong> sayfa ısındı, <strong>${warm.failed} sayfa hata verdi</strong>. Hatalı yollar aşağıda en üstte listeli.`
1241
+ : `Tüm <strong>${warm.total}</strong> sayfa ${(elapsed / 1000).toFixed(1)} saniyede ısıtıldı, toplam <strong>${kb(bytes)}</strong> HTML üretildi. İlk ziyaretçi soğuk render beklemeyecek.`,
1242
+ )}
1243
+ <h4>Özet<span class="head-action">${copyButton("warm", "listeyi kopyala")}</span></h4>
1244
+ ${metricGrid([
1245
+ { label: "Denenen", value: `${warm.done}/${warm.total}`, ratio: warm.done / warm.total, tone: "good" },
1246
+ { label: "Başarılı", value: String(warm.ok), tone: "good" },
1247
+ { label: "Hata", value: String(warm.failed), tone: warm.failed ? "bad" : "" },
1248
+ { label: "Süre", value: `${(elapsed / 1000).toFixed(1)} sn` },
1249
+ { label: "Toplam HTML", value: kb(bytes) },
1250
+ { label: "Ort. render", value: ms(average), tone: grade(average, [150, 500]) },
1251
+ { label: "En yavaş", value: ms(slowest?.ms ?? null), tone: grade(slowest?.ms ?? null, [300, 1000]) },
1252
+ { label: "Cache'ten", value: `${cached}/${entries.length}` },
1253
+ ])}
1254
+ ${
1255
+ failures.length
1256
+ ? `<h4>Hatalar · ${failures.length}<span class="head-action">
1257
+ <button class="btn mini" data-action="warm-retry" ${warm.active ? "disabled" : ""}>${REFRESH_ICON} Tekrar dene</button>
1258
+ </span></h4>
1259
+ <div class="rows">${failures.map(warmRow).join("")}</div>`
1260
+ : ""
1261
+ }
1262
+ <h4>Denenen sayfalar · ${entries.length}</h4>
1263
+ ${
1264
+ entries.length
1265
+ ? `<div class="rows">${[...entries]
1266
+ .sort((a, b) => b.ms - a.ms)
1267
+ .map(warmRow)
1268
+ .join("")}</div>`
1269
+ : `<div class="empty">Henüz sonuç yok.</div>`
1270
+ }
1271
+ `;
1272
+ }
1273
+
1274
+ /**
1275
+ * Sayfanın en üstündeki eylem şeridi.
1276
+ * @param {boolean} active ısıtma sürüyor mu
1277
+ */
1278
+ function warmToolbar(active) {
1279
+ return `<div class="toolbar">
1280
+ <button class="btn mini" data-action="warm-run" ${active ? "disabled" : ""}>
1281
+ ${REFRESH_ICON} ${active ? "Isıtma sürüyor…" : "Baştan prewarm başlat"}
1282
+ </button>
1283
+ </div>`;
1284
+ }
1285
+
1286
+ /**
1287
+ * @param {{ path: string, status: number, ms: number, bytes: number, cache: string | null, error: string | null }} item
1288
+ */
1289
+ function warmRow(item) {
1290
+ return `<div class="row">
1291
+ <span class="status ${item.error ? "bad" : item.status >= 400 ? "warn" : ""}">${item.status || "—"}</span>
1292
+ <span class="path">${pathLink(item.path)}${
1293
+ item.error ? ` <span class="hint">${escapeHtml(item.error)}</span>` : ""
1294
+ }</span>
1295
+ <span class="hint">${item.bytes ? kb(item.bytes) : "—"}</span>
1296
+ <span class="${grade(item.ms, [300, 1000])}">${item.ms} ms</span>
1297
+ </div>`;
1298
+ }
1299
+
1300
+ /**
1301
+ * Panel içindeki mini dokümantasyon. Anahtar sırası aynı zamanda alt sekme
1302
+ * sırasıdır; değer `{ label, html }`.
1303
+ */
1304
+ const ABOUT = {
1305
+ framework: {
1306
+ label: "Framework",
1307
+ html: `
1308
+ <p><strong>JSkelet</strong>, hazır bir meta-framework kullanmadan yazılmış
1309
+ ince bir katman: Express üstünde EJS ile sunucu tarafı render, esbuild ile island
1310
+ tabanlı istemci kodu ve token'lara bağlı bir tema sistemi.</p>
1311
+ <div class="steps">
1312
+ <div class="step"><span class="n">1</span><span><b>Sunucu render</b><span>Express + EJS sayfayı tamamen sunucuda üretir; sonuç HTML önbelleğinde tutulur, tekrar eden istekler milisaniyenin altında döner.</span></span></div>
1313
+ <div class="step"><span class="n">2</span><span><b>Island'lar</b><span>Etkileşimli her parça <code>data-island</code> ile işaretlenir, modülü görünüm alanına girince yüklenir. Sayfanın tamamı hidrate edilmez.</span></span></div>
1314
+ <div class="step"><span class="n">3</span><span><b>Build hattı</b><span>esbuild JS'i, Tailwind + LightningCSS stilleri, sharp görselleri, Phosphor ikonları derler; çıktı hash'lenip <code>manifest.json</code>'a yazılır.</span></span></div>
1315
+ <div class="step"><span class="n">4</span><span><b>Dev döngüsü</b><span>Kaynaklar izlenir: CSS değişince stylesheet takas edilir, JS veya sunucu değişince tek seferlik tam yenileme yapılır.</span></span></div>
1316
+ <div class="step"><span class="n">5</span><span><b>Gözlemlenebilirlik</b><span>Bu panel hataları, Web Vitals ölçümlerini ve sunucu isteklerini toplar; kayıtlar sunucu yeniden başlasa da korunur.</span></span></div>
1317
+ </div>
1318
+ <div class="cards">
1319
+ <div class="card"><b>Çalışma zamanı</b><span>Node + Express 5, ESM. Sayfa şablonları EJS, veri katmanı <code>lib/</code> altındaki servisler.</span></div>
1320
+ <div class="card"><b>Hedef tarayıcılar</b><span>Chrome/Edge/Firefox 111+, Safari 16.4+. Modern CSS ve ESM doğrudan kullanılır, transpile yükü yok.</span></div>
1321
+ <div class="card"><b>Bu panel</b><span>Yalnızca <code>npm run dev</code>'de yüklenir; layout script etiketini sadece development'ta basar, prod çıktısına girmez.</span></div>
1322
+ </div>
1323
+ `,
1324
+ },
1325
+ request: {
1326
+ label: "İstek akışı",
1327
+ html: `
1328
+ <p>Bir sayfa isteğinin sunucuda izlediği yol. Her adım <code>server/</code> altında
1329
+ ayrı bir dosyada durur; hiçbiri diğerinin içine gömülü değildir.</p>
1330
+ <div class="steps">
1331
+ <div class="step"><span class="n">1</span><span><b>Route</b><span><code>server/routes/</code> URL'i eşler ve isteği ilgili controller'a verir.</span></span></div>
1332
+ <div class="step"><span class="n">2</span><span><b>Controller</b><span><code>server/controllers/</code> gerekli veriyi <code>lib/</code> servislerinden toplar, şablona geçecek modeli hazırlar.</span></span></div>
1333
+ <div class="step"><span class="n">3</span><span><b>Render</b><span><code>server/render.js</code> EJS şablonunu çalıştırır, layout'a manifest girdilerini ve head ipuçlarını basar.</span></span></div>
1334
+ <div class="step"><span class="n">4</span><span><b>HTML önbelleği</b><span><code>server/html-cache.js</code> çıktıyı saklar; aynı URL tekrar istenirse şablon hiç çalıştırılmaz. Sunucu sekmesindeki <code>cached</code> etiketi bunu gösterir.</span></span></div>
1335
+ <div class="step"><span class="n">5</span><span><b>Yanıt</b><span>Sıkıştırma ve önbellek başlıkları <code>server/middleware/</code> içinde belirlenir; olay akışı gibi yanıtlar sıkıştırmadan geçer.</span></span></div>
1336
+ </div>
1337
+ <p><strong>Ön ısıtma:</strong> <code>server/prewarm.js</code> açılışta sık gezilen
1338
+ rotaları kendi kendine ziyaret eder, böylece ilk gerçek ziyaretçi sıcak önbelleğe düşer.
1339
+ Aynı işi elle yapmak için <code>npm run warm</code>.</p>
1340
+ `,
1341
+ },
1342
+ islands: {
1343
+ label: "Island'lar",
1344
+ html: `
1345
+ <p>İstemci kodu sayfa başına tek bir dev bundle değil; parça parça yüklenen
1346
+ <strong>island</strong>'lardan oluşur. Şablon parçası HTML'i basar, island yalnızca
1347
+ davranışı ekler.</p>
1348
+ <div class="steps">
1349
+ <div class="step"><span class="n">1</span><span><b>İşaretleme</b><span>EJS partial'ı kök elemana <code>data-island="ad"</code> koyar.</span></span></div>
1350
+ <div class="step"><span class="n">2</span><span><b>Modül</b><span><code>client/islands/</code> altında aynı adla bir dosya bulunur ve mount fonksiyonunu dışa verir.</span></span></div>
1351
+ <div class="step"><span class="n">3</span><span><b>Yükleme</b><span><code>client/core/registry.js</code> IntersectionObserver ile eleman görünüm alanına yaklaşınca modülü dinamik import eder.</span></span></div>
1352
+ <div class="step"><span class="n">4</span><span><b>Bağlanma</b><span>Mount başarılıysa elemana <code>data-island-ready</code> yazılır. Performans sekmesindeki <b>Island</b> kartı bu oranı gösterir.</span></span></div>
1353
+ </div>
1354
+ <div class="cards">
1355
+ <div class="card"><b>Neden</b><span>Ekranın altındaki grafik, harita veya yorum bloğu için JS indirmeye ilk boyamada gerek yok.</span></div>
1356
+ <div class="card"><b>Hata izi</b><span>Bir island patlarsa hata bu panelin <b>Hatalar</b> sekmesine <code>client</code> kaynağıyla düşer, sayfanın kalanı çalışmaya devam eder.</span></div>
1357
+ <div class="card"><b>Stub'lar</b><span><code>build/tasks/island-stubs.mjs</code> eksik island dosyalarını yakalar; isim uyuşmazlıkları build'de görünür.</span></div>
1358
+ </div>
1359
+ `,
1360
+ },
1361
+ build: {
1362
+ label: "Build",
1363
+ html: `
1364
+ <p><code>npm run build</code> tüm varlıkları <code>build/generated/</code> altına
1365
+ üretir ve hash'li dosya adlarını <code>manifest.json</code>'a yazar. Şablonlar dosya
1366
+ adını asla elle yazmaz, hep manifest üzerinden okur.</p>
1367
+ <div class="rows">
1368
+ <div class="row"><span class="tag">client</span><span class="path">esbuild ile island'lar ve çekirdek JS; watch modunda artımlı yeniden paketleme.</span><span class="hint"></span><span></span></div>
1369
+ <div class="row"><span class="tag">css</span><span class="path">Tailwind + PostCSS derlemesi, ardından LightningCSS ile küçültme ve tarayıcı hedefine indirme.</span><span class="hint"></span><span></span></div>
1370
+ <div class="row"><span class="tag">icons</span><span class="path">Phosphor setinden yalnızca kullanılan ikonlar toplanır, tek sprite'a yazılır.</span><span class="hint"></span><span></span></div>
1371
+ <div class="row"><span class="tag">fonts</span><span class="path">Font dosyaları alt kümelenip kopyalanır, preload ipuçları üretilir.</span><span class="hint"></span><span></span></div>
1372
+ <div class="row"><span class="tag">images</span><span class="path">sharp ile boyut varyantları ve modern formatlar.</span><span class="hint"></span><span></span></div>
1373
+ <div class="row"><span class="tag">precompress</span><span class="path">Statik çıktının gzip/brotli sürümleri önceden hazırlanır; sunucu istek anında sıkıştırmaz.</span><span class="hint"></span><span></span></div>
1374
+ </div>
1375
+ <p><code>npm start</code> öncesinde <code>build/ensure-build.mjs</code> çalışır: çıktı
1376
+ eksikse build kendiliğinden tetiklenir, yani derlenmemiş bir sunucu ayağa kalkmaz.</p>
1377
+ `,
1378
+ },
1379
+ dev: {
1380
+ label: "Dev akışı",
1381
+ html: `
1382
+ <p><code>npm run dev</code> tek bir süreç değil: <code>scripts/dev.mjs</code> build'i
1383
+ watch modunda başlatır, sunucuyu ayağa kaldırır ve ikisinin çıktısını tek bir
1384
+ okunur akışta birleştirir.</p>
1385
+ <div class="steps">
1386
+ <div class="step"><span class="n">1</span><span><b>İzleme</b><span>Yalnızca <code>server/</code>, <code>lib/</code> ve <code>views/</code> izlenir. Build çıktısı izlenmediği için kendi kendini tetikleyen restart döngüsü oluşmaz.</span></span></div>
1387
+ <div class="step"><span class="n">2</span><span><b>CSS takası</b><span>Stil değişince sayfa yenilenmez; yeni stylesheet yüklenip eskisi kaldırılır, kaydırma konumu ve açık paneller korunur.</span></span></div>
1388
+ <div class="step"><span class="n">3</span><span><b>Tam yenileme</b><span>JS veya sunucu kodu değişince tek seferlik reload yapılır. Sunucu süreç kimliği değiştiğinde de aynı yol izlenir.</span></span></div>
1389
+ <div class="step"><span class="n">4</span><span><b>Durum korunması</b><span>Panelin açık/kapalı durumu ve sekmesi sekme belleğinde; sunucu günlükleri ise süreç dışında saklanır. Restart panelin içeriğini silmez.</span></span></div>
1390
+ </div>
1391
+ <div class="cards">
1392
+ <div class="card"><b>Alt+D</b><span>Paneli açar/kapatır. <code>Esc</code> kapatır, karartma alanına tıklamak da kapatır.</span></div>
1393
+ <div class="card"><b>Sayfayı yenile</b><span>Panel açıkken yenileme yapar; yenileme sonrası panel aynı sekmeyle geri gelir.</span></div>
1394
+ <div class="card"><b>Kayıtları temizle</b><span>Tarayıcı ve sunucu taraflı hata/istek günlüklerini sıfırlar, paneli kapatmaz.</span></div>
1395
+ </div>
1396
+ `,
1397
+ },
1398
+ theme: {
1399
+ label: "Tema & UI",
1400
+ html: `
1401
+ <p>Stil kararları tek kaynaktan gelir. Renk, boşluk, radius ve tipografi
1402
+ token'ları <code>styles/theme.css</code> içindeki <code>:root</code> değişkenlerinde
1403
+ tanımlıdır; Tailwind sınıfları <code>styles/JSkelet.css</code> içindeki
1404
+ <code>@theme inline</code> bloğuyla bu token'lara bağlanır.</p>
1405
+ <div class="rows">
1406
+ <div class="row"><span class="tag">theme.css</span><span class="path">Token'lar ve <code>.wrapper</code>, <code>.clamp-2</code> gibi hazır sınıflar.</span><span class="hint"></span><span></span></div>
1407
+ <div class="row"><span class="tag">JSkelet.css</span><span class="path">Tailwind utility eşlemeleri ve <code>.JSkelet-type-*</code> gibi bileşik sınıflar.</span><span class="hint"></span><span></span></div>
1408
+ <div class="row"><span class="tag">components/ui</span><span class="path">Buton, kart, modal gibi yeniden kullanılabilir parçaların tek adresi.</span><span class="hint"></span><span></span></div>
1409
+ </div>
1410
+ <p><strong>Kural:</strong> yeni bir renk ya da ölçü gerektiğinde hex/pixel
1411
+ hardcode edilmez; önce token eklenir, gerekiyorsa Tailwind tarafına eşlenir,
1412
+ sonra bileşende kullanılır. Yeni genel amaçlı bir UI parçası da
1413
+ <code>views/components/ui/</code> dışına çıkmaz.</p>
1414
+ `,
1415
+ },
1416
+ commands: {
1417
+ label: "Komutlar",
1418
+ html: `
1419
+ <p>Sık kullanılan npm script'leri. Doğrulama script'leri belirli sayfaların
1420
+ beklenen parçaları içerip içermediğini gerçek çıktı üzerinden kontrol eder.</p>
1421
+ <div class="rows">
1422
+ <div class="row"><span class="tag">dev</span><span class="path">Build watch + sunucu, bu panelle birlikte.</span><span class="hint"></span><span></span></div>
1423
+ <div class="row"><span class="tag">build</span><span class="path">Üretim çıktısı: JS, CSS, ikon, font, görsel, ön sıkıştırma.</span><span class="hint"></span><span></span></div>
1424
+ <div class="row"><span class="tag">start</span><span class="path">Üretim sunucusu; öncesinde build eksikse tamamlanır.</span><span class="hint"></span><span></span></div>
1425
+ <div class="row"><span class="tag">lint</span><span class="path">ESLint. Değişiklik sonrası varsayılan doğrulama yolu budur, tam build şart değil.</span><span class="hint"></span><span></span></div>
1426
+ <div class="row"><span class="tag">warm</span><span class="path">Önbelleği elle ısıtır.</span><span class="hint"></span><span></span></div>
1427
+ <div class="row"><span class="tag">perf</span><span class="path">Performans denetimi raporu üretir.</span><span class="hint"></span><span></span></div>
1428
+ <div class="row"><span class="tag">verify:*</span><span class="path">Rota dumanı, head etiketleri, grafik, yorumlar, hisse sekmeleri, haber akışı ve ana sayfa widget'ları için hedefli kontroller.</span><span class="hint"></span><span></span></div>
1429
+ </div>
1430
+ `,
1431
+ },
1432
+ layout: {
1433
+ label: "Klasörler",
1434
+ html: `
1435
+ <p>Bir şeyi nerede arayacağını bilmek için kısa harita.</p>
1436
+ <div class="rows">
1437
+ <div class="row"><span class="tag">server/</span><span class="path">Express uygulaması: rotalar, controller'lar, middleware, render ve önbellek.</span><span class="hint"></span><span></span></div>
1438
+ <div class="row"><span class="tag">views/</span><span class="path">EJS layout'u, sayfalar ve partial'lar. HTML'in tek kaynağı.</span><span class="hint"></span><span></span></div>
1439
+ <div class="row"><span class="tag">client/</span><span class="path">Tarayıcı kodu: <code>core/</code> island altyapısı, <code>islands/</code> davranışlar, <code>devtools/</code> bu panel.</span><span class="hint"></span><span></span></div>
1440
+ <div class="row"><span class="tag">lib/</span><span class="path">Veri servisleri ve alan mantığı; API çağrıları, dönüşümler, formatlayıcılar.</span><span class="hint"></span><span></span></div>
1441
+ <div class="row"><span class="tag">app/</span><span class="path">Tema dosyaları ve paylaşılan UI bileşenleri.</span><span class="hint"></span><span></span></div>
1442
+ <div class="row"><span class="tag">build/</span><span class="path">Derleme script'leri (<code>tasks/</code>) ve üretilen çıktı (<code>generated/</code>).</span><span class="hint"></span><span></span></div>
1443
+ <div class="row"><span class="tag">framework/</span><span class="path">Build ve dev süreçlerinin paylaştığı yardımcılar, terminal günlüğü dahil.</span><span class="hint"></span><span></span></div>
1444
+ <div class="row"><span class="tag">scripts/</span><span class="path">Dev orkestrasyonu ve doğrulama/bakım araçları.</span><span class="hint"></span><span></span></div>
1445
+ <div class="row"><span class="tag">public/</span><span class="path">Doğrudan servis edilen statik dosyalar.</span><span class="hint"></span><span></span></div>
1446
+ <div class="row"><span class="tag">docs/</span><span class="path">Uzun biçimli notlar ve karar kayıtları.</span><span class="hint"></span><span></span></div>
1447
+ </div>
1448
+ `,
1449
+ },
1450
+ };
1451
+
1452
+ let aboutSection = "framework";
1453
+
1454
+ function aboutTab() {
1455
+ const section = ABOUT[aboutSection] ?? ABOUT.framework;
1456
+
1457
+ return `
1458
+ ${lede(
1459
+ "good",
1460
+ `JSkelet ön yüzü kendi ince framework'ü üstünde çalışıyor: <strong>sunucuda HTML</strong>, ` +
1461
+ `tarayıcıda yalnızca gerekli <strong>island'lar</strong>. Bu sekme panelin içindeki kısa dokümantasyon.`,
1462
+ )}
1463
+ <div class="subtabs">
1464
+ ${Object.entries(ABOUT)
1465
+ .map(
1466
+ ([key, item]) =>
1467
+ `<button class="subtab" data-about="${key}" aria-selected="${aboutSection === key}">${escapeHtml(item.label)}</button>`,
1468
+ )
1469
+ .join("")}
1470
+ </div>
1471
+ <h4>${escapeHtml(section.label)}</h4>
1472
+ <div class="prose">${section.html}</div>
1473
+ `;
1474
+ }
1475
+
1476
+ /** Gövde HTML'i yalnızca gerçekten değiştiyse yazılır (titreme + reflow). */
1477
+ let lastBody = "";
1478
+
1479
+ /** @type {number | null} */
1480
+ let frame = null;
1481
+
1482
+ /**
1483
+ * Ölçüm gözlemcileri ve yoklama aynı karede birçok kez tetikleniyordu; çizim
1484
+ * tek bir kareye toplanır.
1485
+ */
1486
+ function render() {
1487
+ if (frame != null) return;
1488
+ frame = requestAnimationFrame(() => {
1489
+ frame = null;
1490
+ paint();
1491
+ });
1492
+ }
1493
+
1494
+ /** Bitiş rozeti bu süre kadar daha görünür kalır. */
1495
+ const WARM_LINGER_MS = 6000;
1496
+
1497
+ /**
1498
+ * Isıtma sürüyorsa ilerleme, bittikten kısa süre sonra da sonuç gösterilir;
1499
+ * ardından rozet kendiliğinden kaybolur.
1500
+ *
1501
+ * @param {ShadowRoot} root
1502
+ */
1503
+ function paintPrewarm(root) {
1504
+ const chip = root.querySelector("[data-part='warm']");
1505
+ const warm = serverStats?.prewarm;
1506
+
1507
+ if (!warm?.total) {
1508
+ chip.hidden = true;
1509
+ return;
1510
+ }
1511
+
1512
+ const finished = !warm.active && warm.finishedAt;
1513
+ const lingering = finished && Date.now() - warm.finishedAt < WARM_LINGER_MS;
1514
+
1515
+ if (!warm.active && !lingering) {
1516
+ chip.hidden = true;
1517
+ return;
1518
+ }
1519
+
1520
+ chip.hidden = false;
1521
+ chip.className = `warm ${warm.active ? "" : "done"}`;
1522
+ chip.title = `Önbellek ısıtması: ${warm.ok} başarılı${
1523
+ warm.failed ? `, ${warm.failed} hata` : ""
1524
+ } / ${warm.total} sayfa`;
1525
+ chip.querySelector("[data-part='warm-text']").textContent = warm.active
1526
+ ? `${warm.done}/${warm.total} prewarmed`
1527
+ : `${warm.ok}/${warm.total} prewarmed`;
1528
+
1529
+ // Bitiş rozetinin süresi dolduğunda kendiliğinden kaybolması için.
1530
+ if (!warm.active) setTimeout(render, WARM_LINGER_MS);
1531
+ }
1532
+
1533
+ function paint() {
1534
+ const root = ensureRoot();
1535
+
1536
+ const errorCount =
1537
+ clientErrors.filter((item) => item.level === "error").length +
1538
+ (serverStats?.errors ?? []).filter((item) => item.level === "error").length;
1539
+ const warnCount =
1540
+ clientErrors.length + (serverStats?.errors ?? []).length - errorCount;
1541
+
1542
+ const tone = errorCount ? "bad" : warnCount ? "warn" : "";
1543
+ const badgeCount = errorCount || warnCount;
1544
+ const lcpLabel = metrics.lcp ? `${Math.round(metrics.lcp)} ms` : "ölçülüyor";
1545
+
1546
+ const bubble = root.querySelector(".bubble");
1547
+ bubble.className = `bubble ${tone}`;
1548
+ bubble.title = `JSkelet dev araçları — LCP ${lcpLabel}${
1549
+ badgeCount ? `, ${errorCount} hata, ${warnCount} uyarı` : ""
1550
+ }`;
1551
+
1552
+ const badge = root.querySelector(".badge");
1553
+ badge.hidden = !badgeCount;
1554
+ badge.className = `badge ${errorCount ? "" : "warn"}`;
1555
+ badge.textContent = badgeCount > 99 ? "99+" : String(badgeCount);
1556
+
1557
+ paintPrewarm(root);
1558
+
1559
+ const backdrop = root.querySelector(".backdrop");
1560
+ backdrop.hidden = !open;
1561
+ if (!open) return;
1562
+
1563
+ const pill = root.querySelector(".pill");
1564
+ pill.className = `pill ${offline ? "warn" : tone}`;
1565
+ pill.querySelector("[data-part='pill']").textContent = offline
1566
+ ? "sunucu yeniden başlıyor…"
1567
+ : errorCount || warnCount
1568
+ ? `${errorCount} hata · ${warnCount} uyarı`
1569
+ : "sorun yok";
1570
+
1571
+ const chip = root.querySelector("[data-part='chip']");
1572
+ chip.hidden = !errorCount;
1573
+ chip.textContent = String(errorCount);
1574
+
1575
+ const warm = serverStats?.prewarm;
1576
+ const warmChip = root.querySelector("[data-part='warm-chip']");
1577
+ warmChip.hidden = !warm?.total;
1578
+ if (warm?.total) {
1579
+ warmChip.className = `chip ${warm.failed ? "" : "neutral"}`;
1580
+ warmChip.textContent = warm.active
1581
+ ? `${warm.done}/${warm.total}`
1582
+ : warm.failed
1583
+ ? String(warm.failed)
1584
+ : String(warm.ok);
1585
+ }
1586
+
1587
+ for (const tab of root.querySelectorAll(".tab")) {
1588
+ tab.setAttribute("aria-selected", String(tab.dataset.tab === activeTab));
1589
+ }
1590
+
1591
+ const html =
1592
+ activeTab === "errors"
1593
+ ? errorsTab()
1594
+ : activeTab === "perf"
1595
+ ? perfTab()
1596
+ : activeTab === "about"
1597
+ ? aboutTab()
1598
+ : activeTab === "warm"
1599
+ ? warmTab()
1600
+ : serverTab();
1601
+
1602
+ if (html !== lastBody) {
1603
+ const body = root.querySelector(".body");
1604
+ const scrollTop = body.scrollTop;
1605
+ body.innerHTML = html;
1606
+ body.scrollTop = scrollTop;
1607
+ lastBody = html;
1608
+ }
1609
+ }
1610
+
1611
+ /**
1612
+ * Kaydı panoya alır ve butonu kısa süre onay durumuna geçirir.
1613
+ * @param {string} key
1614
+ */
1615
+ async function copy(key) {
1616
+ const text = clipboard.get(key);
1617
+ if (!text) return;
1618
+
1619
+ try {
1620
+ await navigator.clipboard.writeText(text);
1621
+ } catch {
1622
+ // İzin verilmemiş olabilir (odak dışı sekme vb.); eski yönteme düş.
1623
+ const field = document.createElement("textarea");
1624
+ field.value = text;
1625
+ field.style.position = "fixed";
1626
+ field.style.opacity = "0";
1627
+ document.body.append(field);
1628
+ field.select();
1629
+ document.execCommand("copy");
1630
+ field.remove();
1631
+ }
1632
+
1633
+ copiedKey = key;
1634
+ // İkon değişimi görünsün diye gövde yeniden çizilir.
1635
+ lastBody = "";
1636
+ render();
1637
+
1638
+ setTimeout(() => {
1639
+ if (copiedKey !== key) return;
1640
+ copiedKey = null;
1641
+ lastBody = "";
1642
+ render();
1643
+ }, 1400);
1644
+ }
1645
+
1646
+ /** @param {boolean} value */
1647
+ function setOpen(value) {
1648
+ open = value;
1649
+ saveState();
1650
+ render();
1651
+ if (open) pollServer();
1652
+ }
1653
+
1654
+ /** @param {ShadowRoot} root */
1655
+ function bind(root) {
1656
+ root.addEventListener("click", (event) => {
1657
+ const target = /** @type {HTMLElement} */ (event.target).closest(
1658
+ "[data-action], [data-tab], [data-about]",
1659
+ );
1660
+ if (!target) return;
1661
+
1662
+ if (target.dataset.about) {
1663
+ aboutSection = target.dataset.about;
1664
+ saveState();
1665
+ render();
1666
+ return;
1667
+ }
1668
+
1669
+ if (target.dataset.tab) {
1670
+ activeTab = target.dataset.tab;
1671
+ saveState();
1672
+ render();
1673
+ return;
1674
+ }
1675
+
1676
+ if (target.dataset.action === "stack") {
1677
+ const key = target.dataset.key;
1678
+ if (expanded.has(key)) expanded.delete(key);
1679
+ else expanded.add(key);
1680
+ saveState();
1681
+ render();
1682
+ return;
1683
+ }
1684
+
1685
+ if (target.dataset.action === "toggle") {
1686
+ setOpen(!open);
1687
+ return;
1688
+ }
1689
+
1690
+ // Panel dışına (karartma alanına) tıklamak kapatır.
1691
+ if (target.dataset.action === "backdrop" && event.target === target) {
1692
+ setOpen(false);
1693
+ return;
1694
+ }
1695
+
1696
+ if (target.dataset.action === "reload") {
1697
+ // Panelin açık kalması durumun sekme belleğine yazılmasına bağlı.
1698
+ saveState();
1699
+ location.reload();
1700
+ return;
1701
+ }
1702
+
1703
+ if (target.dataset.action === "warm-run") {
1704
+ startPrewarm();
1705
+ return;
1706
+ }
1707
+
1708
+ if (target.dataset.action === "warm-retry") {
1709
+ startPrewarm(
1710
+ (serverStats?.prewarm?.entries ?? [])
1711
+ .filter((item) => item.error)
1712
+ .map((item) => item.path),
1713
+ );
1714
+ return;
1715
+ }
1716
+
1717
+ if (target.dataset.action === "copy") {
1718
+ copy(target.dataset.key);
1719
+ return;
1720
+ }
1721
+
1722
+ if (target.dataset.action === "clear") clearAll();
1723
+ });
1724
+ }
1725
+
1726
+ function start() {
1727
+ loadState();
1728
+ captureErrors();
1729
+ captureMetrics();
1730
+ captureFetch();
1731
+ bind(ensureRoot());
1732
+ render();
1733
+
1734
+ connectEvents();
1735
+
1736
+ setInterval(() => {
1737
+ // Panel kapalıyken de rozet güncel kalsın diye sunucu yine yoklanır.
1738
+ pollServer();
1739
+ }, POLL_MS);
1740
+ pollServer();
1741
+
1742
+ // Ölçümler oturmadan gönderilmesin; sonra sekmeden ayrılırken güncellenir.
1743
+ setTimeout(() => sendPageReport(), 3000);
1744
+ window.addEventListener("visibilitychange", () => {
1745
+ if (document.visibilityState === "hidden") sendPageReport(true);
1746
+ });
1747
+ window.addEventListener("pagehide", () => sendPageReport(true));
1748
+
1749
+ window.addEventListener("keydown", (event) => {
1750
+ if (event.altKey && event.code === "KeyD") {
1751
+ setOpen(!open);
1752
+ return;
1753
+ }
1754
+
1755
+ if (event.key === "Escape" && open) setOpen(false);
1756
+ });
1757
+ }
1758
+
1759
+ if (document.readyState === "loading") {
1760
+ document.addEventListener("DOMContentLoaded", start, { once: true });
1761
+ } else {
1762
+ start();
1763
+ }