Tables
Two layers: Table is semantic markup styled by tokens; DataTable adds sorting, search, pagination, and selection with TanStack Table, rendered through the same parts. The examples show how SaaS data is usually represented.
Table
static · row headers · numeric columns
| Plan | Price / month | Seats | Storage | Support |
|---|---|---|---|---|
| Starter | $0 | 3 | 1 GB | Community |
| Team | $249 | 50 | 100 GB | |
| Business | $749 | 250 | 1 TB | Priority |
Row headers name the row. The plan name is a th scope="row",
so a screen reader announces “Team, Seats, 50” rather than “50”. Numbers are right-aligned
with tabular figures.
Source src/lib/components/display/table/doc.ts · src/lib/components/display/table/Table.svelte · src/lib/components/display/table/SortableTh.svelte · src/lib/components/display/table/table.module.css
src/lib/components/display/table/doc.ts
/**
* Table — semantic tabular markup, styled by tokens.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* Table caption?, label?, density? ("comfortable" | "compact")
* THead, TBody, TFoot, Tr (selected?), Th (scope, numeric?, shrink?),
* Td (numeric?, shrink?), SortableTh (direction, onSort)
* …each forwards its element's attributes and a ref
*
* # Behaviour
*
* R1 The table sits in a named, focusable region that scrolls sideways when
* the table is wider than its container; the page never scrolls.
* R2 It is named by `caption` (shown) or `label` (announced only), so a
* reader jumping between tables knows which this is.
* R3 Every Th has a scope, "col" by default. A row header (scope="row")
* names its row and reads as body text, not as a column label.
* R4 `numeric` cells are right-aligned; every cell uses tabular figures, so
* columns of numbers compare by eye.
* R5 Rows darken on hover; a `selected` row is tinted with `--accent-tint`
* and reports aria-selected.
* R6 `compact` tightens cell padding and text size, for logs and dense data.
* R7 SortableTh reports its order in aria-sort and holds a button that sorts;
* the caller owns the order.
*
* # Use it for
*
* Static or server-rendered tables: plans, specs, a short list. For sorting,
* searching, paging, and selecting, use DataTable.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — NOT the oracle. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* Compositional rather than data-driven: a table that takes columns and rows
* needs a renderer prop per non-string cell and reinvents markup the platform
* already has. DataTable is the data-driven layer, built from these parts.
*/
export {};src/lib/components/display/table/Table.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLTableAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/cn';
import styles from './table.module.css';
import { tableVariants, type TableVariants } from './table.variants';
/* Compositional, not data-driven; DataTable is the data-driven layer. */
let {
caption,
label,
density,
class: className = '',
children,
...rest
}: Omit<HTMLTableAttributes, 'class'> &
TableVariants & {
/** Names the table for readers who reach it by jumping between tables. */
caption?: string;
/** The scroll region's name when there is no caption. */
label?: string;
class?: string;
children?: Snippet;
} = $props();
</script>
<!-- A named, focusable region, so a keyboard user can scroll a wide table
(WCAG 2.1.1: a scrollable region must be reachable by keyboard). -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class={styles.scroll} role="region" tabindex="0" aria-label={label ?? caption ?? 'Table'}>
<table {...rest} class={cn(tableVariants({ density }), className)}>
{#if caption}<caption class={styles.caption}>{caption}</caption>{/if}
{@render children?.()}
</table>
</div>src/lib/components/display/table/SortableTh.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
import { ArrowDown, ArrowUp, ArrowUpDown } from '$lib/components/utility/icon';
import styles from './table.module.css';
import Th from './Th.svelte';
export type SortDirection = 'ascending' | 'descending' | 'none';
/* A column header that sorts. aria-sort states the current order; the
button inside it is what a keyboard user activates. */
let {
direction,
onsort,
numeric,
shrink,
children
}: {
direction: SortDirection;
onsort: (event: MouseEvent) => void;
numeric?: boolean;
shrink?: boolean;
children?: Snippet;
} = $props();
</script>
<Th aria-sort={direction} {numeric} {shrink}>
<button type="button" class={styles.sort} onclick={onsort}>
{@render children?.()}
{#if direction === 'ascending'}
<ArrowUp aria-hidden="true" />
{:else if direction === 'descending'}
<ArrowDown aria-hidden="true" />
{:else}
<ArrowUpDown aria-hidden="true" />
{/if}
</button>
</Th>src/lib/components/display/table/table.module.css
@layer primitive {
/* Wide tables scroll inside their own region, never the page. */
.scroll {
/* The containing block for anything positioned inside, so visually hidden
text in a cell is clipped here instead of widening the page. */
position: relative;
min-width: 0;
overflow-x: auto;
border-radius: inherit;
}
.scroll:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.table {
--cell-y: var(--space-5);
--cell-x: var(--space-6);
width: 100%;
border-collapse: collapse;
color: var(--ink);
font-size: var(--text-body, var(--text-13));
font-variant-numeric: tabular-nums;
}
.compact {
--cell-y: var(--space-3);
--cell-x: var(--space-5);
font-size: var(--text-12);
}
/* The table's name, as a title bar aligned to the cell grid. */
.caption {
padding: var(--space-5) var(--cell-x);
border-bottom: 1px solid var(--line);
color: var(--ink);
font-size: var(--text-body, var(--text-13));
font-weight: var(--weight-strong);
text-align: start;
caption-side: top;
}
.head {
background: var(--surface-panel-2);
}
.th {
padding: var(--cell-y) var(--cell-x);
border-bottom: 1px solid var(--line-strong);
color: var(--ink-3);
font-size: var(--text-11);
font-weight: var(--weight-strong);
letter-spacing: var(--tracking-label);
text-align: start;
text-transform: uppercase;
white-space: nowrap;
}
.td {
padding: var(--cell-y) var(--cell-x);
border-bottom: 1px solid var(--line);
color: var(--ink);
vertical-align: middle;
}
.row:last-child > .td {
border-bottom: 0;
}
/* A row header (scope="row") reads as the row's name, not a column label. */
.row > .th {
border-bottom: 1px solid var(--line);
color: var(--ink);
font-size: inherit;
font-weight: var(--weight-medium);
letter-spacing: normal;
text-transform: none;
}
.row:last-child > .th {
border-bottom: 0;
}
.row:hover > :is(.td, .th) {
background: var(--surface-hover);
}
.row[data-selected] > :is(.td, .th) {
background: var(--accent-tint);
}
.foot > .row > :is(.td, .th) {
border-top: 1px solid var(--line-strong);
border-bottom: 0;
font-weight: var(--weight-strong);
}
/* Numbers align on their digits so a column can be compared by eye. */
.numeric {
text-align: end;
}
.shrink {
width: 1%;
white-space: nowrap;
}
.sort {
display: inline-flex;
align-items: center;
gap: var(--space-2);
margin: calc(var(--space-2) * -1);
padding: var(--space-2);
border: 0;
border-radius: var(--radius-1);
background: none;
color: inherit;
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
cursor: pointer;
}
.sort svg {
width: 12px;
height: 12px;
opacity: 0.6;
}
.sort:hover {
color: var(--ink);
}
.sort:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 0;
}
.th[aria-sort='ascending'],
.th[aria-sort='descending'] {
color: var(--ink);
}
.th[aria-sort='ascending'] svg,
.th[aria-sort='descending'] svg {
opacity: 1;
}
}DataTable
sort · search · paginate · select
| Beacon | Donald Knuth | active | 181 | 26 Sept 2026 | |
| Quartz | Frances Allen | active | 65 | 26 Sept 2026 | |
| Garnet | Barbara Liskov | active | 213 | 25 Sept 2026 | |
| Juniper | Frances Allen | archived | 226 | 24 Sept 2026 | |
| Kestrel | Frances Allen | active | 184 | 14 Sept 2026 | |
| Nimbus | Barbara Liskov | active | 127 | 10 Sept 2026 | |
| Pioneer | Grace Hopper | active | 121 | 10 Sept 2026 | |
| Orchid | Linus Torvalds | paused | 231 | 4 Sept 2026 |
Selected ids: none
Selection is by id, not position. Rows are selected by getRowId, so a selection survives sorting, searching, and paging. The header
checkbox selects the current page.
Source src/lib/components/display/data-table/doc.ts · src/lib/components/display/data-table/DataTable.svelte · src/lib/components/display/data-table/features.ts · src/lib/components/display/data-table/data-table.module.css
src/lib/components/display/data-table/doc.ts
/**
* DataTable — a table of records you can sort, search, page, and select.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* data, columns, getRowId, caption REQUIRED
* searchable?, searchPlaceholder?
* selectable?, onSelectionChange?(ids), bulkActions?(ids, clear)
* toolbar?, pageSize? (default 10; 0 shows every row), initialSorting?
* density?, hideCaption?, footer?
* loading?, error?, onRetry?, empty?
*
* Columns are built with createDataTableColumns<Row>(); `meta.numeric` and
* `meta.shrink` set cell alignment and width.
*
* # Behaviour
*
* R1 Sortable columns sort on their header button, ascending then
* descending; the order is announced through aria-sort.
* R2 Search filters every column except selection and actions, and resets to
* the first page. A search with no matches says "No results for …" with a
* button to clear it — distinct from an empty table.
* R3 Rows are identified by `getRowId`, never by position, so a selection
* survives sorting, searching, and paging. The header checkbox selects or
* clears the current page and shows a dash when the page is partly
* selected. While rows are selected, `bulkActions` replaces the search.
* R4 Pagination shows "first–last of total" and page buttons only when there
* is more than one page.
* R5 States, each different: `loading` shows skeleton rows in an aria-busy
* region; `error` shows an Alert with a retry; no rows shows `empty` (an
* Empty by default); a search with no matches is R2.
* R6 Everything in R1–R5 is built from Table, so it inherits Table's naming,
* scrolling, scope, and numeric rules.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — NOT the oracle. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* TanStack Table v9 supplies the state and row models; this component owns
* all markup. The feature set is fixed in features.ts (sorting, column and
* global filtering, pagination, selection) so every table has the same APIs.
* Selection toggles through row.toggleSelected because the checkbox is a
* button, not an input: Shift-click range selection is not wired yet.
*/
export {};src/lib/components/display/data-table/DataTable.svelte
<script lang="ts" generics="TData extends RowData">
import {
createTable,
FlexRender,
renderSnippet,
type Column,
type ColumnDef,
type RowData,
type SortingState
} from '@tanstack/svelte-table';
import type { Snippet } from 'svelte';
import { untrack } from 'svelte';
import { Empty } from '$lib/components/display/empty';
import { SortableTh, Table, TBody, Td, Th, THead, Tr } from '$lib/components/display/table';
import { Alert } from '$lib/components/feedback/alert';
import { Skeleton } from '$lib/components/feedback/skeleton';
import { Button } from '$lib/components/forms/button';
import { Checkbox } from '$lib/components/forms/checkbox';
import { Input } from '$lib/components/forms/input';
import { Pagination } from '$lib/components/navigation/pagination';
import { cn } from '$lib/utils/cn';
import styles from './data-table.module.css';
import { dataTableFeatures, type DataTableFeatures } from './features';
type Props = {
data: TData[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
columns: ColumnDef<DataTableFeatures, TData, any>[];
/** A stable id per row: selection survives sorting, paging, and refetches. */
getRowId: (row: TData) => string;
/** Names the table; shown above it unless hideCaption. */
caption: string;
hideCaption?: boolean;
density?: 'comfortable' | 'compact';
searchable?: boolean;
searchPlaceholder?: string;
selectable?: boolean;
onSelectionChange?: (ids: string[]) => void;
/** Shown in place of the search while rows are selected. */
bulkActions?: Snippet<[string[], () => void]>;
/** Controls at the end of the toolbar: filters, export, create. */
toolbar?: Snippet;
/** Rows per page; 0 shows every row. */
pageSize?: number;
initialSorting?: SortingState;
/** Rendered after the body, e.g. a TFoot of totals. */
footer?: Snippet;
loading?: boolean;
error?: string;
onRetry?: () => void;
/** Shown when there are no rows at all (not when a search matches none). */
empty?: Snippet;
class?: string;
};
let {
data,
columns,
getRowId,
caption,
hideCaption = false,
density,
searchable = false,
searchPlaceholder = 'Search',
selectable = false,
onSelectionChange,
bulkActions,
toolbar,
pageSize = 10,
initialSorting = [],
footer,
loading = false,
error,
onRetry,
empty,
class: className = ''
}: Props = $props();
const NOT_SEARCHABLE = new Set(['select', 'actions']);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const selectColumn: ColumnDef<DataTableFeatures, TData, any> = {
id: 'select',
enableSorting: false,
meta: { shrink: true },
header: ({ table }) => renderSnippet(selectAll, table),
cell: ({ row }) => renderSnippet(selectRow, row)
};
const allColumns = $derived(selectable ? [selectColumn, ...columns] : columns);
const table = createTable({
features: dataTableFeatures,
get columns() {
return allColumns;
},
get data() {
return data;
},
getRowId: (row) => getRowId(row),
initialState: untrack(() => ({
sorting: initialSorting,
pagination: { pageIndex: 0, pageSize: pageSize || data.length || 1 }
})),
enableSortingRemoval: false,
globalFilterFn: 'includesString',
getColumnCanGlobalFilter: (column) =>
!NOT_SEARCHABLE.has(column.id) &&
(column.columnDef as { enableGlobalFilter?: boolean }).enableGlobalFilter !== false
});
const rowSelection = $derived(table.atoms.rowSelection.get());
const selectedIds = $derived(Object.keys(rowSelection).filter((id) => rowSelection[id]));
const selectionKey = $derived(selectedIds.join('|'));
$effect(() => {
// Report changes to the selection, not to the callback's identity.
const key = selectionKey;
untrack(() => onSelectionChange?.(key ? key.split('|') : []));
});
const query = $derived(String(table.atoms.globalFilter.get() ?? ''));
const pagination = $derived(table.atoms.pagination.get());
const filtered = $derived(table.getFilteredRowModel().rows.length);
const rows = $derived(table.getRowModel().rows);
const first = $derived(filtered === 0 ? 0 : pagination.pageIndex * pagination.pageSize + 1);
const last = $derived(Math.min(filtered, (pagination.pageIndex + 1) * pagination.pageSize));
const clearSelection = () => table.resetRowSelection(true);
function direction(column: Column<DataTableFeatures, RowData>) {
const sorted = column.getIsSorted();
return sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : 'none';
}
</script>
{#snippet selectAll(t: typeof table)}
<Checkbox
aria-label="Select all rows on this page"
checked={t.getIsAllPageRowsSelected()}
indeterminate={!t.getIsAllPageRowsSelected() && t.getIsSomePageRowsSelected()}
onCheckedChange={(value) => t.toggleAllPageRowsSelected(value)}
/>
{/snippet}
{#snippet selectRow(row: ReturnType<typeof table.getRowModel>['rows'][number])}
<Checkbox
aria-label={`Select row ${row.id}`}
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onCheckedChange={(value) => row.toggleSelected(value)}
/>
{/snippet}
<div class={cn(styles.root, className)}>
{#if searchable || toolbar || (selectable && bulkActions)}
<div class={styles.toolbar}>
{#if selectedIds.length && bulkActions}
<div class={styles.bulk} role="status">
{selectedIds.length} selected
{@render bulkActions(selectedIds, clearSelection)}
<Button size="sm" variant="quiet" onclick={clearSelection}>Clear selection</Button>
</div>
{:else if searchable}
<Input
type="search"
size="sm"
class={styles.search}
aria-label={`Search ${caption}`}
placeholder={searchPlaceholder}
value={query}
oninput={(event) => table.setGlobalFilter(event.currentTarget.value)}
/>
{:else}
<span></span>
{/if}
{#if toolbar}<div class={styles.tools}>{@render toolbar()}</div>{/if}
</div>
{/if}
<div class={styles.frame} aria-busy={loading || undefined}>
<Table
{density}
label={caption}
caption={hideCaption ? undefined : caption}
aria-label={hideCaption ? caption : undefined}
>
<THead>
{#each table.getHeaderGroups() as group (group.id)}
<Tr>
{#each group.headers as header (header.id)}
{@const meta = header.column.columnDef.meta}
{#if header.column.getCanSort()}
<SortableTh
numeric={meta?.numeric}
shrink={meta?.shrink}
direction={direction(header.column as Column<DataTableFeatures, RowData>)}
onsort={(event) => header.column.getToggleSortingHandler()?.(event)}
>
{#if !header.isPlaceholder}<FlexRender {header} />{/if}
</SortableTh>
{:else}
<Th numeric={meta?.numeric} shrink={meta?.shrink}>
{#if !header.isPlaceholder}<FlexRender {header} />{/if}
</Th>
{/if}
{/each}
</Tr>
{/each}
</THead>
<TBody>
{#if error}
<Tr>
<Td colspan={allColumns.length} class={styles.state}>
<Alert tone="crit" title="This table could not load" live="polite">
{error}
{#snippet action()}
{#if onRetry}<Button size="sm" onclick={onRetry}>Try again</Button>{/if}
{/snippet}
</Alert>
</Td>
</Tr>
{:else if loading}
{#each { length: Math.min(pagination.pageSize, 5) }, index (index)}
<Tr aria-hidden="true">
{#each allColumns as column, cell (column.id ?? cell)}
<Td><Skeleton width={cell === 0 ? '60%' : '80%'} /></Td>
{/each}
</Tr>
{/each}
{:else if data.length === 0}
<Tr>
<Td colspan={allColumns.length}>
{#if empty}{@render empty()}{:else}<Empty title="Nothing here yet" />{/if}
</Td>
</Tr>
{:else if filtered === 0}
<Tr>
<Td colspan={allColumns.length}>
<div class={styles.noResults} role="status">
No results for “{query}”.
<Button size="sm" variant="quiet" onclick={() => table.setGlobalFilter('')}
>Clear search</Button
>
</div>
</Td>
</Tr>
{:else}
{#each rows as row (row.id)}
<Tr selected={row.getIsSelected()}>
{#each row.getAllCells() as cell (cell.id)}
{@const meta = cell.column.columnDef.meta}
<Td numeric={meta?.numeric} shrink={meta?.shrink}><FlexRender {cell} /></Td>
{/each}
</Tr>
{/each}
{/if}
</TBody>
{#if footer && !loading && !error}{@render footer()}{/if}
</Table>
</div>
{#if pageSize > 0 && !loading && !error && filtered > pagination.pageSize}
<div class={styles.footer}>
<span>{first}–{last} of {filtered}</span>
<Pagination
page={pagination.pageIndex + 1}
totalPages={table.getPageCount()}
onPageChange={(page) => table.setPageIndex(page - 1)}
label={`${caption} pages`}
/>
</div>
{/if}
</div>src/lib/components/display/data-table/features.ts
import {
columnFilteringFeature,
createColumnHelper,
createFilteredRowModel,
createPaginatedRowModel,
createSortedRowModel,
filterFn_includesString,
globalFilteringFeature,
rowPaginationFeature,
rowSelectionFeature,
rowSortingFeature,
type RowData,
sortFn_alphanumeric,
sortFn_basic,
sortFn_datetime,
sortFn_text,
tableFeatures
} from '@tanstack/svelte-table';
/** Per-column presentation, read by DataTable when it renders cells. */
export type DataTableColumnMeta = {
/** Right-aligned with tabular figures: amounts, counts, percentages. */
numeric?: boolean;
/** As narrow as its content: status, actions. */
shrink?: boolean;
};
/** The one feature set every DataTable registers. Features and row models
* are explicit in TanStack v9; a missing one means a missing API. */
export const dataTableFeatures = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns: {
alphanumeric: sortFn_alphanumeric,
basic: sortFn_basic,
datetime: sortFn_datetime,
text: sortFn_text
},
columnFilteringFeature,
globalFilteringFeature,
filteredRowModel: createFilteredRowModel(),
filterFns: { includesString: filterFn_includesString },
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel(),
rowSelectionFeature,
columnMeta: {} as DataTableColumnMeta
});
export type DataTableFeatures = typeof dataTableFeatures;
/** A column helper typed against DataTable's features and meta. */
export const createDataTableColumns = <TData extends RowData>() =>
createColumnHelper<DataTableFeatures, TData>();src/lib/components/display/data-table/data-table.module.css
@layer composition {
.root {
display: grid;
min-width: 0;
gap: var(--space-5);
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
min-height: var(--control-md);
}
.search {
width: min(100%, 18rem);
}
.tools,
.bulk {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
}
.bulk {
color: var(--ink-2);
font-size: var(--text-12);
}
.frame {
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-3);
background: var(--surface-panel);
}
.state {
padding: var(--space-6);
}
.state > * {
margin-inline: auto;
}
.noResults {
display: grid;
justify-items: center;
gap: var(--space-3);
padding: var(--space-8);
color: var(--ink-2);
text-align: center;
}
.footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
color: var(--ink-3);
font-size: var(--text-12);
font-variant-numeric: tabular-nums;
}
}Members
identity cell · role and status · row actions · bulk remove
| Actions | |||||
|---|---|---|---|---|---|
Ada Lovelace Ada Lovelace ada.lovelace@northstar.dev | Owner | active | 24 days ago | ||
Alan Turing Alan Turing alan.turing@northstar.dev | Member | active | 21 days ago | ||
Grace Hopper Grace Hopper grace.hopper@northstar.dev | Member | invited | Never | ||
Katherine Johnson Katherine Johnson katherine.johnson@northstar.dev | Member | active | 6 days ago | ||
Linus Torvalds Linus Torvalds linus.torvalds@northstar.dev | Member | active | 22 Aug 2026 | ||
Margaret Hamilton Margaret Hamilton margaret.hamilton@northstar.dev | Member | invited | Never |
Invoices
money right-aligned · status · totals footer
| Period | Download | ||||
|---|---|---|---|---|---|
| INV-2026-0018 | Sep 2026 | 1 Sept 2026 | open | $374.00 | |
| INV-2026-0017 | Aug 2026 | 1 Aug 2026 | overdue | $417.00 | |
| INV-2026-0016 | Jul 2026 | 1 Jul 2026 | paid | $374.00 | |
| INV-2026-0015 | Jun 2026 | 1 Jun 2026 | paid | $374.00 | |
| INV-2026-0014 | May 2026 | 1 May 2026 | paid | $249.00 | |
| INV-2026-0013 | Apr 2026 | 1 Apr 2026 | paid | $249.00 | |
| INV-2026-0012 | Mar 2026 | 1 Mar 2026 | paid | $249.00 | |
| INV-2026-0011 | Feb 2026 | 1 Feb 2026 | paid | $249.00 | |
| Paid this year | $1,744.00 | ||||
Money is stored in cents. Amounts are integers formatted at render, so totals never pick up floating-point error.
Usage
meters against plan limits
| Resource | Usage | Limit | State | |
|---|---|---|---|---|
| API requests | 102% | 1,020,000 / 1,000,000 requests | Over limit | |
| Seats | 92% | 46 / 50 seats | Near limit | |
| Storage | 71% | 71.4 / 100 GB | ||
| Automation runs | 32% | 3,180 / 10,000 runs | ||
| Projects | 24% | 24 / 100 projects |
Audit log
compact · timestamps · filterable
| IP | ||||
|---|---|---|---|---|
| 26 Sept, 17:24 UTC | Linus TorvaldsLinus Torvalds | project.archived | Iris | 10.69.195.96 |
| 26 Sept, 15:56 UTC | Ada LovelaceAda Lovelace | member.invited | Barbara Liskov | 10.236.83.59 |
| 26 Sept, 13:54 UTC | Linus TorvaldsLinus Torvalds | api_key.created | CI deploy key | 10.195.244.232 |
| 26 Sept, 12:57 UTC | Katherine JohnsonKatherine Johnson | member.role_changed | Katherine Johnson | 10.252.19.198 |
| 26 Sept, 11:26 UTC | Alan TuringAlan Turing | project.archived | Quartz | 10.153.157.226 |
| 26 Sept, 09:50 UTC | Katherine JohnsonKatherine Johnson | project.created | Tundra | 10.44.220.99 |
| 26 Sept, 07:22 UTC | Ada LovelaceAda Lovelace | project.archived | Willow | 10.68.88.11 |
| 26 Sept, 06:27 UTC | Alan TuringAlan Turing | billing.plan_changed | Team → Business | 10.170.185.59 |
| 26 Sept, 04:40 UTC | Linus TorvaldsLinus Torvalds | session.signed_in | Web | 10.70.197.129 |
| 26 Sept, 03:25 UTC | Grace HopperGrace Hopper | member.invited | Barbara Liskov | 10.28.172.226 |
API keys
masked secrets · copy · revoke
| Key | Scopes | Revoke | |||
|---|---|---|---|---|---|
| CI deploy key | bk_live_••••4f2a | deploy read | 14 Mar 2026 | Today | |
| Analytics export | bk_live_••••9c01 | read | 2 May 2026 | 7 days ago | |
| Staging | bk_test_••••77be | read write | 21 Jul 2026 | Never | |
| Legacy integration | bk_live_••••0d3e | read write admin | 8 Nov 2025 | 11 Feb 2026 |
A secret is shown once, then masked. The table keeps a prefix and the last four characters, enough to recognise a key without exposing it.
States
loading · empty · error
No projects yet Projects group the work your team ships. | ||||
This table could not load The server did not respond. Your data is safe. | ||||
Each state says something different. Loading shows skeleton rows in a busy region; empty invites the first action; an error explains and offers a retry. “No results” for a search is a fourth state, with a way to clear it.