- Add DataTable.svelte: declarative columns, built-in sorting, sticky headers, text truncation, column alignment, configurable widths, optional pagination/search - 12 built-in renderers: BadgeRenderer, StatusBadgeRenderer (unified risk/severity/ execution/state/type variant mapping), HealthDotRenderer, RelativeTimeRenderer, DateRenderer, DurationRenderer, StatusDotRenderer, SignalActions, ApprovalActions, ActivityAction, ActivityCancel - Migrate Overview (task board), Signals, Ops (3 tables) to DataTable - Refactor EntityTable treegrid to use shared SortHeader, EmptyState, HealthDotRenderer - Create shared components: EmptyState, StatusBadge, FilterTabs - Clean up Knowledge.svelte: replace inline relTime() and typeVariant() with shared utils - Add width, align, truncate column props; table-fixed layout; rounded-xl borders - Bump version to 0.11.0
18 KiB
Table & Component Standardization Plan
0. Motivation
The app currently has 5 table implementations, each hand-writing <Table.Root> boilerplate
from scratch. The shadcn-svelte Table.* primitives (web/src/lib/components/ui/table/) are
purely presentational wrappers — no sorting, filtering, pagination, row selection, or search.
Every page reinvents sort arrows, empty states, loading skeletons, badge color maps, formatting
utilities, and tab patterns independently.
Goal: One DataTable abstraction that declaratively renders every table in the app,
built on @vincjo/datatables (headless data-handling) with shadcn-svelte visuals and custom
column/renderer composability.
Also: Use this migration as leverage to standardize the component surface — extract repeated patterns into shared primitives so the codebase contracts rather than accumulating yet another abstraction.
1. Audit Summary
1.1 Tables in the App
| # | Page / Component | File | LOC | Features (what it has) | Gaps (what it's missing) |
|---|---|---|---|---|---|
| 1 | EntityTable.svelte |
web/src/lib/components/ |
265 | Sort (5 cols), treegrid grouping, collapsible nesting, row selection, keyboard nav, loading skeleton, health dots | Pagination, search, column toggle, checkbox select |
| 2 | Overview.svelte |
web/src/pages/ |
125 | Filter pills (all/running/input/done/failed), sticky header, responsive cols, animated status dots | Plain <table> (no shadcn), no sort, no pagination |
| 3 | Ops.svelte — 3 tables |
web/src/pages/ |
240 | Inline approve/deny actions, risk/status badges, cancel button, duration formatting (fmtDuration), relative time (fmtWhen) |
No sort, no pagination, no search |
| 4 | Signals.svelte |
web/src/pages/ |
171 | Tab filter (open/muted/resolved), severity dropdown, inline Ack/Mute/Resolve actions, badge colors | No sort, no pagination |
| 5 | Markdown tables | ChatThread.svelte, EntityDetailContent.svelte |
CSS-only | Prose-styled <table> for AI output |
No interactive features (by design) |
1.2 Repeated Patterns (duplicated per-page)
| Pattern | Occurrences | Where |
|---|---|---|
| Sort header with arrow icons | 1 (closed set in EntityTable) |
Only EntityTable has sort; Ops/Signals/Overview don't bother |
riskVariant() / severityVariant() / stateVariant() / execStatusVariant() |
6 | Ops.svelte ×2, Signals.svelte ×1, EntityTable.svelte ×2, Knowledge.svelte ×1 |
fmtWhen() / relTime() inline relative-time formatting |
3 | Ops.svelte, Knowledge.svelte (both inline; utils.ts has relativeTime already) |
<Table.Root> > <Table.Header> > <Table.Row> > <Table.Head> boilerplate |
6 | Every table page |
Empty state <Table.Cell colspan={N}>No ...</Table.Cell> |
6 | Every table page |
<Tabs.Root> > <Tabs.List> > <Tabs.Trigger> with badge counts |
2 | Ops.svelte, Signals.svelte |
| Loading skeleton | 2 | EntityTable.svelte (custom widths), EntityDetailContent.svelte |
1.3 Current Tech Stack
| Layer | What | Version |
|---|---|---|
| Framework | Svelte 5 (runes mode) | ^5.0.0 |
| UI primitives | shadcn-svelte (local copies in ui/) |
— |
| Headless backing | bits-ui | ^2.18.1 |
| CSS | Tailwind v4 (CSS-first config, no PostCSS) | ^4.3.2 |
| Variant system | tailwind-variants | ^3.2.2 |
| Icons | @lucide/svelte | ^1.23.0 |
| Table library | none | — |
2. @vincjo/datatables — Why This Library
Headless. It provides a TableHandler class that handles client-side pagination,
sorting, searching, filtering, column visibility, and row selection — all as runes.
Rendering is entirely up to us. This pairs perfectly with shadcn-svelte visual styling.
API surface (what we care about):
new TableHandler(data)— instantiate with reactive datatable.rows— rune that reflects current page/filter/sort (auto-tracked by Svelte 5)table.rowCount,table.pageCount,table.currentPage,table.pages,table.pagesWithEllipsistable.setRows(data),table.setRowsPerPage(n),table.setPage('next'|'previous'|int)table.createSort(),table.createSearch(),table.createFilter(),table.createView()table.select(id),table.selectAll(),table.selected,table.isAllSelectedtable.createCSV(),table.createCalculation(),table.createRecordFilter()
No dependencies. Lightweight. TypeScript-native. SSR friendly (even though we're SPA).
What it does NOT do (and that's fine)
- No rendering. We build the UI ourselves — use shadcn-svelte primitives.
- No server-side pagination — if we need that later, the library has a separate server-side API.
- No column ordering — we don't need drag-and-drop reorder; we use
createView()for visible/hidden.
3. Architecture Plan
3.1 New Core Component: DataTable.svelte
web/src/lib/components/data-table/
├── DataTable.svelte # The main table component
├── DataTable.svelte.ts # TypeScript type definitions
├── columns.ts # Column definition helpers
├── renderers/ # Built-in cell renderers
│ ├── BadgeRenderer.svelte
│ ├── HealthDotRenderer.svelte
│ ├── RelativeTimeRenderer.svelte
│ └── DateRenderer.svelte
├── pagination/ # Pagination UI
│ ├── Pagination.svelte
│ ├── PageButton.svelte
│ └── RowsPerPage.svelte
├── sort-header.svelte # Sortable column header with arrow icons
├── search-input.svelte # Text search input
└── toolbar.svelte # Top toolbar (search + filter + page size)
3.2 DataTable API (declarative, Svelte 5 runes)
<script lang="ts">
import DataTable from '$lib/components/data-table/DataTable.svelte'
import type { DataTableColumn } from '$lib/components/data-table/DataTable.svelte'
let data = $state<MyRow[]>([])
let selected = $state<Set<string>>(new Set())
const columns: DataTableColumn<MyRow>[] = [
{ key: 'slug', header: 'Slug', sortable: true, class: 'font-mono text-xs' },
{ key: 'type', header: 'Type', sortable: true, render: 'badge' },
{ key: 'health', header: 'Health', sortable: true, render: 'health-dot', accessor: (r) => r },
{ key: 'actions', header: '', sortable: false, render: (row) => component /* snippet or component */ },
]
</script>
<DataTable
{columns}
{data}
bind:selected
pageSize={20}
searchable
paginated
sortKey="slug"
sortDir="asc"
loading
emptyMessage="No items."
>
<!-- optional slot for toolbar actions -->
</DataTable>
3.3 Column System
A DataTableColumn<T> is:
type ColumnRenderer<T> =
| 'badge' // wraps value in <Badge variant="outline">
| 'health-dot' // colored dot + relative time
| 'relative-time' // relativeTime(val)
| 'date' // new Date(val).toLocaleString()
| Component // any Svelte component, receives { row, value }
| ((row: T) => any) // raw value formatter
| undefined // raw value
Built-in renderers cover badge colors, health dots, timestamps — eliminating the 6
inline riskVariant()/severityVariant()/stateVariant() copies. Custom components
cover action buttons and complex cells.
3.4 What ships with the table
| Feature | How | Default |
|---|---|---|
| Sorting | Click column header → createSort() |
Yes, if sortable: true |
| Pagination | table.pages + Pagination component |
Optional (paginated prop) |
| Text search | search-input.svelte → createSearch() |
Optional (searchable prop) |
| Column visibility | createView() → dropdown toggle |
Not in v1 (add later) |
| Row selection | Checkbox column → table.select() |
Optional (bind:selected) |
| Loading state | Skeleton rows via loading prop |
Yes |
| Empty state | Configurable emptyMessage |
Yes |
| Tree/grouping | childToParent prop → recursive rows |
EntityTable-only feature |
| CSV export | table.createCSV() → download button |
Not in v1 (add later) |
| Server-side pagination | handlePageChange callback |
Not needed yet |
4. Standardized Shared Components
Extract the repeated patterns discovered in the audit into shared components:
4.1 StatusBadge.svelte
Replaces: 6 copies of riskVariant(), severityVariant(), stateVariant(), execStatusVariant()
<script lang="ts">
let { value, kind = 'state' }: { value: string; kind?: 'risk' | 'severity' | 'state' | 'execution' } = $props()
// Resolves variant mapping from kind + value
</script>
4.2 EmptyState.svelte
Replaces: 6 <Table.Cell colspan={N}>No ...</Table.Cell> blocks
<script lang="ts">
let { message = 'No items.', colspan = 999, icon = null } = $props()
</script>
4.3 RelativeTime.svelte
Replaces: Oks.svelte:58 (fmtWhen), Knowledge.svelte:49 (relTime)
Consolidates: Already exists as relativeTime() in utils.ts — wrap in a component that auto-updates.
4.4 FilterTabs.svelte
Replaces: Ops.svelte:114-120 and Signals.svelte:153-159 (Tabs.Root boilerplate with badge counts)
<script lang="ts">
let { tabs, value = $bindable(''), class, children }: {
tabs: { value: string; label: string; count?: number }[];
value?: string;
class?: string;
children?: any;
} = $props()
</script>
4.5 PageHeader.svelte
Replaces: Every page's <h1 class="text-lg font-semibold">...</h1> + optional actions row.
5. Migration Sequence (ordered for incremental delivery)
Phase 1 — Library & Foundation (~1 PR)
-
Install
@vincjo/datatablesnpm install -D @vincjo/datatables -
Build
DataTable.svelte+DataTable.svelte.ts+columns.ts- Core loop:
{#each table.rows as row}+ column render dispatch - Pagination sub-components:
Pagination.svelte,PageButton.svelte,RowsPerPage.svelte SortHeader.svelte— click to sort, arrow icons (extract fromEntityTable:163-178)SearchInput.svelte— debounced text search
- Core loop:
-
Build renderers:
BadgeRenderer.svelte,HealthDotRenderer.svelte,RelativeTimeRenderer.svelte,DateRenderer.svelte -
Build
EmptyState.svelte -
Unit tests for
DataTablecolumn dispatch, sort, pagination, selection.
Phase 2 — Simple Tables (no tree, no actions) (~1 PR)
-
Migrate
Overview.svelte(task board)- Plain
<table>→DataTablewithStatusBadge,RelativeTime, filter pills external - Drop sticky-header CSS (
DataTablehandles it) - Verify: filter pills, status dots, responsive summary column, click-to-open
- Plain
-
Migrate
Signals.svelte- Replace
signalTablesnippet →DataTablewith action-column renderer - Extract
FilterTabs.sveltefrom the Tabs boilerplate - Verify: severity dropdown, tab counts, Ack/Mute/Resolve buttons
- Replace
Phase 3 — Action Tables (~1 PR)
-
Migrate
Ops.svelte— Pending Approvals- Approve/Deny buttons as action column renderer
- Risk badge via
StatusBadge kind="risk"
-
Migrate
Ops.svelte— Decided Approvals- Same columns, no actions
-
Migrate
Ops.svelte— Activity- Cancel button, summary + error inline, duration via
RendererComponent - Extract
FilterTabsfor Approvals vs Activity tabs
- Cancel button, summary + error inline, duration via
Phase 4 — Tree Table (~1 PR)
- Migrate
EntityTable.svelte- Treegrid grouping is the hard part. Build a
TreeTablevariant or agroupedprop. childToParentprop stays → recursive rendering whileDataTablehandles sort + selection.- Alternative: Ship
treegridas a separateTreeDataTable.sveltecomponent if the recursive pattern is too divergent to fit intoDataTable.
- Treegrid grouping is the hard part. Build a
Phase 5 — Cleanup & Standardization (~1 PR)
-
Extract shared components everywhere:
- Audit every
.sveltefile for inlineriskVariant()/severityVariant()/fmtWhen()— replace withStatusBadge,RelativeTime - Audit for inline
<Tabs.Root>boilerplate — replace withFilterTabs - Audit for
<Badge variant={...}>with inline logic — consolidate
- Audit every
-
Remove deprecated shadcn-svelte table primitives after confirming nothing else imports them.
-
Delete duplicate utility functions (
fmtWhenin Ops,relTimein Knowledge, etc.)
Phase 6 — Polish (~1 PR)
- Column visibility toggle (optional)
- CSV export for entity tables (optional)
- Responsive tables — horizontal scroll with frozen left column for mobile
6. Risk Assessment
| Risk | Mitigation |
|---|---|
@vincjo/datatables doesn't support treegrid grouping |
EntityTable's recursive rendering stays independent; DataTable wraps flat tables only |
Svelte 5 runes + TableHandler reactivity mismatch |
TableHandler.rows is a rune. Wrap in $derived or $effect to feed data prop → table.setRows() |
| Over-engineering a simple table (3-row decided approvals shouldn't need pagination) | DataTable accepts paginated prop — default off. Small tables stay simple. |
| Treegrid migration breaks KB browser | Phase 4 is isolated. Phases 1–3 deliver value before touching the critical KB table. |
7. Success Criteria
- Every
<Table.Root>in the app routes throughDataTable.svelte - 0 copies of inline
riskVariant()/severityVariant()/stateVariant()— all throughStatusBadge - 0 copies of inline
fmtWhen()/relTime()— all throughRelativeTimeorutils.relativeTime - 0 copies of manual
<Table.Cell colspan={N}>No ...</Table.Cell>— all throughEmptyState web/src/lib/components/ui/table/retained forDataTableinternals only (or removed if unused)- TypeScript compiles with
--noEmitand tests pass (vitest run) - All existing features preserved: sort, tree expand/collapse, tab filters, severity dropdown, approve/deny/cancel/ack/resolve buttons, sticky headers, loading skeletons, health dots, empty states
8. File Manifest (what gets created / modified / deleted)
Created
plan/tables.md ← this file
web/src/lib/components/data-table/DataTable.svelte
web/src/lib/components/data-table/DataTable.svelte.ts
web/src/lib/components/data-table/columns.ts
web/src/lib/components/data-table/columns.test.ts
web/src/lib/components/data-table/renderers/BadgeRenderer.svelte
web/src/lib/components/data-table/renderers/HealthDotRenderer.svelte
web/src/lib/components/data-table/renderers/RelativeTimeRenderer.svelte
web/src/lib/components/data-table/renderers/DateRenderer.svelte
web/src/lib/components/data-table/pagination/Pagination.svelte
web/src/lib/components/data-table/pagination/PageButton.svelte
web/src/lib/components/data-table/pagination/RowsPerPage.svelte
web/src/lib/components/data-table/sort-header.svelte
web/src/lib/components/data-table/search-input.svelte
web/src/lib/components/data-table/toolbar.svelte
web/src/lib/components/StatusBadge.svelte
web/src/lib/components/EmptyState.svelte
web/src/lib/components/RelativeTime.svelte
web/src/lib/components/FilterTabs.svelte
web/src/lib/components/PageHeader.svelte
Modified (in migration order)
web/package.json ← add @vincjo/datatables
web/src/pages/Overview.svelte ← Phase 2
web/src/pages/Signals.svelte ← Phase 2
web/src/pages/Ops.svelte ← Phase 3
web/src/lib/components/EntityTable.svelte ← Phase 4
web/src/pages/KnowledgeBase.svelte ← Phase 4 (consumer of EntityTable)
web/src/pages/Knowledge.svelte ← Phase 5 (remove relTime)
Potentially Removed (Phase 5)
web/src/lib/components/ui/table/* ← if DataTable is the sole consumer
(These stay if DataTable still uses them internally for rendering)
9. Implementation Status
Completed (2026-07-21)
| Phase | Task | Status |
|---|---|---|
| 1 | Install @vincjo/datatables |
Done |
| 1 | DataTable.svelte core component |
Done |
| 1 | Types (DataTable.svelte.ts, columns.ts) |
Done |
| 1 | Pagination (Pagination, PageButton, RowsPerPage) |
Done |
| 1 | Sort header, search input, toolbar | Done |
| 1 | Built-in renderers: BadgeRenderer, HealthDotRenderer, RelativeTimeRenderer, DateRenderer, RiskBadgeRenderer, ExecutionStatusRenderer, DurationRenderer, StatusDotRenderer |
Done |
| 1 | EmptyState.svelte shared component |
Done |
| 2 | Migrate Overview.svelte to DataTable |
Done |
| 2 | Migrate Signals.svelte to DataTable |
Done |
| 3 | Migrate Ops.svelte (3 tables) to DataTable |
Done |
| 4 | Refactor EntityTable.svelte to use shared {SortHeader, EmptyState, HealthDotRenderer} |
Done |
| 5 | Create StatusBadge.svelte (consolidates risk/severity/execution-type variant maps) |
Done |
| 5 | Create FilterTabs.svelte component |
Done |
| 5 | Clean up Knowledge.svelte: replace inline relTime() → relativeTime(), typeVariant() → StatusBadge |
Done |
Key Decisions Made During Implementation
- EntityTable treegrid NOT migrated to DataTable. The recursive tree rendering is too
divergent from flat, paginated data. Instead, EntityTable was refactored to use shared
SortHeader,EmptyState, andHealthDotRendererto eliminate inline duplication. renderPropsadded toDataTableColumnto pass extra props (callbacks, state) to custom cell renderer components (used bySignalActions,ApprovalActions,ActivityCancel).headerClassadded toDataTableColumnfor responsive column visibility onth+td.borderedprop onDataTablefor cases where parent wrappers provide the border.StatusBadgeuses akinddiscriminator (risk,severity,execution,type,default) instead of separate components per domain.FilterTabscreated but not yet wired into Ops/Signals — those pages still use inline<Tabs.Root>for the approvals/activity and open/muted/resolved tabs.
Remaining (Phase 6 — Future PR)
- Wire
FilterTabsinto Ops.svelte and Signals.svelte - Column visibility toggle
- CSV export
- Responsive table with frozen left column for mobile