@polymorphism-tech/morph-spec 8.37.1 → 8.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/CLAUDE.md +19 -6
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +16 -7
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +3 -3
  8. package/framework/MORPH.md +25 -22
  9. package/framework/agents.json +76 -27
  10. package/framework/docs/CLI.md +54 -17
  11. package/framework/evals/rubrics/code-quality.md +5 -0
  12. package/framework/evals/rubrics/condensed.md +6 -2
  13. package/framework/evals/rubrics/contracts.md +20 -0
  14. package/framework/evals/rubrics/test-coverage.md +17 -6
  15. package/framework/hooks/README.md +9 -3
  16. package/framework/hooks/claude-code/core/graph-refresh-kick.js +52 -0
  17. package/framework/hooks/claude-code/core/telemetry-log.js +172 -87
  18. package/framework/hooks/claude-code/core/validate-completion.js +16 -90
  19. package/framework/hooks/claude-code/core/worktree-context.js +10 -0
  20. package/framework/hooks/shared/command-tokens.js +96 -0
  21. package/framework/hooks/shared/git-exclude.js +44 -0
  22. package/framework/hooks/shared/git-paths.js +52 -0
  23. package/framework/hooks/shared/graph-freshness.js +380 -0
  24. package/framework/hooks/shared/graph-refresh.js +416 -0
  25. package/framework/hooks/shared/morph-cli.js +17 -5
  26. package/framework/hooks/shared/redact-secrets.js +55 -0
  27. package/framework/hooks/shared/telemetry-logger.js +25 -2
  28. package/framework/hooks/shared/transcript-usage.js +80 -0
  29. package/framework/rubrics/backend.json +2 -2
  30. package/framework/rubrics/default.json +2 -2
  31. package/framework/rubrics/frontend.json +2 -2
  32. package/framework/rules/csharp-standards.md +1 -0
  33. package/framework/rules/morph-workflow.md +1 -0
  34. package/framework/rules/nextjs-standards.md +1 -0
  35. package/framework/schemas/config.schema.json +41 -1
  36. package/framework/schemas/feature.schema.json +5 -1
  37. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  38. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  39. package/framework/skills/core/morph-apply/SKILL.md +36 -11
  40. package/framework/skills/core/morph-brainstorming/SKILL.md +16 -7
  41. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  42. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  43. package/framework/skills/core/morph-implement/SKILL.md +39 -10
  44. package/framework/skills/core/morph-media/SKILL.md +1 -1
  45. package/framework/skills/core/morph-plan/SKILL.md +72 -15
  46. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  47. package/framework/skills/core/morph-review/SKILL.md +51 -13
  48. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  49. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  50. package/framework/skills/core/morph-uiux/SKILL.md +34 -9
  51. package/framework/standards/STANDARDS.json +187 -12
  52. package/framework/standards/ai-agents/testing-ai.md +2 -1
  53. package/framework/standards/architecture/code-comments.md +64 -0
  54. package/framework/standards/backend/dotnet/testing.md +22 -0
  55. package/framework/standards/frontend/admin-app/data-table.md +562 -0
  56. package/framework/standards/frontend/admin-app/feedback.md +236 -0
  57. package/framework/standards/frontend/admin-app/forms.md +232 -0
  58. package/framework/standards/frontend/admin-app/page-structure.md +203 -0
  59. package/framework/standards/frontend/nextjs/components.md +37 -58
  60. package/framework/standards/frontend/nextjs/data-fetching.md +1 -0
  61. package/framework/standards/frontend/nextjs/forms.md +31 -30
  62. package/framework/standards/frontend/nextjs/state-management.md +2 -1
  63. package/framework/standards/frontend/nextjs/testing.md +18 -0
  64. package/framework/templates/context/design-system.md +3 -3
  65. package/framework/templates/docs/proposal.md +11 -0
  66. package/framework/templates/docs/spec.md +15 -0
  67. package/framework/templates/docs/ui-mockups.md +115 -98
  68. package/framework/templates/feature/decisions.md +124 -122
  69. package/package.json +1 -1
  70. package/src/commands/graph/affected.js +4 -3
  71. package/src/commands/graph/build.js +109 -18
  72. package/src/commands/graph/explain.js +8 -5
  73. package/src/commands/graph/status.js +14 -8
  74. package/src/commands/observability/cost.js +148 -18
  75. package/src/commands/project/doctor.js +82 -10
  76. package/src/commands/project/update.js +25 -0
  77. package/src/commands/project/worktree.js +73 -90
  78. package/src/commands/state/advance.js +18 -0
  79. package/src/commands/state/approve.js +15 -0
  80. package/src/commands/state/archive.js +62 -9
  81. package/src/commands/state/finish.js +62 -14
  82. package/src/commands/state/gate-check.js +8 -1
  83. package/src/commands/state/gate-decision.js +8 -4
  84. package/src/commands/state/report-check.js +90 -9
  85. package/src/commands/state/usecase.js +66 -0
  86. package/src/commands/tasks/dag.js +17 -2
  87. package/src/commands/validation/validate-feature.js +6 -1
  88. package/src/commands/validation/verify.js +20 -1
  89. package/src/dashboard/scanner/agents-scanner.js +5 -1
  90. package/src/dashboard/scanner/hooks-scanner.js +8 -1
  91. package/src/dashboard/scanner/standards-scanner.js +2 -1
  92. package/src/lib/ai/comment-stripper.js +101 -4
  93. package/src/lib/evaluator-report.js +25 -0
  94. package/src/lib/generators/handoff-generator.js +4 -18
  95. package/src/lib/git/default-branch.js +52 -0
  96. package/src/lib/git/tree-fingerprint.js +58 -0
  97. package/src/lib/git/tree-state.js +167 -0
  98. package/src/lib/graph/god-nodes.js +12 -3
  99. package/src/lib/graph/graph-health.js +48 -2
  100. package/src/lib/graph/graph-loader.js +99 -25
  101. package/src/lib/graph/graphifyignore.js +145 -28
  102. package/src/lib/graph/sanitize.js +117 -11
  103. package/src/lib/risk-detector.js +90 -0
  104. package/src/lib/specs/acervo-format.js +70 -0
  105. package/src/lib/specs/acervo-lifecycle.js +264 -0
  106. package/src/lib/specs/adr-index.js +169 -0
  107. package/src/lib/specs/revocations.js +156 -0
  108. package/src/lib/specs/screen-checklist.js +357 -0
  109. package/src/lib/specs/usecase-check.js +20 -16
  110. package/src/lib/specs/usecase-promoter.js +103 -38
  111. package/src/lib/specs/usecase-registry.js +7 -7
  112. package/src/lib/tasks/tasks-json-parser.js +2 -3
  113. package/src/lib/telemetry/metrics.js +386 -0
  114. package/src/lib/telemetry/telemetry-reader.js +161 -51
  115. package/src/lib/telemetry/verify-event.js +79 -0
  116. package/src/lib/validators/code-budget/code-budget-validator.js +172 -0
  117. package/src/lib/validators/code-budget/comment-scanner.js +75 -0
  118. package/src/lib/validators/code-budget/index.js +9 -0
  119. package/src/lib/validators/reuse/reuse-validator.js +4 -2
  120. package/src/lib/validators/reuse/symbol-scanner.js +27 -22
  121. package/src/lib/validators/validation-runner.js +21 -5
  122. package/src/lib/validators/verify-runner.js +11 -0
  123. package/src/lib/worktree/finish-backup.js +739 -0
  124. package/src/lib/worktree/ignored-triage.js +15 -8
  125. package/src/lib/worktree/worktree-audit.js +5 -0
  126. package/src/scripts/setup-infra.js +29 -18
  127. package/src/utils/agents-installer.js +281 -12
  128. package/src/utils/hooks-installer.js +75 -15
@@ -0,0 +1,562 @@
1
+ # Admin App — Lists and Data Tables
2
+
3
+ > **Scope:** frontend/admin-app
4
+ > **Layer:** 2 (on keyword)
5
+ > **Keywords:** data table, list, pagination, server-side pagination, sorting, filtering, searchParams, empty state, loading state, error state, density, row actions, bulk actions, row selection, tanstack table
6
+ > **Load When:** designing or building any screen that lists records (admin panel, CRUD, back office, dashboard list)
7
+
8
+ **Verified against:** Next.js 16 App Router + @tanstack/react-table 9 + Zod 4 + shadcn 4 (the code blocks typecheck under `tsc --strict`). Last-verified: 2026-09-24.
9
+
10
+ ---
11
+
12
+ A list that renders "everything the API returned" works with 20 rows and breaks with 2 000: the page grows to 13 000 px, the browser sorts only what it loaded, and the fix that ships is "show the first 8 + see all" — which is not paging. In an admin app every list is **paged, sorted and filtered on the server**, its state lives **in the URL**, and it renders **three states besides the happy path**. This standard owns the one shared `DataTable`; `nextjs/components.md` points here instead of carrying its own.
13
+
14
+ ## Core Rules
15
+
16
+ - ALWAYS page, sort and filter on the server. The browser receives one page plus the total count, never the whole collection.
17
+ - ALWAYS keep page, page size, sort and filters in the URL (`searchParams`). State that dies on refresh, or that a colleague cannot open from a pasted link, is a bug.
18
+ - ALWAYS parse `searchParams` with a Zod schema that has bounds and defaults — the URL is user input.
19
+ - ALWAYS render loading, empty and error. "Empty" has two kinds: *no records yet* (offer the create action) and *no match for these filters* (offer to clear them).
20
+ - NEVER set `manualPagination` without `manualSorting`: sorting the loaded page only produces an order that looks right and is wrong.
21
+ - NEVER cap a list with "first N + see all" instead of paging it.
22
+ - ONE shared `components/data-table.tsx`. Features pass columns and rows; they never write a second table.
23
+ - A change of filter, sort or page size resets to page 1.
24
+
25
+ ## The List Contract (URL and API)
26
+
27
+ | URL param | Meaning | Rule |
28
+ |---|---|---|
29
+ | `page` | 1-based page | integer ≥ 1, default 1 |
30
+ | `pageSize` | rows per page | allowlist `10 / 25 / 50 / 100`, default 25 |
31
+ | `sort` | column id | allowlist per screen — never pass a raw column name to the query |
32
+ | `dir` | `asc` / `desc` | default per screen |
33
+ | `q` + domain filters | search and filters | trimmed, bounded length |
34
+
35
+ The API answers `{ items: T[], totalCount: number }`. On the backend the slice does `Skip/Take` (`backend/dotnet/vsa-handler-patterns.md`) over an `OrderBy` on the allowlisted column **plus a unique tiebreaker** (the id): paging over a non-unique order repeats and skips rows between pages.
36
+
37
+ ```ts
38
+ // features/products/types/product-list.schemas.ts
39
+ import { z } from 'zod';
40
+
41
+ export const PAGE_SIZES = [10, 25, 50, 100] as const;
42
+ export const PRODUCT_SORTS = ['name', 'price', 'updatedAt'] as const;
43
+
44
+ export const productListQuerySchema = z.object({
45
+ page: z.coerce.number().int().min(1).catch(1),
46
+ pageSize: z.coerce
47
+ .number()
48
+ .int()
49
+ .refine((n) => (PAGE_SIZES as readonly number[]).includes(n))
50
+ .catch(25),
51
+ sort: z.enum(PRODUCT_SORTS).catch('updatedAt'),
52
+ dir: z.enum(['asc', 'desc']).catch('desc'),
53
+ q: z.string().trim().max(100).catch(''),
54
+ status: z.enum(['active', 'archived']).optional().catch(undefined),
55
+ });
56
+
57
+ export type ProductListQuery = z.infer<typeof productListQuerySchema>;
58
+
59
+ export const productSchema = z.object({
60
+ id: z.string(),
61
+ name: z.string(),
62
+ price: z.number(),
63
+ status: z.enum(['active', 'archived']),
64
+ updatedAt: z.string(),
65
+ });
66
+ export type Product = z.infer<typeof productSchema>;
67
+
68
+ export const productPageSchema = z.object({
69
+ items: z.array(productSchema),
70
+ totalCount: z.number().int().min(0),
71
+ });
72
+ export type ProductPage = z.infer<typeof productPageSchema>;
73
+ ```
74
+
75
+ Every field carries `.catch()`: a hand-edited `?page=abc` renders page 1 instead of an error page.
76
+
77
+ ## The Page: Server Component Reads the URL
78
+
79
+ The page parses the query, renders the toolbar, and wraps the results in a `Suspense` **keyed by the query** — each new page, sort or filter shows the skeleton for the table only, while the toolbar stays put.
80
+
81
+ ```tsx
82
+ // app/(admin)/products/page.tsx
83
+ import { Suspense } from 'react';
84
+ import { DataTableSkeleton } from '@/components/data-table-skeleton';
85
+ import { ProductResults } from '@/features/products/components/product-results';
86
+ import { ProductToolbar } from '@/features/products/components/product-toolbar';
87
+ import { productListQuerySchema } from '@/features/products/types/product-list.schemas';
88
+
89
+ export default async function ProductsPage({
90
+ searchParams,
91
+ }: {
92
+ searchParams: Promise<Record<string, string | string[] | undefined>>;
93
+ }) {
94
+ const query = productListQuerySchema.parse(await searchParams);
95
+ return (
96
+ <>
97
+ <ProductToolbar query={query} />
98
+ <Suspense key={JSON.stringify(query)} fallback={<DataTableSkeleton rows={query.pageSize} />}>
99
+ <ProductResults query={query} />
100
+ </Suspense>
101
+ </>
102
+ );
103
+ }
104
+ ```
105
+
106
+ ```tsx
107
+ // features/products/components/product-results.tsx — Server Component
108
+ import { DataTableError } from '@/components/data-table-error';
109
+ import { fetchProductPage } from '@/features/products/api/fetch-product-page';
110
+ import type { ProductListQuery } from '@/features/products/types/product-list.schemas';
111
+ import { ProductTable } from './product-table';
112
+
113
+ export async function ProductResults({ query }: { query: ProductListQuery }) {
114
+ const result = await fetchProductPage(query);
115
+ if (!result.ok) return <DataTableError message={result.message} />;
116
+ return <ProductTable query={query} page={result.page} />;
117
+ }
118
+ ```
119
+
120
+ ```ts
121
+ // features/products/api/fetch-product-page.ts — server-direct (BACKEND_API_PREFIX)
122
+ import { BACKEND_API_PREFIX } from '@/lib/api/prefixes';
123
+ import {
124
+ productPageSchema,
125
+ type ProductListQuery,
126
+ type ProductPage,
127
+ } from '@/features/products/types/product-list.schemas';
128
+
129
+ export type PageResult = { ok: true; page: ProductPage } | { ok: false; message: string };
130
+
131
+ export async function fetchProductPage(query: ProductListQuery): Promise<PageResult> {
132
+ const params = new URLSearchParams({
133
+ page: String(query.page),
134
+ pageSize: String(query.pageSize),
135
+ sort: query.sort,
136
+ dir: query.dir,
137
+ });
138
+ if (query.q) params.set('q', query.q);
139
+ if (query.status) params.set('status', query.status);
140
+
141
+ const res = await fetch(`${BACKEND_API_PREFIX}/products?${params}`, { cache: 'no-store' });
142
+ if (!res.ok) return { ok: false, message: `Não foi possível carregar os produtos (${res.status}).` };
143
+ const parsed = productPageSchema.safeParse(await res.json());
144
+ return parsed.success
145
+ ? { ok: true, page: parsed.data }
146
+ : { ok: false, message: 'A resposta do servidor veio num formato inesperado.' };
147
+ }
148
+ ```
149
+
150
+ A failed load is a **value**, rendered as the error state with a retry — not an exception that blanks the whole segment. `error.tsx` stays for what nobody anticipated.
151
+
152
+ ## Writing the URL From the Client
153
+
154
+ One hook owns "change the list query". It merges a patch into the current params, drops empty values, resets the page unless the patch *is* the page, and replaces the history entry without scrolling to the top.
155
+
156
+ ```tsx
157
+ // components/use-list-query.ts
158
+ 'use client';
159
+
160
+ import { usePathname, useRouter, useSearchParams } from 'next/navigation';
161
+ import { useTransition } from 'react';
162
+
163
+ type Patch = Record<string, string | number | null | undefined>;
164
+
165
+ export function useListQuery() {
166
+ const router = useRouter();
167
+ const pathname = usePathname();
168
+ const searchParams = useSearchParams();
169
+ const [isPending, startTransition] = useTransition();
170
+
171
+ function update(patch: Patch) {
172
+ const params = new URLSearchParams(searchParams.toString());
173
+ for (const [key, value] of Object.entries(patch)) {
174
+ if (value === null || value === undefined || value === '') params.delete(key);
175
+ else params.set(key, String(value));
176
+ }
177
+ if (!('page' in patch)) params.delete('page');
178
+ const qs = params.toString();
179
+ startTransition(() => router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }));
180
+ }
181
+
182
+ return { update, isPending };
183
+ }
184
+ ```
185
+
186
+ ## The Shared DataTable (Tier 2)
187
+
188
+ Headless TanStack Table 9 with the three server-side switches on: `manualPagination` + `rowCount`, `manualSorting`, and selection keyed by a stable id. It never fetches — the page does.
189
+
190
+ ```tsx
191
+ // components/data-table.tsx
192
+ 'use client';
193
+
194
+ import type { ReactNode } from 'react';
195
+ import {
196
+ functionalUpdate,
197
+ rowPaginationFeature,
198
+ rowSelectionFeature,
199
+ rowSortingFeature,
200
+ tableFeatures,
201
+ useTable,
202
+ type ColumnDef,
203
+ type PaginationState,
204
+ type RowData,
205
+ type RowSelectionState,
206
+ type SortingState,
207
+ } from '@tanstack/react-table';
208
+ import { Button } from '@/components/ui/button';
209
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
210
+
211
+ export const dataTableFeatures = tableFeatures({
212
+ rowPaginationFeature,
213
+ rowSortingFeature,
214
+ rowSelectionFeature,
215
+ });
216
+ export type DataTableFeatures = typeof dataTableFeatures;
217
+ export type DataTableColumn<TData extends RowData> = ColumnDef<DataTableFeatures, TData>;
218
+
219
+ export interface DataTableProps<TData extends RowData> {
220
+ columns: Array<DataTableColumn<TData>>;
221
+ rows: TData[];
222
+ rowCount: number;
223
+ getRowId: (row: TData) => string;
224
+ pagination: PaginationState;
225
+ sorting: SortingState;
226
+ onPaginationChange: (next: PaginationState) => void;
227
+ onSortingChange: (next: SortingState) => void;
228
+ rowSelection?: RowSelectionState;
229
+ onRowSelectionChange?: (next: RowSelectionState) => void;
230
+ bulkActions?: (selectedIds: string[]) => ReactNode;
231
+ empty: ReactNode;
232
+ density?: 'compact' | 'comfortable';
233
+ }
234
+
235
+ export function DataTable<TData extends RowData>(props: DataTableProps<TData>) {
236
+ const { rowSelection = {}, onRowSelectionChange, density = 'compact' } = props;
237
+
238
+ const table = useTable({
239
+ features: dataTableFeatures,
240
+ columns: props.columns,
241
+ data: props.rows,
242
+ getRowId: (row) => props.getRowId(row),
243
+ rowCount: props.rowCount,
244
+ manualPagination: true,
245
+ manualSorting: true,
246
+ enableRowSelection: onRowSelectionChange !== undefined,
247
+ state: { pagination: props.pagination, sorting: props.sorting, rowSelection },
248
+ onPaginationChange: (u) => props.onPaginationChange(functionalUpdate(u, props.pagination)),
249
+ onSortingChange: (u) => props.onSortingChange(functionalUpdate(u, props.sorting)),
250
+ onRowSelectionChange: (u) => onRowSelectionChange?.(functionalUpdate(u, rowSelection)),
251
+ });
252
+
253
+ const selectedIds = Object.keys(rowSelection).filter((id) => rowSelection[id]);
254
+ const cellY = density === 'compact' ? 'py-1.5' : 'py-3';
255
+
256
+ if (props.rowCount === 0) return <>{props.empty}</>;
257
+
258
+ return (
259
+ <div className="space-y-2">
260
+ {selectedIds.length > 0 && props.bulkActions && (
261
+ <div className="flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-2 text-sm">
262
+ <span>{selectedIds.length} selecionado(s) nesta página</span>
263
+ {props.bulkActions(selectedIds)}
264
+ </div>
265
+ )}
266
+ <div className="overflow-x-auto rounded-md border">
267
+ <Table>
268
+ <TableHeader className="sticky top-0 bg-background">
269
+ {table.getHeaderGroups().map((group) => (
270
+ <TableRow key={group.id}>
271
+ {group.headers.map((header) => (
272
+ <TableHead key={header.id} aria-sort={ariaSort(header.column.getIsSorted())}>
273
+ {header.isPlaceholder ? null : <table.FlexRender header={header} />}
274
+ </TableHead>
275
+ ))}
276
+ </TableRow>
277
+ ))}
278
+ </TableHeader>
279
+ <TableBody>
280
+ {table.getRowModel().rows.map((row) => (
281
+ <TableRow key={row.id} data-state={row.getIsSelected() ? 'selected' : undefined}>
282
+ {row.getAllCells().map((cell) => (
283
+ <TableCell key={cell.id} className={cellY}>
284
+ <table.FlexRender cell={cell} />
285
+ </TableCell>
286
+ ))}
287
+ </TableRow>
288
+ ))}
289
+ </TableBody>
290
+ </Table>
291
+ </div>
292
+ <div className="flex items-center justify-between text-sm text-muted-foreground">
293
+ <span>
294
+ Página {props.pagination.pageIndex + 1} de {Math.max(1, table.getPageCount())} · {props.rowCount} registro(s)
295
+ </span>
296
+ <div className="flex gap-2">
297
+ <Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
298
+ Anterior
299
+ </Button>
300
+ <Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
301
+ Próxima
302
+ </Button>
303
+ </div>
304
+ </div>
305
+ </div>
306
+ );
307
+ }
308
+
309
+ function ariaSort(sorted: false | 'asc' | 'desc'): 'ascending' | 'descending' | 'none' {
310
+ if (sorted === 'asc') return 'ascending';
311
+ if (sorted === 'desc') return 'descending';
312
+ return 'none';
313
+ }
314
+ ```
315
+
316
+ `pageIndex` in the table is 0-based; the URL `page` is 1-based. The conversion lives in exactly one place — the feature table below.
317
+
318
+ Two small shared pieces complete the tier: the sortable header and the empty state.
319
+
320
+ ```tsx
321
+ // components/sortable-header.tsx
322
+ 'use client';
323
+
324
+ import type { Column, RowData } from '@tanstack/react-table';
325
+ import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react';
326
+ import type { DataTableFeatures } from '@/components/data-table';
327
+ import { Button } from '@/components/ui/button';
328
+ import { cn } from '@/lib/utils';
329
+
330
+ export function SortableHeader<TData extends RowData>({
331
+ column,
332
+ label,
333
+ align = 'left',
334
+ }: {
335
+ column: Column<DataTableFeatures, TData, unknown>;
336
+ label: string;
337
+ align?: 'left' | 'right';
338
+ }) {
339
+ const sorted = column.getIsSorted();
340
+ const Icon = sorted === 'asc' ? ArrowUp : sorted === 'desc' ? ArrowDown : ArrowUpDown;
341
+ return (
342
+ <Button
343
+ variant="ghost"
344
+ size="sm"
345
+ className={cn(align === 'right' ? 'ml-auto flex' : '-ml-3')}
346
+ onClick={() => column.toggleSorting(sorted === 'asc')}
347
+ >
348
+ {label}
349
+ <Icon className="ml-1 size-3.5" aria-hidden />
350
+ </Button>
351
+ );
352
+ }
353
+ ```
354
+
355
+ ```tsx
356
+ // components/data-table-empty.tsx
357
+ 'use client';
358
+
359
+ import type { ReactNode } from 'react';
360
+ import { useListQuery } from '@/components/use-list-query';
361
+ import { Button } from '@/components/ui/button';
362
+
363
+ export function DataTableEmpty({ filtered, entity, create }: {
364
+ filtered: boolean;
365
+ entity: string;
366
+ create?: ReactNode;
367
+ }) {
368
+ const { update } = useListQuery();
369
+ return (
370
+ <div className="flex flex-col items-center gap-3 rounded-md border border-dashed p-10 text-center text-sm">
371
+ {filtered ? (
372
+ <>
373
+ <p>Nenhum {entity} com esses filtros.</p>
374
+ <Button variant="outline" size="sm" onClick={() => update({ q: null, status: null })}>
375
+ Limpar filtros
376
+ </Button>
377
+ </>
378
+ ) : (
379
+ <>
380
+ <p>Ainda não há nenhum {entity} cadastrado.</p>
381
+ {create}
382
+ </>
383
+ )}
384
+ </div>
385
+ );
386
+ }
387
+ ```
388
+
389
+ ## The Feature Table (Tier 3)
390
+
391
+ The feature owns its columns and the URL ⇄ table mapping; the shared table owns rendering.
392
+
393
+ ```tsx
394
+ // features/products/components/product-table.tsx
395
+ 'use client';
396
+
397
+ import { useState } from 'react';
398
+ import type { RowSelectionState } from '@tanstack/react-table';
399
+ import { DataTable, type DataTableColumn } from '@/components/data-table';
400
+ import { DataTableEmpty } from '@/components/data-table-empty';
401
+ import { useListQuery } from '@/components/use-list-query';
402
+ import { Checkbox } from '@/components/ui/checkbox';
403
+ import {
404
+ PRODUCT_SORTS,
405
+ type Product,
406
+ type ProductListQuery,
407
+ type ProductPage,
408
+ } from '@/features/products/types/product-list.schemas';
409
+ import { ProductBulkActions } from './product-bulk-actions';
410
+ import { ProductRowActions } from './product-row-actions';
411
+ import { SortableHeader } from '@/components/sortable-header';
412
+
413
+ const columns: Array<DataTableColumn<Product>> = [
414
+ {
415
+ id: 'select',
416
+ header: ({ table }) => (
417
+ <Checkbox
418
+ checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate')}
419
+ onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
420
+ aria-label="Selecionar a página"
421
+ />
422
+ ),
423
+ cell: ({ row }) => (
424
+ <Checkbox
425
+ checked={row.getIsSelected()}
426
+ onCheckedChange={(v) => row.toggleSelected(!!v)}
427
+ aria-label="Selecionar linha"
428
+ />
429
+ ),
430
+ enableSorting: false,
431
+ },
432
+ { accessorKey: 'name', header: ({ column }) => <SortableHeader column={column} label="Nome" /> },
433
+ {
434
+ accessorKey: 'price',
435
+ header: ({ column }) => <SortableHeader column={column} label="Preço" align="right" />,
436
+ cell: ({ row }) => <span className="block text-right tabular-nums">{formatBRL(row.original.price)}</span>,
437
+ },
438
+ { accessorKey: 'status', header: 'Status', enableSorting: false },
439
+ { id: 'actions', header: '', cell: ({ row }) => <ProductRowActions product={row.original} />, enableSorting: false },
440
+ ];
441
+
442
+ export function ProductTable({ query, page }: { query: ProductListQuery; page: ProductPage }) {
443
+ const { update } = useListQuery();
444
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
445
+
446
+ return (
447
+ <DataTable
448
+ columns={columns}
449
+ rows={page.items}
450
+ rowCount={page.totalCount}
451
+ getRowId={(p) => p.id}
452
+ pagination={{ pageIndex: query.page - 1, pageSize: query.pageSize }}
453
+ sorting={[{ id: query.sort, desc: query.dir === 'desc' }]}
454
+ onPaginationChange={(next) => update({ page: next.pageIndex + 1, pageSize: next.pageSize })}
455
+ onSortingChange={(next) => {
456
+ const first = next[0];
457
+ const sort = PRODUCT_SORTS.find((s) => s === first?.id);
458
+ update({ sort: sort ?? null, dir: first ? (first.desc ? 'desc' : 'asc') : null });
459
+ }}
460
+ rowSelection={rowSelection}
461
+ onRowSelectionChange={setRowSelection}
462
+ bulkActions={(ids) => <ProductBulkActions ids={ids} onDone={() => setRowSelection({})} />}
463
+ empty={<DataTableEmpty filtered={query.q !== '' || query.status !== undefined} entity="produto" />}
464
+ />
465
+ );
466
+ }
467
+
468
+ function formatBRL(value: number) {
469
+ return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
470
+ }
471
+ ```
472
+
473
+ Selection is **per loaded page** (`getIsAllPageRowsSelected`, "selecionado(s) nesta página"). Rows the browser never received cannot be selected — an action over "everything matching the filter" is a separate, server-side operation with its own confirmation (`feedback.md`), never a loop over pages.
474
+
475
+ ## The Three States
476
+
477
+ | State | What renders | Rule |
478
+ |---|---|---|
479
+ | **Loading** | `DataTableSkeleton` with the page's row count, header row visible | Same height as the loaded table — no layout jump. The `Suspense` keyed by the query shows it on the first load **and** on every page/sort/filter change; the toolbar, outside it, shows `isPending` from `useListQuery` (spinner in the search field) |
480
+ | **Empty — no records** | Explanation + the primary create action | Never a bare "Nenhum resultado" on a table nobody has filled yet |
481
+ | **Empty — no match** | "Nenhum produto com esses filtros" + "Limpar filtros" | Keeps the toolbar, so the user sees *which* filters caused it |
482
+ | **Error** | Message from the `PageResult` + "Tentar de novo" (`router.refresh()`) | The toolbar stays; retry never discards the filters in the URL |
483
+
484
+ ```tsx
485
+ // components/data-table-error.tsx
486
+ 'use client';
487
+
488
+ import { useRouter } from 'next/navigation';
489
+ import { useTransition } from 'react';
490
+ import { Button } from '@/components/ui/button';
491
+
492
+ export function DataTableError({ message }: { message: string }) {
493
+ const router = useRouter();
494
+ const [isPending, startTransition] = useTransition();
495
+ return (
496
+ <div role="alert" className="flex flex-col items-center gap-3 rounded-md border border-destructive/40 p-8 text-sm">
497
+ <p>{message}</p>
498
+ <Button variant="outline" size="sm" disabled={isPending} onClick={() => startTransition(() => router.refresh())}>
499
+ Tentar de novo
500
+ </Button>
501
+ </div>
502
+ );
503
+ }
504
+ ```
505
+
506
+ ## Filters and Search
507
+
508
+ - Every filter is a URL param and is applied **by the server**. A filter that runs over the loaded page lies about the result count.
509
+ - Free-text search commits on a 300 ms debounce (or on Enter), never per keystroke into history — `router.replace`, not `push`.
510
+ - Selects and toggles commit immediately.
511
+ - Show active filters as removable chips plus one "Limpar filtros"; the count in the pager is the count **after** filters.
512
+
513
+ ## Density
514
+
515
+ Admin screens are read in bulk: default to **compact** rows (`py-1.5`, 14 px text), offer "comfortable" only if the design asks for it.
516
+
517
+ - Numbers and money right-aligned with `tabular-nums`; dates in one format per app.
518
+ - Long text truncates with the full value in a tooltip — a cell never wraps into a paragraph.
519
+ - Header sticky inside the table's scroll container; the first column is the record's name and links to its detail.
520
+ - At most ~7 visible columns on desktop. More than that is a detail page, not a column.
521
+
522
+ ## Row Actions and Bulk Actions
523
+
524
+ - One primary action inline (usually "Editar", or the name as a link); the rest in a row menu (`DropdownMenu` with a `…` icon button that has an `aria-label`).
525
+ - Destructive actions — row or bulk — go through the confirmation dialog of `feedback.md`, and their result through the toast catalog there.
526
+ - The bulk bar appears only with a selection, says how many rows it acts on, and clears the selection when the action finishes.
527
+ - After a mutation, `router.refresh()` re-reads the current page from the server; never patch the row array by hand.
528
+
529
+ ## Mobile
530
+
531
+ Below the `md` breakpoint a table with more than 3 columns becomes a **card list** (same data, same URL state, same pager): name + the 2 decisive fields + the row menu. Horizontal scrolling a dense table on a phone is the fallback only for tables the design marks as desktop-first, and the design must say so.
532
+
533
+ ## Where the Design Checklist Lands
534
+
535
+ The per-screen checklist of the `morph-uiux` mockups (volume, pagination, sort, filter, the three states, mobile, tabs) is answered by this standard's rules:
536
+
537
+ | Checklist item | Decided by |
538
+ |---|---|
539
+ | Expected volume | picks `pageSize` default and whether search is needed at all |
540
+ | Pagination | [The List Contract](#the-list-contract-url-and-api) — always server-side above ~50 rows |
541
+ | Sort | the `sort` allowlist + default `dir` |
542
+ | Filter | [Filters and Search](#filters-and-search) |
543
+ | Loading / empty / error | [The Three States](#the-three-states) |
544
+ | Mobile | [Mobile](#mobile) |
545
+ | Tabs | `admin-app/page-structure.md` |
546
+
547
+ ## Common Mistakes
548
+
549
+ | Wrong | Right | Why |
550
+ |---|---|---|
551
+ | `useQuery` returns the whole collection, table paginates in memory | Server pages; table gets `rowCount` + `manualPagination` | The browser downloads and sorts everything, and it stops scaling silently |
552
+ | `manualPagination: true` with client sorting | `manualSorting: true` too | Sorting only the loaded page gives a plausible, wrong order |
553
+ | Page/sort/filter in `useState` | In `searchParams` | Refresh and shared links lose the list state |
554
+ | `OrderBy(name).Skip().Take()` | `OrderBy(name).ThenBy(id)` | Non-unique order repeats and skips rows across pages |
555
+ | "First 8 + ver todas" | Paging | A teto is not pagination; "ver todas" is the unbounded list again |
556
+ | `router.push` on every keystroke | Debounced `router.replace` | History filled with one entry per letter |
557
+ | One empty message for every case | "No records" vs "no match" | The user cannot tell a new table from a bad filter |
558
+ | Second table component per feature | `components/data-table.tsx` + columns | Six hand-made tables drift in six ways |
559
+
560
+ ---
561
+
562
+ *MORPH-SPEC by Polymorphism Tech*