@harshankur/viewcounter 3.0.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/.env.example +50 -6
  2. package/README.md +444 -104
  3. package/admin/apple-touch-icon.png +0 -0
  4. package/admin/assets/world-map.json +1 -0
  5. package/admin/css/admin.css +2568 -0
  6. package/admin/favicon.ico +0 -0
  7. package/admin/favicon.svg +9 -0
  8. package/admin/icon-192.png +0 -0
  9. package/admin/icon-512.png +0 -0
  10. package/admin/index.html +95 -0
  11. package/admin/js/api.js +146 -0
  12. package/admin/js/appTabs.js +100 -0
  13. package/admin/js/charts.js +842 -0
  14. package/admin/js/clamp.js +41 -0
  15. package/admin/js/constants.js +239 -0
  16. package/admin/js/dataTable.js +478 -0
  17. package/admin/js/dom.js +83 -0
  18. package/admin/js/format.js +130 -0
  19. package/admin/js/i18n.js +80 -0
  20. package/admin/js/icons.js +168 -0
  21. package/admin/js/listbox.js +145 -0
  22. package/admin/js/logs.js +318 -0
  23. package/admin/js/main.js +399 -0
  24. package/admin/js/modal.js +171 -0
  25. package/admin/js/overview.js +905 -0
  26. package/admin/js/passwordPrompt.js +75 -0
  27. package/admin/js/table.js +94 -0
  28. package/admin/js/theme.js +72 -0
  29. package/admin/js/toast.js +47 -0
  30. package/admin/js/viewDialogs.js +224 -0
  31. package/admin/js/views.js +751 -0
  32. package/admin/locales/en.json +683 -0
  33. package/admin/site.webmanifest +20 -0
  34. package/config/index.js +122 -4
  35. package/constants.js +334 -3
  36. package/db/AdminRepository.js +488 -0
  37. package/db/DatabaseManager.js +148 -26
  38. package/db/LogRepository.js +354 -0
  39. package/db/adminSchema.js +329 -0
  40. package/db/adminSessionStore.js +104 -0
  41. package/db/analysis.js +479 -0
  42. package/db/rejectionCounter.js +117 -0
  43. package/db/retention.js +97 -0
  44. package/index.js +91 -24
  45. package/middleware/adminAuth.js +244 -0
  46. package/middleware/adminValidation.js +319 -0
  47. package/middleware/auth.js +2 -2
  48. package/middleware/security.js +26 -2
  49. package/middleware/validation.js +50 -2
  50. package/package.json +20 -10
  51. package/routes/admin.js +546 -0
  52. package/routes/analytics.js +207 -19
  53. package/tracker/tracker.js +191 -0
  54. package/utils/appIdUtils.js +1 -1
  55. package/utils/cookieUtils.js +47 -0
  56. package/utils/durationUtils.js +33 -0
  57. package/utils/errorUtils.js +39 -1
  58. package/utils/geoCity.js +87 -0
  59. package/utils/ipUtils.js +1 -1
  60. package/utils/privacyUtils.js +2 -2
  61. package/utils/referrerParser.js +23 -5
  62. package/utils/secretStore.js +1 -1
  63. package/utils/userAgentParser.js +52 -3
  64. package/utils/visitorContext.js +70 -0
@@ -0,0 +1,2568 @@
1
+ /*
2
+ * ViewCounter admin UI.
3
+ *
4
+ * Same design language as the documentation site (docs/index.html): the
5
+ * terminal palette, window chrome, grid and glow. Differences, on purpose:
6
+ * - system monospace fonts only. The docs site loads Google Fonts; the admin
7
+ * UI never contacts a third party, so an admin's IP is never disclosed to a
8
+ * font host and the strict CSP needs no exception.
9
+ * - the theme is a class on <html> (FRONTEND.md §6), not a data attribute.
10
+ */
11
+
12
+ /* ---------- Tokens (FRONTEND.md §4) ---------- */
13
+
14
+ :root,
15
+ .theme-dark {
16
+ /* Brand */
17
+ --primary: #3ddc84;
18
+ --primary-bright: #7dffb3;
19
+ --primary-glow: rgba(61, 220, 132, 0.35);
20
+ --secondary: #ffb454;
21
+ /* Background */
22
+ --bg: #0a0f0c;
23
+ /* Surface */
24
+ --card-bg: rgba(16, 24, 20, 0.82);
25
+ --surface-solid: #101814;
26
+ --nav-bg: rgba(8, 11, 9, 0.86);
27
+ --code-bg: #0d1310;
28
+ --row-hover: rgba(61, 220, 132, 0.06);
29
+ --row-selected: rgba(61, 220, 132, 0.12);
30
+ /* Border */
31
+ --border: rgba(61, 220, 132, 0.22);
32
+ --border-strong: rgba(61, 220, 132, 0.45);
33
+ /* Text */
34
+ --text: #e8f6ec;
35
+ --text-muted: #7c9686;
36
+ --text-on-primary: #0a0f0c;
37
+ /* Semantic status */
38
+ --success: #3ddc84;
39
+ --danger: #ef4444;
40
+ --danger-bg: rgba(239, 68, 68, 0.14);
41
+ --warning: #f5b942;
42
+ --warning-bg: rgba(245, 185, 66, 0.14);
43
+ --info: #5aa9e6;
44
+ --info-bg: rgba(90, 169, 230, 0.14);
45
+ --muted-bg: rgba(124, 150, 134, 0.16);
46
+ /* Effects */
47
+ --scanline-opacity: 0.4;
48
+ --shadow: 0 30px 60px -20px rgba(0, 0, 0, 0.45);
49
+ --backdrop: rgba(4, 7, 5, 0.72);
50
+ color-scheme: dark;
51
+
52
+ /* Charts. Validated with the data-viz palette checks against the card
53
+ surfaces of each theme: one series hue inside the lightness band, and a
54
+ one-hue sequential map ramp, monotone, with its low end >= 2:1 so "few
55
+ views" never reads as "no views" (--map-0). */
56
+ --chart-series: #2aa862;
57
+ --chart-area: rgba(42, 168, 98, 0.1);
58
+ --chart-grid: rgba(61, 220, 132, 0.1);
59
+ --chart-baseline: rgba(61, 220, 132, 0.3);
60
+ --map-0: #1a2420;
61
+ --map-1: #236642;
62
+ --map-2: #288552;
63
+ --map-3: #2ea563;
64
+ --map-4: #4cc57f;
65
+ --map-5: #8fe3b2;
66
+
67
+ --font-mono: ui-monospace, 'SFMono-Regular', 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
68
+ --radius: 12px;
69
+ --radius-small: 8px;
70
+ }
71
+
72
+ .theme-light {
73
+ --primary: #1f7a4a;
74
+ --primary-bright: #12653c;
75
+ --primary-glow: rgba(31, 122, 74, 0.16);
76
+ --secondary: #b5590f;
77
+ --bg: #f3efe2;
78
+ --card-bg: rgba(255, 253, 246, 0.9);
79
+ --surface-solid: #fffdf6;
80
+ --nav-bg: rgba(243, 239, 226, 0.9);
81
+ --code-bg: #eae4d1;
82
+ --row-hover: rgba(31, 122, 74, 0.05);
83
+ --row-selected: rgba(31, 122, 74, 0.1);
84
+ --border: rgba(31, 122, 74, 0.25);
85
+ --border-strong: rgba(31, 122, 74, 0.5);
86
+ --text: #1b2a20;
87
+ --text-muted: #5f6f63;
88
+ --text-on-primary: #fffdf6;
89
+ --success: #1f7a4a;
90
+ --danger: #b42318;
91
+ --danger-bg: rgba(180, 35, 24, 0.1);
92
+ --warning: #8a5a00;
93
+ --warning-bg: rgba(181, 89, 15, 0.12);
94
+ --info: #1c6fa8;
95
+ --info-bg: rgba(28, 111, 168, 0.1);
96
+ --muted-bg: rgba(95, 111, 99, 0.12);
97
+ --scanline-opacity: 0;
98
+ --shadow: 0 24px 48px -20px rgba(27, 42, 32, 0.25);
99
+ --backdrop: rgba(27, 42, 32, 0.4);
100
+ --chart-series: #1f7a4a;
101
+ --chart-area: rgba(31, 122, 74, 0.1);
102
+ --chart-grid: rgba(31, 122, 74, 0.1);
103
+ --chart-baseline: rgba(31, 122, 74, 0.35);
104
+ --map-0: #ebe5d2;
105
+ --map-1: #7cc298;
106
+ --map-2: #52ab79;
107
+ --map-3: #33935f;
108
+ --map-4: #1f7a4a;
109
+ --map-5: #11532f;
110
+ color-scheme: light;
111
+ }
112
+
113
+ /* ---------- Base ---------- */
114
+
115
+ *,
116
+ *::before,
117
+ *::after {
118
+ margin: 0;
119
+ padding: 0;
120
+ box-sizing: border-box;
121
+ }
122
+
123
+ html {
124
+ background-color: var(--bg);
125
+ }
126
+
127
+ body {
128
+ font-family: var(--font-mono);
129
+ font-size: 14px;
130
+ background-color: var(--bg);
131
+ color: var(--text);
132
+ line-height: 1.5;
133
+ min-height: 100vh;
134
+ transition: background-color 0.3s, color 0.3s;
135
+ }
136
+
137
+ body.booting main {
138
+ visibility: hidden;
139
+ }
140
+
141
+ body.modal-open {
142
+ overflow: hidden;
143
+ }
144
+
145
+ h1, h2, h3 {
146
+ font-weight: 700;
147
+ letter-spacing: -0.01em;
148
+ }
149
+
150
+ button,
151
+ input,
152
+ textarea {
153
+ font: inherit;
154
+ color: inherit;
155
+ }
156
+
157
+ [hidden] {
158
+ display: none !important;
159
+ }
160
+
161
+ .sr-only {
162
+ position: absolute;
163
+ width: 1px;
164
+ height: 1px;
165
+ overflow: hidden;
166
+ clip: rect(0 0 0 0);
167
+ white-space: nowrap;
168
+ }
169
+
170
+ .mono {
171
+ font-family: var(--font-mono);
172
+ }
173
+
174
+ :focus-visible {
175
+ outline: 2px solid var(--primary);
176
+ outline-offset: 2px;
177
+ }
178
+
179
+ .skip-link {
180
+ position: absolute;
181
+ left: 1rem;
182
+ top: -3rem;
183
+ z-index: 300;
184
+ padding: 0.5rem 0.9rem;
185
+ background: var(--primary);
186
+ color: var(--text-on-primary);
187
+ border-radius: var(--radius-small);
188
+ text-decoration: none;
189
+ }
190
+
191
+ .skip-link:focus {
192
+ top: 1rem;
193
+ }
194
+
195
+ /* ---------- Ambient background (as on the docs site) ---------- */
196
+
197
+ .bg-grid {
198
+ position: fixed;
199
+ inset: 0;
200
+ z-index: -2;
201
+ pointer-events: none;
202
+ background-image:
203
+ linear-gradient(var(--border) 1px, transparent 1px),
204
+ linear-gradient(90deg, var(--border) 1px, transparent 1px);
205
+ background-size: 48px 48px;
206
+ opacity: 0.35;
207
+ mask-image: radial-gradient(ellipse 80% 55% at 50% 0%, black 30%, transparent 100%);
208
+ }
209
+
210
+ .bg-glow {
211
+ position: fixed;
212
+ top: -10%;
213
+ left: 50%;
214
+ transform: translateX(-50%);
215
+ width: min(900px, 100vw);
216
+ height: 500px;
217
+ background: radial-gradient(ellipse at center, var(--primary-glow) 0%, transparent 70%);
218
+ z-index: -1;
219
+ pointer-events: none;
220
+ }
221
+
222
+ .crt-overlay {
223
+ position: fixed;
224
+ inset: 0;
225
+ z-index: 250;
226
+ pointer-events: none;
227
+ background: repeating-linear-gradient(to bottom,
228
+ rgba(255, 255, 255, 0.05) 0px,
229
+ rgba(255, 255, 255, 0.05) 1px,
230
+ transparent 1px,
231
+ transparent 3px);
232
+ opacity: var(--scanline-opacity);
233
+ mix-blend-mode: overlay;
234
+ }
235
+
236
+ /* ---------- Icons ---------- */
237
+
238
+ /* Line icons take the size and colour of the text around them. */
239
+ .icon {
240
+ width: 1.1em;
241
+ height: 1.1em;
242
+ flex-shrink: 0;
243
+ }
244
+
245
+ /* ---------- Top bar ---------- */
246
+
247
+ .topbar {
248
+ position: sticky;
249
+ top: 0;
250
+ z-index: 100;
251
+ display: flex;
252
+ align-items: center;
253
+ justify-content: space-between;
254
+ gap: 1rem;
255
+ padding: 1rem 2rem;
256
+ background: var(--nav-bg);
257
+ backdrop-filter: blur(10px);
258
+ border-bottom: 1px solid var(--border);
259
+ }
260
+
261
+ .logo {
262
+ display: flex;
263
+ align-items: center;
264
+ gap: 0.55rem;
265
+ }
266
+
267
+ .logo-mark {
268
+ height: 24px;
269
+ width: auto;
270
+ color: var(--primary);
271
+ }
272
+
273
+ .logo-cursor {
274
+ animation: blink 1.1s steps(1) infinite;
275
+ }
276
+
277
+ .logo-text {
278
+ font-weight: 700;
279
+ font-size: 1.05rem;
280
+ }
281
+
282
+ .logo-badge {
283
+ font-size: 0.72rem;
284
+ font-weight: 700;
285
+ text-transform: uppercase;
286
+ letter-spacing: 0.08em;
287
+ color: var(--secondary);
288
+ border: 1px solid currentColor;
289
+ border-radius: 4px;
290
+ padding: 0.05rem 0.4rem;
291
+ }
292
+
293
+ .version-badge {
294
+ font-size: 0.72rem;
295
+ color: var(--text-muted);
296
+ font-variant-numeric: tabular-nums;
297
+ }
298
+
299
+ .topbar-actions {
300
+ display: flex;
301
+ align-items: center;
302
+ gap: 0.75rem;
303
+ }
304
+
305
+ .header-link {
306
+ display: inline-flex;
307
+ align-items: center;
308
+ gap: 0.4rem;
309
+ color: var(--text-muted);
310
+ font-size: 0.85rem;
311
+ font-weight: 600;
312
+ text-decoration: none;
313
+ padding: 0.35rem 0.5rem;
314
+ border-radius: var(--radius-small);
315
+ }
316
+
317
+ .header-link:hover {
318
+ color: var(--primary);
319
+ background: var(--row-hover);
320
+ }
321
+
322
+ .theme-switcher {
323
+ display: flex;
324
+ background: var(--code-bg);
325
+ border: 1px solid var(--border);
326
+ padding: 0.2rem;
327
+ border-radius: var(--radius-small);
328
+ gap: 0.2rem;
329
+ }
330
+
331
+ .theme-btn {
332
+ background: transparent;
333
+ border: none;
334
+ color: var(--text-muted);
335
+ width: 30px;
336
+ height: 30px;
337
+ border-radius: 5px;
338
+ display: flex;
339
+ align-items: center;
340
+ justify-content: center;
341
+ cursor: pointer;
342
+ transition: all 0.2s;
343
+ }
344
+
345
+ .theme-btn:hover {
346
+ color: var(--text);
347
+ background: var(--border);
348
+ }
349
+
350
+ .theme-btn.active {
351
+ background: var(--primary);
352
+ color: var(--bg);
353
+ box-shadow: 0 0 12px var(--primary-glow);
354
+ }
355
+
356
+ .theme-btn .icon {
357
+ width: 16px;
358
+ height: 16px;
359
+ }
360
+
361
+ /* ---------- Layout ---------- */
362
+
363
+ /* Wide enough that a large screen shows more columns and wider charts. */
364
+ main {
365
+ max-width: 1920px;
366
+ margin: 0 auto;
367
+ padding: 1.75rem 2rem 3rem;
368
+ }
369
+
370
+ /* ---------- Terminal window chrome ---------- */
371
+
372
+ .term-window {
373
+ background: var(--card-bg);
374
+ border: 1px solid var(--border);
375
+ border-radius: var(--radius);
376
+ overflow: hidden;
377
+ backdrop-filter: blur(10px);
378
+ box-shadow: var(--shadow);
379
+ }
380
+
381
+ .term-titlebar {
382
+ display: flex;
383
+ align-items: center;
384
+ gap: 0.6rem;
385
+ padding: 0.65rem 1rem;
386
+ background: var(--code-bg);
387
+ border-bottom: 1px solid var(--border);
388
+ }
389
+
390
+ .term-dots {
391
+ display: flex;
392
+ gap: 0.4rem;
393
+ }
394
+
395
+ .term-dot {
396
+ width: 10px;
397
+ height: 10px;
398
+ border-radius: 50%;
399
+ }
400
+
401
+ .term-dot.red { background: #ef4444; }
402
+ .term-dot.amber { background: #f5b942; }
403
+ .term-dot.green { background: var(--primary); }
404
+
405
+ .term-title {
406
+ font-size: 0.78rem;
407
+ color: var(--text-muted);
408
+ margin-left: 0.25rem;
409
+ }
410
+
411
+ .term-body {
412
+ padding: 1.75rem;
413
+ }
414
+
415
+ .prompt-char {
416
+ color: var(--primary);
417
+ font-weight: 700;
418
+ }
419
+
420
+ /* ---------- Buttons ---------- */
421
+
422
+ .btn {
423
+ display: inline-flex;
424
+ align-items: center;
425
+ justify-content: center;
426
+ gap: 0.5rem;
427
+ padding: 0.7rem 1.3rem;
428
+ border-radius: var(--radius-small);
429
+ font-weight: 600;
430
+ font-size: 0.9rem;
431
+ border: 1px solid transparent;
432
+ cursor: pointer;
433
+ transition: transform 0.15s, box-shadow 0.2s, border-color 0.2s, color 0.2s, background-color 0.2s;
434
+ white-space: nowrap;
435
+ }
436
+
437
+ .btn:disabled {
438
+ opacity: 0.5;
439
+ cursor: not-allowed;
440
+ transform: none;
441
+ }
442
+
443
+ .btn-small {
444
+ padding: 0.4rem 0.85rem;
445
+ font-size: 0.82rem;
446
+ }
447
+
448
+ .btn-primary {
449
+ background: var(--primary);
450
+ color: var(--text-on-primary);
451
+ }
452
+
453
+ .btn-primary:hover:not(:disabled) {
454
+ transform: translateY(-1px);
455
+ box-shadow: 0 8px 24px var(--primary-glow);
456
+ }
457
+
458
+ .btn-secondary {
459
+ background: transparent;
460
+ color: var(--text);
461
+ border-color: var(--border-strong);
462
+ }
463
+
464
+ .btn-secondary:hover:not(:disabled) {
465
+ border-color: var(--primary);
466
+ color: var(--primary);
467
+ }
468
+
469
+ .btn-danger {
470
+ background: var(--danger-bg);
471
+ color: var(--danger);
472
+ border-color: var(--danger);
473
+ }
474
+
475
+ .btn-danger:hover:not(:disabled) {
476
+ background: var(--danger);
477
+ color: #fff;
478
+ }
479
+
480
+ .btn-ghost {
481
+ background: transparent;
482
+ color: var(--text-muted);
483
+ }
484
+
485
+ .btn-ghost:hover:not(:disabled) {
486
+ color: var(--text);
487
+ }
488
+
489
+ .icon-btn {
490
+ display: inline-flex;
491
+ align-items: center;
492
+ justify-content: center;
493
+ width: 28px;
494
+ height: 28px;
495
+ border-radius: 6px;
496
+ border: 1px solid transparent;
497
+ background: transparent;
498
+ color: var(--text-muted);
499
+ cursor: pointer;
500
+ font-size: 0.95rem;
501
+ line-height: 1;
502
+ transition: all 0.15s;
503
+ }
504
+
505
+ .icon-btn:hover {
506
+ color: var(--primary);
507
+ border-color: var(--border);
508
+ background: var(--row-hover);
509
+ }
510
+
511
+ .icon-btn.danger:hover {
512
+ color: var(--danger);
513
+ border-color: var(--danger);
514
+ background: var(--danger-bg);
515
+ }
516
+
517
+ /* ---------- Form controls ---------- */
518
+
519
+ .input {
520
+ width: 100%;
521
+ padding: 0.6rem 0.8rem;
522
+ background: var(--code-bg);
523
+ border: 1px solid var(--border);
524
+ border-radius: var(--radius-small);
525
+ transition: border-color 0.2s, box-shadow 0.2s;
526
+ }
527
+
528
+ .input:focus {
529
+ outline: none;
530
+ border-color: var(--primary);
531
+ box-shadow: 0 0 0 3px var(--primary-glow);
532
+ }
533
+
534
+ textarea.input {
535
+ resize: vertical;
536
+ min-height: 5rem;
537
+ }
538
+
539
+ .field-label {
540
+ display: block;
541
+ font-size: 0.82rem;
542
+ color: var(--text-muted);
543
+ margin-bottom: 0.4rem;
544
+ }
545
+
546
+ .field-error {
547
+ color: var(--danger);
548
+ font-size: 0.82rem;
549
+ margin-top: 0.4rem;
550
+ }
551
+
552
+ .notice {
553
+ font-size: 0.82rem;
554
+ color: var(--text-muted);
555
+ border-left: 2px solid var(--secondary);
556
+ padding: 0.2rem 0 0.2rem 0.75rem;
557
+ margin: 0.25rem 0 1rem;
558
+ }
559
+
560
+ /* Custom listbox (FRONTEND.md §0: never a native select) */
561
+
562
+ .listbox {
563
+ position: relative;
564
+ min-width: 10rem;
565
+ }
566
+
567
+ .listbox-button {
568
+ width: 100%;
569
+ display: flex;
570
+ align-items: center;
571
+ justify-content: space-between;
572
+ gap: 0.5rem;
573
+ padding: 0.55rem 0.8rem;
574
+ background: var(--code-bg);
575
+ border: 1px solid var(--border);
576
+ border-radius: var(--radius-small);
577
+ cursor: pointer;
578
+ text-align: left;
579
+ }
580
+
581
+ .listbox-button:hover,
582
+ .listbox-button[aria-expanded='true'] {
583
+ border-color: var(--primary);
584
+ }
585
+
586
+ .listbox-value {
587
+ overflow: hidden;
588
+ text-overflow: ellipsis;
589
+ white-space: nowrap;
590
+ }
591
+
592
+ .listbox-caret {
593
+ color: var(--primary);
594
+ font-size: 0.75rem;
595
+ }
596
+
597
+ .listbox-popup {
598
+ position: absolute;
599
+ z-index: 150;
600
+ top: calc(100% + 4px);
601
+ left: 0;
602
+ min-width: 100%;
603
+ max-height: 18rem;
604
+ overflow-y: auto;
605
+ list-style: none;
606
+ padding: 0.3rem;
607
+ background: var(--surface-solid);
608
+ border: 1px solid var(--border-strong);
609
+ border-radius: var(--radius-small);
610
+ box-shadow: var(--shadow);
611
+ animation: pop-in 0.12s ease-out;
612
+ }
613
+
614
+ .listbox-popup:focus {
615
+ outline: none;
616
+ }
617
+
618
+ .listbox-option {
619
+ padding: 0.45rem 0.7rem;
620
+ border-radius: 5px;
621
+ cursor: pointer;
622
+ white-space: nowrap;
623
+ }
624
+
625
+ .listbox-option::before {
626
+ content: ' ';
627
+ white-space: pre;
628
+ color: var(--primary);
629
+ }
630
+
631
+ .listbox-option[aria-selected='true']::before {
632
+ content: '❯ ';
633
+ }
634
+
635
+ .listbox-option.active,
636
+ .listbox-option:hover {
637
+ background: var(--row-selected);
638
+ }
639
+
640
+ /* Segmented control */
641
+
642
+ .segmented {
643
+ display: inline-flex;
644
+ flex-wrap: wrap;
645
+ max-width: 100%;
646
+ background: var(--code-bg);
647
+ border: 1px solid var(--border);
648
+ border-radius: var(--radius-small);
649
+ padding: 0.2rem;
650
+ gap: 0.2rem;
651
+ }
652
+
653
+ .segmented-btn {
654
+ border: none;
655
+ background: transparent;
656
+ color: var(--text-muted);
657
+ padding: 0.35rem 0.75rem;
658
+ border-radius: 5px;
659
+ cursor: pointer;
660
+ font-size: 0.82rem;
661
+ white-space: nowrap;
662
+ }
663
+
664
+ .segmented-btn:hover {
665
+ color: var(--text);
666
+ }
667
+
668
+ .segmented-btn.active {
669
+ background: var(--primary);
670
+ color: var(--text-on-primary);
671
+ }
672
+
673
+ /* Switch */
674
+
675
+ .switch {
676
+ display: inline-flex;
677
+ align-items: center;
678
+ gap: 0.55rem;
679
+ cursor: pointer;
680
+ white-space: nowrap;
681
+ font-size: 0.85rem;
682
+ }
683
+
684
+ .switch-input {
685
+ position: absolute;
686
+ opacity: 0;
687
+ width: 1px;
688
+ height: 1px;
689
+ }
690
+
691
+ .switch-track {
692
+ position: relative;
693
+ width: 34px;
694
+ height: 20px;
695
+ border-radius: 10px;
696
+ background: var(--code-bg);
697
+ border: 1px solid var(--border-strong);
698
+ transition: background-color 0.2s;
699
+ }
700
+
701
+ .switch-track::after {
702
+ content: '';
703
+ position: absolute;
704
+ top: 2px;
705
+ left: 2px;
706
+ width: 14px;
707
+ height: 14px;
708
+ border-radius: 50%;
709
+ background: var(--text-muted);
710
+ transition: transform 0.2s, background-color 0.2s;
711
+ }
712
+
713
+ .switch-input:checked + .switch-track {
714
+ background: var(--primary-glow);
715
+ border-color: var(--primary);
716
+ }
717
+
718
+ .switch-input:checked + .switch-track::after {
719
+ transform: translateX(14px);
720
+ background: var(--primary);
721
+ }
722
+
723
+ .switch-input:focus-visible + .switch-track {
724
+ outline: 2px solid var(--primary);
725
+ outline-offset: 2px;
726
+ }
727
+
728
+ /* ---------- Login ---------- */
729
+
730
+ .login {
731
+ display: flex;
732
+ justify-content: center;
733
+ padding-top: 8vh;
734
+ }
735
+
736
+ .login-window {
737
+ width: min(460px, 100%);
738
+ }
739
+
740
+ .login-form {
741
+ display: flex;
742
+ flex-direction: column;
743
+ gap: 0.25rem;
744
+ }
745
+
746
+ .login-heading {
747
+ font-size: 1.25rem;
748
+ display: flex;
749
+ gap: 0.5rem;
750
+ margin-bottom: 0.4rem;
751
+ }
752
+
753
+ .login-intro {
754
+ color: var(--text-muted);
755
+ font-size: 0.85rem;
756
+ margin-bottom: 1.2rem;
757
+ }
758
+
759
+ .login-form .btn {
760
+ margin-top: 1.1rem;
761
+ }
762
+
763
+ /* ---------- Tabs ---------- */
764
+
765
+ .tabs {
766
+ display: flex;
767
+ gap: 0.25rem;
768
+ border-bottom: 1px solid var(--border);
769
+ margin-bottom: 1.25rem;
770
+ }
771
+
772
+ .tab {
773
+ display: inline-flex;
774
+ gap: 0.5rem;
775
+ align-items: center;
776
+ padding: 0.7rem 1rem;
777
+ border: none;
778
+ border-bottom: 2px solid transparent;
779
+ background: transparent;
780
+ color: var(--text-muted);
781
+ cursor: pointer;
782
+ font-weight: 600;
783
+ margin-bottom: -1px;
784
+ }
785
+
786
+ .tab .icon {
787
+ opacity: 0.7;
788
+ }
789
+
790
+ .tab:hover {
791
+ color: var(--text);
792
+ }
793
+
794
+ .tab.active {
795
+ color: var(--primary);
796
+ border-bottom-color: var(--primary);
797
+ }
798
+
799
+ .tab.active .icon {
800
+ opacity: 1;
801
+ }
802
+
803
+ /* ---------- Panels and toolbar ---------- */
804
+
805
+ .panel {
806
+ display: flex;
807
+ flex-direction: column;
808
+ gap: 0.9rem;
809
+ }
810
+
811
+ .toolbar {
812
+ display: flex;
813
+ flex-wrap: wrap;
814
+ align-items: center;
815
+ justify-content: space-between;
816
+ gap: 0.75rem;
817
+ }
818
+
819
+ .toolbar-group {
820
+ display: flex;
821
+ flex-wrap: wrap;
822
+ align-items: center;
823
+ gap: 0.6rem;
824
+ }
825
+
826
+ .search-input {
827
+ width: 20rem;
828
+ max-width: 100%;
829
+ }
830
+
831
+ .app-tabs {
832
+ display: flex;
833
+ flex-wrap: wrap;
834
+ gap: 0.5rem;
835
+ }
836
+
837
+ .app-tab {
838
+ display: inline-flex;
839
+ align-items: center;
840
+ gap: 0.45rem;
841
+ padding: 0.45rem 0.8rem;
842
+ background: var(--code-bg);
843
+ border: 1px solid var(--border);
844
+ border-radius: var(--radius-small);
845
+ color: var(--text-muted);
846
+ cursor: pointer;
847
+ font-weight: 600;
848
+ transition: color 0.15s, border-color 0.15s, background-color 0.15s, box-shadow 0.2s;
849
+ }
850
+
851
+ .app-tab:hover {
852
+ color: var(--text);
853
+ border-color: var(--border-strong);
854
+ }
855
+
856
+ .app-tab.active {
857
+ color: var(--primary);
858
+ border-color: var(--primary);
859
+ background: var(--row-selected);
860
+ box-shadow: 0 0 12px var(--primary-glow);
861
+ }
862
+
863
+ .app-tab.unavailable {
864
+ opacity: 0.55;
865
+ text-decoration: line-through;
866
+ }
867
+
868
+ .app-tab-count {
869
+ font-size: 0.72rem;
870
+ font-weight: 700;
871
+ padding: 0.05rem 0.45rem;
872
+ border-radius: 999px;
873
+ background: var(--muted-bg);
874
+ color: var(--text-muted);
875
+ }
876
+
877
+ .app-tab.active .app-tab-count {
878
+ background: var(--primary);
879
+ color: var(--text-on-primary);
880
+ }
881
+
882
+ .panel-summary {
883
+ color: var(--text-muted);
884
+ font-size: 0.85rem;
885
+ }
886
+
887
+ /* Each section says what it is and how it differs from the others. */
888
+ .panel-header {
889
+ display: flex;
890
+ flex-direction: column;
891
+ gap: 0.3rem;
892
+ }
893
+
894
+ .panel-title {
895
+ font-size: 1.25rem;
896
+ }
897
+
898
+ .panel-intro {
899
+ color: var(--text-muted);
900
+ font-size: 0.85rem;
901
+ max-width: 62rem;
902
+ }
903
+
904
+ .filter-chips {
905
+ display: flex;
906
+ flex-wrap: wrap;
907
+ align-items: center;
908
+ gap: 0.4rem;
909
+ }
910
+
911
+ .filter-chips:empty {
912
+ display: none;
913
+ }
914
+
915
+ .chip {
916
+ display: inline-flex;
917
+ align-items: center;
918
+ gap: 0.35rem;
919
+ max-width: 22rem;
920
+ padding: 0.3rem 0.55rem;
921
+ font-size: 0.8rem;
922
+ font-weight: 600;
923
+ color: var(--primary);
924
+ background: var(--row-selected);
925
+ border: 1px solid var(--border-strong);
926
+ border-radius: 999px;
927
+ cursor: pointer;
928
+ }
929
+
930
+ .chip:hover {
931
+ border-color: var(--primary);
932
+ }
933
+
934
+ .chip-text {
935
+ overflow: hidden;
936
+ text-overflow: ellipsis;
937
+ white-space: nowrap;
938
+ min-width: 0;
939
+ }
940
+
941
+ .link-btn {
942
+ display: inline-flex;
943
+ align-items: center;
944
+ gap: 0.3rem;
945
+ padding: 0;
946
+ border: none;
947
+ background: none;
948
+ color: var(--primary);
949
+ font-size: 0.82rem;
950
+ cursor: pointer;
951
+ }
952
+
953
+ .link-btn:hover {
954
+ text-decoration: underline;
955
+ }
956
+
957
+ /* "How these are counted": definitions, folded away until wanted. */
958
+ .metric-help {
959
+ font-size: 0.82rem;
960
+ color: var(--text-muted);
961
+ }
962
+
963
+ .metric-help summary {
964
+ cursor: pointer;
965
+ width: fit-content;
966
+ font-weight: 600;
967
+ }
968
+
969
+ .metric-help summary:hover {
970
+ color: var(--text);
971
+ }
972
+
973
+ .metric-help dl {
974
+ display: grid;
975
+ grid-template-columns: minmax(8rem, 12rem) minmax(0, 1fr);
976
+ gap: 0.4rem 1rem;
977
+ margin-top: 0.6rem;
978
+ max-width: 62rem;
979
+ }
980
+
981
+ .metric-help dt {
982
+ color: var(--text);
983
+ font-weight: 600;
984
+ }
985
+
986
+ .outcome-legend dt {
987
+ font-weight: 400;
988
+ }
989
+
990
+ /* ---------- Charts ---------- */
991
+
992
+ /* Headline numbers: label, value, change against the period before, sparkline. */
993
+ .stat-tiles {
994
+ display: grid;
995
+ grid-template-columns: repeat(auto-fit, minmax(10.5rem, 1fr));
996
+ gap: 0.75rem;
997
+ }
998
+
999
+ /* The Overview's nine sit in even rows: all nine, five and four, three by
1000
+ three, or two by two, by the width they have, never seven and two. */
1001
+ .overview-tiles {
1002
+ container: tiles / inline-size;
1003
+ }
1004
+
1005
+ .overview-tiles .stat-tiles {
1006
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1007
+ gap: 0.6rem;
1008
+ }
1009
+
1010
+ @container tiles (width >= 34rem) {
1011
+ .overview-tiles .stat-tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); }
1012
+ }
1013
+
1014
+ @container tiles (width >= 58rem) {
1015
+ .overview-tiles .stat-tiles { grid-template-columns: repeat(5, minmax(0, 1fr)); }
1016
+ }
1017
+
1018
+ @container tiles (width >= 80rem) {
1019
+ .overview-tiles .stat-tiles { grid-template-columns: repeat(9, minmax(0, 1fr)); }
1020
+ }
1021
+
1022
+ .stat-tile {
1023
+ display: flex;
1024
+ flex-direction: column;
1025
+ gap: 0.15rem;
1026
+ min-width: 0;
1027
+ padding: 0.85rem 1rem 0.7rem;
1028
+ font: inherit;
1029
+ color: inherit;
1030
+ text-align: left;
1031
+ background: var(--card-bg);
1032
+ border: 1px solid var(--border);
1033
+ border-radius: var(--radius-small);
1034
+ }
1035
+
1036
+ .stat-tile.selectable {
1037
+ cursor: pointer;
1038
+ transition: border-color 0.15s, box-shadow 0.2s;
1039
+ }
1040
+
1041
+ .stat-tile.selectable:hover {
1042
+ border-color: var(--border-strong);
1043
+ }
1044
+
1045
+ .stat-tile.active {
1046
+ border-color: var(--primary);
1047
+ box-shadow: inset 0 -2px 0 var(--primary), 0 0 12px var(--primary-glow);
1048
+ }
1049
+
1050
+ .stat-label {
1051
+ color: var(--text-muted);
1052
+ font-size: 0.78rem;
1053
+ }
1054
+
1055
+ .stat-value {
1056
+ font-size: 1.55rem;
1057
+ font-weight: 700;
1058
+ letter-spacing: -0.02em;
1059
+ font-variant-numeric: tabular-nums;
1060
+ white-space: nowrap;
1061
+ overflow: hidden;
1062
+ text-overflow: ellipsis;
1063
+ }
1064
+
1065
+ .stat-delta {
1066
+ display: flex;
1067
+ align-items: center;
1068
+ flex-wrap: wrap;
1069
+ gap: 0.25rem;
1070
+ min-height: 1.1rem;
1071
+ font-size: 0.74rem;
1072
+ color: var(--text-muted);
1073
+ }
1074
+
1075
+ .stat-delta-value {
1076
+ font-weight: 700;
1077
+ font-variant-numeric: tabular-nums;
1078
+ }
1079
+
1080
+ /* Good and bad changes carry an arrow and a sign as well as a colour. */
1081
+ .stat-delta.tone-good .stat-delta-value,
1082
+ .stat-delta.tone-good .icon {
1083
+ color: var(--success);
1084
+ }
1085
+
1086
+ .stat-delta.tone-bad .stat-delta-value,
1087
+ .stat-delta.tone-bad .icon {
1088
+ color: var(--danger);
1089
+ }
1090
+
1091
+ .stat-delta .icon {
1092
+ width: 0.9rem;
1093
+ height: 0.9rem;
1094
+ }
1095
+
1096
+ .sparkline {
1097
+ display: block;
1098
+ width: 100%;
1099
+ height: 28px;
1100
+ /* On the tile's bottom edge, level across a row whatever the label's length. */
1101
+ margin-top: auto;
1102
+ padding-top: 0.3rem;
1103
+ overflow: visible;
1104
+ }
1105
+
1106
+ .sparkline-line {
1107
+ fill: none;
1108
+ stroke: var(--chart-series);
1109
+ stroke-width: 1.5;
1110
+ stroke-linejoin: round;
1111
+ stroke-linecap: round;
1112
+ }
1113
+
1114
+ .tracking-summary .sparkline {
1115
+ display: none;
1116
+ }
1117
+
1118
+ /* The Overview */
1119
+
1120
+ .overview-content {
1121
+ display: flex;
1122
+ flex-direction: column;
1123
+ gap: 1rem;
1124
+ transition: opacity 0.2s;
1125
+ }
1126
+
1127
+ /* Refetch keeps the frame: the last render stays, dimmed, until the new one. */
1128
+ .overview.loading .overview-content {
1129
+ opacity: 0.55;
1130
+ }
1131
+
1132
+ .overview-span {
1133
+ color: var(--text-muted);
1134
+ font-size: 0.8rem;
1135
+ margin-top: -0.4rem;
1136
+ }
1137
+
1138
+ .overview-top {
1139
+ display: grid;
1140
+ grid-template-columns: minmax(0, 2.2fr) minmax(0, 1fr);
1141
+ gap: 1rem;
1142
+ }
1143
+
1144
+ .overview-grid {
1145
+ display: grid;
1146
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1147
+ gap: 1rem;
1148
+ container: overview-grid / inline-size;
1149
+ }
1150
+
1151
+ .card-wide {
1152
+ grid-column: 1 / -1;
1153
+ }
1154
+
1155
+ .card-body {
1156
+ min-width: 0;
1157
+ }
1158
+
1159
+ .card-footer {
1160
+ margin-top: auto;
1161
+ }
1162
+
1163
+ .card-note {
1164
+ color: var(--text-muted);
1165
+ font-size: 0.75rem;
1166
+ }
1167
+
1168
+ .card-note a {
1169
+ color: inherit;
1170
+ }
1171
+
1172
+ .card-note a:hover {
1173
+ color: var(--primary);
1174
+ }
1175
+
1176
+ .chart-card-header .segmented {
1177
+ flex-wrap: wrap;
1178
+ }
1179
+
1180
+ /* Ranked tables: the label with its bar behind it, then the measures. */
1181
+
1182
+ .rank-table {
1183
+ width: 100%;
1184
+ border-collapse: collapse;
1185
+ table-layout: fixed;
1186
+ font-size: 0.82rem;
1187
+ }
1188
+
1189
+ .rank-table th,
1190
+ .rank-table td {
1191
+ padding: 0.2rem 0.35rem;
1192
+ text-align: left;
1193
+ font-weight: 400;
1194
+ }
1195
+
1196
+ .rank-table thead th {
1197
+ color: var(--text-muted);
1198
+ font-size: 0.72rem;
1199
+ font-weight: 700;
1200
+ text-transform: uppercase;
1201
+ letter-spacing: 0.04em;
1202
+ padding-bottom: 0.35rem;
1203
+ }
1204
+
1205
+ .rank-table .rank-num {
1206
+ width: 5.2rem;
1207
+ text-align: right;
1208
+ font-variant-numeric: tabular-nums;
1209
+ white-space: nowrap;
1210
+ }
1211
+
1212
+ .rank-table thead .rank-num {
1213
+ white-space: normal;
1214
+ vertical-align: bottom;
1215
+ }
1216
+
1217
+ .rank-label-head {
1218
+ width: auto;
1219
+ vertical-align: bottom;
1220
+ }
1221
+
1222
+ .rank-label {
1223
+ padding-left: 0;
1224
+ }
1225
+
1226
+ .rank-select,
1227
+ .rank-static {
1228
+ position: relative;
1229
+ display: flex;
1230
+ flex-direction: column;
1231
+ justify-content: center;
1232
+ width: 100%;
1233
+ min-height: 1.9rem;
1234
+ padding: 0.2rem 1.6rem 0.2rem 0.5rem;
1235
+ font: inherit;
1236
+ color: var(--text);
1237
+ text-align: left;
1238
+ border: none;
1239
+ border-radius: 4px;
1240
+ background: transparent;
1241
+ overflow: hidden;
1242
+ }
1243
+
1244
+ .rank-select {
1245
+ cursor: pointer;
1246
+ }
1247
+
1248
+ .rank-bar {
1249
+ position: absolute;
1250
+ inset: 0 auto 0 0;
1251
+ background: var(--chart-series);
1252
+ opacity: 0.18;
1253
+ border-radius: 4px;
1254
+ pointer-events: none;
1255
+ }
1256
+
1257
+ .rank-text,
1258
+ .rank-detail {
1259
+ position: relative;
1260
+ }
1261
+
1262
+ .rank-detail {
1263
+ color: var(--text-muted);
1264
+ font-size: 0.72rem;
1265
+ }
1266
+
1267
+ .rank-filter-icon {
1268
+ position: absolute;
1269
+ right: 0.4rem;
1270
+ top: 50%;
1271
+ transform: translateY(-50%);
1272
+ color: var(--primary);
1273
+ opacity: 0;
1274
+ transition: opacity 0.15s;
1275
+ }
1276
+
1277
+ .rank-select:hover,
1278
+ .rank-select:focus-visible {
1279
+ background: var(--row-hover);
1280
+ }
1281
+
1282
+ .rank-select:hover .rank-filter-icon,
1283
+ .rank-select:focus-visible .rank-filter-icon {
1284
+ opacity: 1;
1285
+ }
1286
+
1287
+ .rank-muted .rank-static,
1288
+ .rank-muted td {
1289
+ color: var(--text-muted);
1290
+ }
1291
+
1292
+ .rank-muted .rank-bar {
1293
+ background: var(--text-muted);
1294
+ }
1295
+
1296
+ /* Right now */
1297
+
1298
+ .live-card .chart-title {
1299
+ display: inline-flex;
1300
+ align-items: center;
1301
+ gap: 0.5rem;
1302
+ }
1303
+
1304
+ .live-dot {
1305
+ width: 0.55rem;
1306
+ height: 0.55rem;
1307
+ border-radius: 50%;
1308
+ background: var(--success);
1309
+ box-shadow: 0 0 0 0 var(--primary-glow);
1310
+ animation: pulse 2s ease-out infinite;
1311
+ }
1312
+
1313
+ .live-body {
1314
+ display: flex;
1315
+ flex-direction: column;
1316
+ gap: 0.8rem;
1317
+ }
1318
+
1319
+ .live-headline {
1320
+ display: flex;
1321
+ align-items: baseline;
1322
+ gap: 0.6rem;
1323
+ flex-wrap: wrap;
1324
+ }
1325
+
1326
+ .live-count {
1327
+ font-size: 2.2rem;
1328
+ font-weight: 700;
1329
+ letter-spacing: -0.03em;
1330
+ font-variant-numeric: tabular-nums;
1331
+ line-height: 1;
1332
+ }
1333
+
1334
+ .live-caption {
1335
+ color: var(--text-muted);
1336
+ font-size: 0.8rem;
1337
+ }
1338
+
1339
+ .minute-svg {
1340
+ display: block;
1341
+ width: 100%;
1342
+ height: 56px;
1343
+ cursor: crosshair;
1344
+ }
1345
+
1346
+ .minute-svg:focus-visible {
1347
+ outline: 2px solid var(--primary);
1348
+ outline-offset: 3px;
1349
+ border-radius: 4px;
1350
+ }
1351
+
1352
+ .minute-bar {
1353
+ fill: var(--chart-series);
1354
+ }
1355
+
1356
+ .minute-bar.active {
1357
+ fill: var(--primary-bright);
1358
+ }
1359
+
1360
+ .minute-axis {
1361
+ display: flex;
1362
+ justify-content: space-between;
1363
+ color: var(--text-muted);
1364
+ font-size: 0.7rem;
1365
+ margin-top: 0.2rem;
1366
+ }
1367
+
1368
+ /* Weekday by hour */
1369
+
1370
+ .heatmap {
1371
+ display: grid;
1372
+ grid-template-columns: 3rem repeat(24, minmax(0, 1fr));
1373
+ gap: 2px;
1374
+ align-items: center;
1375
+ }
1376
+
1377
+ .heatmap:focus-visible {
1378
+ outline: 2px solid var(--primary);
1379
+ outline-offset: 4px;
1380
+ border-radius: 4px;
1381
+ }
1382
+
1383
+ .heatmap-hour,
1384
+ .heatmap-day {
1385
+ color: var(--text-muted);
1386
+ font-size: 0.68rem;
1387
+ white-space: nowrap;
1388
+ }
1389
+
1390
+ .heatmap-hour {
1391
+ overflow: visible;
1392
+ }
1393
+
1394
+ .heatmap-cell {
1395
+ display: block;
1396
+ height: 1.35rem;
1397
+ border-radius: 3px;
1398
+ }
1399
+
1400
+ .heatmap-cell.active {
1401
+ outline: 2px solid var(--text);
1402
+ outline-offset: -1px;
1403
+ }
1404
+
1405
+ .heatmap-body {
1406
+ display: flex;
1407
+ flex-direction: column;
1408
+ gap: 0.7rem;
1409
+ }
1410
+
1411
+ .chart-card {
1412
+ position: relative;
1413
+ display: flex;
1414
+ flex-direction: column;
1415
+ gap: 0.6rem;
1416
+ padding: 1rem 1.1rem;
1417
+ background: var(--card-bg);
1418
+ border: 1px solid var(--border);
1419
+ border-radius: var(--radius);
1420
+ min-width: 0;
1421
+ }
1422
+
1423
+ .chart-card-header {
1424
+ display: flex;
1425
+ align-items: center;
1426
+ justify-content: space-between;
1427
+ gap: 0.75rem;
1428
+ }
1429
+
1430
+ .chart-title {
1431
+ font-size: 0.85rem;
1432
+ font-weight: 700;
1433
+ }
1434
+
1435
+ .chart-body {
1436
+ position: relative;
1437
+ min-width: 0;
1438
+ }
1439
+
1440
+ .chart-empty {
1441
+ color: var(--text-muted);
1442
+ font-size: 0.85rem;
1443
+ padding: 1.5rem 0;
1444
+ text-align: center;
1445
+ list-style: none;
1446
+ }
1447
+
1448
+ /* Views over time */
1449
+
1450
+ .trend-svg {
1451
+ display: block;
1452
+ width: 100%;
1453
+ height: auto;
1454
+ overflow: visible;
1455
+ }
1456
+
1457
+ .trend-svg:focus-visible {
1458
+ outline: 2px solid var(--primary);
1459
+ outline-offset: 4px;
1460
+ border-radius: 4px;
1461
+ }
1462
+
1463
+ .chart-grid {
1464
+ stroke: var(--chart-grid);
1465
+ stroke-width: 1;
1466
+ shape-rendering: crispEdges;
1467
+ }
1468
+
1469
+ .chart-baseline {
1470
+ stroke: var(--chart-baseline);
1471
+ stroke-width: 1;
1472
+ shape-rendering: crispEdges;
1473
+ }
1474
+
1475
+ .chart-tick {
1476
+ fill: var(--text-muted);
1477
+ font-size: 11px;
1478
+ font-variant-numeric: tabular-nums;
1479
+ }
1480
+
1481
+ .chart-area {
1482
+ fill: var(--chart-area);
1483
+ }
1484
+
1485
+ .chart-line {
1486
+ fill: none;
1487
+ stroke: var(--chart-series);
1488
+ stroke-width: 2;
1489
+ stroke-linejoin: round;
1490
+ stroke-linecap: round;
1491
+ }
1492
+
1493
+ /* >= 8px across, with a 2px ring in the surface colour. */
1494
+ .chart-dot {
1495
+ fill: var(--chart-series);
1496
+ stroke: var(--surface-solid);
1497
+ stroke-width: 2;
1498
+ }
1499
+
1500
+ .chart-crosshair {
1501
+ stroke: var(--text-muted);
1502
+ stroke-width: 1;
1503
+ shape-rendering: crispEdges;
1504
+ }
1505
+
1506
+ .chart-table-wrap {
1507
+ max-height: 16rem;
1508
+ overflow-y: auto;
1509
+ }
1510
+
1511
+ .chart-table {
1512
+ width: 100%;
1513
+ border-collapse: collapse;
1514
+ font-size: 0.82rem;
1515
+ font-variant-numeric: tabular-nums;
1516
+ }
1517
+
1518
+ .chart-table th,
1519
+ .chart-table td {
1520
+ text-align: left;
1521
+ padding: 0.3rem 0.5rem;
1522
+ border-bottom: 1px solid var(--border);
1523
+ }
1524
+
1525
+ .chart-table th {
1526
+ color: var(--text-muted);
1527
+ font-weight: 700;
1528
+ }
1529
+
1530
+ /* Tooltip: values lead, labels follow, keyed with a short stroke. */
1531
+
1532
+ .chart-tooltip {
1533
+ position: absolute;
1534
+ z-index: 20;
1535
+ pointer-events: none;
1536
+ min-width: 9rem;
1537
+ padding: 0.55rem 0.7rem;
1538
+ background: var(--surface-solid);
1539
+ border: 1px solid var(--border-strong);
1540
+ border-radius: var(--radius-small);
1541
+ box-shadow: var(--shadow);
1542
+ font-size: 0.8rem;
1543
+ }
1544
+
1545
+ .chart-tooltip-title {
1546
+ color: var(--text-muted);
1547
+ margin-bottom: 0.25rem;
1548
+ }
1549
+
1550
+ .chart-tooltip-row {
1551
+ display: flex;
1552
+ align-items: center;
1553
+ gap: 0.45rem;
1554
+ }
1555
+
1556
+ .chart-tooltip-row strong {
1557
+ color: var(--text);
1558
+ font-variant-numeric: tabular-nums;
1559
+ }
1560
+
1561
+ .chart-tooltip-row span:last-child {
1562
+ color: var(--text-muted);
1563
+ }
1564
+
1565
+ .chart-tooltip-key {
1566
+ width: 0.8rem;
1567
+ height: 2px;
1568
+ background: var(--chart-series);
1569
+ flex-shrink: 0;
1570
+ }
1571
+
1572
+ /* The country list beside the map */
1573
+
1574
+ .country-list {
1575
+ display: flex;
1576
+ flex-direction: column;
1577
+ gap: 0.2rem;
1578
+ list-style: none;
1579
+ }
1580
+
1581
+ .country-row {
1582
+ display: grid;
1583
+ grid-template-columns: minmax(0, 7.5rem) minmax(0, 1fr) auto;
1584
+ align-items: center;
1585
+ gap: 0.6rem;
1586
+ width: 100%;
1587
+ font: inherit;
1588
+ font-size: 0.82rem;
1589
+ color: inherit;
1590
+ text-align: left;
1591
+ padding: 0.2rem 0.3rem;
1592
+ border: none;
1593
+ border-radius: 4px;
1594
+ background: transparent;
1595
+ }
1596
+
1597
+ button.country-row {
1598
+ cursor: pointer;
1599
+ }
1600
+
1601
+ .country-row:hover,
1602
+ .country-row:focus-visible {
1603
+ background: var(--row-hover);
1604
+ }
1605
+
1606
+ .bar-track {
1607
+ display: block;
1608
+ height: 10px;
1609
+ }
1610
+
1611
+ .bar-fill {
1612
+ display: block;
1613
+ height: 100%;
1614
+ min-width: 2px;
1615
+ background: var(--chart-series);
1616
+ border-radius: 0 4px 4px 0;
1617
+ }
1618
+
1619
+ .bar-value {
1620
+ font-variant-numeric: tabular-nums;
1621
+ color: var(--text);
1622
+ text-align: right;
1623
+ min-width: 2.5rem;
1624
+ }
1625
+
1626
+ /* World map */
1627
+
1628
+ .map-layout {
1629
+ display: grid;
1630
+ grid-template-columns: minmax(0, 1fr) minmax(11rem, 14rem);
1631
+ gap: 1rem;
1632
+ align-items: start;
1633
+ }
1634
+
1635
+ .map-column {
1636
+ display: flex;
1637
+ flex-direction: column;
1638
+ gap: 0.6rem;
1639
+ min-width: 0;
1640
+ }
1641
+
1642
+ .map-host {
1643
+ position: relative;
1644
+ }
1645
+
1646
+ .map-svg {
1647
+ display: block;
1648
+ width: 100%;
1649
+ height: auto;
1650
+ }
1651
+
1652
+ /* Borders are gaps in the surface colour, not drawn outlines. */
1653
+ .country {
1654
+ stroke: var(--surface-solid);
1655
+ stroke-width: 0.5;
1656
+ transition: opacity 0.15s;
1657
+ }
1658
+
1659
+ .country-point {
1660
+ stroke-width: 2;
1661
+ }
1662
+
1663
+ .country.selectable {
1664
+ cursor: pointer;
1665
+ }
1666
+
1667
+ .country.active {
1668
+ stroke: var(--text);
1669
+ stroke-width: 1;
1670
+ }
1671
+
1672
+ .map-0 { fill: var(--map-0); background: var(--map-0); }
1673
+ .map-1 { fill: var(--map-1); background: var(--map-1); }
1674
+ .map-2 { fill: var(--map-2); background: var(--map-2); }
1675
+ .map-3 { fill: var(--map-3); background: var(--map-3); }
1676
+ .map-4 { fill: var(--map-4); background: var(--map-4); }
1677
+ .map-5 { fill: var(--map-5); background: var(--map-5); }
1678
+
1679
+ .map-legend {
1680
+ display: flex;
1681
+ flex-wrap: wrap;
1682
+ gap: 0.4rem 0.9rem;
1683
+ font-size: 0.75rem;
1684
+ color: var(--text-muted);
1685
+ }
1686
+
1687
+ .legend-item {
1688
+ display: inline-flex;
1689
+ align-items: center;
1690
+ gap: 0.35rem;
1691
+ }
1692
+
1693
+ .legend-swatch {
1694
+ width: 0.85rem;
1695
+ height: 0.6rem;
1696
+ border-radius: 2px;
1697
+ }
1698
+
1699
+ .country-name {
1700
+ color: var(--text);
1701
+ }
1702
+
1703
+ .country-more {
1704
+ padding: 0.2rem 0.25rem;
1705
+ }
1706
+
1707
+ .country-more .btn {
1708
+ padding: 0.25rem 0.5rem;
1709
+ }
1710
+
1711
+ .event-type-picker {
1712
+ min-width: 11rem;
1713
+ }
1714
+
1715
+ /* Batch bar */
1716
+
1717
+ .batch-bar {
1718
+ position: sticky;
1719
+ top: 4.2rem;
1720
+ z-index: 50;
1721
+ display: flex;
1722
+ flex-wrap: wrap;
1723
+ align-items: center;
1724
+ gap: 0.75rem;
1725
+ padding: 0.6rem 1rem;
1726
+ background: var(--surface-solid);
1727
+ border: 1px solid var(--primary);
1728
+ border-radius: var(--radius-small);
1729
+ box-shadow: 0 0 18px var(--primary-glow);
1730
+ animation: pop-in 0.15s ease-out;
1731
+ }
1732
+
1733
+ .batch-count {
1734
+ font-weight: 700;
1735
+ }
1736
+
1737
+ .batch-actions {
1738
+ display: flex;
1739
+ flex-wrap: wrap;
1740
+ gap: 0.5rem;
1741
+ margin-left: auto;
1742
+ }
1743
+
1744
+ /* ---------- Data table ---------- */
1745
+
1746
+ .table-wrap {
1747
+ background: var(--card-bg);
1748
+ border: 1px solid var(--border);
1749
+ border-radius: var(--radius);
1750
+ backdrop-filter: blur(10px);
1751
+ }
1752
+
1753
+ .data-table {
1754
+ width: 100%;
1755
+ border-collapse: collapse;
1756
+ table-layout: fixed;
1757
+ }
1758
+
1759
+ .data-table th,
1760
+ .data-table td {
1761
+ padding: 0.55rem 0.7rem;
1762
+ text-align: left;
1763
+ vertical-align: middle;
1764
+ border-bottom: 1px solid var(--border);
1765
+ }
1766
+
1767
+ .data-table thead th {
1768
+ font-size: 0.72rem;
1769
+ text-transform: uppercase;
1770
+ letter-spacing: 0.06em;
1771
+ color: var(--text-muted);
1772
+ background: var(--code-bg);
1773
+ font-weight: 700;
1774
+ }
1775
+
1776
+ .data-table thead th:first-child {
1777
+ border-top-left-radius: var(--radius);
1778
+ }
1779
+
1780
+ .data-table thead th:last-child {
1781
+ border-top-right-radius: var(--radius);
1782
+ }
1783
+
1784
+ .data-table tbody tr:last-child td {
1785
+ /* Transparent rather than removed, so the last row is exactly as tall as
1786
+ every other (FRONTEND.md §14). */
1787
+ border-bottom-color: transparent;
1788
+ }
1789
+
1790
+ .data-table tbody tr {
1791
+ height: 3.6rem;
1792
+ transition: background-color 0.15s;
1793
+ }
1794
+
1795
+ .data-table tbody tr:hover {
1796
+ background: var(--row-hover);
1797
+ }
1798
+
1799
+ .data-table tbody tr:has(input[type='checkbox']:checked) {
1800
+ background: var(--row-selected);
1801
+ }
1802
+
1803
+ .data-table[aria-busy='true'] tbody {
1804
+ opacity: 0.55;
1805
+ }
1806
+
1807
+ .row-deleted td:not(.col-select):not(.col-actions):not(.col-status) {
1808
+ opacity: 0.55;
1809
+ text-decoration: line-through;
1810
+ text-decoration-color: var(--danger);
1811
+ }
1812
+
1813
+ .table-message {
1814
+ text-align: center !important;
1815
+ color: var(--text-muted);
1816
+ padding: 2.5rem 1rem !important;
1817
+ }
1818
+
1819
+ .sort-btn {
1820
+ display: inline-flex;
1821
+ align-items: center;
1822
+ gap: 0.35rem;
1823
+ border: none;
1824
+ background: none;
1825
+ color: inherit;
1826
+ font: inherit;
1827
+ text-transform: inherit;
1828
+ letter-spacing: inherit;
1829
+ cursor: pointer;
1830
+ padding: 0;
1831
+ }
1832
+
1833
+ .sort-btn:hover,
1834
+ .sort-btn.active {
1835
+ color: var(--primary);
1836
+ }
1837
+
1838
+ .sort-indicator {
1839
+ font-size: 0.7rem;
1840
+ opacity: 0.8;
1841
+ }
1842
+
1843
+ input[type='checkbox'] {
1844
+ width: 16px;
1845
+ height: 16px;
1846
+ accent-color: var(--primary);
1847
+ cursor: pointer;
1848
+ }
1849
+
1850
+ /*
1851
+ * Columns (FRONTEND.md §12: reflow, never scroll sideways). dataTable.js
1852
+ * decides which of the admin's chosen columns fit the table's own width, in
1853
+ * their order, and sets their widths; what does not fit is under each row,
1854
+ * behind its "more" button. Below a card's width, each row is a card.
1855
+ */
1856
+
1857
+ .data-table th {
1858
+ position: relative;
1859
+ overflow: hidden;
1860
+ text-overflow: ellipsis;
1861
+ white-space: nowrap;
1862
+ }
1863
+
1864
+ .th-label {
1865
+ overflow: hidden;
1866
+ text-overflow: ellipsis;
1867
+ }
1868
+
1869
+ /* The draggable right edge of a column header. */
1870
+ .resize-handle {
1871
+ position: absolute;
1872
+ top: 0;
1873
+ right: 0;
1874
+ bottom: 0;
1875
+ width: 11px;
1876
+ cursor: col-resize;
1877
+ touch-action: none;
1878
+ }
1879
+
1880
+ .resize-handle::after {
1881
+ content: '';
1882
+ position: absolute;
1883
+ top: 25%;
1884
+ bottom: 25%;
1885
+ right: 3px;
1886
+ width: 2px;
1887
+ border-radius: 1px;
1888
+ background: var(--border);
1889
+ transition: background-color 0.15s;
1890
+ }
1891
+
1892
+ .resize-handle:hover::after,
1893
+ .resize-handle:focus-visible::after,
1894
+ .resizing-column .resize-handle:active::after {
1895
+ background: var(--primary);
1896
+ }
1897
+
1898
+ .resize-handle:focus-visible {
1899
+ outline: 2px solid var(--primary);
1900
+ outline-offset: -2px;
1901
+ }
1902
+
1903
+ /* While an edge is dragged, the pointer and the text stay put. */
1904
+ body.resizing-column {
1905
+ cursor: col-resize;
1906
+ user-select: none;
1907
+ }
1908
+
1909
+ .data-table .col-select {
1910
+ padding-left: 0.6rem;
1911
+ padding-right: 0.3rem;
1912
+ }
1913
+
1914
+ /* The "more" button's own 28px, and a little air. */
1915
+ .data-table .col-more {
1916
+ padding-left: 0.3rem;
1917
+ padding-right: 0.3rem;
1918
+ }
1919
+
1920
+ .col-actions {
1921
+ white-space: nowrap;
1922
+ }
1923
+
1924
+ /* What did not fit, under its row: labelled, in the admin's order. */
1925
+ .data-table tbody tr.row-more {
1926
+ height: auto;
1927
+ background: var(--code-bg);
1928
+ }
1929
+
1930
+ .data-table tbody tr.row-more:hover {
1931
+ background: var(--code-bg);
1932
+ }
1933
+
1934
+ .more-list {
1935
+ display: grid;
1936
+ grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
1937
+ gap: 0.6rem 1.25rem;
1938
+ padding: 0.2rem 0 0.3rem 2.4rem;
1939
+ }
1940
+
1941
+ .more-list dt {
1942
+ font-size: 0.68rem;
1943
+ font-weight: 700;
1944
+ text-transform: uppercase;
1945
+ letter-spacing: 0.06em;
1946
+ color: var(--text-muted);
1947
+ }
1948
+
1949
+ .more-list dd {
1950
+ min-width: 0;
1951
+ margin-bottom: 0.1rem;
1952
+ }
1953
+
1954
+ /* Cards: below a table's width, each row stacks its fields, each labelled. */
1955
+ .data-table.cards thead {
1956
+ display: none;
1957
+ }
1958
+
1959
+ .data-table.cards,
1960
+ .data-table.cards tbody,
1961
+ .data-table.cards tr,
1962
+ .data-table.cards td {
1963
+ display: block;
1964
+ width: 100%;
1965
+ }
1966
+
1967
+ .data-table.cards tbody tr {
1968
+ height: auto;
1969
+ position: relative;
1970
+ padding: 0.6rem 0.8rem;
1971
+ border-bottom: 1px solid var(--border);
1972
+ }
1973
+
1974
+ .data-table.cards td {
1975
+ border: none;
1976
+ padding: 0.15rem 0;
1977
+ }
1978
+
1979
+ .data-table.cards td[data-label] {
1980
+ display: grid;
1981
+ grid-template-columns: 6.5rem minmax(0, 1fr);
1982
+ column-gap: 0.75rem;
1983
+ /* From the top, not the baseline: a badge's baseline would otherwise make
1984
+ a card with badges a few pixels taller than one without. */
1985
+ align-items: start;
1986
+ }
1987
+
1988
+ .data-table.cards td[data-label]::before {
1989
+ content: attr(data-label);
1990
+ grid-row: 1 / span 2;
1991
+ padding-top: 0.2rem;
1992
+ font-size: 0.68rem;
1993
+ font-weight: 700;
1994
+ text-transform: uppercase;
1995
+ letter-spacing: 0.06em;
1996
+ color: var(--text-muted);
1997
+ }
1998
+
1999
+ .data-table.cards td[data-label] > * {
2000
+ grid-column: 2;
2001
+ }
2002
+
2003
+ /* A badge keeps its own width; only text fills the value column. */
2004
+ .data-table.cards td[data-label] > .badge {
2005
+ justify-self: start;
2006
+ }
2007
+
2008
+ /* The left gutter of a views card holds its checkbox. */
2009
+ .views-table.cards tbody tr:not(.row-more) {
2010
+ padding-left: 2.8rem;
2011
+ }
2012
+
2013
+ .views-table.cards td.col-select {
2014
+ position: absolute;
2015
+ left: 0.8rem;
2016
+ top: 0.8rem;
2017
+ width: auto;
2018
+ padding: 0;
2019
+ }
2020
+
2021
+ /* A card's status line is full width, so every badge fits on one line;
2022
+ fixed, so a card with no badges is as tall as one with some. */
2023
+ .data-table.cards .badges {
2024
+ height: 1.4rem;
2025
+ }
2026
+
2027
+ .data-table.cards .col-more .btn {
2028
+ padding-left: 0;
2029
+ }
2030
+
2031
+ .data-table.cards tr.row-more {
2032
+ padding-top: 0;
2033
+ }
2034
+
2035
+ .data-table.cards .more-list {
2036
+ grid-template-columns: 6.5rem minmax(0, 1fr);
2037
+ column-gap: 0.75rem;
2038
+ row-gap: 0.3rem;
2039
+ padding: 0;
2040
+ }
2041
+
2042
+ .views-table.cards .more-list {
2043
+ padding-left: 2rem;
2044
+ }
2045
+
2046
+ /* Choosing columns: a checkbox and two move buttons per column. */
2047
+ .column-chooser {
2048
+ list-style: none;
2049
+ display: flex;
2050
+ flex-direction: column;
2051
+ gap: 0.15rem;
2052
+ margin-bottom: 0.75rem;
2053
+ }
2054
+
2055
+ .column-choice {
2056
+ display: grid;
2057
+ grid-template-columns: minmax(0, 1fr) auto auto;
2058
+ align-items: center;
2059
+ gap: 0.25rem;
2060
+ padding: 0.15rem 0.4rem;
2061
+ border-radius: 6px;
2062
+ }
2063
+
2064
+ .column-choice:hover {
2065
+ background: var(--row-hover);
2066
+ }
2067
+
2068
+ .column-choice-label {
2069
+ display: flex;
2070
+ align-items: center;
2071
+ gap: 0.6rem;
2072
+ min-width: 0;
2073
+ padding: 0.3rem 0;
2074
+ cursor: pointer;
2075
+ }
2076
+
2077
+ .column-choice-note {
2078
+ color: var(--text-muted);
2079
+ font-size: 0.78rem;
2080
+ }
2081
+
2082
+ .icon-btn:disabled {
2083
+ opacity: 0.3;
2084
+ pointer-events: none;
2085
+ }
2086
+
2087
+ .toolbar-slot {
2088
+ display: inline-flex;
2089
+ }
2090
+
2091
+ .cell-primary {
2092
+ color: var(--text);
2093
+ }
2094
+
2095
+ .cell-secondary {
2096
+ color: var(--text-muted);
2097
+ font-size: 0.8rem;
2098
+ }
2099
+
2100
+ /* Clamp: text never outgrows its cell (FRONTEND.md §14) */
2101
+
2102
+ .clamp {
2103
+ display: block;
2104
+ overflow: hidden;
2105
+ overflow-wrap: anywhere;
2106
+ }
2107
+
2108
+ .clamp-1 {
2109
+ white-space: nowrap;
2110
+ text-overflow: ellipsis;
2111
+ }
2112
+
2113
+ .clamp-2 {
2114
+ display: -webkit-box;
2115
+ -webkit-line-clamp: 2;
2116
+ -webkit-box-orient: vertical;
2117
+ }
2118
+
2119
+ /* Badges */
2120
+
2121
+ .badges {
2122
+ display: flex;
2123
+ flex-wrap: wrap;
2124
+ align-content: center;
2125
+ align-items: center;
2126
+ gap: 0.2rem 0.3rem;
2127
+ overflow: hidden;
2128
+ /* Fixed at the row's two text lines, so a row with no badges is exactly
2129
+ as tall as one with four, which wrap onto a second line rather than
2130
+ being cut off (FRONTEND.md §14: like items share one height). */
2131
+ height: 2.5rem;
2132
+ }
2133
+
2134
+ .badge {
2135
+ font-size: 0.68rem;
2136
+ font-weight: 700;
2137
+ text-transform: uppercase;
2138
+ letter-spacing: 0.04em;
2139
+ padding: 0.1rem 0.45rem;
2140
+ border-radius: 4px;
2141
+ border: 1px solid currentColor;
2142
+ line-height: 1.2;
2143
+ white-space: nowrap;
2144
+ }
2145
+
2146
+ .badge-success { color: var(--success); background: var(--row-selected); }
2147
+ .badge-danger { color: var(--danger); background: var(--danger-bg); }
2148
+ .badge-warning { color: var(--warning); background: var(--warning-bg); }
2149
+ .badge-info { color: var(--info); background: var(--info-bg); }
2150
+ .badge-muted { color: var(--text-muted); background: var(--muted-bg); }
2151
+
2152
+ .row-actions {
2153
+ display: flex;
2154
+ gap: 0.15rem;
2155
+ }
2156
+
2157
+ /* Pager */
2158
+
2159
+ .pager {
2160
+ display: flex;
2161
+ align-items: center;
2162
+ justify-content: space-between;
2163
+ gap: 1rem;
2164
+ }
2165
+
2166
+ .pager-summary {
2167
+ color: var(--text-muted);
2168
+ font-size: 0.85rem;
2169
+ }
2170
+
2171
+ /* ---------- Footer ---------- */
2172
+
2173
+ .app-footer {
2174
+ max-width: 1920px;
2175
+ margin: 0 auto;
2176
+ padding: 1.5rem 2rem 2.5rem;
2177
+ border-top: 1px solid var(--border);
2178
+ color: var(--text-muted);
2179
+ font-size: 0.8rem;
2180
+ display: flex;
2181
+ flex-direction: column;
2182
+ gap: 0.6rem;
2183
+ }
2184
+
2185
+ .footer-row {
2186
+ display: flex;
2187
+ flex-wrap: wrap;
2188
+ align-items: center;
2189
+ justify-content: space-between;
2190
+ gap: 0.75rem 1.5rem;
2191
+ }
2192
+
2193
+ .footer-copyright {
2194
+ color: var(--text);
2195
+ }
2196
+
2197
+ .footer-links {
2198
+ display: flex;
2199
+ flex-wrap: wrap;
2200
+ gap: 0.4rem 1.1rem;
2201
+ }
2202
+
2203
+ .footer-link {
2204
+ display: inline-flex;
2205
+ align-items: center;
2206
+ gap: 0.35rem;
2207
+ color: var(--text-muted);
2208
+ text-decoration: none;
2209
+ }
2210
+
2211
+ .footer-link:hover {
2212
+ color: var(--primary);
2213
+ }
2214
+
2215
+ .footer-legal {
2216
+ font-size: 0.75rem;
2217
+ }
2218
+
2219
+ .footer-copyright a,
2220
+ .footer-credits a {
2221
+ color: inherit;
2222
+ }
2223
+
2224
+ .footer-copyright a:hover,
2225
+ .footer-credits a:hover {
2226
+ color: var(--primary);
2227
+ }
2228
+
2229
+ /* ---------- Modal (FRONTEND.md §1.1) ---------- */
2230
+
2231
+ #modal-root {
2232
+ position: fixed;
2233
+ inset: 0;
2234
+ z-index: 200;
2235
+ }
2236
+
2237
+ .modal-backdrop {
2238
+ position: absolute;
2239
+ inset: 0;
2240
+ display: flex;
2241
+ align-items: flex-start;
2242
+ justify-content: center;
2243
+ padding: 8vh 1rem 2rem;
2244
+ background: var(--backdrop);
2245
+ backdrop-filter: blur(3px);
2246
+ overflow-y: auto;
2247
+ animation: fade-in 0.15s ease-out;
2248
+ }
2249
+
2250
+ .modal {
2251
+ width: min(520px, 100%);
2252
+ background: var(--surface-solid);
2253
+ animation: pop-in 0.18s ease-out;
2254
+ }
2255
+
2256
+ .modal-wide {
2257
+ width: min(760px, 100%);
2258
+ }
2259
+
2260
+ .modal-title {
2261
+ font-size: 0.9rem;
2262
+ margin-left: 0.25rem;
2263
+ flex: 1;
2264
+ }
2265
+
2266
+ .modal-close {
2267
+ margin-left: auto;
2268
+ }
2269
+
2270
+ .modal-body {
2271
+ padding: 1.4rem 1.5rem 0.5rem;
2272
+ }
2273
+
2274
+ .modal-message {
2275
+ margin-bottom: 1rem;
2276
+ }
2277
+
2278
+ .modal-actions {
2279
+ display: flex;
2280
+ justify-content: flex-end;
2281
+ flex-wrap: wrap;
2282
+ gap: 0.6rem;
2283
+ padding: 1rem 1.5rem 1.4rem;
2284
+ }
2285
+
2286
+ .detail-groups {
2287
+ display: flex;
2288
+ flex-direction: column;
2289
+ gap: 1.1rem;
2290
+ }
2291
+
2292
+ .detail-group-title {
2293
+ font-size: 0.75rem;
2294
+ text-transform: uppercase;
2295
+ letter-spacing: 0.06em;
2296
+ color: var(--secondary);
2297
+ margin-bottom: 0.45rem;
2298
+ }
2299
+
2300
+ .detail-list {
2301
+ display: grid;
2302
+ grid-template-columns: 11rem 1fr;
2303
+ gap: 0.45rem 1rem;
2304
+ font-size: 0.85rem;
2305
+ }
2306
+
2307
+ .detail-list dt {
2308
+ color: var(--text-muted);
2309
+ }
2310
+
2311
+ .detail-list dd {
2312
+ overflow-wrap: anywhere;
2313
+ min-width: 0;
2314
+ }
2315
+
2316
+ .detail-json {
2317
+ background: var(--code-bg);
2318
+ border: 1px solid var(--border);
2319
+ border-radius: 6px;
2320
+ padding: 0.6rem;
2321
+ white-space: pre-wrap;
2322
+ overflow-wrap: anywhere;
2323
+ font-size: 0.8rem;
2324
+ }
2325
+
2326
+ .edit-grid {
2327
+ display: flex;
2328
+ flex-direction: column;
2329
+ gap: 0.8rem;
2330
+ margin-bottom: 0.5rem;
2331
+ }
2332
+
2333
+ .edit-row {
2334
+ display: grid;
2335
+ grid-template-columns: 1.4rem 9rem 1fr;
2336
+ align-items: start;
2337
+ gap: 0.6rem;
2338
+ }
2339
+
2340
+ .edit-row .field-include {
2341
+ margin-top: 0.7rem;
2342
+ }
2343
+
2344
+ .edit-label {
2345
+ padding-top: 0.6rem;
2346
+ color: var(--text-muted);
2347
+ font-size: 0.85rem;
2348
+ }
2349
+
2350
+ .edit-control {
2351
+ min-width: 0;
2352
+ }
2353
+
2354
+ /* ---------- Toasts (FRONTEND.md §2) ---------- */
2355
+
2356
+ .toast-root {
2357
+ position: fixed;
2358
+ right: 1.5rem;
2359
+ bottom: 1.5rem;
2360
+ z-index: 260;
2361
+ display: flex;
2362
+ flex-direction: column;
2363
+ gap: 0.6rem;
2364
+ width: min(420px, calc(100vw - 2rem));
2365
+ }
2366
+
2367
+ .toast {
2368
+ display: flex;
2369
+ align-items: flex-start;
2370
+ gap: 0.6rem;
2371
+ padding: 0.8rem 0.9rem;
2372
+ background: var(--surface-solid);
2373
+ border: 1px solid var(--border-strong);
2374
+ border-left: 3px solid var(--primary);
2375
+ border-radius: var(--radius-small);
2376
+ box-shadow: var(--shadow);
2377
+ animation: slide-in 0.2s ease-out;
2378
+ }
2379
+
2380
+ .toast-error { border-left-color: var(--danger); }
2381
+ .toast-error .toast-prompt { color: var(--danger); }
2382
+ .toast-info { border-left-color: var(--info); }
2383
+
2384
+ .toast-prompt {
2385
+ color: var(--primary);
2386
+ font-weight: 700;
2387
+ }
2388
+
2389
+ .toast-message {
2390
+ flex: 1;
2391
+ overflow-wrap: anywhere;
2392
+ }
2393
+
2394
+ .toast-leaving {
2395
+ animation: slide-out 0.2s ease-in forwards;
2396
+ }
2397
+
2398
+ /* ---------- Motion ---------- */
2399
+
2400
+ @keyframes blink {
2401
+ 50% { opacity: 0; }
2402
+ }
2403
+
2404
+ @keyframes pulse {
2405
+ 0% { box-shadow: 0 0 0 0 var(--primary-glow); }
2406
+ 70% { box-shadow: 0 0 0 0.45rem transparent; }
2407
+ 100% { box-shadow: 0 0 0 0 transparent; }
2408
+ }
2409
+
2410
+ @keyframes fade-in {
2411
+ from { opacity: 0; }
2412
+ }
2413
+
2414
+ @keyframes pop-in {
2415
+ from { opacity: 0; transform: scale(0.97) translateY(-4px); }
2416
+ }
2417
+
2418
+ @keyframes slide-in {
2419
+ from { opacity: 0; transform: translateX(16px); }
2420
+ }
2421
+
2422
+ @keyframes slide-out {
2423
+ to { opacity: 0; transform: translateX(16px); }
2424
+ }
2425
+
2426
+ @media (prefers-reduced-motion: reduce) {
2427
+ *,
2428
+ *::before,
2429
+ *::after {
2430
+ animation: none !important;
2431
+ transition: none !important;
2432
+ }
2433
+ }
2434
+
2435
+ /* ---------- Narrow screens (FRONTEND.md §12: reflow, never scroll sideways) ---------- */
2436
+
2437
+ /* The Overview reflows by its own width: two columns of cards, then one. */
2438
+ @container overview-grid (width < 56rem) {
2439
+ .overview-grid > .chart-card {
2440
+ grid-column: 1 / -1;
2441
+ }
2442
+ }
2443
+
2444
+ @media (max-width: 1100px) {
2445
+ .overview-top {
2446
+ grid-template-columns: minmax(0, 1fr);
2447
+ }
2448
+
2449
+ .overview-grid {
2450
+ grid-template-columns: minmax(0, 1fr);
2451
+ }
2452
+ }
2453
+
2454
+ @media (max-width: 960px) {
2455
+ main {
2456
+ padding: 1.25rem 1rem 3rem;
2457
+ }
2458
+
2459
+ .app-footer {
2460
+ padding: 1.25rem 1rem 2rem;
2461
+ }
2462
+
2463
+ .header-link span {
2464
+ display: none;
2465
+ }
2466
+
2467
+ .topbar {
2468
+ padding: 0.8rem 1rem;
2469
+ }
2470
+
2471
+ .batch-bar {
2472
+ top: 3.6rem;
2473
+ }
2474
+ }
2475
+
2476
+ @media (max-width: 720px) {
2477
+ .tabs {
2478
+ flex-wrap: wrap;
2479
+ }
2480
+
2481
+ .search-input {
2482
+ width: 100%;
2483
+ }
2484
+
2485
+ .toolbar-group {
2486
+ width: 100%;
2487
+ }
2488
+
2489
+ /* A segmented control spans the row and shares it evenly, rather than
2490
+ pushing the page sideways (FRONTEND.md §12). */
2491
+ .segmented {
2492
+ width: 100%;
2493
+ }
2494
+
2495
+ .segmented-btn {
2496
+ flex: 1 1 auto;
2497
+ padding: 0.35rem 0.45rem;
2498
+ }
2499
+
2500
+ .edit-row {
2501
+ grid-template-columns: 1.4rem 1fr;
2502
+ }
2503
+
2504
+ .map-layout {
2505
+ grid-template-columns: minmax(0, 1fr);
2506
+ }
2507
+
2508
+ .edit-row .edit-control {
2509
+ grid-column: 1 / -1;
2510
+ }
2511
+
2512
+ .detail-list {
2513
+ grid-template-columns: 1fr;
2514
+ }
2515
+
2516
+ .metric-help dl {
2517
+ grid-template-columns: 1fr;
2518
+ }
2519
+
2520
+ .logo-text,
2521
+ .version-badge {
2522
+ display: none;
2523
+ }
2524
+
2525
+ .topbar {
2526
+ gap: 0.5rem;
2527
+ }
2528
+
2529
+ .topbar-actions {
2530
+ gap: 0.4rem;
2531
+ }
2532
+
2533
+ /* Sign out keeps its name for assistive technology, and shows its icon. */
2534
+ #logout-btn {
2535
+ padding: 0.4rem 0.55rem;
2536
+ }
2537
+
2538
+ #logout-btn span {
2539
+ position: absolute;
2540
+ width: 1px;
2541
+ height: 1px;
2542
+ overflow: hidden;
2543
+ clip: rect(0 0 0 0);
2544
+ white-space: nowrap;
2545
+ }
2546
+
2547
+ .rank-table .rank-share {
2548
+ display: none;
2549
+ }
2550
+
2551
+ .rank-table .rank-num {
2552
+ width: 4.2rem;
2553
+ }
2554
+
2555
+ /* Hours stay readable: every sixth is labelled, cells get shorter. */
2556
+ .heatmap {
2557
+ grid-template-columns: 2.2rem repeat(24, minmax(0, 1fr));
2558
+ gap: 1px;
2559
+ }
2560
+
2561
+ .heatmap-cell {
2562
+ height: 1rem;
2563
+ }
2564
+
2565
+ .heatmap-hour:nth-child(6n + 5) {
2566
+ visibility: hidden;
2567
+ }
2568
+ }