---
name: dashboard-panel
description: >-
  Scaffold a new dashboard panel plugin from a backend endpoint — the plugin
  file, a barrel-import registration, a pure-function relevance unit test,
  and a build check. Use when adding a new panel to a self-hosted status
  dashboard that already has a plugin-registry architecture, or building
  that architecture for the first time. Trigger /dashboard-panel <endpoint>.
  NOT for theming and NOT for backend/API changes.
user_invocable: true
---

> **Public / shared version** — trimmed from a private Claude Code setup and
> posted at smereski.com as a reusable pattern. It scaffolds panels for a
> personal command-center dashboard that visualizes an autonomous dev
> system's live state (see github.com/DSmereski/artificer for the public
> branding that system ships under). The repo path, service ports, and
> deploy-host specifics have been replaced with generic equivalents; the
> plugin contract and templates are unchanged.

# dashboard-panel skill

Scaffolds a new panel plugin for a dashboard built on a **self-registering
plugin architecture**: each panel is one file that registers itself on
import, declares its own data needs, and reports its own relevance so an
adaptive layout engine can decide how much screen real estate it deserves.
This skill exists so adding panel #40 is exactly as cheap as adding panel
#2 — no central switch statement to edit, no layout code to touch by hand.

## Trigger

- "add a dashboard panel for X"
- "new panel hitting `<some endpoint>`"
- "/dashboard-panel `<endpoint-path>`"
- "scaffold a plugin for `<endpoint>`"

## What it generates

1. `src/plugins/<id>.ts` — a complete plugin implementation.
2. One import line appended to the plugin barrel (`src/plugins/index.ts`)
   that makes the new plugin self-register on app load.
3. `tests/plugins/<id>.test.ts` — unit tests for the plugin's pure
   relevance function.
4. A build check, plus a reminder of whatever your redeploy step is.

## The plugin contract

Every generated plugin implements:

- `id`, `title`, `dataSources[]` — declarative: each source has a kind
  (poll / websocket / shared-state), an endpoint, and an interval key that
  maps to a polling cadence bucket (fast/medium/slow — pick the bucket that
  matches how often the underlying data actually changes, don't poll a
  once-an-hour value every three seconds).
- `relevance(state)` — a **pure function** of current dashboard state,
  returning a priority number and a size hint (`hero`/`lg`/`md`/`sm`/
  `min`/`hidden`). This is what lets the layout engine place panels without
  any panel knowing about any other panel. Keep it pure and cheap — it may
  be called on every state tick.
- `mount(el)` — one-time DOM construction: a header plus a content
  container.
- `update(state, budget)` — re-render, honoring a render budget (frame
  rate cap, animation on/off, max chart points) so a background panel
  doesn't burn CPU a foreground one needs.
- `suspend()` / `resume()` — optional: pause timers or simulations while
  the panel is hidden.

## Usage

When asked to add a panel for a given endpoint:

1. Derive a kebab-case `id` from the endpoint (e.g. `/v1/lessons` → `lessons`).
2. Derive a human `title` (e.g. `LESSONS`).
3. Pick a polling-cadence bucket from the endpoint's nature, not its path —
   fast for anything that changes multiple times a minute, medium for
   something that updates every few seconds to a minute, slow for anything
   closer to hourly. Reuse whatever cadence buckets your dashboard already
   defines rather than inventing a new one per panel.
4. Write `src/plugins/<id>.ts` from the template below.
5. Append the barrel import (`export * from './<id>.js';`) to
   `src/plugins/index.ts`.
6. Write a relevance unit test.
7. Run the project's build.
8. Deploy however your dashboard is actually served — if the build output
   is picked up directly by a host (a browser tab, a kiosk display, a
   native wallpaper/desktop host), the build step *is* the deploy; if the
   host caches the old build, trigger whatever reload/refresh mechanism it
   uses.
9. Persist the update through however you keep your own skills in sync.

## Panel template (fill in {ID}, {TITLE}, {ENDPOINT}, {INTERVAL_KEY})

```typescript
/**
 * plugins/{ID}.ts — {TITLE} panel.
 * Auto-generated by dashboard-panel skill.
 */
import { register } from './registry.js';
import type { PanelPlugin, RelevanceResult } from './contract.js';
import type { SystemState, RenderBudget } from '../state/types.js';
import { escHtml } from '../format.js';

function relevance(state: SystemState): RelevanceResult {
  // Adjust priority/size based on what actually matters for this panel:
  if (state.activity === 'idle') return { priority: 60, size: 'md' };
  return { priority: 25, size: 'sm' };
}

let _rootEl: HTMLElement | null = null;

function mount(el: HTMLElement): void {
  _rootEl = el;
  el.innerHTML = `
    <div class="panel-header">
      <span class="panel-label">{TITLE}</span>
    </div>
    <div id="v2-{ID}-content" class="{ID}-content">
      <p class="offline-state">Loading…</p>
    </div>
  `;
}

function update(_state: SystemState, budget: RenderBudget): void {
  if (!_rootEl) return;
  // TODO: render data from the last poll result
  // Use budget.chartFps, budget.animate, budget.chartMaxPoints to throttle
}

/** Called by the poll adapter when new data arrives from {ENDPOINT}. */
export function on{ID_PASCAL}Data(data: unknown): void {
  const content = document.getElementById('v2-{ID}-content');
  if (!content) return;
  // TODO: render data
  content.innerHTML = `<pre>${escHtml(JSON.stringify(data, null, 2).slice(0, 500))}</pre>`;
}

const plugin: PanelPlugin = {
  id:          '{ID}',
  title:       '{TITLE}',
  dataSources: [{ kind: 'poll', endpoint: '{ENDPOINT}', intervalKey: '{INTERVAL_KEY}' }],
  relevance,
  mount,
  update,
};

register(plugin);
export { plugin as {ID_CAMEL}Plugin };
```

## Relevance unit test template

```typescript
import { describe, it, expect } from 'vitest';
import { {ID_CAMEL}Plugin } from '../../src/plugins/{ID}.js';

function makeState(overrides = {}) {
  return { activity: 'idle', tier: 'idle', gatewayUp: true,
    tasks: { building: [], review: 0, qa: 0, ready: 0, done: 0 },
    escalations: { open: 0 }, resources: { gpus: [], cpuPct: 0, ramPct: 0, gaming: false, contended: false },
    counts: { costUsd: 0, tokRateHive: 0, tokRateClaude: 0, parseFailRate: 0 },
    ts: Date.now(), ...overrides } as any;
}

describe('{ID} plugin relevance', () => {
  it('returns a valid size hint', () => {
    const rel = {ID_CAMEL}Plugin.relevance(makeState());
    expect(['hero','lg','md','sm','min','hidden']).toContain(rel.size);
    expect(rel.priority).toBeGreaterThanOrEqual(0);
    expect(rel.priority).toBeLessThanOrEqual(100);
  });
});
```

## Notes

- Keep one existing simple panel in the plugin folder as your canonical
  reference implementation — point new contributors (or yourself, months
  later) at it instead of re-deriving the pattern from scratch.
- Find an existing panel with a nontrivial relevance function (one that
  transitions between `hidden` and a large size based on state) to see how
  attention-worthy transitions are meant to work.
- Always run the test suite after generating to check for regressions.
- Version the plugin contract itself (a constant like
  `PLUGIN_CONTRACT_VERSION`) so a breaking change to the interface is
  detectable, not silent.

## Guardrails

- **Verify done:** unit tests green, build exits 0, and the new panel is
  actually visible on the dashboard after whatever reload step your host
  needs (a human confirms this visually — a green build is not the same as
  a panel that renders).
- **Before scaffolding, confirm the endpoint exists** — a cheap HTTP status
  probe against it (200/401/403 = it exists, 401/403 just means it's
  behind auth; 404 = it doesn't). If the endpoint 404s, or which endpoint
  is meant is ambiguous, stop and report — never scaffold against a guessed
  endpoint.
- **Build fails twice → stop and report the error verbatim.** Don't churn
  edits hoping one sticks.
- **This is a pure frontend skill** — never restart backend services or
  touch infrastructure from here.
