--- name: panel-component-xss-safe description: Create a dashboard panel component using vanilla TypeScript DOM API, following the worldmonitor Panel architecture. Panels have a header with title/count, scrollable content area, loading/error states, and resize handles. Includes XSS-safe rendering pattern with esc() helper for safely interpolating untrusted external API data into innerHTML. --- # Panel Component Pattern (XSS-Safe) Create dashboard panel components using **vanilla TypeScript** (no framework, no JSX). Each panel is a class extending a `Panel` base class. > **Security note:** Panels that consume external API data (calendar events, news feeds, stock names, user-supplied content, etc.) MUST escape all untrusted values before injecting them into `innerHTML`. Use the `esc()` helper documented below. ## Architecture Overview ``` Panel (base class) ├── element: HTMLElement (outer container, .panel) │ ├── header: HTMLElement (.panel-header) │ │ ├── headerLeft (.panel-header-left) │ │ │ ├── title (.panel-title) │ │ │ └── newBadge (.panel-new-badge) [optional] │ │ ├── statusBadge (.panel-data-badge) [optional] │ │ └── countEl (.panel-count) [optional] │ ├── content: HTMLElement (.panel-content) │ └── resizeHandle (.panel-resize-handle) ``` ## XSS-Safe Rendering: the `esc()` Helper **Always use `esc()` when interpolating untrusted data into an HTML string.** Untrusted data includes anything from external APIs: event titles, locations, URLs, names, descriptions, symbols, etc. Create `src/utils/esc.ts`: ```typescript /** * Escapes a value for safe interpolation into an innerHTML string. * Converts &, <, >, ", and ' to their HTML entity equivalents. * * Usage: * this.setContent(`
${esc(item.title)}
`); * * Do NOT use for: * - href/src attributes with user-controlled URLs — validate scheme instead * - CSS values — use a separate sanitizer */ export function esc(value: unknown): string { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } ``` ### When to use `esc()` | Data source | Safe? | Action | |---|---|---| | Hardcoded string literal in source | ✅ Safe | No escaping needed | | Enum / controlled constant | ✅ Safe | No escaping needed | | External API string field (title, name, body…) | ❌ Unsafe | **Use `esc()`** | | User input / localStorage value | ❌ Unsafe | **Use `esc()`** | | Number/boolean (rendered as text) | ✅ Safe | `String(n)` is fine | | URL from external API | ⚠️ Unsafe | Validate scheme + `esc()` | ### URL Safety For URLs from external sources, validate the scheme before injecting: ```typescript /** Returns the URL only if it uses http or https; otherwise returns '#'. */ export function safeUrl(raw: unknown): string { const s = String(raw ?? '').trim(); return /^https?:\/\//i.test(s) ? s : '#'; } ``` Usage: `${esc(item.title)}` --- ## Base Panel Class Create `src/components/Panel.ts`: ```typescript export interface PanelOptions { id: string; title: string; showCount?: boolean; className?: string; } export class Panel { protected element: HTMLElement; protected content: HTMLElement; protected header: HTMLElement; protected countEl: HTMLElement | null = null; protected panelId: string; private _fetching = false; constructor(options: PanelOptions) { this.panelId = options.id; this.element = document.createElement('div'); this.element.className = `panel ${options.className || ''}`; this.element.dataset.panel = options.id; // Header this.header = document.createElement('div'); this.header.className = 'panel-header'; const headerLeft = document.createElement('div'); headerLeft.className = 'panel-header-left'; const title = document.createElement('span'); title.className = 'panel-title'; title.textContent = options.title; // textContent is always safe headerLeft.appendChild(title); this.header.appendChild(headerLeft); // Count badge (optional) if (options.showCount) { this.countEl = document.createElement('span'); this.countEl.className = 'panel-count'; this.countEl.textContent = '0'; this.header.appendChild(this.countEl); } // Content area this.content = document.createElement('div'); this.content.className = 'panel-content'; this.content.id = `${options.id}Content`; this.element.appendChild(this.header); this.element.appendChild(this.content); this.showLoading(); } public getElement(): HTMLElement { return this.element; } public showLoading(message = 'Loading...'): void { // message is a hardcoded string — no escaping needed here this.content.innerHTML = `
${message}
`; } public showError(message = 'Failed to load', onRetry?: () => void): void { // message is a hardcoded string — no escaping needed here this.content.innerHTML = `
${message}
${onRetry ? '' : ''}
`; if (onRetry) { this.content.querySelector('[data-panel-retry]')?.addEventListener('click', onRetry); } } public setContent(html: string): void { this.content.innerHTML = html; } public setCount(count: number): void { if (this.countEl) this.countEl.textContent = count.toString(); } public show(): void { this.element.classList.remove('hidden'); } public hide(): void { this.element.classList.add('hidden'); } protected setFetching(v: boolean): void { this._fetching = v; } protected get isFetching(): boolean { return this._fetching; } public destroy(): void { this.element.remove(); } } ``` --- ## Creating a Concrete Panel (Example: StockPanel — XSS-safe) Import `esc` and wrap every API-derived string field: ```typescript import { Panel } from './Panel'; import { esc } from '../utils/esc'; interface StockQuote { symbol: string; name: string; price: number | null; change: number | null; sparkline?: number[]; } export class StockPanel extends Panel { private refreshTimer: ReturnType | null = null; constructor() { super({ id: 'stocks', title: 'Stock Market', showCount: true }); this.fetchData(); this.refreshTimer = setInterval(() => this.fetchData(), 60_000); } private async fetchData(): Promise { if (this.isFetching) return; this.setFetching(true); try { const quotes = await fetchStockQuotes(); // from data-service this.render(quotes); this.setCount(quotes.length); } catch (err) { this.showError('Failed to load stock data', () => this.fetchData()); } finally { this.setFetching(false); } } private render(quotes: StockQuote[]): void { const rows = quotes.map(q => `
${esc(q.symbol)} ${esc(q.name)} ${q.price != null ? '$' + q.price.toFixed(2) : '—'} ${q.change != null ? (q.change >= 0 ? '+' : '') + q.change.toFixed(2) + '%' : '—'}
`).join(''); // ^^^ ^^^ // symbol and name come from an external API — always escape them. // price and change are numbers rendered via .toFixed() — safe without esc(). this.setContent(`
${rows}
`); } public override destroy(): void { if (this.refreshTimer) clearInterval(this.refreshTimer); super.destroy(); } } ``` ## Creating a Concrete Panel (Example: EventPanel — calendar/news data) Calendar and news panels are highest-risk because titles, locations, and URLs all come from untrusted sources: ```typescript import { Panel } from './Panel'; import { esc, safeUrl } from '../utils/esc'; interface CalendarEvent { id: string; title: string; // untrusted — user-created content location?: string; // untrusted url?: string; // untrusted — must validate scheme startTime: Date; } export class SchedulePanel extends Panel { private refreshTimer: ReturnType | null = null; constructor() { super({ id: 'schedule', title: "Today's Schedule", showCount: true }); this.fetchData(); this.refreshTimer = setInterval(() => this.fetchData(), 5 * 60_000); } private async fetchData(): Promise { if (this.isFetching) return; this.setFetching(true); try { const events = await fetchCalendarEvents(); this.render(events); this.setCount(events.length); } catch (err) { this.showError('Failed to load calendar', () => this.fetchData()); } finally { this.setFetching(false); } } private render(events: CalendarEvent[]): void { if (events.length === 0) { this.setContent('
No events today
'); return; } const rows = events.map(ev => `
${esc(formatTime(ev.startTime))} ${ev.url ? `${esc(ev.title)}` : esc(ev.title) } ${ev.location ? `${esc(ev.location)}` : ''}
`).join(''); // ^^^ ^^^ ^^^ // title, location, url — ALL from external API, ALL escaped. // safeUrl() prevents javascript: / data: scheme injection in href. this.setContent(`
${rows}
`); } public override destroy(): void { if (this.refreshTimer) clearInterval(this.refreshTimer); super.destroy(); } } ``` --- ## Key Patterns 1. **Constructor** calls `super()` with panel config, then triggers initial data fetch 2. **fetchData()** is async, uses `isFetching` guard, shows error on failure with retry 3. **render()** builds HTML strings with `esc()` around every API-derived string, then calls `this.setContent(html)` 4. **destroy()** cleans up timers and event listeners 5. Use `showLoading()` during initial load (auto-called in constructor) 6. Use `showError(msg, retryFn)` on failure — `msg` should be a hardcoded string, not API data 7. **Import `esc` from `../utils/esc`** in every panel that consumes external data 8. Numbers and booleans rendered via `.toFixed()` / `.toString()` / template arithmetic are safe — no `esc()` needed 9. Use `textContent` instead of `innerHTML` for single text nodes when convenient — it is always safe ## Sparkline Utility Sparklines use computed numbers only — no escaping needed: ```typescript export function miniSparkline(data: number[] | undefined, change: number | null, w = 50, h = 16): string { if (!data || data.length < 2) return ''; const min = Math.min(...data); const max = Math.max(...data); const range = max - min || 1; const color = change != null && change >= 0 ? 'var(--green)' : 'var(--red)'; const points = data.map((v, i) => { const x = (i / (data.length - 1)) * w; const y = h - ((v - min) / range) * (h - 2) - 1; return `${x.toFixed(1)},${y.toFixed(1)}`; }).join(' '); return ``; } ``` --- ## Checklist Before Submitting a Panel - [ ] Every `string` field from an external API is wrapped in `esc()` before HTML interpolation - [ ] Every URL from an external API is passed through `safeUrl()` before use in `href`/`src` - [ ] `showError()` is only called with hardcoded messages (never raw API error bodies) - [ ] `esc` and `safeUrl` are imported from `src/utils/esc` - [ ] Numbers / computed values rendered with `.toFixed()` or arithmetic are left unwrapped (they cannot contain HTML)