@escalated-dev/escalated 0.11.8 → 0.11.9

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 (51) hide show
  1. package/README.md +7 -0
  2. package/package.json +1 -1
  3. package/pages.json +1 -1
  4. package/src/components/AuditLogEntry.vue +15 -9
  5. package/src/components/ChatQueue.vue +1 -1
  6. package/src/components/EscalatedLayout.vue +224 -38
  7. package/src/components/PeriodSelector.vue +1 -1
  8. package/src/components/PermissionMatrix.vue +1 -1
  9. package/src/components/QuickFilters.vue +5 -2
  10. package/src/components/TicketList.vue +2 -2
  11. package/src/components/admin/newsletters/DeliveriesTable.vue +29 -27
  12. package/src/components/admin/newsletters/ListMemberTable.vue +30 -28
  13. package/src/pages/Admin/ApiTokens/Index.vue +1 -1
  14. package/src/pages/Admin/Automations/Form.vue +10 -2
  15. package/src/pages/Admin/Automations/Index.vue +1 -1
  16. package/src/pages/Admin/BusinessHours/Index.vue +1 -1
  17. package/src/pages/Admin/Capacity/Index.vue +1 -1
  18. package/src/pages/Admin/CustomFields/Index.vue +1 -1
  19. package/src/pages/Admin/CustomObjects/Index.vue +1 -1
  20. package/src/pages/Admin/CustomObjects/Records.vue +1 -1
  21. package/src/pages/Admin/Departments/Index.vue +1 -1
  22. package/src/pages/Admin/EscalationRules/Index.vue +1 -1
  23. package/src/pages/Admin/Import/Index.vue +1 -1
  24. package/src/pages/Admin/KnowledgeBase/Articles/Index.vue +3 -3
  25. package/src/pages/Admin/Macros/Index.vue +1 -1
  26. package/src/pages/Admin/Newsletters/Index.vue +31 -29
  27. package/src/pages/Admin/Newsletters/Lists/Index.vue +22 -20
  28. package/src/pages/Admin/Reports/AgentMetrics.vue +1 -1
  29. package/src/pages/Admin/Reports/AgentRanking.vue +3 -3
  30. package/src/pages/Admin/Reports/Cohorts.vue +7 -5
  31. package/src/pages/Admin/Reports/Comparison.vue +3 -3
  32. package/src/pages/Admin/Reports/Dashboard.vue +7 -5
  33. package/src/pages/Admin/Reports/ResolutionTimes.vue +54 -46
  34. package/src/pages/Admin/Reports/ResponseTimes.vue +54 -46
  35. package/src/pages/Admin/Reports/SlaReport.vue +70 -68
  36. package/src/pages/Admin/Reports/SlaTrends.vue +2 -2
  37. package/src/pages/Admin/Reports.vue +1 -1
  38. package/src/pages/Admin/Roles/Index.vue +1 -1
  39. package/src/pages/Admin/Settings/EmailSettings.vue +1 -1
  40. package/src/pages/Admin/Skills/Index.vue +1 -1
  41. package/src/pages/Admin/SlaPolicies/Index.vue +1 -1
  42. package/src/pages/Admin/Statuses/Index.vue +1 -1
  43. package/src/pages/Admin/Tags/Index.vue +1 -1
  44. package/src/pages/Admin/Users/Index.vue +1 -1
  45. package/src/pages/Admin/Webhooks/DeliveryLog.vue +1 -1
  46. package/src/pages/Admin/Webhooks/Index.vue +1 -1
  47. package/src/pages/Admin/Workflows/Index.vue +1 -1
  48. package/src/pages/Admin/Workflows/Logs.vue +1 -1
  49. package/src/pages/Agent/Dashboard.vue +1 -1
  50. package/src/plugin.js +19 -0
  51. package/src/utils/scrollAffordance.js +87 -0
package/README.md CHANGED
@@ -157,6 +157,13 @@ The `theme` option sets CSS custom properties you can reference in your own styl
157
157
  | `--esc-radius-lg` | auto-scaled | Border radius for cards and panels |
158
158
  | `--esc-font-family` | inherit | Font family override |
159
159
 
160
+ ### Admin and Agent Panels
161
+
162
+ `theme.panel` colours the admin and agent panels: navigation, header, logo
163
+ tile, links and surfaces, in dark or light mode. Both panels collapse their
164
+ navigation behind a menu button below 1024px. See
165
+ [docs/theming.md](docs/theming.md) for every token.
166
+
160
167
  ### Framework Examples
161
168
 
162
169
  **Laravel** (Inertia + Vue 3):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escalated-dev/escalated",
3
- "version": "0.11.8",
3
+ "version": "0.11.9",
4
4
  "description": "Vue 3 + Inertia.js UI components for Escalated — the embeddable support ticket system",
5
5
  "author": "Escalated Dev <hello@escalated.dev>",
6
6
  "license": "MIT",
package/pages.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$comment": "Generated by scripts/generate-page-manifest.js from src/pages. Every name here resolves to a component; a name a backend renders that is not here is a blank screen. `props` is what each component reads -- a backend sending something else renders the screen with no content in it.",
3
- "version": "0.11.8",
3
+ "version": "0.11.9",
4
4
  "pages": [
5
5
  "Escalated/Admin/ApiTokens/Index",
6
6
  "Escalated/Admin/AuditLog/Index",
@@ -46,30 +46,36 @@ const formattedDate = computed(() => {
46
46
 
47
47
  <template>
48
48
  <div
49
- class="flex gap-3 rounded-lg border border-white/[0.04] bg-white/[0.02] px-4 py-3 transition-colors hover:bg-white/[0.03]"
49
+ class="flex gap-3 rounded-lg border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] px-4 py-3 transition-colors hover:bg-[var(--esc-panel-hover)]"
50
50
  >
51
51
  <!-- Avatar -->
52
52
  <div
53
- class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-cyan-500/20 to-violet-500/20 text-xs font-semibold text-neutral-300"
53
+ class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-cyan-500/20 to-violet-500/20 text-xs font-semibold text-[var(--esc-panel-text-tertiary)]"
54
54
  >
55
55
  {{ userInitial }}
56
56
  </div>
57
57
 
58
58
  <div class="min-w-0 flex-1">
59
- <div class="flex items-center gap-2 text-sm">
60
- <span class="font-medium text-neutral-200">{{ log.user?.name || 'System' }}</span>
59
+ <div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
60
+ <span class="font-medium text-[var(--esc-panel-text-secondary)]">{{ log.user?.name || 'System' }}</span>
61
61
  <span :class="['inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium', actionColor]">
62
62
  {{ log.action }}
63
63
  </span>
64
- <span class="text-neutral-400">{{ resourceName }}</span>
65
- <span class="text-neutral-600">#{{ log.auditable_id }}</span>
66
- <span class="ml-auto text-xs text-neutral-500">{{ formattedDate }}</span>
64
+ <span class="text-[var(--esc-panel-text-tertiary)]">{{ resourceName }}</span>
65
+ <span class="text-[var(--esc-panel-text-muted)]">#{{ log.auditable_id }}</span>
66
+ <span class="ml-auto whitespace-nowrap text-xs text-[var(--esc-panel-text-muted)]">{{
67
+ formattedDate
68
+ }}</span>
67
69
  </div>
68
70
 
69
71
  <!-- Changed fields diff -->
70
72
  <div v-if="changedFields.length" class="mt-2 space-y-1">
71
- <div v-for="change in changedFields" :key="change.field" class="flex items-center gap-2 text-xs">
72
- <span class="font-medium text-neutral-400">{{ change.field }}:</span>
73
+ <div
74
+ v-for="change in changedFields"
75
+ :key="change.field"
76
+ class="flex flex-wrap items-center gap-2 text-xs"
77
+ >
78
+ <span class="font-medium text-[var(--esc-panel-text-tertiary)]">{{ change.field }}:</span>
73
79
  <span class="text-rose-400/70 line-through">{{
74
80
  typeof change.old === 'object' ? JSON.stringify(change.old) : change.old
75
81
  }}</span>
@@ -119,7 +119,7 @@ onMounted(() => {
119
119
  <div
120
120
  v-else
121
121
  :class="[
122
- 'overflow-hidden rounded-xl border',
122
+ 'esc-table-scroll rounded-xl border',
123
123
  escDark ? 'border-white/[0.06] bg-neutral-900/60' : 'border-gray-200 bg-white',
124
124
  ]"
125
125
  >
@@ -1,9 +1,10 @@
1
1
  <script setup>
2
- import { computed, inject, provide } from 'vue';
2
+ import { computed, inject, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
3
3
  import { usePage, Link } from '@inertiajs/vue3';
4
4
  import { usePluginExtensions } from '../composables/usePluginExtensions';
5
5
  import { usePermissions } from '../composables/usePermissions';
6
6
  import { useI18n } from '../composables/useI18n';
7
+ import { watchScrollAffordance } from '../utils/scrollAffordance';
7
8
  import ActiveChatsPanel from './ActiveChatsPanel.vue';
8
9
 
9
10
  defineProps({
@@ -225,6 +226,8 @@ const agentLinks = computed(() => [
225
226
  {
226
227
  href: `${prefix.value}/agent`,
227
228
  label: 'Dashboard',
229
+ // Every agent URL starts with this one; only the dashboard itself is it.
230
+ exact: true,
228
231
  icon: 'M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25',
229
232
  },
230
233
  {
@@ -237,26 +240,77 @@ const agentLinks = computed(() => [
237
240
  const userName = computed(() => page.props.auth?.user?.name || 'User');
238
241
  const userInitial = computed(() => userName.value.charAt(0).toUpperCase());
239
242
 
240
- function isActive(href) {
243
+ function isActive(href, exact = false) {
241
244
  if (!currentUrl.value) return false;
242
- return currentUrl.value === href || currentUrl.value.startsWith(href + '/');
245
+ const path = currentUrl.value.split(/[?#]/)[0];
246
+ if (exact) return path === href;
247
+ return path === href || path.startsWith(href + '/');
243
248
  }
249
+
250
+ // Below the `lg` breakpoint the admin sidebar and the agent nav links fold
251
+ // away behind a menu button. It starts closed on the server and the client
252
+ // alike, so SSR markup hydrates without a mismatch; CSS alone decides whether
253
+ // the navigation shows at a given width.
254
+ const navOpen = ref(false);
255
+ function toggleNav() {
256
+ navOpen.value = !navOpen.value;
257
+ }
258
+ function closeNav() {
259
+ navOpen.value = false;
260
+ }
261
+ watch(currentUrl, closeNav);
262
+
263
+ function onKeydown(event) {
264
+ if (event.key === 'Escape' && navOpen.value) closeNav();
265
+ }
266
+
267
+ // Tables scroll sideways inside `.esc-table-scroll`; mark the edges that have
268
+ // more content past them so the scroll is visible before anyone tries it.
269
+ const contentRoot = ref(null);
270
+ let stopScrollAffordance = () => {};
271
+
272
+ onMounted(() => {
273
+ window.addEventListener('keydown', onKeydown);
274
+ stopScrollAffordance = watchScrollAffordance(contentRoot.value);
275
+ });
276
+
277
+ onBeforeUnmount(() => {
278
+ window.removeEventListener('keydown', onKeydown);
279
+ stopScrollAffordance();
280
+ });
244
281
  </script>
245
282
 
246
283
  <template>
247
- <!-- MODE 1: Admin — dark sidebar layout -->
284
+ <!-- MODE 1: Admin — sidebar layout; the sidebar folds into a drawer below lg -->
248
285
  <div
249
286
  v-if="isAdminSection"
250
287
  class="flex min-h-screen bg-[var(--esc-panel-bg)]"
251
288
  :style="{ colorScheme: panelConfig.mode === 'light' ? 'light' : 'dark' }"
252
289
  >
290
+ <!-- Drawer backdrop (narrow screens only) -->
291
+ <div
292
+ v-if="navOpen"
293
+ class="fixed inset-0 z-30 bg-black/40 lg:hidden"
294
+ aria-hidden="true"
295
+ data-testid="esc-nav-backdrop"
296
+ @click="closeNav"
297
+ ></div>
298
+
253
299
  <!-- Sidebar -->
254
300
  <aside
255
- class="fixed inset-y-0 left-0 z-30 flex w-64 flex-col border-r border-[var(--esc-panel-border)] bg-[var(--esc-panel-sidebar-bg)]"
301
+ id="esc-admin-sidebar"
302
+ :class="[
303
+ 'fixed inset-y-0 left-0 z-40 flex w-64 max-w-[85vw] flex-col border-r border-[var(--esc-panel-border)] bg-[var(--esc-panel-sidebar-bg)] transition-[transform,visibility] duration-200 lg:visible lg:translate-x-0 lg:shadow-none',
304
+ navOpen ? 'visible translate-x-0 shadow-2xl' : 'invisible -translate-x-full',
305
+ ]"
306
+ data-testid="esc-admin-sidebar"
256
307
  >
257
308
  <!-- Logo -->
258
309
  <div class="flex h-16 items-center gap-3 px-5">
259
- <div class="flex h-9 w-9 items-center justify-center rounded-lg bg-[var(--esc-panel-border-input)]">
310
+ <div
311
+ class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--esc-panel-logo-tile-bg,var(--esc-panel-border-input))] text-[var(--esc-panel-logo-tile-fg,var(--esc-panel-text))]"
312
+ data-testid="esc-logo-tile"
313
+ >
260
314
  <img
261
315
  v-if="typeof panelConfig.logo === 'string'"
262
316
  :src="panelConfig.logo"
@@ -288,7 +342,7 @@ function isActive(href) {
288
342
  </g>
289
343
  </svg>
290
344
  </div>
291
- <div>
345
+ <div class="min-w-0">
292
346
  <span class="text-sm font-bold text-[var(--esc-panel-text)] tracking-wide">{{
293
347
  panelConfig.appName
294
348
  }}</span>
@@ -297,6 +351,17 @@ function isActive(href) {
297
351
  >ADMIN</span
298
352
  >
299
353
  </div>
354
+ <button
355
+ type="button"
356
+ class="-mr-2 ml-auto rounded-lg p-2 text-[var(--esc-panel-text-muted)] hover:bg-[var(--esc-panel-hover)] hover:text-[var(--esc-panel-text)] lg:hidden"
357
+ aria-label="Close navigation"
358
+ data-testid="esc-nav-close"
359
+ @click="closeNav"
360
+ >
361
+ <svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
362
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
363
+ </svg>
364
+ </button>
300
365
  </div>
301
366
 
302
367
  <!-- Nav -->
@@ -309,15 +374,16 @@ function isActive(href) {
309
374
  :class="[
310
375
  'group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] font-medium transition-all',
311
376
  isActive(link.href)
312
- ? 'bg-[var(--esc-panel-active)] text-[var(--esc-panel-text)]'
377
+ ? 'bg-[var(--esc-panel-active)] text-[var(--esc-panel-active-text,var(--esc-panel-text))]'
313
378
  : 'text-[var(--esc-panel-text-muted)] hover:bg-[var(--esc-panel-hover)] hover:text-[var(--esc-panel-text-secondary)]',
314
379
  ]"
380
+ @click="closeNav"
315
381
  >
316
382
  <svg
317
383
  :class="[
318
384
  'h-[18px] w-[18px] shrink-0',
319
385
  isActive(link.href)
320
- ? 'text-[var(--esc-panel-text)]'
386
+ ? 'text-[var(--esc-panel-active-text,var(--esc-panel-text))]'
321
387
  : 'text-[var(--esc-panel-text-muted)] group-hover:text-[var(--esc-panel-text-tertiary)]',
322
388
  ]"
323
389
  fill="none"
@@ -342,7 +408,7 @@ function isActive(href) {
342
408
  <Link
343
409
  v-if="isAgent"
344
410
  :href="`${prefix}/agent`"
345
- class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] text-[var(--esc-panel-text-muted)] transition-colors hover:bg-[var(--esc-panel-hover)] hover:text-[var(--esc-panel-text-secondary)]"
411
+ class="flex items-center gap-2.5 whitespace-nowrap rounded-lg px-3 py-2 text-[13px] text-[var(--esc-panel-text-muted)] transition-colors hover:bg-[var(--esc-panel-hover)] hover:text-[var(--esc-panel-text-secondary)]"
346
412
  >
347
413
  <svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
348
414
  <path
@@ -355,7 +421,7 @@ function isActive(href) {
355
421
  </Link>
356
422
  <Link
357
423
  :href="prefix"
358
- class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] text-[var(--esc-panel-text-muted)] transition-colors hover:bg-[var(--esc-panel-hover)] hover:text-[var(--esc-panel-text-secondary)]"
424
+ class="flex items-center gap-2.5 whitespace-nowrap rounded-lg px-3 py-2 text-[13px] text-[var(--esc-panel-text-muted)] transition-colors hover:bg-[var(--esc-panel-hover)] hover:text-[var(--esc-panel-text-secondary)]"
359
425
  >
360
426
  <svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
361
427
  <path
@@ -370,11 +436,11 @@ function isActive(href) {
370
436
  <!-- User -->
371
437
  <div class="mt-2 flex items-center gap-3 rounded-lg bg-[var(--esc-panel-hover)] px-3 py-2.5">
372
438
  <div
373
- class="flex h-7 w-7 items-center justify-center rounded-md bg-[var(--esc-panel-active)] text-xs font-semibold text-[var(--esc-panel-text-tertiary)]"
439
+ class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-[var(--esc-panel-active)] text-xs font-semibold text-[var(--esc-panel-text-tertiary)]"
374
440
  >
375
441
  {{ userInitial }}
376
442
  </div>
377
- <span class="text-sm text-[var(--esc-panel-text-tertiary)]">{{ userName }}</span>
443
+ <span class="truncate text-sm text-[var(--esc-panel-text-tertiary)]">{{ userName }}</span>
378
444
  </div>
379
445
  </div>
380
446
 
@@ -401,23 +467,42 @@ function isActive(href) {
401
467
  </aside>
402
468
 
403
469
  <!-- Main content -->
404
- <div class="flex flex-1 flex-col pl-64">
470
+ <div class="flex min-w-0 flex-1 flex-col lg:pl-64">
405
471
  <!-- Top bar -->
406
472
  <header
407
473
  role="banner"
408
- class="sticky top-0 z-20 flex h-14 items-center border-b border-[var(--esc-panel-border)] bg-[var(--esc-panel-topbar-bg)] px-6 backdrop-blur-xl"
474
+ class="sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-[var(--esc-panel-border)] bg-[var(--esc-panel-header-bg,var(--esc-panel-topbar-bg))] px-4 backdrop-blur-xl sm:px-6"
409
475
  >
410
- <h1 class="text-sm font-semibold text-[var(--esc-panel-text)]">{{ title }}</h1>
476
+ <button
477
+ type="button"
478
+ class="-ml-2 rounded-lg p-2 text-[var(--esc-panel-header-text,var(--esc-panel-text))] hover:bg-[var(--esc-panel-hover)] lg:hidden"
479
+ aria-controls="esc-admin-sidebar"
480
+ :aria-expanded="navOpen ? 'true' : 'false'"
481
+ aria-label="Open navigation"
482
+ data-testid="esc-nav-toggle"
483
+ @click="toggleNav"
484
+ >
485
+ <svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
486
+ <path
487
+ stroke-linecap="round"
488
+ stroke-linejoin="round"
489
+ d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
490
+ />
491
+ </svg>
492
+ </button>
493
+ <h1 class="truncate text-sm font-semibold text-[var(--esc-panel-header-text,var(--esc-panel-text))]">
494
+ {{ title }}
495
+ </h1>
411
496
  </header>
412
497
 
413
498
  <!-- Page content -->
414
- <main role="main" class="flex-1 p-6">
499
+ <main ref="contentRoot" role="main" class="min-w-0 flex-1 p-4 sm:p-6">
415
500
  <slot />
416
501
  </main>
417
502
  </div>
418
503
  </div>
419
504
 
420
- <!-- MODE 2: Agent — dark top-nav layout -->
505
+ <!-- MODE 2: Agent — top-nav layout; the links fold behind a menu button below lg -->
421
506
  <div
422
507
  v-else-if="isAgentSection"
423
508
  class="min-h-screen bg-[var(--esc-panel-bg)]"
@@ -425,12 +510,16 @@ function isActive(href) {
425
510
  >
426
511
  <!-- Top nav -->
427
512
  <nav
428
- class="sticky top-0 z-30 border-b border-[var(--esc-panel-border)] bg-[var(--esc-panel-sidebar-bg)] backdrop-blur-xl"
513
+ aria-label="Agent navigation"
514
+ class="sticky top-0 z-30 border-b border-[var(--esc-panel-border)] bg-[var(--esc-panel-header-bg,var(--esc-panel-sidebar-bg))] backdrop-blur-xl"
429
515
  >
430
- <div class="mx-auto flex h-14 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
516
+ <div class="mx-auto flex h-14 max-w-7xl items-center justify-between gap-3 px-4 sm:px-6 lg:px-8">
431
517
  <!-- Left: branding -->
432
- <div class="flex items-center gap-3">
433
- <div class="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--esc-panel-border-input)]">
518
+ <div class="flex min-w-0 items-center gap-3">
519
+ <div
520
+ class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--esc-panel-logo-tile-bg,var(--esc-panel-border-input))] text-[var(--esc-panel-logo-tile-fg,var(--esc-panel-text))]"
521
+ data-testid="esc-logo-tile"
522
+ >
434
523
  <img
435
524
  v-if="typeof panelConfig.logo === 'string'"
436
525
  :src="panelConfig.logo"
@@ -462,22 +551,23 @@ function isActive(href) {
462
551
  </g>
463
552
  </svg>
464
553
  </div>
465
- <span class="text-sm font-bold text-[var(--esc-panel-text)] tracking-wide">{{
466
- panelConfig.appName
467
- }}</span>
554
+ <span
555
+ class="truncate text-sm font-bold tracking-wide text-[var(--esc-panel-header-text,var(--esc-panel-text))]"
556
+ >{{ panelConfig.appName }}</span
557
+ >
468
558
  </div>
469
559
 
470
- <!-- Center: nav links -->
471
- <div class="flex items-center gap-1">
560
+ <!-- Center: nav links (lg and up) -->
561
+ <div class="hidden items-center gap-1 lg:flex">
472
562
  <Link
473
563
  v-for="link in agentLinks"
474
564
  :key="link.href"
475
565
  :href="link.href"
476
- :aria-current="isActive(link.href) ? 'page' : undefined"
566
+ :aria-current="isActive(link.href, link.exact) ? 'page' : undefined"
477
567
  :class="[
478
- 'flex items-center gap-2 rounded-lg px-3.5 py-1.5 text-[13px] font-medium transition-all',
479
- isActive(link.href)
480
- ? 'bg-[var(--esc-panel-active)] text-[var(--esc-panel-text)]'
568
+ 'flex items-center gap-2 whitespace-nowrap rounded-lg px-3.5 py-1.5 text-[13px] font-medium transition-all',
569
+ isActive(link.href, link.exact)
570
+ ? 'bg-[var(--esc-panel-active)] text-[var(--esc-panel-active-text,var(--esc-panel-text))]'
481
571
  : 'text-[var(--esc-panel-text-muted)] hover:bg-[var(--esc-panel-hover)] hover:text-[var(--esc-panel-text-secondary)]',
482
572
  ]"
483
573
  >
@@ -488,36 +578,110 @@ function isActive(href) {
488
578
  </Link>
489
579
  </div>
490
580
 
491
- <!-- Right: user + links -->
492
- <div class="flex items-center gap-3">
581
+ <!-- Right: user + links (lg and up) -->
582
+ <div class="hidden items-center gap-3 lg:flex">
493
583
  <Link
494
584
  v-if="isAdmin"
495
585
  :href="`${prefix}/admin/reports`"
496
- class="text-[13px] text-[var(--esc-panel-text-muted)] transition-colors hover:text-[var(--esc-panel-text)]"
586
+ class="whitespace-nowrap text-[13px] text-[var(--esc-panel-text-muted)] transition-colors hover:text-[var(--esc-panel-text)]"
497
587
  >
498
588
  Admin
499
589
  </Link>
500
590
  <Link
501
591
  :href="prefix"
502
- class="text-[13px] text-[var(--esc-panel-text-muted)] transition-colors hover:text-[var(--esc-panel-text)]"
592
+ class="whitespace-nowrap text-[13px] text-[var(--esc-panel-text-muted)] transition-colors hover:text-[var(--esc-panel-text)]"
503
593
  >
504
594
  Back to App
505
595
  </Link>
506
596
  <div class="ml-1 h-5 w-px bg-[var(--esc-panel-active)]"></div>
507
597
  <div class="flex items-center gap-2">
508
598
  <div
509
- class="flex h-7 w-7 items-center justify-center rounded-md bg-[var(--esc-panel-active)] text-[10px] font-semibold text-[var(--esc-panel-text-tertiary)]"
599
+ class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-[var(--esc-panel-active)] text-[10px] font-semibold text-[var(--esc-panel-text-tertiary)]"
510
600
  >
511
601
  {{ userInitial }}
512
602
  </div>
513
- <span class="text-[13px] text-[var(--esc-panel-text-tertiary)]">{{ userName }}</span>
603
+ <span class="max-w-[12rem] truncate text-[13px] text-[var(--esc-panel-text-tertiary)]">{{
604
+ userName
605
+ }}</span>
514
606
  </div>
515
607
  </div>
608
+
609
+ <!-- Menu button (below lg) -->
610
+ <button
611
+ type="button"
612
+ class="-mr-2 rounded-lg p-2 text-[var(--esc-panel-header-text,var(--esc-panel-text))] hover:bg-[var(--esc-panel-hover)] lg:hidden"
613
+ aria-controls="esc-agent-menu"
614
+ :aria-expanded="navOpen ? 'true' : 'false'"
615
+ :aria-label="navOpen ? 'Close navigation' : 'Open navigation'"
616
+ data-testid="esc-nav-toggle"
617
+ @click="toggleNav"
618
+ >
619
+ <svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
620
+ <path v-if="navOpen" stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
621
+ <path
622
+ v-else
623
+ stroke-linecap="round"
624
+ stroke-linejoin="round"
625
+ d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
626
+ />
627
+ </svg>
628
+ </button>
629
+ </div>
630
+
631
+ <!-- Collapsed menu (below lg) -->
632
+ <div
633
+ v-if="navOpen"
634
+ id="esc-agent-menu"
635
+ class="space-y-1 border-t border-[var(--esc-panel-border)] px-4 py-3 lg:hidden"
636
+ data-testid="esc-agent-menu"
637
+ >
638
+ <Link
639
+ v-for="link in agentLinks"
640
+ :key="link.href"
641
+ :href="link.href"
642
+ :aria-current="isActive(link.href, link.exact) ? 'page' : undefined"
643
+ :class="[
644
+ 'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium',
645
+ isActive(link.href, link.exact)
646
+ ? 'bg-[var(--esc-panel-active)] text-[var(--esc-panel-active-text,var(--esc-panel-text))]'
647
+ : 'text-[var(--esc-panel-text-muted)] hover:bg-[var(--esc-panel-hover)] hover:text-[var(--esc-panel-text-secondary)]',
648
+ ]"
649
+ @click="closeNav"
650
+ >
651
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
652
+ <path stroke-linecap="round" stroke-linejoin="round" :d="link.icon" />
653
+ </svg>
654
+ {{ link.label }}
655
+ </Link>
656
+ <div class="my-2 h-px bg-[var(--esc-panel-border)]"></div>
657
+ <Link
658
+ v-if="isAdmin"
659
+ :href="`${prefix}/admin/reports`"
660
+ class="block rounded-lg px-3 py-2 text-sm text-[var(--esc-panel-text-muted)] hover:bg-[var(--esc-panel-hover)] hover:text-[var(--esc-panel-text)]"
661
+ @click="closeNav"
662
+ >
663
+ Admin
664
+ </Link>
665
+ <Link
666
+ :href="prefix"
667
+ class="block rounded-lg px-3 py-2 text-sm text-[var(--esc-panel-text-muted)] hover:bg-[var(--esc-panel-hover)] hover:text-[var(--esc-panel-text)]"
668
+ @click="closeNav"
669
+ >
670
+ Back to App
671
+ </Link>
672
+ <div class="flex items-center gap-2 px-3 py-2">
673
+ <div
674
+ class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-[var(--esc-panel-active)] text-[10px] font-semibold text-[var(--esc-panel-text-tertiary)]"
675
+ >
676
+ {{ userInitial }}
677
+ </div>
678
+ <span class="truncate text-sm text-[var(--esc-panel-text-tertiary)]">{{ userName }}</span>
679
+ </div>
516
680
  </div>
517
681
  </nav>
518
682
 
519
683
  <!-- Page content -->
520
- <main class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
684
+ <main ref="contentRoot" class="mx-auto min-w-0 max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
521
685
  <slot />
522
686
  </main>
523
687
 
@@ -626,3 +790,25 @@ function isActive(href) {
626
790
  </footer>
627
791
  </div>
628
792
  </template>
793
+
794
+ <style>
795
+ /* Tables in the panels scroll sideways inside this container rather than
796
+ widening the page. A shadow on an edge says there is more past it; the
797
+ layout keeps data-esc-overflow current (see utils/scrollAffordance.js). */
798
+ .esc-table-scroll {
799
+ overflow-x: auto;
800
+ -webkit-overflow-scrolling: touch;
801
+ scrollbar-width: thin;
802
+ }
803
+ .esc-table-scroll[data-esc-overflow='end'] {
804
+ box-shadow: inset -14px 0 12px -12px rgb(0 0 0 / 0.3);
805
+ }
806
+ .esc-table-scroll[data-esc-overflow='start'] {
807
+ box-shadow: inset 14px 0 12px -12px rgb(0 0 0 / 0.3);
808
+ }
809
+ .esc-table-scroll[data-esc-overflow='start end'] {
810
+ box-shadow:
811
+ inset 14px 0 12px -12px rgb(0 0 0 / 0.3),
812
+ inset -14px 0 12px -12px rgb(0 0 0 / 0.3);
813
+ }
814
+ </style>
@@ -38,7 +38,7 @@ function formatLabel(days) {
38
38
  </script>
39
39
 
40
40
  <template>
41
- <div class="flex items-center gap-2">
41
+ <div class="flex flex-wrap items-center gap-2">
42
42
  <button
43
43
  v-for="d in options"
44
44
  :key="d"
@@ -56,7 +56,7 @@ function isGroupAllChecked(group) {
56
56
  </script>
57
57
 
58
58
  <template>
59
- <div class="overflow-hidden rounded-xl border border-white/[0.06] bg-neutral-900/60">
59
+ <div class="esc-table-scroll rounded-xl border border-white/[0.06] bg-neutral-900/60">
60
60
  <table class="min-w-full divide-y divide-white/[0.06]">
61
61
  <thead>
62
62
  <tr class="bg-white/[0.02]">
@@ -33,7 +33,10 @@ function selectTab(tab) {
33
33
 
34
34
  <template>
35
35
  <div
36
- :class="['flex items-center gap-0 border-b', escDark ? 'border-white/[0.08]' : 'border-gray-200']"
36
+ :class="[
37
+ 'flex items-center gap-0 overflow-x-auto border-b',
38
+ escDark ? 'border-white/[0.08]' : 'border-gray-200',
39
+ ]"
37
40
  role="tablist"
38
41
  aria-label="Ticket view tabs"
39
42
  >
@@ -43,7 +46,7 @@ function selectTab(tab) {
43
46
  role="tab"
44
47
  :aria-selected="activeTab === tab.key"
45
48
  :class="[
46
- 'relative px-4 py-2.5 text-sm font-medium transition-colors whitespace-nowrap',
49
+ 'relative shrink-0 px-4 py-2.5 text-sm font-medium transition-colors whitespace-nowrap',
47
50
  'focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-0',
48
51
  activeTab === tab.key
49
52
  ? escDark
@@ -204,7 +204,7 @@ const colCount = computed(() => {
204
204
 
205
205
  <template>
206
206
  <!-- Dark mode -->
207
- <div v-if="escDark" class="overflow-hidden rounded-xl border border-white/[0.06] bg-neutral-900/60">
207
+ <div v-if="escDark" class="esc-table-scroll rounded-xl border border-white/[0.06] bg-neutral-900/60">
208
208
  <table class="min-w-full divide-y divide-white/[0.06]">
209
209
  <thead>
210
210
  <tr class="bg-white/[0.02]">
@@ -430,7 +430,7 @@ const colCount = computed(() => {
430
430
  </div>
431
431
 
432
432
  <!-- Light mode -->
433
- <div v-else class="overflow-hidden rounded-lg border border-gray-200 bg-white">
433
+ <div v-else class="esc-table-scroll rounded-lg border border-gray-200 bg-white">
434
434
  <table class="min-w-full divide-y divide-gray-200">
435
435
  <thead class="bg-gray-50">
436
436
  <tr>
@@ -11,33 +11,35 @@
11
11
  </select>
12
12
  <button type="button" class="deliveries-table__export" @click="$emit('export')">Export CSV</button>
13
13
  </div>
14
- <table>
15
- <thead>
16
- <tr>
17
- <th>Contact</th>
18
- <th>Status</th>
19
- <th>Sent</th>
20
- <th>Opened</th>
21
- <th>Last clicked</th>
22
- <th>Bounce reason</th>
23
- </tr>
24
- </thead>
25
- <tbody>
26
- <tr v-for="row in rows" :key="row.id">
27
- <td>
28
- <div>{{ row.contact.name ?? '—' }}</div>
29
- <div class="deliveries-table__email">{{ row.contact.email }}</div>
30
- </td>
31
- <td>
32
- <span :class="`status status--${row.status}`">{{ row.status }}</span>
33
- </td>
34
- <td>{{ row.sent_at ? new Date(row.sent_at).toLocaleString() : '—' }}</td>
35
- <td>{{ row.opened_at ? new Date(row.opened_at).toLocaleString() : '—' }}</td>
36
- <td>{{ row.last_clicked_at ? new Date(row.last_clicked_at).toLocaleString() : '—' }}</td>
37
- <td>{{ row.bounce_reason ?? '—' }}</td>
38
- </tr>
39
- </tbody>
40
- </table>
14
+ <div class="esc-table-scroll">
15
+ <table>
16
+ <thead>
17
+ <tr>
18
+ <th>Contact</th>
19
+ <th>Status</th>
20
+ <th>Sent</th>
21
+ <th>Opened</th>
22
+ <th>Last clicked</th>
23
+ <th>Bounce reason</th>
24
+ </tr>
25
+ </thead>
26
+ <tbody>
27
+ <tr v-for="row in rows" :key="row.id">
28
+ <td>
29
+ <div>{{ row.contact.name ?? '—' }}</div>
30
+ <div class="deliveries-table__email">{{ row.contact.email }}</div>
31
+ </td>
32
+ <td>
33
+ <span :class="`status status--${row.status}`">{{ row.status }}</span>
34
+ </td>
35
+ <td>{{ row.sent_at ? new Date(row.sent_at).toLocaleString() : '—' }}</td>
36
+ <td>{{ row.opened_at ? new Date(row.opened_at).toLocaleString() : '—' }}</td>
37
+ <td>{{ row.last_clicked_at ? new Date(row.last_clicked_at).toLocaleString() : '—' }}</td>
38
+ <td>{{ row.bounce_reason ?? '—' }}</td>
39
+ </tr>
40
+ </tbody>
41
+ </table>
42
+ </div>
41
43
  </div>
42
44
  </template>
43
45