---
name: Build Dashboard
slug: build-dashboard-2
category: Frontend
description: "Build Dashboard scaffolds the visual layer of a holaOS dashboard app using TanStack Start, @holaboss/ui, and workspace tokens. Use it when an app's SDK primitives are wired and it needs a src/client/ UI surface."
github: "https://github.com/holaboss-ai/holaOS/tree/main/runtime/harnesses/src/embedded-skills/build-dashboard"
language: TypeScript
stars: 7387
forks: 641
install: "npx degit https://github.com/holaboss-ai/holaOS/tree/main/runtime/harnesses/src/embedded-skills/build-dashboard ~/.claude/skills/build-dashboard"
installs_to: ~/.claude/skills/build-dashboard
source_path: runtime/harnesses/src/embedded-skills/build-dashboard/SKILL.md
collection_size: 25
category_size: 567
collection_url: "https://dirskills.com/collections/holaboss-ai/holaOS"
added: 2026-08-15T06:51:36.252Z
last_synced: 2026-08-15T06:51:36.252Z
canonical_url: "https://dirskills.com/skills/build-dashboard-2"
---

# Build Dashboard

Build Dashboard scaffolds the visual layer of a holaOS dashboard app using TanStack Start, @holaboss/ui, and workspace tokens. Use it when an app's SDK primitives are wired and it needs a src/client/ UI surface.

**Install:**

```bash
npx degit https://github.com/holaboss-ai/holaOS/tree/main/runtime/harnesses/src/embedded-skills/build-dashboard ~/.claude/skills/build-dashboard
```

## README

# build-dashboard

The agent before you reliably produces ugly dashboards because it starts from a blank page and reaches for default shapes (single-column full-width cards, KPI strips that don't fit, sidebars that don't earn their space). This skill exists to **bypass that default**. The visual decisions are already made — your job is to fill in the data and copy.

## When to use

- The user asked for a dashboard, workspace pane, list view, kanban, calendar, or "let me see my X"
- The app's `app.ts` already declares `resource(...)` rows via the SDK (set up via the `app-builder-sdk` skill first)
- The app dir needs a `src/client/` directory

**Skip this skill when:**
- Integration-only module (Slack/Discord/Stripe-style MCP-only) — those use only `app-builder-sdk`
- Marketing landing page → use `frontend-design`
- One-off static HTML report → not this product class

## The two non-negotiables

1. **Copy the bundled reference. Don't invent.** `reference/messaging-dashboard/src/client/` is the canonical starting point. Three files below are verbatim across every dashboard; the rest gets customized per shape.
2. **Two style imports, not one.** `@holaboss/ui/styles.css` only bakes in utilities used inside the library. Every Tailwind class your `src/client/` writes needs your own app-side compile pass. The register-time lint `workspace_app_missing_tailwind_compile` rejects apps missing this.

## The shape catalog — pick one

Look at the user's data, NOT at "what dashboards usually have". Most apps are shape 1.

| # | Shape | Pick when the data is… | Template |
|---|---|---|---|
| 1 | **Queue / feed** | scheduled items, drafts, an action queue, an activity log, anything time-ordered | `reference/messaging-dashboard/` (full, ready to copy) |
| 2 | **Dense table** | flat records (CRM contacts, log rows, ticket list) that the user scans like a spreadsheet | Replace shape-1's `messages-table.tsx` with the `<Table>` primitive (see snippet below) |
| 3 | **Kanban** | rows that move between named statuses; user drags between columns | Replace shape-1's main column with horizontal status columns (see snippet below) |
| 4 | **Detail / form** | a single resource the user edits or watches in depth | Replace shape-1's main column with `<Field>` form (see snippet below) |
| 5 | **Calendar week** | rows with `start_time` + duration that pin to a day-grid | Replace shape-1's main column with `@holaboss/ui`'s `Calendar` primitive |

Shapes 2–5 still keep shape 1's header, app.css, connection pill, status badge, and tokens. **Only the main content area changes.**

ASCII for shape 1 (the most common, read this even if you're using another shape):

```
                ┌────────────────────────────────────────────┐
                │  Outgoing ●            ● Connected · @jot  │  ← header
                │  5 queued · agent will send on schedule    │
                │                                            │
                │  NEEDS ATTENTION                           │
                │  ┌──────────────────────────────────┐     │  ← attention strip
                │  │ #ops · Failed · 3h ago           │     │    (warning-bordered,
                │  │  Composio retry exhausted…       │     │     always-visible Retry)
                │  │                  [Retry] [Edit]  │     │
                │  └──────────────────────────────────┘     │
                │                                            │
                │  TODAY ───────────────────────────── 02   │  ← day divider
                │  NOW · 08:42 ─────────────────────────    │  ← "now" cursor on rail
                │  09:00 ● #general · ● Scheduled            │  ← next-up marker
                │        Heads-up: pricing page goes live…  │
                │  17:00 · #growth · ● Draft                 │
                │        Weekly recap — KPI strip…           │
                │                                            │
                │  TOMORROW ────────────────────────── 02   │
                │  …                                         │
                └────────────────────────────────────────────┘
                       (max-w-3xl centered on bg-background)
```

## Foundation — paste verbatim into every dashboard

These five files do NOT vary per shape. Copy them exactly.

### `src/client/app.css` (13 lines)

```css
/* App-local Tailwind compile entry.
 *
 * `@holaboss/ui/styles.css` only bakes in utilities used INSIDE the library.
 * Every Tailwind class your `src/client/` writes (max-w-3xl, grid-cols-*,
 * text-fg-48, bg-card, flex-1, etc.) needs an app-side compile pass to land
 * in the bundle. Without it the page renders mostly unstyled.
 *
 * Required by the register-time lint `workspace_app_missing_tailwind_compile`.
 */

@import "tailwindcss";

@source "../client";
```

### `src/client/routes/__root.tsx`

```tsx
import "@holaboss/ui/styles.css"
import "../app.css"

import type { ReactNode } from "react"

export function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en" data-theme="holaos-light">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width,initial-scale=1" />
        <title>Your App — holaOS</title>
      </head>
      <body className="antialiased">{children}</body>
    </html>
  )
}
```

### `src/client/components/connection-pill.tsx`

```tsx
import { StatusDot } from "@holaboss/ui"

type Props = {
  state: "ready" | "needs_connect" | "needs_reauth" | "checking"
  handle?: string
}

const COPY: Record<Props["state"], { label: string; tone: "success" | "warning" | "muted" }> = {
  ready: { label: "Connected", tone: "success" },
  needs_connect: { label: "Not connected", tone: "warning" },
  needs_reauth: { label: "Reauth required", tone: "warning" },
  checking: { label: "Checking…", tone: "muted" },
}

export function ConnectionPill({ state, handle }: Props) {
  const { label, tone } = COPY[state]
  return (
    <span className="inline-flex items-center gap-1.5 text-xs text-fg-64">
      <StatusDot variant={tone} size="sm" />
      <span className="text-fg-80">{label}</span>
      {handle ? <span className="text-fg-48">· {handle}</span> : null}
    </span>
  )
}
```

Wire `state` from `getIntegrationStatus()` — `ready === true` → `"ready"`, `code === "integration_not_connected"` → `"needs_connect"`, `code === "integration_needs_reauth"` → `"needs_reauth"`. See app-builder-sdk skill for the helper.

### `src/client/components/header-bar.tsx`

```tsx
import { Button, StatusDot } from "@holaboss/ui"
import { Plus } from "lucide-react"
import type { ReactNode } from "react"

type Props = {
  title: string
  subtitle?: string
  rightSlot?: ReactNode
  onCompose?: () => void
}

export function HeaderBar({ title, subtitle, rightSlot, onCompose }: Props) {
  return (
    <header className="px-10 pt-12 pb-8">
      <div className="flex items-center gap-3">
        <div className="flex min-w-0 flex-1 items-center gap-2.5">
          <h1
            className="font-serif text-[22px] leading-none text-foreground"
            style={{ fontFamily: "'Source Serif 4', serif", fontWeight: 500 }}
          >
            {title}
          </h1>
          <StatusDot variant="success" size="sm" pulse />
        </div>
        {rightSlot}
        <Button
          variant="ghost"
          size="sm"
          onClick={onCompose}
          className="h-7 gap-1.5 px-2 text-xs text-fg-64 hover:text-foreground"
        >
          <Plus className="size-3" />
          Add draft
        </Button>
      </div>
      {subtitle ? (
        <p className="mt-2 truncate text-xs text-fg-48">{subtitle}</p>
      ) : null}
    </header>
  )
}
```

### `src/client/components/status-badge.tsx`

```tsx
import { StatusDot } from "@holaboss/ui"

// REPLACE this union with your resource's state machine.
type MyStatus = "draft" | "scheduled" | "sent" | "edited" | "failed"

// REPLACE this map: one entry per state with its label and dot variant.
// Use `success` for completed/healthy, `info` for in-flight/scheduled,
// `muted` for inert/draft, `warning` for soft problems, `destructive`
// for hard failures.
const MAP: Record<MyStatus, { label: string; dot: "success" | "warning" | "destructive" | "muted" | "info" }> = {
  draft: { label: "Draft", dot: "muted" },
  scheduled: { label: "Scheduled", dot: "info" },
  sent: { label: "Sent", dot: "success" },
  edited: { label: "Edited", dot: "info" },
  failed: { label: "Failed", dot: "destructive" },
}

export function StatusBadge({ status }: { status: MyStatus }) {
  const { label, dot } = MAP[status]
  return (
    <span className="inline-flex items-center gap-1 text-[11px] text-fg-64">
      <StatusDot variant={dot} size="sm" />
      {label}
    </span>
  )
}
```

## Shape 1: queue / feed (canonical, fully bundled)

The full implementation is bundled at `reference/messaging-dashboard/src/client/` next to this skill. Two files vary per app — read them directly from the bundled reference:

- `routes/index.tsx` — page composition (~100 lines). Sets up the 3-region layout: header → attention strip → grouped sections. Read this whole file before copying.
- `components/messages-table.tsx` — the row + rail + attention list (~150 lines). The hardest file; spent the most iteration. Read this whole file before copying.
- `lib/sample-data.ts` — mock data with the `MessageRow` shape (`channel / text / status / bucket / timeLabel / authorHandle / errorReason`). **Replace this file entirely** with TanStack Start server functions that read from your `app.resource()` rows.

What to swap when copying shape 1:

| File | Change | Keep |
|---|---|---|
| `lib/sample-data.ts` | Replace entirely with server functions; rename to `data.ts`. | The row shape — your data should map to the same field set, or the table needs JSX changes too. |
| `routes/index.tsx` | Page title (`"Outgoing"`), subtitle, `nowLabel` (real current time), day-divider labels. | The 3-region structure (header → attention → grouped sections), `max-w-3xl`, spatial sketch comment, useMemo grouping. |
| `messages-table.tsx` | Column meta layout (`#{channel} · author · time`), body field, error-reason placement. | 3-col grid (`64px_16px_1fr_auto`), rail (`bg-fg-32`), marker treatment, attention strip styling. |
| `status-badge.tsx` | `MAP` lookup → your states. | Component shape. |
| `header-bar.tsx`, `connection-pill.tsx`, `__root.tsx`, `app.css` | nothing. | everything. |

## Shape 2: dense table (CRM / log / ticket list)

When the data is naturally rows-and-columns and the user scans like a spreadsheet, replace shape-1's `messages-table.tsx` with the `<Table>` primitive. Header + connection pill + `app.css` setup stay.

```tsx
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Badge } from "@holaboss/ui"
import { StatusBadge } from "./status-badge"

export function RecordsTable({ rows }: { rows: MyRow[] }) {
  return (
    <Table className="text-[13px]">
      <TableHeader>
        <TableRow className="text-fg-48">
          <TableHead className="w-[180px] pl-6">Name</TableHead>
          <TableHead>Email</TableHead>
          <TableHead className="w-[140px]">Owner</TableHead>
          <TableHead className="w-[120px]">Status</TableHead>
          <TableHead className="w-[140px] text-right pr-6">Last touch</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {rows.map((row) => (
          <TableRow key={row.id} className="hover:bg-muted/40">
            <TableCell className="pl-6 text-foreground">{row.name}</TableCell>
            <TableCell className="text-fg-64">{row.email}</TableCell>
            <TableCell className="text-fg-64">{row.owner}</TableCell>
            <TableCell><StatusBadge status={row.status} /></TableCell>
            <TableCell className="text-right text-fg-64 tabular-nums pr-6">{row.lastTouchLabel}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}
```

Layout shape: drop the time-rail; keep `max-w-3xl` (or bump to `max-w-5xl` if 5+ columns). Day-divider sections from shape 1 become optional — usually one flat table is fine.

## Shape 3: kanban (status board)

When rows move between named statuses and the user drags between them. Replace shape-1's main column with horizontally-arranged status columns. Header + connection pill + `app.css` stay.

```tsx
import { Card } from "@holaboss/ui"
import { StatusBadge } from "./status-badge"

const COLUMNS = ["draft", "scheduled", "sent", "failed"] as const

export function KanbanBoard({ rows }: { rows: MyRow[] }) {
  const byStatus = COLUMNS.map((status) => ({
    status,
    rows: rows.filter((r) => r.status === status),
  }))

  return (
    <div className="grid grid-cols-4 gap-3 px-10 pb-12">
      {byStatus.map(({ status, rows }) => (
        <div key={status} className="flex flex-col gap-2">
          <div className="flex items-baseline justify-between px-1">
            <span className="text-[10px] tracking-wider text-fg-48 uppercase">{status}</span>
            <span className="font-mono text-[10px] text-fg-32 tabular-nums">
              {rows.length.toString().padStart(2, "0")}
            </span>
          </div>
          <div className="flex flex-col gap-2">
            {rows.map((row) => (
              <Card key={row.id} size="sm" className="cursor-pointer hover:bg-muted/40">
                <div className="px-3 py-2">
                  <div className="text-[11px] text-fg-48">#{row.channel}</div>
                  <p className="mt-1 line-clamp-3 text-sm leading-snug text-fg-80">{row.text}</p>
                  <div className="mt-2">
                    <StatusBadge status={row.status} />
                  </div>
                </div>
              </Card>
            ))}
          </div>
        </div>
      ))}
    </div>
  )
}
```

Layout shape: change `max-w-3xl` on the outer container to `max-w-6xl` for breathing room. Drop the attention strip (failed rows surface naturally in the "failed" column).

## Shape 4: single-resource detail / form

For workflows where the user edits one resource at a time (settings, single-record CRM contact, single bookmark editor). Replace shape-1's main column with a `<Field>`-based form. Header + connection pill + `app.css` stay.

```tsx
import { Button, Field, FieldDescription, FieldGroup, FieldLabel, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Textarea } from "@holaboss/ui"

export function RecordForm({ record, onSave }: { record: MyRow; onSave: (r: MyRow) => void }) {
  return (
    <form className="mx-auto flex max-w-2xl flex-col gap-6 px-10 pb-12">
      <FieldGroup>
        <Field>
          <FieldLabel>Title</FieldLabel>
          <Input defaultValue={record.title} />
        </Field>
        <Field>
          <FieldLabel>Owner</FieldLabel>
          <Select defaultValue={record.owner}>
            <SelectTrigger><SelectValue /></SelectTrigger>
            <SelectContent>
              <SelectItem value="alice">Alice</SelectItem>
              <SelectItem value="bob">Bob</SelectItem>
            </SelectContent>
          </Select>
        </Field>
        <Field>
          <FieldLabel>Notes</FieldLabel>
          <Textarea rows={6} defaultValue={record.notes} />
          <FieldDescription>Markdown is supported.</FieldDescription>
        </Field>
      </FieldGroup>
      <div className="flex justify-end gap-2">
        <Button variant="ghost" type="button">Cancel</Button>
        <Button type="submit">Save</Button>
      </div>
    </form>
  )
}
```

Layout shape: tighter column (`max-w-2xl`). No attention strip; surface validation errors inline via `FieldError`.

## Shape 5: calendar week

When rows have a real `start_time` + duration that pin to a day-grid. Use `@holaboss/ui`'s `Calendar` primitive. This shape is intentionally less battle-tested — extend the base only when calendar truly fits.

```tsx
import { Calendar } from "@holaboss/ui"

export function WeekCalendar({ rows }: { rows: MyRow[] }) {
  // Calendar is the base-ui primitive; for full week-view with custom
  // event rendering you'll need to compose it yourself. The skeleton:
  return (
    <div className="px-10 pb-12">
      <Calendar mode="single" />
      {/* Layer events on top via absolute-positioned cards keyed by date. */}
    </div>
  )
}
```

## Wiring the rest of the app

Everything below is the same as the `app-builder-sdk` skill describes for any app — repeat here only for the dashboard-specific gotchas.

### Required deps in `package.json`

```json
{
  "dependencies": {
    "@holaboss/app-builder-sdk": "latest",
    "@holaboss/ui": "latest",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "lucide-react": "^0.542.0"
  },
  "devDependencies": {
    "@tailwindcss/vite": "^4.2.1",
    "@vitejs/plugin-react": "^5.0.0",
    "tailwindcss": "^4.2.1",
    "vite": "^6.3.0"
  }
}
```

Use `"latest"` literally for the two `@holaboss/*` packages — pre-1.0 caret semver drifts.

### Required `vite.config.ts`

```ts
import tailwind from "@tailwindcss/vite"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"

export default defineConfig({
  plugins: [react(), tailwind()],
})
```

Without `@tailwindcss/vite`, the `@import "tailwindcss"` in `app.css` is a no-op and your custom utilities don't compile.

### `server.ts` boots BOTH MCP and the dashboard

```ts
import { startMcpServer, SqliteStateBackend, createRuntimeBrokerTransport } from "@holaboss/app-builder-sdk"
import { buildMyApp } from "./app.ts"

const state = new SqliteStateBackend({ path: process.env.WORKSPACE_DB_PATH! })
const bridge = createRuntimeBrokerTransport({ provider: "<provider>" })
const app = buildMyApp({ state, bridge })

// MCP for the agent
await startMcpServer({
  port: Number(process.env.MCP_PORT),
  app, bridge,
})

// Dashboard for the user (iframe loads this URL)
// Use TanStack Start's production build output OR Vite's dev server.
// IMPORTANT: read from the SAME SqliteStateBackend the SDK uses; never
// spin up a second DB.
import { build } from "./client/build"
Bun.serve({ port: Number(process.env.PORT), fetch: build.fetch })
```

The desktop's `AppSurfacePane` iframe resolves to `process.env.PORT` — whatever you serve there is what the user sees.

## Required setup checklist (lint-enforced)

The register-time lint rejects dashboard apps that fail any of these. Run through the list before declaring done.

| Check | Lint code (if fails) |
|---|---|
| `src/client/` has ≥3 distinct named imports from `@holaboss/ui` | `workspace_app_holaboss_ui_named_imports_too_few` |
| At least one `.css` file under `src/client/` contains `@import "tailwindcss"` | `workspace_app_missing_tailwind_compile` |
| No hex / `rgb()` / `hsl()` / `oklch()` literals in `src/client/**/*.css` | `workspace_app_parallel_design_system` |
| No custom `--<token>:` definitions in CSS (passthroughs like `--mine: var(--background)` allowed) | `workspace_app_parallel_design_system` |

If any lint fires, the runtime returns the file + line + suggested fix. Don't try to bypass — read the message and fix the root cause.

## What you may NOT do (read once, internalize)

These are hard rules. The lint catches some; the rest are caught by review (or by the user noticing the dashboard looks alien).

- **No `font-bold` / `font-semibold` / `font-extrabold` / inline `style={{ fontWeight: ... }}`.** Design system clamps all of those to 500. Hierarchy comes from **size** (`text-2xl` for hero numbers, `text-base` for headings, `text-xs` for labels) and **color** (`text-foreground` → `text-fg-80` → `text-fg-64` → `text-fg-48`).
- **No hex / `rgb()` / `oklch()` literals anywhere.** Lint rejects in CSS; review catches in JSX. Use tokens: `bg-background`, `bg-card`, `bg-muted`, `text-foreground`, `text-fg-{12,16,32,48,64,80,92}`, `border`, `border-warning`, `bg-warning/[0.06]`, `text-primary`.
- **No second component library.** No MUI, Ant, Chakra, raw Radix, Headless UI, 
