diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml index a3a7e5c..959679d 100644 --- a/.github/workflows/screenshots.yml +++ b/.github/workflows/screenshots.yml @@ -7,7 +7,7 @@ on: branches: ["main"] permissions: - contents: read + contents: write pull-requests: write jobs: @@ -48,6 +48,17 @@ jobs: path: screenshot-results/*.png retention-days: 30 + - name: Commit updated README screenshots + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add docs/assets/escalated_admin_1.png docs/assets/escalated_admin_2.png + git diff --cached --quiet && echo "No screenshot changes" || { + git commit -m "chore: update README screenshots from Storybook" + git push + } + - name: Comment on PR with screenshot summary if: github.event_name == 'pull_request' && always() uses: actions/github-script@v7 diff --git a/.storybook/preview.js b/.storybook/preview.js index 51be07f..d89880d 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,6 +1,75 @@ import { computed } from 'vue'; import './storybook.css'; +const panelDarkDefaults = { + '--esc-panel-bg': '#000000', + '--esc-panel-sidebar-bg': '#0a0a0a', + '--esc-panel-topbar-bg': 'rgba(0,0,0,0.8)', + '--esc-panel-surface': 'rgba(23,23,23,0.6)', + '--esc-panel-surface-alt': '#0a0a0a', + '--esc-panel-border': 'rgba(255,255,255,0.06)', + '--esc-panel-border-input': 'rgba(255,255,255,0.1)', + '--esc-panel-text': '#ffffff', + '--esc-panel-text-secondary': '#e5e5e5', + '--esc-panel-text-tertiary': '#a3a3a3', + '--esc-panel-text-muted': '#737373', + '--esc-panel-accent': '#06b6d4', + '--esc-panel-accent-secondary': '#8b5cf6', + '--esc-panel-accent-hover': '#22d3ee', + '--esc-panel-accent-secondary-hover': '#a78bfa', + '--esc-panel-hover': 'rgba(255,255,255,0.03)', + '--esc-panel-active': 'rgba(255,255,255,0.08)', +}; + +const panelPresets = { + 'default-dark': panelDarkDefaults, + 'default-light': { + '--esc-panel-bg': '#f9fafb', + '--esc-panel-sidebar-bg': '#ffffff', + '--esc-panel-topbar-bg': 'rgba(255,255,255,0.95)', + '--esc-panel-surface': '#ffffff', + '--esc-panel-surface-alt': '#f9fafb', + '--esc-panel-border': '#e5e7eb', + '--esc-panel-border-input': '#d1d5db', + '--esc-panel-text': '#111827', + '--esc-panel-text-secondary': '#374151', + '--esc-panel-text-tertiary': '#6b7280', + '--esc-panel-text-muted': '#9ca3af', + '--esc-panel-accent': '#3b82f6', + '--esc-panel-accent-secondary': '#6366f1', + '--esc-panel-accent-hover': '#2563eb', + '--esc-panel-accent-secondary-hover': '#818cf8', + '--esc-panel-hover': 'rgba(0,0,0,0.02)', + '--esc-panel-active': '#eff6ff', + }, + 'custom-brand': { + '--esc-panel-bg': '#0f0f23', + '--esc-panel-sidebar-bg': '#1a1a2e', + '--esc-panel-topbar-bg': 'rgba(15,15,35,0.8)', + '--esc-panel-surface': '#16213e', + '--esc-panel-surface-alt': '#0f0f23', + '--esc-panel-border': 'rgba(255,255,255,0.08)', + '--esc-panel-border-input': 'rgba(255,255,255,0.12)', + '--esc-panel-text': '#ffffff', + '--esc-panel-text-secondary': '#a0aec0', + '--esc-panel-text-tertiary': '#718096', + '--esc-panel-text-muted': '#4a5568', + '--esc-panel-accent': '#e94560', + '--esc-panel-accent-secondary': '#ff6b6b', + '--esc-panel-accent-hover': '#c81e45', + '--esc-panel-accent-secondary-hover': '#ff8787', + '--esc-panel-hover': 'rgba(255,255,255,0.03)', + '--esc-panel-active': 'rgba(233,69,96,0.12)', + }, +}; + +function applyPanelPreset(el, presetName) { + const preset = panelPresets[presetName] || panelDarkDefaults; + for (const [prop, value] of Object.entries(preset)) { + el.style.setProperty(prop, value); + } +} + /** @type { import('@storybook/vue3-vite').Preview } */ const preview = { parameters: { @@ -29,10 +98,28 @@ const preview = { dynamicTitle: true, }, }, + panelTheme: { + name: 'Panel Theme', + description: 'Panel theme preset', + defaultValue: 'default-dark', + toolbar: { + icon: 'grid', + items: [ + { value: 'default-dark', title: 'Default Dark' }, + { value: 'default-light', title: 'Default Light' }, + { value: 'custom-brand', title: 'Custom Brand' }, + ], + dynamicTitle: true, + }, + }, }, decorators: [ (story, context) => { const theme = context.globals.theme || 'dark'; + const panelTheme = context.globals.panelTheme || 'default-dark'; + + // Apply panel theme CSS variables to document root + applyPanelPreset(document.documentElement, panelTheme); if (theme === 'side-by-side') { return { diff --git a/README.md b/README.md index 57ab4ed..0857953 100644 --- a/README.md +++ b/README.md @@ -127,12 +127,14 @@ All the Vue 3 + Inertia.js components that power the Escalated UI. These are ide ## 📸 Screenshots +> Screenshots are auto-generated from Storybook via the [component-screenshots](.github/workflows/screenshots.yml) workflow. +

- Escalated Admin Screenshot 1 + Escalated dashboard — StatsCard grid with metrics and trends

- Escalated Admin Screenshot 2 + Escalated analytics — KPI cards with icons and trend indicators

### Pages diff --git a/docs/assets/escalated_admin_1.png b/docs/assets/escalated_admin_1.png index b8d1eba..c7d10ab 100644 Binary files a/docs/assets/escalated_admin_1.png and b/docs/assets/escalated_admin_1.png differ diff --git a/docs/assets/escalated_admin_2.png b/docs/assets/escalated_admin_2.png index eb59f87..8ee53bd 100644 Binary files a/docs/assets/escalated_admin_2.png and b/docs/assets/escalated_admin_2.png differ diff --git a/docs/plans/2026-03-01-panel-theming-design.md b/docs/plans/2026-03-01-panel-theming-design.md new file mode 100644 index 0000000..1a72e70 --- /dev/null +++ b/docs/plans/2026-03-01-panel-theming-design.md @@ -0,0 +1,132 @@ +# Panel Theming Design + +**Date:** 2026-03-01 +**Branch:** `feature/panel-theming` +**Status:** Approved + +## Problem + +The Escalated framework allows theming for the customer-facing frontend via `EscalatedPlugin` (primary color, border radius, font family), but the admin and agent panels are locked to a hardcoded dark theme. Users who need to whitelabel or brand these panels have no mechanism to do so. + +## Goals + +- Enable theming/branding for both admin and agent panels (they share a visual identity) +- Support two tiers: quick customization (accent color, logo, mode) and full whitelabel (granular token overrides) +- Support both dark and light mode for panels +- Allow custom logo via URL or Vue component +- Maintain zero-config backwards compatibility — default tokens reproduce the current look exactly + +## Approach + +CSS custom properties (`--esc-panel-*`) applied to `:root`, consistent with the existing frontend theming pattern. All admin/agent hardcoded Tailwind color classes are migrated to `var()` references. + +## Token System + +~15 base tokens with dark and light default palettes: + +| Token | Purpose | Dark Default | Light Default | +|-------|---------|-------------|--------------| +| `--esc-panel-bg` | Page background | `#000000` | `#f9fafb` | +| `--esc-panel-sidebar-bg` | Sidebar background | `#0a0a0a` | `#ffffff` | +| `--esc-panel-topbar-bg` | Top bar background | `rgba(0,0,0,0.8)` | `rgba(255,255,255,0.95)` | +| `--esc-panel-surface` | Cards, panels | `rgba(23,23,23,0.6)` | `#ffffff` | +| `--esc-panel-surface-alt` | Nested surfaces, inputs | `#0a0a0a` | `#f9fafb` | +| `--esc-panel-border` | Primary borders | `rgba(255,255,255,0.06)` | `#e5e7eb` | +| `--esc-panel-border-input` | Input borders | `rgba(255,255,255,0.1)` | `#d1d5db` | +| `--esc-panel-text` | Primary text | `#ffffff` | `#111827` | +| `--esc-panel-text-secondary` | Labels, body text | `#e5e5e5` | `#374151` | +| `--esc-panel-text-muted` | Hints, placeholders | `#737373` | `#6b7280` | +| `--esc-panel-accent` | Primary accent | `#06b6d4` | `#3b82f6` | +| `--esc-panel-accent-secondary` | Secondary accent (gradient end) | `#8b5cf6` | `#6366f1` | +| `--esc-panel-accent-hover` | Accent hover state | `#22d3ee` | `#2563eb` | +| `--esc-panel-hover` | Row/item hover bg | `rgba(255,255,255,0.03)` | `#f9fafb` | +| `--esc-panel-active` | Active nav item bg | `rgba(255,255,255,0.08)` | `#eff6ff` | + +**Not tokenized:** Status colors (open/closed/escalated/etc.) and priority colors remain hardcoded — they are semantic indicators, not brand colors. + +## Plugin API + +### Tier 1 — Quick Customization + +```js +app.use(EscalatedPlugin, { + theme: { + primary: '#3b82f6', // existing frontend theming (unchanged) + panel: { + mode: 'dark', // 'dark' | 'light' + accent: '#e94560', // overrides accent + accent-secondary + appName: 'HelpDesk Pro', + logo: '/img/brand-logo.svg', // URL string + } + } +}) +``` + +### Tier 2 — Full Whitelabel + +```js +app.use(EscalatedPlugin, { + theme: { + panel: { + mode: 'dark', + appName: 'ClientDesk', + logo: MyLogoComponent, // Vue component + accent: '#e94560', + accentSecondary: '#ff6b6b', + bg: '#0f0f23', + sidebarBg: '#1a1a2e', + topbarBg: '#0f0f23', + surface: '#16213e', + surfaceAlt: '#0f0f23', + border: 'rgba(255,255,255,0.08)', + borderInput: 'rgba(255,255,255,0.12)', + text: '#ffffff', + textSecondary: '#a0aec0', + textMuted: '#718096', + } + } +}) +``` + +### Logo Detection + +```js +// String → image URL → renders +// Object/Function → Vue component → renders +``` + +## Migration Scope + +### Changes: + +1. **`plugin.js`** — New `applyPanelTheme()` function with dark/light default palettes. `provide()` for logo, appName, panel config. + +2. **`EscalatedLayout.vue`** — Admin sidebar, topbar, and agent nav switch from hardcoded Tailwind colors to `var(--esc-panel-*)` references. Logo section becomes dynamic (default SVG / URL / component). App name becomes dynamic. + +3. **All admin pages (~25)** — Card containers, form inputs, buttons, tables, text all migrate to `var()`. Gradient buttons become `from-[var(--esc-panel-accent)] to-[var(--esc-panel-accent-secondary)]`. + +4. **Shared components in dark-mode branch** — Components using `inject('esc-dark')` have their dark-mode styling branch updated to use `var()` references. + +5. **Storybook** — Add panel theme selector to preview different theme configurations. + +### No changes: + +- Customer-facing pages (light mode) — untouched +- Status/priority semantic colors — hardcoded +- Existing `theme.primary`, `theme.radius`, `theme.fontFamily` — untouched +- Component APIs/props — no breaking changes + +## Color Scheme Handling + +When `mode: 'light'` is set: +- The `color-scheme: dark` inline style on admin/agent containers switches to `color-scheme: light` +- The `esc-dark` injection value respects the mode setting (light mode panels inject `false`) +- Components that use `inject('esc-dark')` will automatically pick their light-mode branch +- The light default palette is applied via CSS custom properties + +## Backwards Compatibility + +- Zero-config behavior is identical to current — dark tokens default to exact current values +- Existing `theme` options continue to work unchanged +- No new peer dependencies +- No build step changes required for consumers diff --git a/docs/plans/2026-03-01-panel-theming-plan.md b/docs/plans/2026-03-01-panel-theming-plan.md new file mode 100644 index 0000000..434111b --- /dev/null +++ b/docs/plans/2026-03-01-panel-theming-plan.md @@ -0,0 +1,758 @@ +# Panel Theming Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add CSS custom property-based theming for admin and agent panels, supporting quick customization (mode, accent, logo) and full whitelabel (granular token overrides). + +**Architecture:** Extend `plugin.js` to set `--esc-panel-*` CSS custom properties on `:root` with dark/light default palettes. Migrate all admin/agent hardcoded Tailwind color classes to `var()` references. Provide logo/appName via Vue `provide()`. The `esc-dark` injection respects the panel mode setting. + +**Tech Stack:** Vue 3, Tailwind CSS (arbitrary value syntax for `var()` refs), Vitest + +--- + +## Token Mapping Reference + +All tasks reference this mapping. **Dark defaults** reproduce the current look exactly. + +| Token | CSS Property | Dark Default | Light Default | +|-------|-------------|-------------|--------------| +| bg | `--esc-panel-bg` | `#000000` | `#f9fafb` | +| sidebar-bg | `--esc-panel-sidebar-bg` | `#0a0a0a` | `#ffffff` | +| topbar-bg | `--esc-panel-topbar-bg` | `rgba(0,0,0,0.8)` | `rgba(255,255,255,0.95)` | +| surface | `--esc-panel-surface` | `rgba(23,23,23,0.6)` | `#ffffff` | +| surface-alt | `--esc-panel-surface-alt` | `#0a0a0a` | `#f9fafb` | +| border | `--esc-panel-border` | `rgba(255,255,255,0.06)` | `#e5e7eb` | +| border-input | `--esc-panel-border-input` | `rgba(255,255,255,0.1)` | `#d1d5db` | +| text | `--esc-panel-text` | `#ffffff` | `#111827` | +| text-secondary | `--esc-panel-text-secondary` | `#e5e5e5` | `#374151` | +| text-tertiary | `--esc-panel-text-tertiary` | `#a3a3a3` | `#6b7280` | +| text-muted | `--esc-panel-text-muted` | `#737373` | `#9ca3af` | +| accent | `--esc-panel-accent` | `#06b6d4` | `#3b82f6` | +| accent-secondary | `--esc-panel-accent-secondary` | `#8b5cf6` | `#6366f1` | +| accent-hover | `--esc-panel-accent-hover` | `#22d3ee` | `#2563eb` | +| accent-secondary-hover | `--esc-panel-accent-secondary-hover` | `#a78bfa` | `#818cf8` | +| hover | `--esc-panel-hover` | `rgba(255,255,255,0.03)` | `rgba(0,0,0,0.02)` | +| active | `--esc-panel-active` | `rgba(255,255,255,0.08)` | `#eff6ff` | + +### Class Replacement Patterns + +These are the mechanical replacements applied across all admin/agent templates: + +| Original Tailwind Class | Replacement | +|------------------------|-------------| +| `bg-black` (page bg) | `bg-[var(--esc-panel-bg)]` | +| `bg-neutral-950` (sidebar, inputs) | `bg-[var(--esc-panel-surface-alt)]` | +| `bg-neutral-900/60` (cards) | `bg-[var(--esc-panel-surface)]` | +| `bg-neutral-900` (inputs alt) | `bg-[var(--esc-panel-surface)]` | +| `border-white/[0.06]` | `border-[var(--esc-panel-border)]` | +| `border-white/[0.04]` | `border-[var(--esc-panel-border)]` | +| `border-white/10` | `border-[var(--esc-panel-border-input)]` | +| `border-white/20` | `border-[var(--esc-panel-border-input)]` | +| `text-white` (headings) | `text-[var(--esc-panel-text)]` | +| `text-neutral-200` | `text-[var(--esc-panel-text-secondary)]` | +| `text-neutral-300` | `text-[var(--esc-panel-text-secondary)]` | +| `text-neutral-400` | `text-[var(--esc-panel-text-tertiary)]` | +| `text-neutral-500` | `text-[var(--esc-panel-text-muted)]` | +| `text-neutral-600` | `text-[var(--esc-panel-text-muted)]` | +| `text-neutral-700` | `text-[var(--esc-panel-text-muted)]` | +| `bg-gradient-to-r from-cyan-500 to-violet-500` | `bg-gradient-to-r from-[var(--esc-panel-accent)] to-[var(--esc-panel-accent-secondary)]` | +| `hover:from-cyan-400 hover:to-violet-400` | `hover:from-[var(--esc-panel-accent-hover)] hover:to-[var(--esc-panel-accent-secondary-hover)]` | +| `hover:bg-white/[0.03]` | `hover:bg-[var(--esc-panel-hover)]` | +| `hover:bg-white/[0.04]` | `hover:bg-[var(--esc-panel-hover)]` | +| `hover:bg-white/[0.06]` | `hover:bg-[var(--esc-panel-hover)]` | +| `bg-white/[0.08]` (active) | `bg-[var(--esc-panel-active)]` | +| `bg-white/[0.03]` | `bg-[var(--esc-panel-hover)]` | +| `bg-white/[0.02]` (table header) | `bg-[var(--esc-panel-hover)]` | +| `bg-white/[0.04]` | `bg-[var(--esc-panel-hover)]` | +| `shadow-black/20` | `shadow-[var(--esc-panel-bg)]/20` | +| `hover:text-neutral-300` | `hover:text-[var(--esc-panel-text-secondary)]` | +| `hover:text-neutral-200` | `hover:text-[var(--esc-panel-text-secondary)]` | +| `hover:text-white` | `hover:text-[var(--esc-panel-text)]` | +| `bg-neutral-700` (toggle off) | `bg-[var(--esc-panel-text-muted)]` | +| `focus:border-white/20` | `focus:border-[var(--esc-panel-border-input)]` | +| `focus:ring-white/10` | `focus:ring-[var(--esc-panel-border-input)]` | +| `bg-black/80` (topbar) | `bg-[var(--esc-panel-topbar-bg)]` | +| `divide-white/[0.06]` | `divide-[var(--esc-panel-border)]` | +| `divide-white/[0.04]` | `divide-[var(--esc-panel-border)]` | +| `placeholder-neutral-500` | `placeholder-[var(--esc-panel-text-muted)]` | +| `placeholder-neutral-600` | `placeholder-[var(--esc-panel-text-muted)]` | +| `text-cyan-400` (link/info) | `text-[var(--esc-panel-accent)]` | + +**DO NOT replace:** +- Status colors: `cyan-500`, `violet-500`, `amber-*`, `rose-*`, `emerald-*`, `orange-*`, `gray-*` when used in `StatusBadge`, `PriorityBadge`, `SlaTimer`, `AuditLogEntry` status contexts +- `bg-emerald-500` / `bg-neutral-700` toggle patterns (semantic: on/off states) +- Any colors in light-mode (customer-facing) template branches +- SVG gradient hex values in logo definitions + +--- + +### Task 1: Add panel theme defaults and applyPanelTheme to plugin.js + +**Files:** +- Modify: `src/plugin.js` +- Modify: `tests/plugin.test.js` + +**Step 1: Write failing tests for panel theme** + +Add to `tests/plugin.test.js` after the existing test blocks: + +```js +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'); + // Others still get dark defaults + 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: '
' }); + const provideSpy = vi.spyOn(app, 'provide'); + + app.use(EscalatedPlugin, { + theme: { + panel: { appName: 'HelpDesk Pro', logo: '/img/logo.svg' } + } + }); + + expect(provideSpy).toHaveBeenCalledWith( + 'escalated-panel', + expect.objectContaining({ + appName: 'HelpDesk Pro', + logo: '/img/logo.svg', + mode: 'dark', + }) + ); + }); + + it('defaults appName to Escalated and logo to null', () => { + const app = createApp({ template: '
' }); + const provideSpy = vi.spyOn(app, 'provide'); + + app.use(EscalatedPlugin, { theme: { panel: {} } }); + + expect(provideSpy).toHaveBeenCalledWith( + 'escalated-panel', + expect.objectContaining({ + appName: 'Escalated', + logo: null, + mode: 'dark', + }) + ); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd escalated && npx vitest run tests/plugin.test.js` +Expected: FAIL — `--esc-panel-bg` is never set, provide never called with `escalated-panel` + +**Step 3: Implement panel theme in plugin.js** + +Replace the entire `src/plugin.js` with the updated version that adds: +- `panelDarkDefaults` and `panelLightDefaults` objects +- `applyPanelTheme(panelConfig)` function +- Updated `install()` to handle `theme.panel`, call `applyPanelTheme()`, and `provide('escalated-panel', ...)` + +```js +import { markRaw } from 'vue'; + +/** + * EscalatedPlugin — Vue plugin for integrating Escalated into your app's design system. + * + * Usage: + * import { EscalatedPlugin } from '@escalated-dev/escalated' + * import AppLayout from '@/Layouts/AppLayout.vue' + * + * app.use(EscalatedPlugin, { + * layout: AppLayout, + * theme: { + * primary: '#3b82f6', + * radius: '0.75rem', + * panel: { + * mode: 'dark', // 'dark' | 'light' + * accent: '#e94560', // primary accent color + * appName: 'My Helpdesk', + * logo: '/img/logo.svg', // URL string or Vue component + * } + * } + * }) + */ +export const EscalatedPlugin = { + install(app, options = {}) { + if (options.layout) { + app.provide('escalated-layout', markRaw(options.layout)); + } + + 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', + }); + } + } + }, +}; + +const themeDefaults = { + primary: '#4f46e5', + primaryHover: null, + radius: '0.5rem', + radiusLg: null, + fontFamily: null, +}; + +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 merged = { ...defaults }; + + // Apply user overrides (skip non-token keys like mode, appName, logo) + for (const key of Object.keys(panelTokenMap)) { + if (panelConfig[key] !== undefined) { + merged[key] = panelConfig[key]; + } + } + + const style = document.documentElement.style; + for (const [key, prop] of Object.entries(panelTokenMap)) { + style.setProperty(prop, merged[key]); + } +} + +function applyTheme(theme) { + const merged = { ...themeDefaults, ...theme }; + const style = document.documentElement.style; + + style.setProperty('--esc-primary', merged.primary); + style.setProperty('--esc-primary-hover', merged.primaryHover || darken(merged.primary, 10)); + style.setProperty('--esc-radius', merged.radius); + style.setProperty('--esc-radius-lg', merged.radiusLg || scaleBorderRadius(merged.radius, 1.5)); + + if (merged.fontFamily) { + style.setProperty('--esc-font-family', merged.fontFamily); + } +} + +function darken(hex, percent) { + const num = parseInt(hex.replace('#', ''), 16); + 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')}`; +} + +function scaleBorderRadius(radius, factor) { + const match = radius.match(/^([\d.]+)(.*)$/); + if (!match) return radius; + return `${(parseFloat(match[1]) * factor).toFixed(2)}${match[2] || 'rem'}`; +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd escalated && npx vitest run tests/plugin.test.js` +Expected: All tests PASS (existing + new panel tests) + +**Step 5: Commit** + +```bash +git add src/plugin.js tests/plugin.test.js +git commit -m "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." +``` + +--- + +### Task 2: Migrate EscalatedLayout.vue — Admin sidebar and topbar + +**Files:** +- Modify: `src/components/EscalatedLayout.vue` + +**Step 1: Add panel config injection to script setup** + +In `EscalatedLayout.vue`, add after line 11 (`const hostLayout = inject(...)`): + +```js +const panelConfig = inject('escalated-panel', { appName: 'Escalated', logo: null, mode: 'dark' }); +``` + +**Step 2: Update isDark to respect panel mode** + +Change line 23 from: +```js +const isDark = computed(() => isAdminSection.value || isAgentSection.value); +``` +To: +```js +const isPanel = computed(() => isAdminSection.value || isAgentSection.value); +const isDark = computed(() => isPanel.value && panelConfig.mode !== 'light'); +``` + +**Step 3: Migrate admin sidebar template (MODE 1, lines 203-332)** + +Replace all hardcoded color classes in the admin section with `var()` references following the Class Replacement Patterns table above. Key replacements in this section: + +- Line 203: `bg-black` → `bg-[var(--esc-panel-bg)]` +- Line 205: `bg-neutral-950` → `bg-[var(--esc-panel-sidebar-bg)]`, `border-white/[0.06]` → `border-[var(--esc-panel-border)]` +- Line 208: `bg-white/10` → `bg-[var(--esc-panel-border-input)]` +- Line 233: `text-white` → `text-[var(--esc-panel-text)]` +- Line 235: `bg-white/[0.08]` → `bg-[var(--esc-panel-active)]`, `text-neutral-500` → `text-[var(--esc-panel-text-muted)]` +- Line 249-251: `bg-white/[0.08] text-white` → `bg-[var(--esc-panel-active)] text-[var(--esc-panel-text)]`, `text-neutral-500 hover:bg-white/[0.04] hover:text-neutral-300` → `text-[var(--esc-panel-text-muted)] hover:bg-[var(--esc-panel-hover)] hover:text-[var(--esc-panel-text-secondary)]` +- Line 256-258: `text-white` → `text-[var(--esc-panel-text)]`, `text-neutral-600 group-hover:text-neutral-400` → `text-[var(--esc-panel-text-muted)] group-hover:text-[var(--esc-panel-text-tertiary)]` +- Lines 269-270: `bg-cyan-500/20` and `text-cyan-400` → `bg-[var(--esc-panel-accent)]/20` and `text-[var(--esc-panel-accent)]` +- Line 277: `border-white/[0.06]` → `border-[var(--esc-panel-border)]` +- Lines 281, 294: `text-neutral-500 ... hover:bg-white/[0.04] hover:text-neutral-300` → `text-[var(--esc-panel-text-muted)] ... hover:bg-[var(--esc-panel-hover)] hover:text-[var(--esc-panel-text-secondary)]` +- Lines 307-313: User section colors similarly migrated +- Line 322: `border-white/[0.06] bg-black/80` → `border-[var(--esc-panel-border)] bg-[var(--esc-panel-topbar-bg)]` +- Line 324: `text-white` → `text-[var(--esc-panel-text)]` + +**Step 4: Migrate admin topbar (lines 320-326)** + +Same pattern — `bg-black/80` → `bg-[var(--esc-panel-topbar-bg)]`, border and text similarly. + +**Step 5: Migrate agent nav section (MODE 2, lines 335-417)** + +Apply same replacements to the agent section. Key targets: +- Line 335: `bg-black` → `bg-[var(--esc-panel-bg)]` +- Line 337: `border-white/[0.06] bg-neutral-950/95` → `border-[var(--esc-panel-border)] bg-[var(--esc-panel-sidebar-bg)]` +- All text-white, text-neutral-* classes in agent nav +- Line 341: `bg-white/10` → `bg-[var(--esc-panel-border-input)]` +- Line 365: `text-white` → `text-[var(--esc-panel-text)]` + +**Step 6: Add dynamic logo rendering** + +Replace the admin logo block (lines 207-231) with: + +```html +
+
+ + + + + + + + + + + + + + + + + +
+
+ {{ 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" +``` diff --git a/screenshots/components.spec.js b/screenshots/components.spec.js index a572371..709c16f 100644 --- a/screenshots/components.spec.js +++ b/screenshots/components.spec.js @@ -35,20 +35,48 @@ const stories = [ { id: 'components-followbutton--following', name: 'FollowButton-Following' }, ]; -for (const story of stories) { - test(`screenshot: ${story.name}`, async ({ page }) => { - await page.goto(`/iframe.html?id=${story.id}&viewMode=story`); - - // Wait for the story to render - await page.waitForSelector('#storybook-root', { state: 'attached' }); - await page.waitForTimeout(500); // Allow animations to settle +/** + * Navigate to a story and wait for it to render. + */ +async function openStory(page, storyId) { + await page.goto(`/iframe.html?id=${storyId}&viewMode=story`); + await page.waitForSelector('#storybook-root', { state: 'attached' }); + await page.waitForTimeout(500); + const root = page.locator('#storybook-root'); + await expect(root).toBeVisible(); + return root; +} - const root = page.locator('#storybook-root'); - await expect(root).toBeVisible(); +// ---------------------------------------------------------------- +// Individual component screenshots → screenshot-results/ +// ---------------------------------------------------------------- +for (const story of stories) { + test(`screenshot: ${story.name}`, async ({ page }) => { + const root = await openStory(page, story.id); await root.screenshot({ path: `screenshot-results/${story.name}.png`, animations: 'disabled', }); }); } + +// ---------------------------------------------------------------- +// README hero screenshots → docs/assets/ (referenced by README.md) +// ---------------------------------------------------------------- + +test('README hero: Dashboard components (escalated_admin_1)', async ({ page }) => { + const root = await openStory(page, 'components-statscard--dashboard-grid'); + await root.screenshot({ + path: 'docs/assets/escalated_admin_1.png', + animations: 'disabled', + }); +}); + +test('README hero: KPI metrics (escalated_admin_2)', async ({ page }) => { + const root = await openStory(page, 'components-kpicard--dashboard-row'); + await root.screenshot({ + path: 'docs/assets/escalated_admin_2.png', + animations: 'disabled', + }); +}); diff --git a/src/components/ActivityTimeline.vue b/src/components/ActivityTimeline.vue index 44d0bba..d5b5a64 100644 --- a/src/components/ActivityTimeline.vue +++ b/src/components/ActivityTimeline.vue @@ -5,7 +5,10 @@ defineProps({ activities: { type: Array, required: true }, }); -const dark = inject('esc-dark', computed(() => false)); +const dark = inject( + 'esc-dark', + computed(() => false), +); const typeLabels = { status_changed: 'changed status', @@ -55,12 +58,26 @@ function describeActivity(activity) { diff --git a/src/components/AssigneeSelect.vue b/src/components/AssigneeSelect.vue index 3baedce..1d13a1d 100644 --- a/src/components/AssigneeSelect.vue +++ b/src/components/AssigneeSelect.vue @@ -7,15 +7,28 @@ defineProps({ }); const emit = defineEmits(['update:modelValue']); -const dark = inject('esc-dark', computed(() => false)); +const dark = inject( + 'esc-dark', + computed(() => false), +);