OpenSpace panel-component-with-push-sidebar
Dashboard panel components (class-based, self-fetching) and push-update sidebar modules (functional, externally-driven) using vanilla TypeScript DOM API, with retry logic, localStorage persistence, and guarded CSS injection.
git clone https://github.com/HKUDS/OpenSpace
T=$(mktemp -d) && git clone --depth=1 https://github.com/HKUDS/OpenSpace "$T" && mkdir -p ~/.claude/skills && cp -r "$T/showcase/skills/panel-component-with-push-sidebar" ~/.claude/skills/hkuds-openspace-panel-component-with-push-sidebar && rm -rf "$T"
showcase/skills/panel-component-with-push-sidebar/SKILL.mdPanel & Push-Update Sidebar Patterns
Two complementary patterns for building dashboard UI in vanilla TypeScript (no framework, no JSX):
| Pattern | Use when… |
|---|---|
Panel class (extends ) | The component fetches its own data on a timer |
| Push-update sidebar module | Data arrives from outside (caller pushes it in) |
Part 1 — Panel Class Pattern
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)
Base Panel Class
Create
src/components/Panel.ts:
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; // --- Retry state (reset before each logical fetch sequence) --- private retryAttempts = 0; private maxRetries = 3; private retryDelay = 1000; // ms; doubles on each attempt 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; 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 API // ---------------------------------------------------------------- public getElement(): HTMLElement { return this.element; } public showLoading(message = 'Loading...'): void { this.content.innerHTML = ` <div class="panel-loading"> <div class="panel-loading-spinner"></div> <div class="panel-loading-text">${message}</div> </div>`; } public showError(message = 'Failed to load', onRetry?: () => void): void { this.content.innerHTML = ` <div class="panel-error-state"> <div class="panel-error-msg">${message}</div> ${onRetry ? '<button class="panel-retry-btn" data-panel-retry>Retry</button>' : ''} </div>`; 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'); } public destroy(): void { this.element.remove(); } // ---------------------------------------------------------------- // Protected API — available in subclasses // ---------------------------------------------------------------- protected setFetching(v: boolean): void { this._fetching = v; } protected get isFetching(): boolean { return this._fetching; } /** * Fetch a URL with exponential-backoff retry. * Call resetRetry() before each new fetch sequence. */ protected async fetchWithRetry(url: string): Promise<unknown> { while (this.retryAttempts < this.maxRetries) { try { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json(); } catch (err: unknown) { this.retryAttempts++; if (this.retryAttempts >= this.maxRetries) { const msg = err instanceof Error ? err.message : String(err); throw new Error(`Failed after ${this.maxRetries} attempts: ${msg}`); } await new Promise(r => setTimeout(r, this.retryDelay)); this.retryDelay *= 2; } } } /** Reset retry counters before a fresh fetch sequence. */ protected resetRetry(): void { this.retryAttempts = 0; this.retryDelay = 1000; } // ---------------------------------------------------------------- // State persistence (localStorage) // ---------------------------------------------------------------- public saveState(): void { localStorage.setItem(`panelState_${this.panelId}`, JSON.stringify({ isExpanded: !this.element.classList.contains('collapsed'), width: this.element.style.width, height: this.element.style.height, })); } public loadState(): void { const raw = localStorage.getItem(`panelState_${this.panelId}`); if (!raw) return; const { isExpanded, width, height } = JSON.parse(raw) as { isExpanded: boolean; width: string; height: string; }; if (!isExpanded) this.element.classList.add('collapsed'); if (width) this.element.style.width = width; if (height) this.element.style.height = height; } }
Protected API Reference
| Member / Method | Type | Description |
|---|---|---|
| | Outer container div () |
| | Header bar — append extra controls here |
| | Scrollable content area |
| | Count badge, or if not set |
| | The from |
| getter | while an async fetch is in progress |
| | Set/clear the fetching guard |
| | Replace content with a spinner |
| | Replace content with error + optional retry button |
| | Set raw HTML into the content area |
| | Update count badge (no-op if is null) |
| | Fetch with exponential-backoff retry (3 attempts) |
| | Reset retry counters before a new fetch sequence |
| | Persist expanded/size state to localStorage |
| | Restore state from localStorage |
Creating a Concrete Panel (Example: StockPanel)
import { Panel } from './Panel'; interface StockQuote { symbol: string; name: string; price: number | null; change: number | null; sparkline?: number[]; } export class StockPanel extends Panel { private refreshTimer: ReturnType<typeof setInterval> | null = null; constructor() { super({ id: 'stocks', title: 'Stock Market', showCount: true }); this.loadState(); // restore saved size/collapsed state this.fetchData(); this.refreshTimer = setInterval(() => this.fetchData(), 60_000); } private async fetchData(): Promise<void> { if (this.isFetching) return; this.setFetching(true); this.resetRetry(); // start a fresh retry sequence try { const quotes = await this.fetchWithRetry('/api/stocks') as StockQuote[]; this.render(quotes); this.setCount(quotes.length); this.saveState(); } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Unknown error'; this.showError(`Failed to load stock data: ${msg}`, () => this.fetchData()); } finally { this.setFetching(false); } } private render(quotes: StockQuote[]): void { const rows = quotes.map(q => ` <div class="stock-row"> <span class="stock-symbol">${q.symbol}</span> <span class="stock-name">${q.name}</span> <span class="stock-price">${q.price != null ? '$' + q.price.toFixed(2) : '—'}</span> <span class="stock-change ${(q.change ?? 0) >= 0 ? 'positive' : 'negative'}"> ${q.change != null ? (q.change >= 0 ? '+' : '') + q.change.toFixed(2) + '%' : '—'} </span> ${miniSparkline(q.sparkline, q.change)} </div>`).join(''); this.setContent(`<div class="stock-list">${rows}</div>`); } public override destroy(): void { if (this.refreshTimer) clearInterval(this.refreshTimer); super.destroy(); } }
Key Patterns for Self-Fetching Panels
- Constructor →
→super()
→ initialloadState()
→ start refresh timerfetchData() - fetchData() →
guard →isFetching
→resetRetry()
→fetchWithRetry()
+render()saveState() - render() builds HTML strings →
this.setContent(html) - destroy() clears timers, calls
super.destroy() - Use
during initial load (auto-called in constructor)showLoading() - Use
on failure; retryFn must callshowError(msg, retryFn)
(which callsfetchData()
)resetRetry()
Part 2 — Push-Update Sidebar Pattern
Use this pattern when the component does not fetch data itself — instead it receives data pushed by an external caller (e.g. a WebSocket handler, a store subscription, or a parent orchestrator).
When to use this pattern vs. Panel class
Self-fetching? → Panel class (Part 1) Data pushed in? → Push-update sidebar module (Part 2)
Module Structure
A push-update sidebar is a plain TypeScript module (not a class) with:
| Export | Purpose |
|---|---|
| Build and return the root element (idempotent singleton) |
| Re-render only the sections that changed |
| The data shape the caller must provide |
Internally the module uses:
- A module-level singleton
let sidebarEl: HTMLElement | null = null - A
interface caching live DOM node references to avoid repeatedSectionRefs
callsquerySelector - A guarded
function that inserts ainjectStyles()
tag exactly once<style>
Skeleton
// src/components/FooSidebar.ts // ── Types ──────────────────────────────────────────────────────────────────── export interface FooData { title: string; items: { id: string; label: string; value: number }[]; lastUpdated: Date; } // ── DOM node cache ──────────────────────────────────────────────────────────── interface SectionRefs { root: HTMLElement; titleEl: HTMLElement; listEl: HTMLElement; footerEl: HTMLElement; } // ── Singleton state ─────────────────────────────────────────────────────────── let sidebarEl: HTMLElement | null = null; let refs: SectionRefs | null = null; let stylesInjected = false; // ── CSS injection ───────────────────────────────────────────────────────────── function injectStyles(): void { if (stylesInjected) return; // guard: run exactly once stylesInjected = true; const style = document.createElement('style'); style.dataset.owner = 'foo-sidebar'; // easy to find in DevTools style.textContent = ` .foo-sidebar { /* … */ } .foo-sidebar__title { font-weight: 600; } .foo-sidebar__list { list-style: none; padding: 0; } .foo-sidebar__footer { font-size: 0.75rem; color: var(--muted); } `; document.head.appendChild(style); } // ── Builder ─────────────────────────────────────────────────────────────────── /** * Create (or return the existing) sidebar element. * Idempotent — safe to call multiple times; always returns the same node. */ export function createFooSidebar(): HTMLElement { if (sidebarEl) return sidebarEl; // singleton guard injectStyles(); const root = document.createElement('aside'); root.className = 'foo-sidebar'; const titleEl = document.createElement('h2'); titleEl.className = 'foo-sidebar__title'; root.appendChild(titleEl); const listEl = document.createElement('ul'); listEl.className = 'foo-sidebar__list'; root.appendChild(listEl); const footerEl = document.createElement('div'); footerEl.className = 'foo-sidebar__footer'; root.appendChild(footerEl); // Cache references — avoids querySelector on every update refs = { root, titleEl, listEl, footerEl }; sidebarEl = root; return root; } // ── Updater ─────────────────────────────────────────────────────────────────── /** * Push new data into the sidebar. * Calls createFooSidebar() defensively if refs is not yet initialised. */ export function updateFoo(data: FooData): void { if (!refs) createFooSidebar(); const r = refs!; r.titleEl.textContent = data.title; r.listEl.innerHTML = data.items .map(item => ` <li class="foo-sidebar__item" data-id="${item.id}"> <span class="foo-item-label">${item.label}</span> <span class="foo-item-value">${item.value}</span> </li>`) .join(''); r.footerEl.textContent = `Updated ${data.lastUpdated.toLocaleTimeString()}`; }
Real-World Example: TodayFocusSidebar
A sidebar that summarises the user's day — greeting, meeting countdown, inbox counts, stock alerts, CI failures, and an AI briefing with truncate/expand toggle. Data is pushed in from an external orchestrator.
// src/components/TodayFocusSidebar.ts export interface FocusData { userName: string; nextMeeting: { title: string; startsInMinutes: number } | null; inboxCounts: { email: number; slack: number; github: number }; stockAlerts: { symbol: string; changePercent: number }[]; // pre-filtered >= 2% ciFailures: { repo: string; branch: string }[]; aiBriefing: string; // may be long — sidebar truncates with toggle } interface SectionRefs { root: HTMLElement; greetingEl: HTMLElement; meetingEl: HTMLElement; inboxEl: HTMLElement; stocksEl: HTMLElement; ciEl: HTMLElement; briefingEl: HTMLElement; } let sidebarEl: HTMLElement | null = null; let refs: SectionRefs | null = null; let stylesInjected = false; // ── Helpers ─────────────────────────────────────────────────────────────────── function greeting(name: string): string { const h = new Date().getHours(); const salutation = h < 12 ? 'Good morning' : h < 17 ? 'Good afternoon' : 'Good evening'; return `${salutation}, ${name}`; } function formatCountdown(minutes: number): string { if (minutes <= 0) return 'Now'; if (minutes < 60) return `in ${minutes}m`; return `in ${Math.floor(minutes / 60)}h ${minutes % 60}m`; } // ── CSS ─────────────────────────────────────────────────────────────────────── function injectStyles(): void { if (stylesInjected) return; stylesInjected = true; const s = document.createElement('style'); s.dataset.owner = 'today-focus-sidebar'; s.textContent = ` .tfs { display:flex; flex-direction:column; gap:12px; padding:16px; background:var(--bg-surface, #1e1e2e); color:var(--fg, #cdd6f4); border-radius:8px; font-family:inherit; } .tfs__greeting { font-size:1.1rem; font-weight:600; } .tfs__meeting { font-size:0.85rem; color:var(--yellow, #f9e2af); } .tfs__section { font-size:0.85rem; } .tfs__label { font-size:0.7rem; text-transform:uppercase; letter-spacing:.06em; color:var(--muted, #6c7086); margin-bottom:4px; } .tfs__briefing { font-size:0.82rem; line-height:1.5; } .tfs__toggle { background:none; border:none; color:var(--blue, #89b4fa); cursor:pointer; font-size:0.8rem; padding:2px 0; } .tfs__alert-pos { color:var(--green, #a6e3a1); } .tfs__alert-neg { color:var(--red, #f38ba8); } .tfs__ci-fail { color:var(--red, #f38ba8); } `; document.head.appendChild(s); } // ── Builder ─────────────────────────────────────────────────────────────────── export function createTodayFocusSidebar(): HTMLElement { if (sidebarEl) return sidebarEl; injectStyles(); const make = (tag: string, cls: string): HTMLElement => { const el = document.createElement(tag); el.className = cls; return el as HTMLElement; }; const root = make('aside', 'tfs'); const greetingEl = make('div', 'tfs__greeting'); const meetingEl = make('div', 'tfs__meeting'); const inboxEl = make('div', 'tfs__section'); const stocksEl = make('div', 'tfs__section'); const ciEl = make('div', 'tfs__section'); const briefingEl = make('div', 'tfs__section'); root.append(greetingEl, meetingEl, inboxEl, stocksEl, ciEl, briefingEl); refs = { root, greetingEl, meetingEl, inboxEl, stocksEl, ciEl, briefingEl }; sidebarEl = root; return root; } // ── Updater ─────────────────────────────────────────────────────────────────── const BRIEFING_LIMIT = 200; export function updateTodayFocus(data: FocusData): void { if (!refs) createTodayFocusSidebar(); const r = refs!; // Greeting r.greetingEl.textContent = greeting(data.userName); // Next meeting if (data.nextMeeting) { r.meetingEl.textContent = `Next: ${data.nextMeeting.title} — ${formatCountdown(data.nextMeeting.startsInMinutes)}`; r.meetingEl.hidden = false; } else { r.meetingEl.hidden = true; } // Inbox counts const { email, slack, github } = data.inboxCounts; r.inboxEl.innerHTML = ` <div class="tfs__label">Inbox</div> Email: ${email} Slack: ${slack} GitHub: ${github}`; // Stock alerts (caller should pre-filter >= 2%, but we guard here too) const alerts = data.stockAlerts.filter(s => Math.abs(s.changePercent) >= 2); r.stocksEl.innerHTML = alerts.length === 0 ? '' : `<div class="tfs__label">Stock Alerts</div>` + alerts.map(s => { const cls = s.changePercent >= 0 ? 'tfs__alert-pos' : 'tfs__alert-neg'; const sign = s.changePercent >= 0 ? '+' : ''; return `<span class="${cls}">${s.symbol} ${sign}${s.changePercent.toFixed(1)}%</span>`; }).join(' '); // CI failures r.ciEl.innerHTML = data.ciFailures.length === 0 ? '' : `<div class="tfs__label">CI Failures</div>` + data.ciFailures .map(f => `<div class="tfs__ci-fail">${f.repo} / ${f.branch}</div>`) .join(''); // AI briefing with truncate/expand toggle const text = data.aiBriefing; if (text.length <= BRIEFING_LIMIT) { r.briefingEl.innerHTML = `<div class="tfs__label">Briefing</div><div class="tfs__briefing">${text}</div>`; } else { const short = text.slice(0, BRIEFING_LIMIT) + '…'; r.briefingEl.innerHTML = ` <div class="tfs__label">Briefing</div> <div class="tfs__briefing" data-full="${encodeURIComponent(text)}" data-short="${encodeURIComponent(short)}" data-expanded="false">${short}</div> <button class="tfs__toggle" data-briefing-toggle>Show more</button>`; r.briefingEl.querySelector('[data-briefing-toggle]') ?.addEventListener('click', function (this: HTMLButtonElement) { const div = r.briefingEl.querySelector<HTMLElement>('[data-full]')!; const expanded = div.dataset.expanded === 'true'; div.textContent = decodeURIComponent( expanded ? div.dataset.short! : div.dataset.full!); div.dataset.expanded = String(!expanded); this.textContent = expanded ? 'Show more' : 'Show less'; }); } }
Push-Update Checklist
- Define
(exported — callers need it)interface XData - Define
(internal — not exported)interface SectionRefs - Declare module-level
,let sidebarEl
,let refslet stylesInjected -
inserts oneinjectStyles()
tag, guarded by<style>
; setstylesInjected
for DevTools visibilitystyle.dataset.owner -
is idempotent: returns existingcreateXSidebar()
if already builtsidebarEl -
populatescreateXSidebar()
with live node referencesrefs -
callsupdateX(data)
defensively ifcreateXSidebar()
is nullrefs -
mutates only nodes inupdateX(data)
— never callsrefsdocument.querySelector - Export
,createXSidebar
, andupdateX
interface; keepXData
module-privateSectionRefs
Barrel File Wiring
After creating a push-update sidebar, register it in both barrel files:
// src/components/index.ts export { createTodayFocusSidebar, updateTodayFocus } from './TodayFocusSidebar'; export type { FocusData } from './TodayFocusSidebar'; // src/index.ts export * from './components';
Part 3 — Sparkline Utility
Used by both patterns for inline SVG sparklines:
// src/utils/sparkline.ts 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 `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">` + `<polyline points="${points}" fill="none" stroke="${color}" ` + `stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>`; }
Quick-Reference Decision Tree
Need a new dashboard component? │ ├─ Will it fetch its own data (polling / one-shot)? │ └─ YES → extend Panel (Part 1) │ • constructor → super() → loadState() → fetchData() → setInterval │ • fetchData() → resetRetry() → fetchWithRetry() → render() → saveState() │ • destroy() → clearInterval → super.destroy() │ └─ Will data be pushed from outside? └─ YES → functional push-update module (Part 2) • createXSidebar() — idempotent, builds DOM, fills SectionRefs • updateX(data) — mutates only SectionRefs nodes • injectStyles() — guarded, runs once • export createX, updateX, XData; keep SectionRefs private