--- 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 `` 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 `` 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 ( Your App — holaOS {children} ) } ``` ### `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 = { 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 ( {label} {handle ? · {handle} : null} ) } ``` 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 (

{title}

{rightSlot}
{subtitle ? (

{subtitle}

) : null}
) } ``` ### `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 = { 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 ( {label} ) } ``` ## 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 `
` 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 (
Name Email Owner Status Last touch {rows.map((row) => ( {row.name} {row.email} {row.owner} {row.lastTouchLabel} ))}
) } ``` 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 (
{byStatus.map(({ status, rows }) => (
{status} {rows.length.toString().padStart(2, "0")}
{rows.map((row) => (
#{row.channel}

{row.text}

))}
))}
) } ``` 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 ``-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 (
Title Owner Notes