build-dashboard

star 5.5k

Build the visual layer of a holaOS dashboard app — TanStack Start + @holaboss/ui + workspace tokens. Use when an app has SDK primitives wired (via app-builder-sdk) AND needs a `src/client/` UI surface. NOT for marketing pages, NOT for snapshot HTML reports.

holaboss-ai By holaboss-ai schedule Updated 5/25/2026

name: build-dashboard description: Build the visual layer of a holaOS dashboard app — TanStack Start + @holaboss/ui + workspace tokens. Use when an app has SDK primitives wired (via app-builder-sdk) AND needs a src/client/ UI surface. NOT for marketing pages, NOT for snapshot HTML reports.

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)

/* 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

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

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

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

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.

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.

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.

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.

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

{
  "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

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

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-foregroundtext-fg-80text-fg-64text-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, react-aria. The @holaboss/ui package wraps base-ui's shadcn-flavored primitives; that is the only allowed source.
  • No components/ui/ directory (shadcn-add copy). Import primitives from @holaboss/ui only.
  • No bg-gradient-* on cards, no hover:shadow-*, no hover:-translate-y-* lift effects. Subtle hover via hover:bg-muted/40 only.
  • No per-app theme toggle. Theme is workspace-level; the app inherits via CSS variables.
  • No custom CSS files beyond app.css (and app.css must contain only @import "tailwindcss" + @source, possibly empty @layer blocks).
  • No KPI strip "because dashboards usually have them". Add stat cards only when the user genuinely tracks ≥3 comparable numbers that need to sit side-by-side. The single count of "5 queued" goes in the subtitle, not in a card.
  • No sidebar "because dashboards usually have them". Add one only when the user has a genuine second navigation axis that must stay on screen (multi-channel chat, multi-project switcher).
  • No Promise.all rendering gate. Each card / table / chart renders the moment its own data lands, with Skeleton during fetch and EmptyState if empty. A 0.5s skeleton beats a 4s blank page.

What you SHOULD do

  • Read reference/messaging-dashboard/ end-to-end (it lives next to this skill — 4 component files, 1 lib file, 1 routes file). Even if your shape isn't queue/feed, the patterns transfer.
  • Spend 30 seconds on a one-line spatial sketch BEFORE writing JSX. Single sentence: "Header strip + grouped sections of rows" or "Header + 4-column kanban". If you can't say it in one sentence, pick from the shape catalog above.
  • Replace lib/sample-data.ts with TanStack Start server functions that read from the SDK's SqliteStateBackend (the table app.resource() declared). Never spin up a second DB. Never call MCP tools from the dashboard.
  • Wire ConnectionPill to getIntegrationStatus() (helper from @holaboss/app-builder-sdk). The four state values map directly to readiness codes.
  • Test in both light AND dark. Workspace theme can be either; the dashboard inherits via [data-theme]. Set data-theme="holaos-dark" on <html> to verify dark renders correctly.

Examples

User: "Build a GitHub work tracker dashboard"
→ Shape 1 (queue / feed). Rows = issues + PRs, grouped by today / this week / older.
  Status mapping: open → info, in_progress → success, closed → muted, failed → destructive.
  Replace `sample-data.ts` with a server function reading from your `app.resource("issue")` rows.

User: "Make me a Notion page tracker"
→ Shape 2 (dense table). One row per page, columns: title / database / author / last-edited / status.
  No attention strip; just one flat sortable table.

User: "I want to see my Linear issues organized by status"
→ Shape 3 (kanban). Columns: backlog / todo / in-progress / done / canceled. Cards have title + assignee + priority.

User: "I want to manage my Mailchimp campaigns"
→ Shape 1 (queue / feed) grouped by send time — campaigns are time-ordered drafts → scheduled → sent, exactly the messaging metaphor.

User: "I want a calendar of my upcoming gcal events"
→ Shape 5 (calendar week). Compose from `Calendar` primitive; this is the least-tested shape, expect to iterate.

When you're done

Run through this list — if any item is uncertain, fix before declaring done:

  1. bun install in the app dir → exit 0
  2. bun run server.ts → "MCP server listening on :" AND dashboard responds on :$PORT
  3. curl http://localhost:<PORT>/ returns a TanStack Start HTML response, NOT the SDK's "headless module" placeholder (search for headless module in the response body)
  4. Open the dashboard in the desktop iframe. Visually:
    • Fonts are Inter + Source Serif 4 (NOT Times / system-ui)
    • Connection pill shows correct state (Connected / Reauth / Not connected)
    • Empty state renders when filtered to a status with no rows
    • Hover affordances on rows (subtle bg shift, no shadow lift)
    • Light mode AND dark mode both look intentional (set data-theme="holaos-dark" on <html> to test)
  5. The dashboard looks like the rest of the workspace (same fonts, borders, radii, card surface color). If it looks alien, you've broken a token import, imported from outside @holaboss/ui, or redefined a primitive.

If all 5 pass, you're done. Do not invoke interface-design. The build pass should already produce shipping-quality output via this skill + the bundled reference + the register-time lints.

If you later receive an [Auto-queued post-build polish pass] input for this app (the runtime queues one when a binding completes and unblocks pendingIntegrations), re-enter this skill — not interface-design — and re-evaluate src/client/ against the bundled reference now that real data is wired in. That second pass is the safety net for cases the build-time agent finished before the integrations were connected, not a license to add chrome that this skill didn't already endorse.

Install via CLI
npx skills add https://github.com/holaboss-ai/holaOS --skill build-dashboard
Repository Details
star Stars 5,513
call_split Forks 389
navigation Branch main
article Path SKILL.md
More from Creator