Rembric Dashboard UI
Reach for this skill whenever the user wants UI work on the Rembric dashboard
(/dashboard/*). The dashboard is a Next.js App Router + React 19 surface
styled with Tailwind v4 and shadcn/Radix primitives. It is no longer the
Hono/HTMX/SSR-helper stack the old version of this skill described — there is no
src/dashboard/, dashboard-router.ts, components.ts, or templates.ts.
Trigger
Auto-trigger when the user asks for:
- A new dashboard page, route, or view
- Edits to an existing dashboard page (memories, sessions, entities, judgments,
consolidation, prompts, projects, tokens, maintenance, settings, activity,
overview, login, OAuth consent)
- New visual components (cards, pills, buttons, banners, modals)
- Mobile / responsive tweaks
- New destructive actions that need confirmation
- New tables, filters, paginators
- Anything that mentions:
lime, zinc, command bar, StatusPill,
data-table, hl-lime, ConfirmSubmit, ActionForm, Time
Do NOT trigger for:
- Server / service / DB / MCP / consolidation backend work
- OpenSpec change scaffolding (use the
openspec-* skills for that)
- Plugin / hook / CLI /
apps/plugin/ work
The surface, verified
apps/web/src/app/dashboard/layout.tsx CommandFrame wrapper (command bar, counters, version)
apps/web/src/app/dashboard/**/page.tsx one async server component per route
apps/web/src/components/dashboard/command-bar.tsx CommandFrame + floating CommandBar (no sidebar)
apps/web/src/components/dashboard/sessions-table.tsx canonical Spectrum data-table usage
apps/web/src/components/dashboard/ui.tsx StatusPill (canonical) + LEGACY primitives
apps/web/src/components/spectrumui/data-table.tsx the shared list-view table engine
apps/web/src/components/dashboard/filters.tsx LEGACY FilterForm/Field/Input/Select/Actions + Pager
apps/web/src/components/dashboard/action-form.tsx ActionForm + useActionFormId
apps/web/src/components/dashboard/csrf-field.tsx CsrfField
apps/web/src/components/dashboard/confirm-submit.tsx ConfirmSubmit (AlertDialog ↔ form)
apps/web/src/components/dashboard/app-sidebar.tsx LEGACY Sidebar rail (only updaterReadState is live)
apps/web/src/components/dashboard/time.tsx Time (client, UTC fallback → viewer TZ)
apps/web/src/components/dashboard/support.ts PAGE_SIZE, queryWithPage, relativeTime, …
apps/web/src/components/dashboard/{markdown-panel,nav-user,site-header}.tsx
apps/web/src/components/ui/* shadcn primitives (alert-dialog, button, table, …)
apps/web/src/app/layout.tsx Geist / Geist Mono localFont wiring (--font-geist*)
apps/web/src/app/fonts/ self-hosted Geist + Geist Mono woff2
apps/web/src/app/globals.css Tailwind v4 theme + design tokens
apps/web/src/lib/nav.ts NAV / NAV_GROUPS / navEntryForPath / CHROME_FREE_PATHS
apps/web/src/lib/utils.ts cn()
apps/web/src/test/dashboard/*.test.tsx per-page tests
Pages are async server components. searchParams is a Promise in this
Next.js version — const params = await searchParams before reading. Data comes
from getServices() (@/lib/services) — const { repos, ... } = getServices().
Identity
The dashboard was redesigned to a dark monochrome zinc system with the Rembric
lime as the single accent. apps/web/src/app/globals.css is the live token
surface, mapped into Tailwind via @theme inline so utilities like bg-background,
bg-card, bg-popover, text-foreground, text-muted-foreground, border-border,
bg-primary, text-warn, text-destructive resolve to the tokens.
- Surfaces (dark monochrome zinc). Background
#09090b; cards #101012;
raised / popover / muted / secondary #18181b; accent #1c1c1f; borders
#1f1f23 (1px); input #27272a; ring #3f3f46. Text ladder: #fafafa
(--foreground) → #a1a1aa → #71717a (--muted-foreground).
- Lime is the ONLY accent.
--primary: #c6f24e, --primary-foreground: #09090b. Primary buttons are lime fill + near-black text. On light surfaces
lime is chrome only (fills, borders, dots, bars) — never lime text on a
light background. Amber --warn: #ff8c00 and --destructive stay for warn /
danger tones; charts use the zinc ladder with lime as --chart-1.
- Fonts. UI and display output use Geist (
--font-geist, wired to
--font-sans / --font-display). Geist Mono (--font-geist-mono,
--font-mono) owns labels, meta, counters and tabular data. Both are
self-hosted woff2 in apps/web/src/app/fonts/ and loaded in
apps/web/src/app/layout.tsx. No CDN fonts, no second display face.
- Radius & depth. Radius
--radius: 1rem: cards rounded-2xl (16), inputs
rounded-xl (12), buttons rounded-lg (10). Soft shadows only
(shadow-lg shadow-black/20–shadow-black/40). Depth comes from radial lime
glows — e.g. bg-[radial-gradient(640px_260px_at_50%_-60px,rgba(198,242,78,0.09),transparent_70%)]
— instead of heavy borders.
StatusPill (ui.tsx) is the shared app-wide status pill: a
rounded-full container with a dot plus a tinted background/border per tone
(TONE_PILL), driven by the STATUS_TONE map (active/judged → lime,
abandoned/superseded → amber, archived/ended/pending → dim, orphaned/deleted →
danger). Use it for status, never a hand-rolled badge.
- List views: Spectrum UI data-table. Every list/table page goes through
DataTable from @/components/spectrumui/data-table (variant "panel"):
quick-filter pills with live counts, client search, selectable rows with a
rising bulk-action bar, contextual ⋯ row menu, and a detail disclosure.
apps/web/src/components/dashboard/sessions-table.tsx is the canonical
implementation to copy from — see also judgments-table.tsx,
prompts-table.tsx, memories-table.tsx, entities-table.tsx,
tokens-table.tsx.
- Charts.
activity-chart.tsx is the reference for lime/zinc bar visuals:
lime for saves, amber for superseded/archived ops, rounded caps, hover tooltips
on bg-popover with soft shadow-black/40.
- Shell: floating command bar, no sidebar.
command-bar.tsx renders a
floating centered header (rounded-2xl border bg-card/90 backdrop-blur) with
primary nav, a "More" dropdown, counters and a mobile Sheet. There is no
sidebar rail. /dashboard/login and /dashboard/oauth-consent are
chrome-free standalone screens — CHROME_FREE_PATHS in
apps/web/src/lib/nav.ts plus isChromeFreePath() short-circuit the frame.
Pending OpenSpec change
The formal OpenSpec dashboard spec update for this identity is pending
(branch feat/dashboard-identity-redesign). Until it is written and merged, the
LIVE reference is the branch code — sessions-table.tsx,
command-bar.tsx, activity-chart.tsx, ui.tsx and globals.css — not the
old spec text or DESIGN.md, whose prose still describes the previous brutalist
identity. Do not touch openspec/specs/dashboard/spec.md or archived change
design.md files from UI work; that is a separate task.
Legacy primitives (do NOT use for new work)
The old brutalist primitives are still exported from ui.tsx and remain in
unrestyled pages (consolidation, maintenance, and several detail pages) pending
migration. Marked LEGACY; do not document or copy them as the standard:
ViewHead, SectionBar, StatCard/StatGrid, Kv/KvGrid,
Panel/PanelHead, DataTable/DataHead/DataTh/DataBody/DataTr/DataTd,
TableEmpty, Pill/ReviewPill/Chip/Tag, Bar, Notice, Flash. The
hard-bordered Pill and the ViewHead lime-block title belong to the old
identity. filters.tsx (FilterForm/Pager) and the app-sidebar.tsx rail are
legacy too — list pages now use the Spectrum data-table's own toolbar and
pagination. When you restyle a legacy page, replace these with the new identity,
not with another legacy primitive.
Hard rules
- New list views use the Spectrum
DataTable. Import
DataTable/DataTableColumn from @/components/spectrumui/data-table, pass
variant="panel", and mirror sessions-table.tsx: searchable,
quickFilter, selectable + bulkActions, rowActions (the ⋯ menu),
renderDetail, pageSize. Do not hand-roll a table and do not reach for the
legacy DataTable from ui.tsx.
- Use
StatusPill for status. One pill, tone-driven, app-wide. Don't invent
a second status badge.
- Every mutation is a server action through
ActionForm. Import
ActionForm/ActionState from @/components/dashboard/action-form, put a
<CsrfField form={FORM_NAME} /> in it, and start the action by calling
guardAction(formData, FORM_NAME)/guardFailure(...) from @/lib/actions/guard
(session + admin + CSRF gate). The action returns { error: string | null }.
- Destructive actions MUST use
ConfirmSubmit. It wires a Radix AlertDialog
to the enclosing ActionForm via useActionFormId, so it only works inside
an ActionForm, wrapping the trigger button (<Button type="button">). Pass
tone="danger" for irreversible actions, "warn" for reversible ones. Mirror
the call sites in sessions-table.tsx (row ⋯ menu + bulk bar) and under
apps/web/src/app/dashboard/.
- Timestamps go through
<Time>. It emits <time dateTime data-rembric-ts>
with a UTC fallback and re-renders in the viewer's timezone after mount. Never
hand-write toISOString / toLocaleString in views.
- Nav lives in
apps/web/src/lib/nav.ts. Add entries to the NAV array
(group, num, label, href, lucide icon, optional badgeKey); the command
bar and its mobile sheet both read it. Chrome-free screens are listed in
CHROME_FREE_PATHS in the same file.
export const dynamic = 'force-dynamic' on dashboard routes/layouts — the
dashboard reads per-request session + DB state.
- No new CSS files and no inline
<style>. Style with Tailwind utilities
bound to the tokens in globals.css.
PAGE_SIZE (from support.ts) stays the single page size for paginated
data queries — import it, never hard-code a second one. The Spectrum table's
pageSize prop controls rows per page in the client.
Recipes
- Add a new page — create
apps/web/src/app/dashboard/<name>/page.tsx (an
async server component with export const dynamic = 'force-dynamic'), compose
it from the new identity (Spectrum DataTable for lists, StatusPill for
status), add the nav entry in apps/web/src/lib/nav.ts, and add a co-located
test in apps/web/src/test/dashboard/.
- Filterable listing — a
<DataTable> with searchable, quickFilter
(pills keyed on the status column), selectable + bulkActions, rowActions
for the ⋯ menu, and pageSize. Copy sessions-table.tsx verbatim, then swap
the columns.
- Destructive action — server action (
guardAction + verifyCsrf) inside an
<ActionForm>, <CsrfField>, and a <ConfirmSubmit tone="warn|danger" title=… description=… confirmLabel=…> wrapping a type="button" trigger.
- Row navigation — the
⋯ menu carries "View details" as a next/link; there
is no data-href/ROW_LINK script anymore.
- Chrome-free screen — if a route must render without the command bar, add it
to
CHROME_FREE_PATHS in lib/nav.ts; CommandFrame short-circuits on
isChromeFreePath().
Quick reference: helper signatures
// spectrumui/data-table.tsx (canonical for list views)
DataTable<T>({
data, columns, rowId, rowLabel,
variant?: 'default'|'bordered'|'striped'|'minimal'|'panel', density,
searchable?, searchPlaceholder?, searchText?,
quickFilter?: { columnId, label?, getValue?, options?, allLabel? },
selectable?, bulkActions?: (ctx) => ReactNode,
renderDetail?: (row) => ReactNode, rowActions?: (row, actions) => ReactNode,
onDelete?, pageSize?, emptyState?, totals?, stickyHeader?, …
})
DataTableColumn<T>({ id, header, cell?, value?, sortable?, numeric?, hideBelow?, … })
// ui.tsx — LIVE
StatusPill({ status }) Time({ value, className? }) LABEL
// ui.tsx — LEGACY (unrestyled pages only), see Legacy primitives above
ViewHead({ num?, title, hl?, meta?, titleVisible? }) BackLink({ href, label })
SectionBar({ name, meta?, more? }) StatCard / StatGrid Kv / KvGrid
Panel / PanelHead DataTable / DataHead / DataTh / DataBody / DataTr / DataTd
TableEmpty Pill / ReviewPill / Chip / Tag / Bar / Notice / Flash
// filters.tsx — LEGACY
FilterForm({ action, children, className? }) FilterField / FilterInput / FilterSelect
FilterActions({ clearHref }) Pager({ page, hasMore, total, totalLabel, path, query })
// action-form.tsx
ActionForm({ action, children, className? }) // action: FormAction
useActionFormId() ActionState = { error: string | null }
Files you will most likely touch
apps/web/src/app/dashboard/<view>/page.tsx server component route
apps/web/src/app/dashboard/layout.tsx CommandFrame wrapper
apps/web/src/components/dashboard/<view>-table.tsx Spectrum data-table (copy sessions-table.tsx)
apps/web/src/components/dashboard/command-bar.tsx shared shell / nav (floating, no sidebar)
apps/web/src/components/dashboard/ui.tsx StatusPill (live) + legacy primitives
apps/web/src/components/dashboard/confirm-submit.tsx / action-form.tsx / csrf-field.tsx
apps/web/src/lib/nav.ts NAV array + CHROME_FREE_PATHS
apps/web/src/app/globals.css design tokens (Tailwind v4 theme)
apps/web/src/app/layout.tsx Geist / Geist Mono localFont wiring
apps/web/src/test/dashboard/<view>.test.tsx co-located page test
Build, test, validate
After any change:
pnpm run typecheck # tsc --noEmit, must be clean
pnpm run lint # ESLint, must be clean
pnpm test # turbo test across workspaces, must stay green
pnpm run build # next build
Dashboard component tests are Vitest + Testing Library under
apps/web/src/test/dashboard/*.test.tsx; run one file with
pnpm --filter @rembric/web exec vitest run src/test/dashboard/<name>.test.tsx.
For visual verification, bring up the host dev server (see docs/docker.md →
"Local dev (host)"):
REMBRIC_DATA_DIR=./data-dev REMBRIC_ADMIN_TOKEN=<16+-char-token> pnpm run dev
# → http://127.0.0.1:3000/dashboard
next dev hot-reloads, so there is no build-and-restart step. Point a fresh
REMBRIC_DATA_DIR at a scratch directory; never aim a dev server at a real
deployment's database.
When to ask vs proceed
Proceed without asking when the change is mechanical and matches an existing
pattern (e.g. new column on a data-table, new StatusPill status mapping, new
destructive row action, new responsive tweak).
Ask the user first when:
- The change introduces a new colour, font face, or radius (a token change). The
new identity is not yet locked by an OpenSpec spec — the formal
dashboard
spec update is pending on feat/dashboard-identity-redesign — so token edits
need an agreed OpenSpec change rather than a drive-by.
- The change adds a new top-level nav item (affects the
NAV array / command-bar
order and the mobile sheet).
- The change touches behaviour: pagination model, modal flow, mobile sheet
behaviour.
- The change would add a new runtime dependency or client-side framework.
- The user's request is ambiguous about destructive vs. reversible tone (
warn vs
danger is a semantic decision).
Validation checklist before "done"
pnpm run typecheck → clean
pnpm run lint → clean
pnpm test → all green
pnpm run build → succeeds
- Visual smoke at the affected page in the host dev server, at desktop +
narrow viewports
- If a destructive action was added: the dialog opens with the correct tone,
Cancel doesn't submit, Confirm does