noecosystem-design 0.2.3 → 0.4.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 (73) hide show
  1. package/README.md +2 -2
  2. package/apps/storybook/.storybook/preview.tsx +1 -1
  3. package/apps/storybook/stories/catalog/app-frame.stories.tsx +213 -1
  4. package/apps/storybook/stories/catalog/checkbox.stories.tsx +7 -2
  5. package/apps/storybook/stories/catalog/data-table.stories.tsx +226 -3
  6. package/apps/storybook/stories/catalog/dialog.stories.tsx +2 -2
  7. package/apps/storybook/stories/catalog/icon-system.stories.tsx +11 -0
  8. package/apps/storybook/stories/catalog/menu.stories.tsx +3 -3
  9. package/apps/web/styles.css +0 -1
  10. package/docs/design-system/brand-theme.md +8 -0
  11. package/docs/design-system/components.md +38 -0
  12. package/docs/design-system/inventory.md +1 -0
  13. package/docs/design-system/visual-qa.md +6 -0
  14. package/package.json +1 -1
  15. package/packages/data-ui/package.json +6 -1
  16. package/packages/data-ui/src/data-table.tsx +2 -99
  17. package/packages/data-ui/src/pagination.tsx +2 -2
  18. package/packages/data-ui/src/table/adapters/tanstack.test.ts +116 -0
  19. package/packages/data-ui/src/table/adapters/tanstack.ts +179 -0
  20. package/packages/data-ui/src/table/contracts.ts +133 -0
  21. package/packages/data-ui/src/table/data-table-visual.evidence.browser.test.tsx +157 -0
  22. package/packages/data-ui/src/table/data-table.browser.test.tsx +180 -0
  23. package/packages/data-ui/src/table/data-table.tsx +524 -0
  24. package/packages/design-tokens/src/tailwind.css +5 -0
  25. package/packages/design-tokens/src/tokens.css +10 -4
  26. package/packages/design-tokens/src/tokens.json +8 -4
  27. package/packages/design-tokens/src/tokens.mjs +8 -4
  28. package/packages/icons/package.json +6 -1
  29. package/packages/icons/src/brands/nocfo.svg +8 -0
  30. package/packages/icons/src/nocfo-logo.browser.test.tsx +14 -0
  31. package/packages/icons/src/nocfo-logo.tsx +23 -0
  32. package/packages/registry/catalog-index.json +2 -2
  33. package/packages/registry/manifest.json +60 -18
  34. package/packages/registry/r/activity-feed.json +1 -1
  35. package/packages/registry/r/agent-composer.json +7 -1
  36. package/packages/registry/r/agent-message.json +1 -1
  37. package/packages/registry/r/agent-plan.json +1 -1
  38. package/packages/registry/r/agent-workspace.json +1 -1
  39. package/packages/registry/r/app-frame.json +9 -2
  40. package/packages/registry/r/approval-request.json +7 -1
  41. package/packages/registry/r/calendar.json +6 -5
  42. package/packages/registry/r/chart-frame.json +1 -1
  43. package/packages/registry/r/chart.json +1 -1
  44. package/packages/registry/r/dashboard-01.json +22 -2
  45. package/packages/registry/r/dashboard-02.json +22 -2
  46. package/packages/registry/r/dashboard-shell.json +1 -1
  47. package/packages/registry/r/data-table.json +22 -2
  48. package/packages/registry/r/date-picker.json +6 -5
  49. package/packages/registry/r/filter-bar.json +1 -1
  50. package/packages/registry/r/icon-system.json +18 -5
  51. package/packages/registry/r/inline-cta.json +6 -6
  52. package/packages/registry/r/metric-card.json +1 -1
  53. package/packages/registry/r/pagination.json +7 -5
  54. package/packages/registry/r/run-inspector.json +1 -1
  55. package/packages/registry/r/sidebar-navigation.json +5 -2
  56. package/packages/registry/r/tool-call.json +1 -1
  57. package/packages/registry/r/workflow-canvas.json +1 -1
  58. package/packages/registry/r/workflow-node.json +1 -1
  59. package/packages/registry/registry.json +12 -3
  60. package/packages/registry/search-index.json +23 -8
  61. package/packages/ui/package.json +5 -1
  62. package/packages/ui/src/app-frame.browser.test.tsx +6 -0
  63. package/packages/ui/src/app-frame.tsx +9 -2
  64. package/packages/ui/src/badge.tsx +4 -4
  65. package/packages/ui/src/calendar.tsx +1 -1
  66. package/packages/ui/src/checkbox.tsx +1 -1
  67. package/packages/ui/src/code.tsx +3 -1
  68. package/packages/ui/src/field.tsx +1 -1
  69. package/packages/ui/src/file-uploader.tsx +1 -1
  70. package/packages/ui/src/progress-steps.tsx +1 -1
  71. package/packages/ui/src/sidebar-navigation.tsx +11 -1
  72. package/scripts/generate-manifest.mjs +71 -0
  73. package/vitest.config.ts +5 -0
package/README.md CHANGED
@@ -100,8 +100,8 @@ Advanced Security are enabled in repository settings.
100
100
  CodeQL is similarly opt-in through `ENABLE_CODEQL=true`: first enable **Code
101
101
  Security** for this private repository in GitHub’s security settings, then set the
102
102
  variable. This avoids a misleading failed workflow when GitHub cannot accept SARIF
103
- uploads. The workflows use Node 24; the runner’s Node 20 deprecation notice does not
104
- require a compatibility override.
103
+ uploads. The workflows configure Node 24 and use `actions/setup-node@v6`, whose
104
+ action runtime is also Node 24.
105
105
 
106
106
  ## Architecture
107
107
 
@@ -58,7 +58,7 @@ const preview: Preview = {
58
58
  data-locale={meta.locale}
59
59
  style={{
60
60
  minHeight: '100vh',
61
- padding: '2rem',
61
+ padding: context.parameters.layout === 'fullscreen' ? 0 : '2rem',
62
62
  background: 'var(--noe-color-background)',
63
63
  color: 'var(--noe-color-foreground)',
64
64
  }}
@@ -1,7 +1,22 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
 
3
3
  import { AppFrame } from '../../../../packages/ui/src/app-frame';
4
- import { ChartSquare, ReceiptSearch, WalletMoney } from '../../../../packages/icons/src';
4
+ import { Breadcrumb, PageHeader } from '../../../../packages/ui/src/page-header';
5
+ import { Button } from '../../../../packages/ui/src/button';
6
+ import { HeaderNavigation } from '../../../../packages/ui/src/header-navigation';
7
+ import { Input } from '../../../../packages/ui/src/input';
8
+ import { Select } from '../../../../packages/ui/src/select';
9
+ import { DataTable, type DataColumn } from '../../../../packages/data-ui/src/data-table';
10
+ import { FilterBar } from '../../../../packages/data-ui/src/filter-bar';
11
+ import {
12
+ Add,
13
+ BoxSearch,
14
+ ChartSquare,
15
+ ReceiptSearch,
16
+ Setting2,
17
+ WalletMoney,
18
+ } from '../../../../packages/icons/src';
19
+ import { NocfoLogo } from '../../../../packages/icons/src/nocfo-logo';
5
20
  import { SidebarNavigation } from '../../../../packages/ui/src/sidebar-navigation';
6
21
 
7
22
  const navigation = (
@@ -17,6 +32,7 @@ const navigation = (
17
32
  ],
18
33
  },
19
34
  ]}
35
+ wrapLabels
20
36
  />
21
37
  );
22
38
 
@@ -75,6 +91,196 @@ function FinanceAppFrameDemo() {
75
91
  );
76
92
  }
77
93
 
94
+ type BankAccountRow = {
95
+ id: string;
96
+ bank: string;
97
+ company: string;
98
+ country: string;
99
+ branch: string;
100
+ code: string;
101
+ accountNumber: string;
102
+ iban: string;
103
+ };
104
+
105
+ const bankAccountColumns: DataColumn<BankAccountRow>[] = [
106
+ { key: 'bank', header: 'بانک / Bank', sortable: true },
107
+ { key: 'company', header: 'شرکت مالک / Owner Company', sortable: true },
108
+ { key: 'country', header: 'کشور / Country', sortable: true },
109
+ { key: 'branch', header: 'شعبه / Branch', sortable: true },
110
+ { key: 'code', header: 'کد حساب / Account Code', technical: true },
111
+ { key: 'accountNumber', header: 'شماره حساب / Account Number', technical: true },
112
+ { key: 'iban', header: 'شبا / IBAN', technical: true },
113
+ ];
114
+
115
+ const bankAccountRows: BankAccountRow[] = [
116
+ {
117
+ id: '1',
118
+ bank: 'Emirates NBD',
119
+ company: 'بازرگانی امین برتر اقتصاد ملل',
120
+ country: 'امارات متحده عربی',
121
+ branch: 'Deira Branch',
122
+ code: 'ACC-AMIN-AED',
123
+ accountNumber: 'AE-ENBD-AED-1000006',
124
+ iban: 'AE5700001234567',
125
+ },
126
+ {
127
+ id: '2',
128
+ bank: 'Emirates NBD',
129
+ company: 'بازرگانی امین برتر اقتصاد ملل',
130
+ country: 'امارات متحده عربی',
131
+ branch: 'Deira Branch',
132
+ code: 'ACC-AMIN-USD',
133
+ accountNumber: 'AE-ENBD-USD-1000004',
134
+ iban: 'AE5600001234567',
135
+ },
136
+ {
137
+ id: '3',
138
+ bank: 'Ziraat Bankası',
139
+ company: 'ارمغان صنعت میلا',
140
+ country: 'ترکیه',
141
+ branch: 'Kadıköy Branch',
142
+ code: 'ARMAGHAN-EUR',
143
+ accountNumber: 'TR-ZIR-EUR-1000005',
144
+ iban: 'TR7600012345678',
145
+ },
146
+ ];
147
+
148
+ const financeNavigation = (
149
+ <SidebarNavigation
150
+ className="text-sm"
151
+ label="ناوبری NOCFO"
152
+ sections={[
153
+ {
154
+ id: 'overview',
155
+ items: [{ href: '#overview', icon: <ChartSquare />, label: 'نمای کلی / Overview' }],
156
+ },
157
+ {
158
+ id: 'settings',
159
+ label: 'تنظیمات / Settings',
160
+ items: [
161
+ { href: '#companies', label: 'شرکت‌ها / Companies' },
162
+ { href: '#countries', label: 'کشورها / Countries' },
163
+ { href: '#banks', label: 'بانک‌ها / Banks' },
164
+ { href: '#accounts', label: 'حساب‌های بانکی / Bank Accounts', current: true },
165
+ { href: '#currencies', label: 'ارزها / Currencies' },
166
+ { href: '#transaction-types', label: 'نوع تراکنش / Transaction Types' },
167
+ { href: '#transaction-natures', label: 'ماهیت تراکنش‌ها / Transaction Natures' },
168
+ { href: '#descriptions', label: 'شرح‌های استاندارد / Standard Descriptions' },
169
+ { href: '#rates', label: 'نرخ تسعیر / Exchange Rates' },
170
+ ],
171
+ },
172
+ {
173
+ id: 'operations',
174
+ items: [
175
+ { href: '#operations', icon: <ReceiptSearch />, label: 'عملیات / Operations' },
176
+ { href: '#security', icon: <Setting2 />, label: 'امنیت / Security' },
177
+ { href: '#modules', icon: <WalletMoney />, label: 'زیرسیستم‌ها / Modules' },
178
+ ],
179
+ },
180
+ ]}
181
+ />
182
+ );
183
+
184
+ function NocfoBankAccountsWorkspace() {
185
+ return (
186
+ <div data-theme="dark" dir="rtl" lang="fa" style={{ height: '100dvh' }}>
187
+ <AppFrame
188
+ className="h-full min-h-0 overflow-hidden"
189
+ header={
190
+ <HeaderNavigation
191
+ actions={
192
+ <>
193
+ <Button size="sm" variant="outline">
194
+ دوشنبه ۱۰ شهریور ۱۴۰۵
195
+ </Button>
196
+ <Select aria-label="سال مالی" defaultValue="1405" className="h-9">
197
+ <option value="1405">سال مالی ۱۴۰۵–۱۴۰۶</option>
198
+ </Select>
199
+ <span className="hidden text-xs text-muted-foreground md:inline">
200
+ fse.prepare@example.com / مدیر
201
+ </span>
202
+ </>
203
+ }
204
+ brand={<NocfoLogo style={{ inlineSize: '6.25rem' }} />}
205
+ className="bg-surface-sunken"
206
+ label="نوار ابزار NOCFO"
207
+ />
208
+ }
209
+ mobileNavigation={financeNavigation}
210
+ navigation={financeNavigation}
211
+ variant="finance"
212
+ >
213
+ <div className="flex min-w-0 flex-col gap-3">
214
+ <PageHeader
215
+ actions={
216
+ <Button variant="secondary">
217
+ <Add />
218
+ افزودن حساب بانکی / Add Bank Account
219
+ </Button>
220
+ }
221
+ breadcrumb={
222
+ <Breadcrumb
223
+ items={[
224
+ { label: 'بانکداری شرکتی', href: '#corporate-banking' },
225
+ { label: 'تنظیمات', href: '#settings' },
226
+ { label: 'حساب‌های بانکی / Bank Accounts', current: true },
227
+ ]}
228
+ />
229
+ }
230
+ title="حساب‌های بانکی / Bank Accounts"
231
+ />
232
+ <FilterBar
233
+ filters={
234
+ <div className="grid w-full grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-4">
235
+ {[
236
+ 'شرکت مالک / Owner Company',
237
+ 'کشور',
238
+ 'بانک',
239
+ 'شعبه',
240
+ 'ارز پایه / Base Currency',
241
+ 'نوع حساب / Account Type',
242
+ 'کارگزار / عامل',
243
+ ].map((label) => (
244
+ <Select aria-label={label} key={label} defaultValue="">
245
+ <option value="">{label}</option>
246
+ </Select>
247
+ ))}
248
+ </div>
249
+ }
250
+ label="فیلتر حساب‌های بانکی"
251
+ search={
252
+ <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
253
+ <Input aria-label="جستجوی شماره حساب" placeholder="جستجوی شماره حساب" />
254
+ <Input aria-label="جستجوی شبا" placeholder="جستجوی IBAN / شبا" />
255
+ </div>
256
+ }
257
+ />
258
+ <section
259
+ className="min-w-0 rounded-lg bg-surface p-3"
260
+ aria-label="ابزار و جدول حساب‌های بانکی"
261
+ >
262
+ <div className="mb-3 flex items-center gap-2">
263
+ <BoxSearch className="size-4 text-muted-foreground" />
264
+ <Input
265
+ aria-label="جستجو در حساب‌های بانکی"
266
+ className="ms-auto max-w-sm"
267
+ placeholder="جستجو در حساب‌های بانکی / Search accounts"
268
+ />
269
+ </div>
270
+ <DataTable
271
+ caption="حساب‌های بانکی"
272
+ columns={bankAccountColumns}
273
+ density="compact"
274
+ empty={<span>حساب بانکی پیدا نشد</span>}
275
+ rows={bankAccountRows}
276
+ />
277
+ </section>
278
+ </div>
279
+ </AppFrame>
280
+ </div>
281
+ );
282
+ }
283
+
78
284
  const meta = {
79
285
  title: 'Catalog/Patterns/Pages/App Frame',
80
286
  component: AppFrameDemo,
@@ -88,3 +294,9 @@ export const Default: Story = {};
88
294
  export const Finance: Story = {
89
295
  render: () => <FinanceAppFrameDemo />,
90
296
  };
297
+
298
+ export const NocfoBankAccounts: Story = {
299
+ name: 'Template — NOCFO bank accounts workspace',
300
+ render: () => <NocfoBankAccountsWorkspace />,
301
+ parameters: { layout: 'fullscreen' },
302
+ };
@@ -8,11 +8,16 @@ const meta = {
8
8
  } satisfies Meta<typeof Checkbox>;
9
9
  export default meta;
10
10
  type Story = StoryObj<typeof meta>;
11
- export const Default: Story = {};
11
+ export const Default: Story = {
12
+ render: () => <Checkbox label="Enable notifications" />,
13
+ };
14
+ export const Bare: Story = {
15
+ render: () => <Checkbox aria-label="Enable notifications" />,
16
+ };
12
17
  export const RTL: Story = {
13
18
  render: () => (
14
19
  <div dir="rtl">
15
- <Checkbox />
20
+ <Checkbox label="فعال‌سازی اعلان‌ها" />
16
21
  </div>
17
22
  ),
18
23
  };
@@ -1,11 +1,23 @@
1
+ import { useState } from 'react';
1
2
  import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { Button } from '@noe/ui/button';
4
+ import { EmptyState } from '@noe/ui/empty-state';
5
+ import { Input } from '@noe/ui/input';
2
6
 
3
- import { type DataColumn, DataTable } from '../../../../packages/data-ui/src/data-table';
7
+ import {
8
+ type DataColumn,
9
+ DataTable,
10
+ type DataSort,
11
+ type FilterSpec,
12
+ type PageSpec,
13
+ type RowId,
14
+ } from '../../../../packages/data-ui/src/data-table';
15
+ import { FilterBar } from '../../../../packages/data-ui/src/filter-bar';
4
16
 
5
17
  type ServiceRow = { id: string; service: string; owner: string; status: string; updated: string };
6
18
 
7
19
  const columns: DataColumn<ServiceRow>[] = [
8
- { key: 'service', header: 'Service' },
20
+ { key: 'service', header: 'Service', technical: true },
9
21
  { key: 'owner', header: 'Owner' },
10
22
  { key: 'status', header: 'Status' },
11
23
  { key: 'updated', header: 'Updated', technical: true, align: 'end' },
@@ -29,6 +41,18 @@ const rows: ServiceRow[] = [
29
41
  },
30
42
  ];
31
43
 
44
+ const moreRows: ServiceRow[] = [
45
+ ...rows,
46
+ {
47
+ id: '4',
48
+ service: 'registry-api',
49
+ owner: 'Design Systems',
50
+ status: 'Healthy',
51
+ updated: '3h ago',
52
+ },
53
+ { id: '5', service: 'agent-gateway', owner: 'AI Platform', status: 'Healthy', updated: '5h ago' },
54
+ ];
55
+
32
56
  function DataTableDemo() {
33
57
  return (
34
58
  <DataTable columns={columns} rows={rows} caption="Service health" empty="No services yet." />
@@ -44,8 +68,207 @@ export default meta;
44
68
  type Story = StoryObj<typeof meta>;
45
69
 
46
70
  export const Default: Story = {};
71
+
47
72
  export const Empty: Story = {
48
73
  render: () => (
49
- <DataTable columns={columns} rows={[]} caption="Service health" empty="No services yet." />
74
+ <DataTable
75
+ columns={columns}
76
+ rows={[]}
77
+ caption="Service health"
78
+ empty={
79
+ <EmptyState
80
+ title="No services yet"
81
+ description="Services appear here once they report health to the platform."
82
+ />
83
+ }
84
+ />
85
+ ),
86
+ };
87
+
88
+ function SortingDemo() {
89
+ const [sorting, setSorting] = useState<DataSort[]>([{ field: 'service', direction: 'asc' }]);
90
+ return (
91
+ <DataTable
92
+ columns={columns}
93
+ rows={[...moreRows]}
94
+ caption="Service health"
95
+ empty="No services yet."
96
+ sorting={sorting}
97
+ onSortingChange={setSorting}
98
+ />
99
+ );
100
+ }
101
+ export const Sorting: Story = { render: () => <SortingDemo /> };
102
+
103
+ function SelectionDemo() {
104
+ const [selected, setSelected] = useState<RowId[]>(['2']);
105
+ return (
106
+ <DataTable
107
+ columns={columns}
108
+ rows={rows}
109
+ caption="Service health"
110
+ empty="No services yet."
111
+ selection={selected}
112
+ onSelectionChange={setSelected}
113
+ selectAllLabel="Select all services"
114
+ rowSelectionLabel={(row: ServiceRow) => `Select ${row.service}`}
115
+ selectedCountLabel={(count: number) => `${count} selected`}
116
+ renderSelectionActions={({ selectedIds }) => (
117
+ <Button size="sm" variant="outline">
118
+ Restart {selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
119
+ </Button>
120
+ )}
121
+ />
122
+ );
123
+ }
124
+ export const Selection: Story = { render: () => <SelectionDemo /> };
125
+
126
+ function PaginationDemo() {
127
+ const [page, setPage] = useState<PageSpec>({ index: 0, size: 2 });
128
+ return (
129
+ <DataTable
130
+ columns={columns}
131
+ rows={[...moreRows]}
132
+ caption="Service health"
133
+ empty="No services yet."
134
+ page={page}
135
+ onPageChange={setPage}
136
+ previousPageLabel="Previous page"
137
+ nextPageLabel="Next page"
138
+ paginationLabel="Service health pagination"
139
+ />
140
+ );
141
+ }
142
+ export const Pagination: Story = { render: () => <PaginationDemo /> };
143
+
144
+ function WithFiltersDemo() {
145
+ const [search, setSearch] = useState('');
146
+ const [filters, setFilters] = useState<FilterSpec[]>([]);
147
+ return (
148
+ <div className="flex flex-col gap-3">
149
+ <FilterBar
150
+ label="Filter services"
151
+ search={
152
+ <Input
153
+ aria-label="Search services"
154
+ onChange={(event) => {
155
+ setSearch(event.target.value);
156
+ setFilters(
157
+ event.target.value
158
+ ? [{ field: 'service', operator: 'contains', value: event.target.value }]
159
+ : [],
160
+ );
161
+ }}
162
+ placeholder="Search services"
163
+ size="sm"
164
+ value={search}
165
+ />
166
+ }
167
+ />
168
+ <DataTable
169
+ columns={columns}
170
+ rows={[...moreRows]}
171
+ caption="Service health"
172
+ empty="No services match the current filter."
173
+ filters={filters}
174
+ onFiltersChange={setFilters}
175
+ />
176
+ </div>
177
+ );
178
+ }
179
+ export const WithFilters: Story = { render: () => <WithFiltersDemo /> };
180
+
181
+ function CompactDemo() {
182
+ return (
183
+ <DataTable
184
+ columns={columns}
185
+ rows={[...moreRows]}
186
+ caption="Service health"
187
+ density="compact"
188
+ empty="No services yet."
189
+ page={{ index: 0, size: 3 }}
190
+ onPageChange={() => {}}
191
+ previousPageLabel="Previous page"
192
+ nextPageLabel="Next page"
193
+ paginationLabel="Service health pagination"
194
+ />
195
+ );
196
+ }
197
+ export const Compact: Story = { render: () => <CompactDemo /> };
198
+
199
+ function RtlDemo() {
200
+ const [sorting, setSorting] = useState<DataSort[]>([{ field: 'service', direction: 'asc' }]);
201
+ const faColumns: DataColumn<ServiceRow>[] = [
202
+ { key: 'service', header: 'سرویس', technical: true },
203
+ { key: 'owner', header: 'مالک' },
204
+ { key: 'status', header: 'وضعیت' },
205
+ { key: 'updated', header: 'آخرین به‌روزرسانی', align: 'end' },
206
+ ];
207
+ return (
208
+ <div dir="rtl" lang="fa">
209
+ <DataTable
210
+ columns={faColumns}
211
+ rows={rows}
212
+ caption="وضعیت سرویس‌ها"
213
+ density="compact"
214
+ empty="هنوز سرویسی ثبت نشده است."
215
+ sorting={sorting}
216
+ onSortingChange={setSorting}
217
+ selectAllLabel="انتخاب همه سرویس‌ها"
218
+ rowSelectionLabel={(row: ServiceRow) => `انتخاب ${row.service}`}
219
+ selectedCountLabel={(count: number) => `${count} مورد انتخاب شده`}
220
+ page={{ index: 0, size: 2 }}
221
+ onPageChange={() => {}}
222
+ previousPageLabel="صفحه قبلی"
223
+ nextPageLabel="صفحه بعدی"
224
+ paginationLabel="صفحه‌بندی وضعیت سرویس‌ها"
225
+ />
226
+ </div>
227
+ );
228
+ }
229
+ export const RTL: Story = { render: () => <RtlDemo /> };
230
+
231
+ function ServerSideDemo() {
232
+ const [query, setQuery] = useState<{ sorts: DataSort[]; filters: FilterSpec[]; page: PageSpec }>({
233
+ sorts: [],
234
+ filters: [],
235
+ page: { index: 0, size: 2 },
236
+ });
237
+ // A real product sends `toDataQuery(query)` to Django and renders the page.
238
+ const serverPage = moreRows.slice(
239
+ query.page.index * query.page.size,
240
+ (query.page.index + 1) * query.page.size,
241
+ );
242
+ return (
243
+ <DataTable
244
+ columns={columns}
245
+ rows={serverPage}
246
+ caption="Service health"
247
+ empty="No services yet."
248
+ sorting={query.sorts}
249
+ onSortingChange={(sorts) => setQuery((q) => ({ ...q, sorts }))}
250
+ manualSorting
251
+ page={query.page}
252
+ onPageChange={(next) => setQuery((q) => ({ ...q, page: next }))}
253
+ manualPagination
254
+ totalRowCount={moreRows.length}
255
+ previousPageLabel="Previous page"
256
+ nextPageLabel="Next page"
257
+ paginationLabel="Service health pagination"
258
+ />
259
+ );
260
+ }
261
+ export const ServerSide: Story = { render: () => <ServerSideDemo /> };
262
+
263
+ export const Loading: Story = {
264
+ render: () => (
265
+ <DataTable
266
+ columns={columns}
267
+ rows={[]}
268
+ caption="Service health"
269
+ empty="No services yet."
270
+ loading
271
+ loadingRows={3}
272
+ />
50
273
  ),
51
274
  };
@@ -1,5 +1,5 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
- import { expect, userEvent, within } from 'storybook/test';
2
+ import { expect, userEvent, waitFor, within } from 'storybook/test';
3
3
 
4
4
  import { Button } from '../../../../packages/ui/src/button';
5
5
  import {
@@ -53,7 +53,7 @@ export const OpenAndClose: Story = {
53
53
  play: async ({ canvas }) => {
54
54
  await userEvent.click(canvas.getByRole('button', { name: /review change/i }));
55
55
  const dialog = within(document.body).getByRole('dialog');
56
- await expect(dialog).toBeVisible();
56
+ await waitFor(() => expect(dialog).toBeVisible());
57
57
  await userEvent.click(within(dialog).getByRole('button', { name: /cancel/i }));
58
58
  },
59
59
  };
@@ -6,6 +6,7 @@ import {
6
6
  iconsaxFreeSource,
7
7
  NoeMark,
8
8
  } from '../../../../packages/icons/src/index';
9
+ import { NocfoLogo } from '../../../../packages/icons/src/nocfo-logo';
9
10
 
10
11
  function IconGallery() {
11
12
  return (
@@ -24,6 +25,16 @@ function IconGallery() {
24
25
  <span>Backward</span>
25
26
  </div>
26
27
  </div>
28
+ <div
29
+ data-theme="dark"
30
+ style={{
31
+ background: 'var(--noe-color-surface-sunken)',
32
+ borderRadius: 'var(--noe-radius-md)',
33
+ padding: 'var(--noe-space-4)',
34
+ }}
35
+ >
36
+ <NocfoLogo label="NOCFO" />
37
+ </div>
27
38
  <div>
28
39
  <strong>Mirror in RTL:</strong> {iconDirectionPolicy.mirror.join(', ')}
29
40
  </div>
@@ -1,5 +1,5 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
- import { expect, userEvent, within } from 'storybook/test';
2
+ import { expect, userEvent, waitFor, within } from 'storybook/test';
3
3
 
4
4
  import { Button } from '../../../../packages/ui/src/button';
5
5
  import {
@@ -40,7 +40,7 @@ export const KeyboardOpen: Story = {
40
40
  trigger.focus();
41
41
  await userEvent.keyboard('{Enter}');
42
42
  const menu = within(document.body).getByRole('menu');
43
- await expect(menu).toBeVisible();
44
- await expect(within(menu).getByText('Rerun')).toBeVisible();
43
+ await waitFor(() => expect(menu).toBeVisible());
44
+ await waitFor(() => expect(within(menu).getByText('Rerun')).toBeVisible());
45
45
  },
46
46
  };
@@ -18,7 +18,6 @@ input,
18
18
  textarea,
19
19
  select {
20
20
  font: inherit;
21
- color: inherit;
22
21
  }
23
22
  button,
24
23
  a,
@@ -30,3 +30,11 @@ Do not create a separate app palette.
30
30
  - Admin, expert and profile shells: the same paper/graphite canvas, thin utility
31
31
  dividers, and one mint primary action per region.
32
32
  - Destructive paths continue to use coral and must retain explicit labels.
33
+
34
+ ## Product marks
35
+
36
+ `NocfoLogo` from `@noe/icons/nocfo-logo` owns the official wide NOCFO wordmark and its intrinsic
37
+ aspect ratio. Render it on a dark product-chrome surface, give it a localized accessible
38
+ label, and never mirror, crop, redraw or recolor the artwork. Product shells compose the
39
+ mark through the `HeaderNavigation.brand` slot instead of referencing a product asset
40
+ from page code.
@@ -60,6 +60,12 @@ surface tokens and spacing; their outer stroke and every shadow are absent. Use
60
60
  the standard `Button variant="secondary"` for transparent, currentColor outline
61
61
  actions. It is white on dark/colored surfaces and graphite on paper.
62
62
 
63
+ The `Template — NOCFO bank accounts workspace` story on `app-frame` is the canonical
64
+ dense finance composition: official `NocfoLogo`, compact header actions, logical RTL
65
+ sidebar, bilingual `PageHeader`, multi-control `FilterBar`, and compact `DataTable`.
66
+ Product repositories keep domain columns and queries local while reusing this
67
+ composition instead of maintaining separate Cashflow and Loan shell/table wrappers.
68
+
63
69
  Mapping to the Untitled UI reference taxonomy (honest as of 2026-08-27 — foundation stabilization):
64
70
 
65
71
  | Untitled UI category | NOE coverage |
@@ -101,6 +107,38 @@ Implemented source includes Button, Input, Field, Dialog, Drawer, Tabs, auto-dir
101
107
  - Tab arrow behavior resolves against active direction.
102
108
  - Email, URL, telephone, numeric, code and identifier content preserve technical direction.
103
109
 
110
+ ## Data tables
111
+
112
+ `DataTable` in `@noe/data-ui` owns the semantic `<table>` markup, NOE tokens,
113
+ RTL behavior and accessibility, while TanStack Table v9 provides the headless
114
+ state engine behind an internal adapter
115
+ (`packages/data-ui/src/table/adapters/tanstack.ts`, per ADR 0008). TanStack
116
+ types never appear in the public API: products program against NOE contracts
117
+ (`DataColumn`, `DataSort`, `FilterSpec`, `PageSpec`, `RowId`, `SelectionState`,
118
+ `DataQuery`) exported from `@noe/data-ui`.
119
+
120
+ - Sorting headers cycle ascending → descending → unsorted and expose `aria-sort`;
121
+ icons are direction-neutral so RTL needs no mirroring.
122
+ - Filtering uses NOE `FilterSpec` operators (`contains`, `equals`, `in`,
123
+ `between`, …). `FilterBar` composes the controls; `DataTable` owns state.
124
+ - Pagination is controlled via `PageSpec` with caller-provided accessible
125
+ labels, matching the `Pagination` primitive contract.
126
+ - Selection keeps stable row ids (`RowId`), supports single/multiple modes and
127
+ never selects by positional index.
128
+ - Server mode (`manualSorting`/`manualFiltering`/`manualPagination` +
129
+ `totalRowCount`) trusts incoming data order and slicing; pair it with
130
+ `toDataQuery` to serialize state into a Django query.
131
+ - `density="compact"` tightens row rhythm for dense workspaces using the
132
+ spacing scale; `regular` is the default.
133
+ - `empty` accepts composed content — pass an `EmptyState` for full empty
134
+ affordances. The `WithFilters` catalog story demonstrates the FilterBar +
135
+ Input + DataTable composition.
136
+
137
+ Deterministic visual evidence (light, dark, RTL, selection, loading) is
138
+ captured by `packages/data-ui/src/table/data-table-visual.evidence.browser.test.tsx`
139
+ into `evidence/data-table/`; the browser test project renders with the real
140
+ Tailwind-expanded NOE tokens.
141
+
104
142
  ## Product adapters
105
143
 
106
144
  Data, chart, agent and workflow packages own the NOE composition. Product screens should never import an unnormalized marketplace component directly.
@@ -386,3 +386,4 @@ Total catalog entries: **129** · registry items: **67**
386
386
  - `fab`: Mobile-only floating action button anchored to the logical end edge above the bottom navigation, with safe-area padding and required accessible label.
387
387
  - `mobile-screen`: Single-column mobile page scaffold composing an app bar, scrollable content region, optional floating action button and bottom navigation.
388
388
  - `bottom-navigation`: Mobile-only bottom tab bar with current-page state, badges, icon slots and safe-area padding; hidden from the medium breakpoint upward.
389
+