+
+
+ {{ panelConfig.appName }}
+ ADMIN
+
+
+```
+
+Do the same for the agent logo block (lines 340-365), but without the ADMIN badge.
+
+**Step 7: Update color-scheme style attribute**
+
+Line 203: Change `style="color-scheme: dark"` to `:style="{ colorScheme: panelConfig.mode === 'light' ? 'light' : 'dark' }"`
+
+Same for line 335 (agent section).
+
+**Step 8: Commit**
+
+```bash
+git add src/components/EscalatedLayout.vue
+git commit -m "feat: migrate EscalatedLayout to panel theme tokens
+
+Admin sidebar, topbar, and agent nav now use --esc-panel-* CSS
+custom properties. Logo and appName are dynamic from panel config.
+color-scheme respects panel mode setting."
+```
+
+---
+
+### Task 3: Migrate admin pages — Batch 1 (Reports, Settings, Tickets)
+
+**Files:**
+- `src/pages/Admin/Reports.vue`
+- `src/pages/Admin/Reports/Dashboard.vue`
+- `src/pages/Admin/Reports/AgentMetrics.vue`
+- `src/pages/Admin/Reports/CsatReport.vue`
+- `src/pages/Admin/Reports/SlaReport.vue`
+- `src/pages/Admin/Settings.vue`
+- `src/pages/Admin/Settings/CsatSettings.vue`
+- `src/pages/Admin/Settings/DataRetention.vue`
+- `src/pages/Admin/Settings/EmailSettings.vue`
+- `src/pages/Admin/Settings/Sandbox.vue`
+- `src/pages/Admin/Settings/SsoSettings.vue`
+- `src/pages/Admin/Settings/TwoFactor.vue`
+- `src/pages/Admin/Tickets/Index.vue`
+- `src/pages/Admin/Tickets/Show.vue`
+
+**Step 1: Apply class replacements**
+
+For each file, apply the Class Replacement Patterns from the reference table above. These are mechanical find-and-replace operations within the template section only:
+
+1. Replace card containers: `border-white/[0.06] bg-neutral-900/60` → `border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)]`
+2. Replace input classes: `border-white/10 bg-neutral-950` → `border-[var(--esc-panel-border-input)] bg-[var(--esc-panel-surface-alt)]`
+3. Replace text classes: `text-white` → `text-[var(--esc-panel-text)]`, etc.
+4. Replace gradient buttons: `from-cyan-500 to-violet-500` → `from-[var(--esc-panel-accent)] to-[var(--esc-panel-accent-secondary)]`
+5. Replace hover/focus states per the table
+6. Replace table divide classes: `divide-white/[0.06]` → `divide-[var(--esc-panel-border)]`
+7. Replace table header bg: `bg-white/[0.02]` → `bg-[var(--esc-panel-hover)]`
+8. Replace row hover: `hover:bg-white/[0.03]` → `hover:bg-[var(--esc-panel-hover)]`
+
+**Important:** Do NOT replace status/priority semantic colors (emerald, amber, rose, etc.) in StatusBadge, PriorityBadge, or similar status indicator contexts.
+
+**Step 2: Commit**
+
+```bash
+git add src/pages/Admin/Reports.vue src/pages/Admin/Reports/ src/pages/Admin/Settings.vue src/pages/Admin/Settings/ src/pages/Admin/Tickets/
+git commit -m "feat: migrate reports, settings, and tickets pages to panel tokens"
+```
+
+---
+
+### Task 4: Migrate admin pages — Batch 2 (CRUD pages A-E)
+
+**Files:**
+- `src/pages/Admin/ApiTokens/Index.vue`
+- `src/pages/Admin/AuditLog/Index.vue`
+- `src/pages/Admin/Automations/Form.vue`
+- `src/pages/Admin/Automations/Index.vue`
+- `src/pages/Admin/BusinessHours/Form.vue`
+- `src/pages/Admin/BusinessHours/Index.vue`
+- `src/pages/Admin/CannedResponses/Index.vue`
+- `src/pages/Admin/Capacity/Index.vue`
+- `src/pages/Admin/CustomFields/Form.vue`
+- `src/pages/Admin/CustomFields/Index.vue`
+- `src/pages/Admin/CustomObjects/Form.vue`
+- `src/pages/Admin/CustomObjects/Index.vue`
+- `src/pages/Admin/CustomObjects/Records.vue`
+- `src/pages/Admin/Departments/Form.vue`
+- `src/pages/Admin/Departments/Index.vue`
+- `src/pages/Admin/EscalationRules/Form.vue`
+- `src/pages/Admin/EscalationRules/Index.vue`
+
+**Step 1:** Apply same mechanical Class Replacement Patterns as Task 3.
+
+**Step 2: Commit**
+
+```bash
+git add src/pages/Admin/ApiTokens/ src/pages/Admin/AuditLog/ src/pages/Admin/Automations/ src/pages/Admin/BusinessHours/ src/pages/Admin/CannedResponses/ src/pages/Admin/Capacity/ src/pages/Admin/CustomFields/ src/pages/Admin/CustomObjects/ src/pages/Admin/Departments/ src/pages/Admin/EscalationRules/
+git commit -m "feat: migrate CRUD pages (A-E) to panel tokens"
+```
+
+---
+
+### Task 5: Migrate admin pages — Batch 3 (CRUD pages K-W)
+
+**Files:**
+- `src/pages/Admin/KnowledgeBase/Articles/Form.vue`
+- `src/pages/Admin/KnowledgeBase/Articles/Index.vue`
+- `src/pages/Admin/KnowledgeBase/Categories/Index.vue`
+- `src/pages/Admin/Macros/Index.vue`
+- `src/pages/Admin/Plugins/Index.vue`
+- `src/pages/Admin/Roles/Form.vue`
+- `src/pages/Admin/Roles/Index.vue`
+- `src/pages/Admin/Skills/Form.vue`
+- `src/pages/Admin/Skills/Index.vue`
+- `src/pages/Admin/SlaPolicies/Form.vue`
+- `src/pages/Admin/SlaPolicies/Index.vue`
+- `src/pages/Admin/Statuses/Form.vue`
+- `src/pages/Admin/Statuses/Index.vue`
+- `src/pages/Admin/Tags/Index.vue`
+- `src/pages/Admin/Webhooks/DeliveryLog.vue`
+- `src/pages/Admin/Webhooks/Form.vue`
+- `src/pages/Admin/Webhooks/Index.vue`
+
+**Step 1:** Apply same mechanical Class Replacement Patterns as Task 3.
+
+**Step 2: Commit**
+
+```bash
+git add src/pages/Admin/KnowledgeBase/ src/pages/Admin/Macros/ src/pages/Admin/Plugins/ src/pages/Admin/Roles/ src/pages/Admin/Skills/ src/pages/Admin/SlaPolicies/ src/pages/Admin/Statuses/ src/pages/Admin/Tags/ src/pages/Admin/Webhooks/
+git commit -m "feat: migrate CRUD pages (K-W) to panel tokens"
+```
+
+---
+
+### Task 6: Migrate shared components (dark-mode branches)
+
+**Files (18 components with `inject('esc-dark')`):**
+- `src/components/ActivityTimeline.vue`
+- `src/components/AssigneeSelect.vue`
+- `src/components/AttachmentList.vue`
+- `src/components/BulkActionBar.vue`
+- `src/components/FileDropzone.vue`
+- `src/components/FollowButton.vue`
+- `src/components/KeyboardShortcutHelp.vue`
+- `src/components/MacroDropdown.vue`
+- `src/components/PinnedNotes.vue`
+- `src/components/PriorityBadge.vue`
+- `src/components/QuickFilters.vue`
+- `src/components/ReplyThread.vue`
+- `src/components/SatisfactionRating.vue`
+- `src/components/SlaTimer.vue`
+- `src/components/StatsCard.vue`
+- `src/components/TagSelect.vue`
+- `src/components/TicketFilters.vue`
+- `src/components/TicketSidebar.vue`
+
+**Step 1: Apply class replacements in dark-mode config/branch only**
+
+These components have a pattern like:
+```js
+const darkConfig = {
+ container: 'bg-neutral-900/60 border-white/[0.06]',
+ text: 'text-white',
+ // ...
+};
+```
+
+Replace color values in the dark config objects/branches only. Do NOT touch the light config objects — those are for the customer-facing frontend.
+
+Apply the same Class Replacement Patterns table, but only within:
+- `darkConfig` / `dark` style objects
+- Inline ternary classes where `escDark.value` / `dark.value` selects the dark branch
+- e.g., `dark ? 'bg-neutral-950 text-white' : 'bg-white text-gray-900'` — only replace the dark branch
+
+**Important exceptions:**
+- `StatusBadge.vue`: Do NOT migrate — all colors are semantic status indicators
+- `PriorityBadge.vue`: Do NOT migrate status color mappings (low=gray, high=amber, etc.). Only migrate container/wrapper classes if present.
+- `SlaTimer.vue`: Do NOT migrate compliant/at_risk/breached colors. Only migrate container styling.
+- `AuditLogEntry.vue` (if it uses inject): Do NOT migrate action type colors.
+- In all components, gradient accent classes (`from-cyan-500 to-violet-500`) that represent the brand accent SHOULD be migrated to `from-[var(--esc-panel-accent)] to-[var(--esc-panel-accent-secondary)]`.
+
+**Step 2: Commit**
+
+```bash
+git add src/components/ActivityTimeline.vue src/components/AssigneeSelect.vue src/components/AttachmentList.vue src/components/BulkActionBar.vue src/components/FileDropzone.vue src/components/FollowButton.vue src/components/KeyboardShortcutHelp.vue src/components/MacroDropdown.vue src/components/PinnedNotes.vue src/components/PriorityBadge.vue src/components/QuickFilters.vue src/components/ReplyThread.vue src/components/SatisfactionRating.vue src/components/SlaTimer.vue src/components/StatsCard.vue src/components/TagSelect.vue src/components/TicketFilters.vue src/components/TicketSidebar.vue
+git commit -m "feat: migrate shared components dark-mode branches to panel tokens"
+```
+
+---
+
+### Task 7: Update Storybook panel theme preview
+
+**Files:**
+- Modify: `.storybook/preview.js`
+
+**Step 1: Add panel theme global type**
+
+In `.storybook/preview.js`, add a `panelTheme` global type alongside the existing `theme` type:
+
+```js
+panelTheme: {
+ description: 'Panel theme preset',
+ defaultValue: 'default-dark',
+ toolbar: {
+ title: 'Panel Theme',
+ items: [
+ { value: 'default-dark', title: 'Default Dark' },
+ { value: 'default-light', title: 'Default Light' },
+ { value: 'custom-brand', title: 'Custom Brand' },
+ ],
+ },
+},
+```
+
+**Step 2: Add decorator that applies panel CSS vars**
+
+Add a decorator that reads `globals.panelTheme` and sets `--esc-panel-*` properties:
+
+```js
+const panelPresets = {
+ 'default-dark': {}, // uses panelDarkDefaults
+ 'default-light': {
+ bg: '#f9fafb', sidebarBg: '#ffffff', topbarBg: 'rgba(255,255,255,0.95)',
+ surface: '#ffffff', surfaceAlt: '#f9fafb',
+ border: '#e5e7eb', borderInput: '#d1d5db',
+ text: '#111827', textSecondary: '#374151', textTertiary: '#6b7280', textMuted: '#9ca3af',
+ accent: '#3b82f6', accentSecondary: '#6366f1', accentHover: '#2563eb', accentSecondaryHover: '#818cf8',
+ hover: 'rgba(0,0,0,0.02)', active: '#eff6ff',
+ },
+ 'custom-brand': {
+ bg: '#0f0f23', sidebarBg: '#1a1a2e', topbarBg: 'rgba(15,15,35,0.8)',
+ surface: '#16213e', surfaceAlt: '#0f0f23',
+ border: 'rgba(255,255,255,0.08)', borderInput: 'rgba(255,255,255,0.12)',
+ text: '#ffffff', textSecondary: '#a0aec0', textTertiary: '#718096', textMuted: '#4a5568',
+ accent: '#e94560', accentSecondary: '#ff6b6b', accentHover: '#c81e45', accentSecondaryHover: '#ff8787',
+ hover: 'rgba(255,255,255,0.03)', active: 'rgba(233,69,96,0.12)',
+ },
+};
+```
+
+**Step 3: Commit**
+
+```bash
+git add .storybook/preview.js
+git commit -m "feat: add panel theme preview to Storybook"
+```
+
+---
+
+### Task 8: Verification pass
+
+**Step 1: Run full test suite**
+
+Run: `cd escalated && npx vitest run`
+Expected: All tests pass
+
+**Step 2: Run Storybook build**
+
+Run: `cd escalated && npx storybook build`
+Expected: Builds without errors
+
+**Step 3: Visual spot-check in Storybook**
+
+Run: `cd escalated && npx storybook dev -p 6006`
+Check that:
+- Default Dark panel theme looks identical to before
+- Default Light panel theme renders a clean light admin panel
+- Custom Brand theme shows the alternative brand colors
+- Switching between themes works smoothly
+
+**Step 4: Final commit with any fixes**
+
+```bash
+git add -A
+git commit -m "fix: address verification findings for panel theming"
+```
From b58bdc76d6e91485afd37bf1c74807edfef580ff Mon Sep 17 00:00:00 2001
From: Matt Gros <3311227+mpge@users.noreply.github.com>
Date: Sun, 1 Mar 2026 16:51:15 -0500
Subject: [PATCH 03/11] feat: add panel theming support to EscalatedPlugin
Adds --esc-panel-* CSS custom properties with dark and light default
palettes. Panel config (appName, logo, mode) is provided via Vue
provide() for layout components.
---
src/plugin.js | 87 ++++++++++++++++++++++++++++++++++++++++-
tests/plugin.test.js | 92 +++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 177 insertions(+), 2 deletions(-)
diff --git a/src/plugin.js b/src/plugin.js
index 8c1a65f..6e2030f 100644
--- a/src/plugin.js
+++ b/src/plugin.js
@@ -24,6 +24,16 @@ export const EscalatedPlugin = {
if (options.theme) {
app.provide('escalated-theme', options.theme);
applyTheme(options.theme);
+
+ if (options.theme.panel) {
+ const panelConfig = options.theme.panel;
+ applyPanelTheme(panelConfig);
+ app.provide('escalated-panel', {
+ appName: panelConfig.appName || 'Escalated',
+ logo: panelConfig.logo || null,
+ mode: panelConfig.mode || 'dark',
+ });
+ }
}
},
};
@@ -55,7 +65,7 @@ function darken(hex, percent) {
const r = Math.max(0, (num >> 16) - Math.round(2.55 * percent));
const g = Math.max(0, ((num >> 8) & 0x00ff) - Math.round(2.55 * percent));
const b = Math.max(0, (num & 0x0000ff) - Math.round(2.55 * percent));
- return `#${(r << 16 | g << 8 | b).toString(16).padStart(6, '0')}`;
+ return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
}
function scaleBorderRadius(radius, factor) {
@@ -63,3 +73,78 @@ function scaleBorderRadius(radius, factor) {
if (!match) return radius;
return `${(parseFloat(match[1]) * factor).toFixed(2)}${match[2] || 'rem'}`;
}
+
+const panelDarkDefaults = {
+ bg: '#000000',
+ sidebarBg: '#0a0a0a',
+ topbarBg: 'rgba(0,0,0,0.8)',
+ surface: 'rgba(23,23,23,0.6)',
+ surfaceAlt: '#0a0a0a',
+ border: 'rgba(255,255,255,0.06)',
+ borderInput: 'rgba(255,255,255,0.1)',
+ text: '#ffffff',
+ textSecondary: '#e5e5e5',
+ textTertiary: '#a3a3a3',
+ textMuted: '#737373',
+ accent: '#06b6d4',
+ accentSecondary: '#8b5cf6',
+ accentHover: '#22d3ee',
+ accentSecondaryHover: '#a78bfa',
+ hover: 'rgba(255,255,255,0.03)',
+ active: 'rgba(255,255,255,0.08)',
+};
+
+const panelLightDefaults = {
+ bg: '#f9fafb',
+ sidebarBg: '#ffffff',
+ topbarBg: 'rgba(255,255,255,0.95)',
+ surface: '#ffffff',
+ surfaceAlt: '#f9fafb',
+ border: '#e5e7eb',
+ borderInput: '#d1d5db',
+ text: '#111827',
+ textSecondary: '#374151',
+ textTertiary: '#6b7280',
+ textMuted: '#9ca3af',
+ accent: '#3b82f6',
+ accentSecondary: '#6366f1',
+ accentHover: '#2563eb',
+ accentSecondaryHover: '#818cf8',
+ hover: 'rgba(0,0,0,0.02)',
+ active: '#eff6ff',
+};
+
+const panelTokenMap = {
+ bg: '--esc-panel-bg',
+ sidebarBg: '--esc-panel-sidebar-bg',
+ topbarBg: '--esc-panel-topbar-bg',
+ surface: '--esc-panel-surface',
+ surfaceAlt: '--esc-panel-surface-alt',
+ border: '--esc-panel-border',
+ borderInput: '--esc-panel-border-input',
+ text: '--esc-panel-text',
+ textSecondary: '--esc-panel-text-secondary',
+ textTertiary: '--esc-panel-text-tertiary',
+ textMuted: '--esc-panel-text-muted',
+ accent: '--esc-panel-accent',
+ accentSecondary: '--esc-panel-accent-secondary',
+ accentHover: '--esc-panel-accent-hover',
+ accentSecondaryHover: '--esc-panel-accent-secondary-hover',
+ hover: '--esc-panel-hover',
+ active: '--esc-panel-active',
+};
+
+function applyPanelTheme(panelConfig) {
+ const defaults = panelConfig.mode === 'light' ? panelLightDefaults : panelDarkDefaults;
+ const overrides = {};
+ for (const key of Object.keys(panelTokenMap)) {
+ if (panelConfig[key] !== undefined) {
+ overrides[key] = panelConfig[key];
+ }
+ }
+ const merged = { ...defaults, ...overrides };
+ const style = document.documentElement.style;
+ for (const [key, cssVar] of Object.entries(panelTokenMap)) {
+ style.setProperty(cssVar, merged[key]);
+ }
+}
diff --git a/tests/plugin.test.js b/tests/plugin.test.js
index 1056110..8e6d57e 100644
--- a/tests/plugin.test.js
+++ b/tests/plugin.test.js
@@ -154,7 +154,7 @@ describe('EscalatedPlugin', () => {
app.use(EscalatedPlugin, {});
- const layoutCalls = provideSpy.mock.calls.filter(c => c[0] === 'escalated-layout');
+ const layoutCalls = provideSpy.mock.calls.filter((c) => c[0] === 'escalated-layout');
expect(layoutCalls).toHaveLength(0);
});
});
@@ -231,3 +231,93 @@ describe('scaleBorderRadius() utility (tested indirectly via theme)', () => {
expect(lg).toBe('3.00rem');
});
});
+
+describe('Panel theming', () => {
+ const panelProps = [
+ '--esc-panel-bg',
+ '--esc-panel-sidebar-bg',
+ '--esc-panel-topbar-bg',
+ '--esc-panel-surface',
+ '--esc-panel-surface-alt',
+ '--esc-panel-border',
+ '--esc-panel-border-input',
+ '--esc-panel-text',
+ '--esc-panel-text-secondary',
+ '--esc-panel-text-tertiary',
+ '--esc-panel-text-muted',
+ '--esc-panel-accent',
+ '--esc-panel-accent-secondary',
+ '--esc-panel-accent-hover',
+ '--esc-panel-accent-secondary-hover',
+ '--esc-panel-hover',
+ '--esc-panel-active',
+ ];
+
+ beforeEach(() => {
+ const style = document.documentElement.style;
+ panelProps.forEach((p) => style.removeProperty(p));
+ });
+
+ it('does not set panel CSS variables when no panel option is provided', () => {
+ installPlugin({ theme: {} });
+ const style = document.documentElement.style;
+ expect(style.getPropertyValue('--esc-panel-bg')).toBe('');
+ });
+
+ it('sets all dark-mode panel defaults when panel is empty object', () => {
+ installPlugin({ theme: { panel: {} } });
+ const style = document.documentElement.style;
+ expect(style.getPropertyValue('--esc-panel-bg')).toBe('#000000');
+ expect(style.getPropertyValue('--esc-panel-sidebar-bg')).toBe('#0a0a0a');
+ expect(style.getPropertyValue('--esc-panel-text')).toBe('#ffffff');
+ expect(style.getPropertyValue('--esc-panel-accent')).toBe('#06b6d4');
+ expect(style.getPropertyValue('--esc-panel-accent-secondary')).toBe('#8b5cf6');
+ });
+
+ it('sets light-mode defaults when mode is light', () => {
+ installPlugin({ theme: { panel: { mode: 'light' } } });
+ const style = document.documentElement.style;
+ expect(style.getPropertyValue('--esc-panel-bg')).toBe('#f9fafb');
+ expect(style.getPropertyValue('--esc-panel-sidebar-bg')).toBe('#ffffff');
+ expect(style.getPropertyValue('--esc-panel-text')).toBe('#111827');
+ expect(style.getPropertyValue('--esc-panel-accent')).toBe('#3b82f6');
+ expect(style.getPropertyValue('--esc-panel-accent-secondary')).toBe('#6366f1');
+ });
+
+ it('allows individual token overrides on top of dark defaults', () => {
+ installPlugin({ theme: { panel: { accent: '#e94560', bg: '#0f0f23' } } });
+ const style = document.documentElement.style;
+ expect(style.getPropertyValue('--esc-panel-accent')).toBe('#e94560');
+ expect(style.getPropertyValue('--esc-panel-bg')).toBe('#0f0f23');
+ expect(style.getPropertyValue('--esc-panel-text')).toBe('#ffffff');
+ });
+
+ it('allows individual token overrides on top of light defaults', () => {
+ installPlugin({ theme: { panel: { mode: 'light', accent: '#e94560' } } });
+ const style = document.documentElement.style;
+ expect(style.getPropertyValue('--esc-panel-accent')).toBe('#e94560');
+ expect(style.getPropertyValue('--esc-panel-bg')).toBe('#f9fafb');
+ });
+
+ it('provides panel config including appName and logo via Vue provide', () => {
+ const app = createApp({ template: '