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.
Sourcecomponents/display/table/doc.ts · components/display/table/table.tsx · components/display/table/sortable-th.tsx · components/display/table/table.module.css
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 {};components/display/table/table.tsx
import type { ComponentPropsWithRef, ReactNode } from "react";
import { cn } from "@/lib/utils/cn";
import styles from "./table.module.css";
import { tableVariants, type TableVariants } from "./table.variants";
/* Compositional, not data-driven: a <Table rows columns /> needs a renderer
prop per non-string cell and reinvents markup the platform already has.
DataTable is the data-driven layer, built on these parts. */
export type TableProps = ComponentPropsWithRef<"table"> &
TableVariants & {
/** Names the table for readers who reach it by jumping between tables. */
caption?: ReactNode;
/** The scroll region's name when there is no caption. */
label?: string;
};
export function Table({
caption,
label,
density,
className,
children,
...props
}: TableProps) {
return (
// A named, focusable region, so a keyboard user can scroll a wide table.
<div
className={styles.scroll}
role="region"
tabIndex={0}
aria-label={label ?? (typeof caption === "string" ? caption : "Table")}
>
<table {...props} className={cn(tableVariants({ density }), className)}>
{caption ? (
<caption className={styles.caption}>{caption}</caption>
) : null}
{children}
</table>
</div>
);
}
export function THead({ className, ...props }: ComponentPropsWithRef<"thead">) {
return <thead {...props} className={cn(styles.head, className)} />;
}
export function TBody(props: ComponentPropsWithRef<"tbody">) {
return <tbody {...props} />;
}
export function TFoot({ className, ...props }: ComponentPropsWithRef<"tfoot">) {
return <tfoot {...props} className={cn(styles.foot, className)} />;
}
export type TrProps = ComponentPropsWithRef<"tr"> & { selected?: boolean };
export function Tr({ selected, className, ...props }: TrProps) {
return (
<tr
{...props}
data-selected={selected || undefined}
aria-selected={selected}
className={cn(styles.row, className)}
/>
);
}
type CellOptions = {
/** Right-aligned with tabular figures. */
numeric?: boolean;
/** As narrow as its content: checkboxes, row actions. */
shrink?: boolean;
};
export type ThProps = ComponentPropsWithRef<"th"> & CellOptions;
/** Always carries a scope, so readers know whether it heads a column or a
* row. Column by default. */
export function Th({
numeric,
shrink,
scope = "col",
className,
...props
}: ThProps) {
return (
<th
{...props}
scope={scope}
className={cn(
styles.th,
numeric && styles.numeric,
shrink && styles.shrink,
className,
)}
/>
);
}
export type TdProps = ComponentPropsWithRef<"td"> & CellOptions;
export function Td({ numeric, shrink, className, ...props }: TdProps) {
return (
<td
{...props}
className={cn(
styles.td,
numeric && styles.numeric,
shrink && styles.shrink,
className,
)}
/>
);
}components/display/table/sortable-th.tsx
"use client";
import type { ReactNode } from "react";
import { ArrowDown, ArrowUp, ArrowUpDown } from "@/components/utility/icon";
import styles from "./table.module.css";
import { Th, type ThProps } from "./table";
export type SortDirection = "ascending" | "descending" | "none";
export type SortableThProps = Omit<ThProps, "children" | "aria-sort"> & {
children: ReactNode;
direction: SortDirection;
onSort: (event: React.MouseEvent<HTMLButtonElement>) => void;
};
/** A column header that sorts. aria-sort states the current order; the button
* inside it is what a keyboard user activates. */
export function SortableTh({
children,
direction,
onSort,
...props
}: SortableThProps) {
const Icon =
direction === "ascending"
? ArrowUp
: direction === "descending"
? ArrowDown
: ArrowUpDown;
return (
<Th {...props} aria-sort={direction}>
<button type="button" className={styles.sort} onClick={onSort}>
{children}
<Icon aria-hidden="true" />
</button>
</Th>
);
}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.
Sourcecomponents/display/data-table/doc.ts · components/display/data-table/data-table.tsx · components/display/data-table/features.ts · components/display/data-table/data-table.module.css
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 {};components/display/data-table/data-table.tsx
"use client";
import { useEffect, useMemo, type ReactNode } from "react";
import {
useTable,
type Column,
type ColumnDef,
type RowData,
type SortingState,
} from "@tanstack/react-table";
import { Empty } from "@/components/display/empty";
import {
SortableTh,
Table,
TBody,
Td,
Th,
THead,
Tr,
} from "@/components/display/table";
import { Alert } from "@/components/feedback/alert";
import { Skeleton } from "@/components/feedback/skeleton";
import { Button } from "@/components/forms/button";
import { Checkbox } from "@/components/forms/checkbox";
import { Input } from "@/components/forms/input";
import { Pagination } from "@/components/navigation/pagination";
import { cn } from "@/lib/utils/cn";
import styles from "./data-table.module.css";
import { dataTableFeatures, type DataTableFeatures } from "./features";
/* A column definition built with createDataTableColumns(). Columns in one
table have different value types, so the value type is left open here. */
export type DataTableColumn<TData extends RowData> = ColumnDef<
DataTableFeatures,
TData,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
any
>;
export type DataTableProps<TData extends RowData> = {
data: TData[];
columns: DataTableColumn<TData>[];
/** 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";
/** Show a search box that filters every searchable column. */
searchable?: boolean;
searchPlaceholder?: string;
/** Add a checkbox column. */
selectable?: boolean;
onSelectionChange?: (ids: string[]) => void;
/** Shown in place of the toolbar while rows are selected. */
bulkActions?: (ids: string[], clear: () => void) => ReactNode;
/** Controls at the end of the toolbar: filters, export, create. */
toolbar?: ReactNode;
/** Rows per page; 0 shows every row. */
pageSize?: number;
initialSorting?: SortingState;
/** Rendered after the body, e.g. a TFoot of totals. */
footer?: ReactNode;
loading?: boolean;
/** A failure message; replaces the rows with an Alert. */
error?: ReactNode;
onRetry?: () => void;
/** Shown when there are no rows at all (not when a search matches none). */
empty?: ReactNode;
className?: string;
};
const NOT_SEARCHABLE = new Set(["select", "actions"]);
function direction(column: Column<DataTableFeatures, RowData>) {
const sorted = column.getIsSorted();
return sorted === "asc"
? "ascending"
: sorted === "desc"
? "descending"
: "none";
}
export function DataTable<TData extends RowData>({
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,
className,
}: DataTableProps<TData>) {
const allColumns = useMemo<DataTableColumn<TData>[]>(
() =>
selectable
? [
{
id: "select",
enableSorting: false,
meta: { shrink: true },
header: ({ table }) => (
<Checkbox
aria-label="Select all rows on this page"
checked={
table.getIsAllPageRowsSelected()
? true
: table.getIsSomePageRowsSelected()
? "indeterminate"
: false
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(value === true)
}
/>
),
cell: ({ row }) => (
<Checkbox
aria-label={`Select row ${row.id}`}
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onCheckedChange={(value) =>
row.toggleSelected(value === true)
}
/>
),
},
...columns,
]
: columns,
[columns, selectable],
);
const table = useTable(
{
features: dataTableFeatures,
columns: allColumns,
data,
getRowId,
initialState: {
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,
},
(state) => ({
globalFilter: state.globalFilter,
pagination: state.pagination,
rowSelection: state.rowSelection,
sorting: state.sorting,
}),
);
const selectedIds = Object.keys(table.state.rowSelection).filter(
(id) => table.state.rowSelection[id],
);
const selectionKey = selectedIds.join("|");
useEffect(() => {
onSelectionChange?.(selectionKey ? selectionKey.split("|") : []);
// Report changes to the selection, not to the callback's identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectionKey]);
const query = String(table.state.globalFilter ?? "");
const filtered = table.getFilteredRowModel().rows.length;
const rows = table.getRowModel().rows;
const { pageIndex, pageSize: size } = table.state.pagination;
const first = filtered === 0 ? 0 : pageIndex * size + 1;
const last = Math.min(filtered, (pageIndex + 1) * size);
const columnCount = allColumns.length;
const clearSelection = () => table.resetRowSelection(true);
let body: ReactNode;
if (error) {
body = (
<Tr>
<Td colSpan={columnCount} className={styles.state}>
<Alert
tone="crit"
title="This table could not load"
live="polite"
action={
onRetry ? (
<Button size="sm" onClick={onRetry}>
Try again
</Button>
) : null
}
>
{error}
</Alert>
</Td>
</Tr>
);
} else if (loading) {
body = Array.from({ length: Math.min(size, 5) }, (_, index) => (
<Tr key={index} aria-hidden="true">
{allColumns.map((column, cell) => (
<Td key={column.id ?? cell}>
<Skeleton width={cell === 0 ? "60%" : "80%"} />
</Td>
))}
</Tr>
));
} else if (data.length === 0) {
body = (
<Tr>
<Td colSpan={columnCount}>
{empty ?? <Empty title="Nothing here yet" />}
</Td>
</Tr>
);
} else if (filtered === 0) {
body = (
<Tr>
<Td colSpan={columnCount}>
<div className={styles.noResults} role="status">
No results for “{query}”.
<Button
size="sm"
variant="quiet"
onClick={() => table.setGlobalFilter("")}
>
Clear search
</Button>
</div>
</Td>
</Tr>
);
} else {
body = rows.map((row) => (
<Tr key={row.id} selected={row.getIsSelected()}>
{row.getAllCells().map((cell) => {
const meta = cell.column.columnDef.meta;
return (
<Td key={cell.id} numeric={meta?.numeric} shrink={meta?.shrink}>
<table.FlexRender cell={cell} />
</Td>
);
})}
</Tr>
));
}
const hasToolbar = searchable || toolbar || (selectable && bulkActions);
return (
<div className={cn(styles.root, className)}>
{hasToolbar ? (
<div className={styles.toolbar}>
{selectedIds.length && bulkActions ? (
<div className={styles.bulk} role="status">
{selectedIds.length} selected
{bulkActions(selectedIds, clearSelection)}
<Button size="sm" variant="quiet" onClick={clearSelection}>
Clear selection
</Button>
</div>
) : searchable ? (
<Input
type="search"
size="sm"
className={styles.search}
aria-label={`Search ${caption}`}
placeholder={searchPlaceholder}
value={query}
onChange={(event) => table.setGlobalFilter(event.target.value)}
/>
) : (
<span />
)}
{toolbar ? <div className={styles.tools}>{toolbar}</div> : null}
</div>
) : null}
<div className={styles.frame} aria-busy={loading || undefined}>
<Table
density={density}
label={caption}
caption={hideCaption ? undefined : caption}
aria-label={hideCaption ? caption : undefined}
>
<THead>
{table.getHeaderGroups().map((group) => (
<Tr key={group.id}>
{group.headers.map((header) => {
const meta = header.column.columnDef.meta;
const content = header.isPlaceholder ? null : (
<table.FlexRender header={header} />
);
return header.column.getCanSort() ? (
<SortableTh
key={header.id}
numeric={meta?.numeric}
shrink={meta?.shrink}
direction={direction(
header.column as Column<DataTableFeatures, RowData>,
)}
onSort={(event) =>
header.column.getToggleSortingHandler()?.(event)
}
>
{content}
</SortableTh>
) : (
<Th
key={header.id}
numeric={meta?.numeric}
shrink={meta?.shrink}
>
{content}
</Th>
);
})}
</Tr>
))}
</THead>
<TBody>{body}</TBody>
{footer && !loading && !error ? footer : null}
</Table>
</div>
{pageSize > 0 && !loading && !error && filtered > size ? (
<div className={styles.footer}>
<span>
{first}–{last} of {filtered}
</span>
<Pagination
page={pageIndex + 1}
totalPages={table.getPageCount()}
onPageChange={(page) => table.setPageIndex(page - 1)}
label={`${caption} pages`}
/>
</div>
) : null}
</div>
);
}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/react-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>();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 | deployread | 14 Mar 2026 | Today | |
| Analytics export | bk_live_••••9c01 | read | 2 May 2026 | 7 days ago | |
| Staging | bk_test_••••77be | readwrite | 21 Jul 2026 | Never | |
| Legacy integration | bk_live_••••0d3e | readwriteadmin | 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.