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.
64
76%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Fix and improve this skill with Tessl
tessl review fix ./runtime/harnesses/src/embedded-skills/build-dashboard/SKILL.mdThe 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.
app.ts already declares resource(...) rows via the SDK (set up via the app-builder-sdk skill first)src/client/ directorySkip this skill when:
app-builder-sdkfrontend-designreference/messaging-dashboard/src/client/ is the canonical starting point. Three files below are verbatim across every dashboard; the rest gets customized per shape.@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.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)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.tsximport "@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.tsximport { 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.tsximport { 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.tsximport { 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>
)
}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. |
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.
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).
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.
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>
)
}Everything below is the same as the app-builder-sdk skill describes for any app — repeat here only for the dashboard-specific gotchas.
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.
vite.config.tsimport 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 dashboardimport { 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.
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.
These are hard rules. The lint catches some; the rest are caught by review (or by the user noticing the dashboard looks alien).
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).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.@holaboss/ui package wraps base-ui's shadcn-flavored primitives; that is the only allowed source.components/ui/ directory (shadcn-add copy). Import primitives from @holaboss/ui only.bg-gradient-* on cards, no hover:shadow-*, no hover:-translate-y-* lift effects. Subtle hover via hover:bg-muted/40 only.app.css (and app.css must contain only @import "tailwindcss" + @source, possibly empty @layer blocks).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.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.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.ConnectionPill to getIntegrationStatus() (helper from @holaboss/app-builder-sdk). The four state values map directly to readiness codes.[data-theme]. Set data-theme="holaos-dark" on <html> to verify dark renders correctly.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.Run through this list — if any item is uncertain, fix before declaring done:
bun install in the app dir → exit 0bun run server.ts → "MCP server listening on :" AND dashboard responds on :$PORTcurl http://localhost:<PORT>/ returns a TanStack Start HTML response, NOT the SDK's "headless module" placeholder (search for headless module in the response body)data-theme="holaos-dark" on <html> to test)@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.
74c7b6f
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.