| name | full-stack-panel-authoring |
| description | Single cohesive workflow for creating complete dashboard panel features in vanilla TypeScript — covers project-structure exploration, server dispatch pattern detection and wiring (Express or manual http.createServer), Panel base class component authoring with CircuitBreaker resilience and localStorage persistence, panel registration in index.ts, and safe file writing (including emoji workarounds). |
Unified Full-Stack Panel Authoring
One guide for the complete workflow: backend route → service → server wiring → Panel component with resilience → registration. Eliminates the need to consult separate skills for what is always a single cohesive task.
When to Use
- Adding a new panel type to a vanilla-TypeScript dashboard
- Creating a feature that spans all layers: server route, service, component, registration
- Ensuring pattern-consistency across the stack
- Projects using a manual
http.createServer dispatch loop or Express
Quick Reference — File Checklist
| # | File | Action |
|---|
| 1 | server/routes/{feature}-route.ts | Create |
| 2 | src/services/{feature}.ts | Create |
| 3 | src/components/{Feature}Panel.ts | Create |
| 4 | src/components/index.ts | Update — add export |
| 5 | server/index.ts | Update — import + dispatch case |
| 6 | src/utils/circuit-breaker.ts | Create if absent |
| 7 | src/utils/sparkline.ts | Create if absent |
Step 1 — Explore Project Structure
Run these commands before writing a single line of code. Reading existing patterns prevents style drift and reveals the exact dispatch mechanism.
find . -name "index.ts" -o -name "index.js" -o -name "server.ts" -o -name "server.js" \
| grep -v node_modules | head -10
find server/routes -name "*.ts" -o -name "*.js" 2>/dev/null | head -10
find src/services -name "*.ts" -o -name "*.js" 2>/dev/null | head -10
find src/components -name "*Panel.ts" 2>/dev/null | head -10
find src -name "Panel.ts" 2>/dev/null | head -5
find src/components -name "index.ts" 2>/dev/null | head -3
find src/utils -name "circuit-breaker.ts" 2>/dev/null
grep -s '"react"' package.json | head -3
Read 1-2 examples from each layer before generating any code:
cat server/routes/<existing>-route.ts
cat src/services/<existing>.ts
cat src/components/<existing>Panel.ts
cat src/components/index.ts
cat server/index.ts
Step 2 — Detect Server Dispatch Pattern
Read server/index.ts (or server/index.js, src/server.ts) and classify:
grep -n "express\|app\.use\|router\." server/index.ts 2>/dev/null
grep -n "createServer\|switch.*url\|switch.*pathname\|req\.url\|case '/" server/index.ts 2>/dev/null
| Pattern in file | Wiring method (Step 5) |
|---|
app.use('/api/x', xRouter) | 5A - Express |
switch(pathname) { case '/api/x': | 5B - Manual switch/case |
if (url.startsWith('/api/x')) | 5C - Manual if/else chain |
const handlers = { '/api/x': fn } | 5D - Handler map |
NOTE: Misidentifying the dispatch pattern is the most common cause of silent 404s.
If unsure, read the full server entry point before proceeding.
Step 3 — Create the Server Route Handler
Use the manual dispatch template for http.createServer projects (most common in this codebase). Use the Express template only when Express is confirmed.
Manual dispatch (TypeScript) — preferred template
import { IncomingMessage, ServerResponse } from 'http';
import { fetch{Feature}Data } from '../../src/services/{feature}';
export async function handle{Feature}Request(
req: IncomingMessage,
res: ServerResponse,
action?: string
): Promise<void> {
try {
const data = await fetch{Feature}Data(action);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, data }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
error: err instanceof Error ? err. : (err)
}));
}
}
Express template (only when confirmed)
import { Router } from 'express';
import { fetch{Feature}Data } from '../../src/services/{feature}';
const router = Router();
router.get('/', async (req, res) => {
try {
const data = await fetch{Feature}Data(req.query.action as string | undefined);
res.json({ success: true, data });
} catch (err) {
res.status(500).json({ success: false, error: (err as Error).message });
}
});
export default router;
Step 4 — Create the Service Layer
The service owns data fetching and transformation. The CircuitBreaker (Step 6) wraps the service call at the component level — keep the service itself simple.
export interface {Feature}Item {
id: string;
}
export async function fetch{Feature}Data(action?: string): Promise<{Feature}Item[]> {
const url = `/api/panels/{feature}${action ? `?action=${encodeURIComponent(action)}` : ''}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${localStorage.getItem('token') ?? ''}` }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { data } = await res.json() as { data: {Feature}Item[] };
data;
}
Step 5 — Wire the Route Handler into the Server
This step is the most frequently missed and causes features to silently return 404.
After creating the route file, open server/index.ts and add both an import and a dispatch entry.
5A - Express
import {feature}Router from './routes/{feature}-route';
app.use('/api/panels/{feature}', {feature}Router);
5B - Manual switch/case (most common pattern)
import { handle{Feature}Request } from './routes/{feature}-route';
case '/api/panels/{feature}':
await handle{Feature}Request(req, res, action);
break;
5C - Manual if/else chain
import { handle{Feature}Request } from './routes/{feature}-route';
} else if (url.startsWith('/api/panels/{feature}')) {
await handle{Feature}Request(req, res, action);
}
5D - Handler map
import { handle{Feature}Request } from './routes/{feature}-route';
const handlers: Record<string, HandlerFn> = {
'/api/panels/{feature}': handle{Feature}Request,
};
Verification: After wiring, start the server and curl /api/panels/{feature} — you should receive JSON, not a 404.
Step 6 — Create the Panel Component
Architecture overview
Panel (base class)
+-- element: HTMLElement (.panel)
+-- header (.panel-header)
| +-- headerLeft (.panel-header-left)
| | +-- title (.panel-title)
| +-- countEl (.panel-count) [optional]
+-- content (.panel-content)
Resilience layers
| Layer | When to use |
|---|
isFetching guard | Always -- prevents duplicate concurrent requests |
fetchWithRetry() | When you own the raw fetch and want per-request retry |
CircuitBreaker.execute() | When a service may fail repeatedly; serves stale cache during cooldown |
saveState() / loadState() | When collapsed/size state should survive page reloads |
Complete Panel component template
import { Panel } from './Panel';
import { createCircuitBreaker } from '../utils/circuit-breaker';
import { fetch{Feature}Data, {Feature}Item } from '../services/{feature}';
const breaker = createCircuitBreaker<{Feature}Item[]>({
name: '{Feature}',
cacheTtlMs: 60_000,
cooldownMs: 5 * 60_000,
maxFailures: 2,
});
export class {Feature}Panel extends Panel {
private refreshTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
super({ id: '{feature}', title: '{Feature Display Name}', : });
.();
.();
. = ( .(), );
}
(): <> {
(.) ;
.();
{
items = breaker.( fetch{}(), []);
.(items.);
.(items);
} (err) {
.(
err ? err. : ,
{
. = ;
. = ;
.();
}
);
} {
.();
}
}
(: {}[]): {
(items. === ) {
.();
;
}
container = .();
container. = ;
( item items) {
row = .();
row. = ;
label = .();
label. = ;
label. = item.;
row.(label);
container.(row);
}
.. = ;
..(container);
.();
}
(): {
(. !== ) (.);
.();
}
}
Key rules for vanilla TS panels (no React):
- Extend
Panel base class; use setContent() / showError() / setCount() / showLoading() instead of React state.
- Build DOM via
document.createElement -- not JSX, not innerHTML with untrusted strings.
- Lifecycle:
constructor -> fetchData() -> render() -> destroy(). No useEffect.
- File extension is
.ts, not .jsx/.tsx.
Step 7 — Register the Component in index.ts
Export the new panel from the components barrel file so the dashboard can discover it.
export { {Feature}Panel } from './{Feature}Panel';
Then instantiate and mount in the dashboard bootstrap (follow the existing pattern):
import { {Feature}Panel } from './components';
const {feature}Panel = new {Feature}Panel();
document.getElementById('{feature}-slot')?.appendChild({feature}Panel.getElement());
Step 8 — Add Utility Files (if absent)
Only create these if find src/utils -name "circuit-breaker.ts" returns nothing.
src/utils/circuit-breaker.ts
interface CircuitState { failures: number; cooldownUntil: number; }
interface CacheEntry<T> { data: T; timestamp: number; }
export interface CircuitBreakerOptions {
name: string;
maxFailures?: number;
cooldownMs?: number;
cacheTtlMs?: number;
}
export class CircuitBreaker<T> {
private state: CircuitState = { failures: 0, cooldownUntil: 0 };
private cache: CacheEntry<T> | null = null;
private readonly name: string;
private readonly maxFailures: number;
private readonly cooldownMs: number;
private readonly : ;
() {
. = options.;
. = options. ?? ;
. = options. ?? * * ;
. = options. ?? * * ;
}
(): {
(.() < ..) ;
(.. > ) . = { : , : };
;
}
(): T | {
(. && .() - .. < .) ..;
;
}
(: T): {
. = { : , : };
. = { data, : .() };
}
(): {
..++;
(.. >= .) {
.. = .() + .;
.();
}
}
execute<R T>(: <R>, : R): <R> {
(.()) (.() R) ?? defaultValue;
cached = .();
(cached !== ) cached R;
{
result = ();
.(result);
result;
} (e) {
.(, e);
.();
defaultValue;
}
}
}
createCircuitBreaker<T>(: ): <T> {
<T>(options);
}
src/utils/sparkline.ts (optional — for numeric trend data)
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="" height="" viewBox="0 0 ">` +
+
;
}
Step 9 — Panel Base Class (if absent)
Only create src/components/Panel.ts if no existing base class is found.
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;
protected retryAttempts = 0;
protected retryDelay = 1000;
private maxRetries = 3;
constructor(options: PanelOptions) {
this.panelId = options.id;
this.element = document.createElement();
.. = ;
... = options.;
. = .();
.. = ;
headerLeft = .();
headerLeft. = ;
title = .();
title. = ;
title. = options.;
headerLeft.(title);
..(headerLeft);
(options.) {
. = .();
.. = ;
.. = ;
..(.);
}
. = .();
.. = ;
.. = ;
..(.);
..(.);
.();
}
(): { .; }
(message = ): {
.. = ;
}
(message = , ?: ): {
.. = ;
(onRetry) {
..()?.(, onRetry);
}
}
(: ): { .. = html; }
(: ): {
(.) .. = count.();
}
(): { ...(); }
(): { ...(); }
(): { ..(); }
(: ): { . = v; }
(): { .; }
(: ): <> {
(. < .) {
{
response = (url);
(!response.) ();
response.();
} (error) {
.++;
(. >= .) {
();
}
( (resolve, .));
. *= ;
}
}
}
(): {
.(, .({
: !...(),
: ...,
: ...,
}));
}
(): {
raw = .();
(!raw) ;
{ isExpanded, width, height } = .(raw) {
: ; : ; : ;
};
(!isExpanded) ...();
(width) ... = width;
(height) ... = height;
}
}
Step 10 — Safe File Writing
Known tool issue: write_file (and some shell tools) fail silently or with "unknown error" when file content contains multi-byte Unicode characters such as emoji (e.g., document, chart, checkmark emoji).
Rules for safe content
-
Never use emoji in generated source code. Replace with plain-text equivalents:
- Document emoji ->
[doc] or (file)
- Chart emoji ->
[chart]
- Checkmark emoji ->
[ok] or (done)
- Warning emoji ->
[warn] or WARNING:
-
Use ASCII substitutes in comments instead of Unicode box-drawing characters.
-
Test-write suspect content to a throwaway file first if unsure:
echo "test content" > /tmp/write_test.txt && cat /tmp/write_test.txt
-
Recovery pattern -- if a write_file call fails:
- Strip all non-ASCII characters from the content.
- Retry the write.
- If still failing, split the file into smaller chunks and concatenate with
cat.
Validation Checklist
Before considering the feature complete, verify each layer:
Server route
[ ] File created at server/routes/{feature}-route.ts
[ ] Exports handle{Feature}Request function
[ ] Returns { success: true, data } on success
[ ] Returns { success: false, error } on failure with correct HTTP status
Service
[ ] File created at src/services/{feature}.ts
[ ] Exports fetch{Feature}Data function
[ ] Throws on non-ok HTTP status (so CircuitBreaker can record failures)
Server wiring
[ ] Import added to server/index.ts
[ ] Dispatch case/route added (matching the detected pattern)
[ ] curl /api/panels/{feature} returns JSON (not 404)
Panel component
[ ] Extends Panel base class
[ ] Uses isFetching guard
[ ] CircuitBreaker wraps the service call
[ ] showError() called with retry callback on failure
[ ] destroy() clears interval and calls super.destroy()
Registration
[ ] Export added to src/components/index.ts
[ ] Panel instantiated and mounted in dashboard bootstrap
File safety
[ ] No emoji in any generated source file
[ ] All write_file calls succeeded (check file size > 0)
Common Mistakes
| Mistake | Symptom | Fix |
|---|
| Skipping Step 5 (server wiring) | Feature returns 404 | Add import + dispatch entry to server/index.ts |
| Wrong dispatch pattern | 404 or runtime error | Re-read server/index.ts; use grep from Step 2 |
| Emoji in source files | write_file silent failure | Replace all emoji with ASCII equivalents |
| React template in vanilla TS project | Compile errors | Confirm no React in package.json; use Panel base class template |
| Creating Panel.ts when one exists | Overwrites base class | Check with find src -name "Panel.ts" first |
Not calling super.destroy() | Memory leak | Always chain super.destroy() in subclass destroy() |
| CircuitBreaker declared inside class | Cache lost between fetches | Declare const breaker = ... at module level outside the class |