basalt-ui 1.23.1 → 1.25.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 (63) hide show
  1. package/AGENTS.md +11 -6
  2. package/CHANGELOG.md +21 -0
  3. package/MIGRATING.md +138 -4
  4. package/README.md +123 -32
  5. package/agent/rules/basalt-data.md +104 -12
  6. package/agent/rules/basalt-query.md +57 -0
  7. package/agent/skills/basalt-app/SKILL.md +7 -0
  8. package/dist/cli/index.d.ts +4 -2
  9. package/dist/cli/index.d.ts.map +1 -1
  10. package/dist/cli/index.js +101 -17
  11. package/dist/dashboard/empty-state.d.ts +6 -2
  12. package/dist/dashboard/empty-state.d.ts.map +1 -1
  13. package/dist/dashboard/empty-state.js +1 -1
  14. package/dist/dashboard/index.d.ts +1 -0
  15. package/dist/dashboard/index.d.ts.map +1 -1
  16. package/dist/dashboard/index.js +8 -0
  17. package/dist/dashboard/query-state.d.ts +96 -0
  18. package/dist/dashboard/query-state.d.ts.map +1 -0
  19. package/dist/dashboard/query-state.js +123 -0
  20. package/dist/dashboard/query-state.test.d.ts +2 -0
  21. package/dist/dashboard/query-state.test.d.ts.map +1 -0
  22. package/dist/data/data-table.d.ts +125 -8
  23. package/dist/data/data-table.d.ts.map +1 -1
  24. package/dist/data/data-table.js +121 -13
  25. package/dist/data/data-table.test.d.ts +2 -0
  26. package/dist/data/data-table.test.d.ts.map +1 -0
  27. package/dist/data/table.d.ts +2 -2
  28. package/dist/data/table.d.ts.map +1 -1
  29. package/dist/guard/index.d.ts.map +1 -1
  30. package/dist/guard/index.js +21 -6
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +7 -1
  34. package/dist/query/error-message.d.ts +41 -0
  35. package/dist/query/error-message.d.ts.map +1 -0
  36. package/dist/query/error-message.js +30 -0
  37. package/dist/query/error-message.test.d.ts +2 -0
  38. package/dist/query/error-message.test.d.ts.map +1 -0
  39. package/dist/query/index.d.ts +1 -19
  40. package/dist/query/index.d.ts.map +1 -1
  41. package/dist/query/index.js +2 -14
  42. package/dist/surfaces.d.ts +1 -1
  43. package/dist/surfaces.js +1 -1
  44. package/llms.txt +3 -3
  45. package/package.json +1 -1
  46. package/src/cli/check-theme.test.ts +34 -0
  47. package/src/cli/index.ts +222 -44
  48. package/src/cli/tokens-css.test.ts +73 -6
  49. package/src/cli/toolchain-wiring.test.ts +146 -0
  50. package/src/dashboard/empty-state.tsx +9 -3
  51. package/src/dashboard/index.ts +11 -0
  52. package/src/dashboard/query-state.test.tsx +152 -0
  53. package/src/dashboard/query-state.tsx +272 -0
  54. package/src/data/data-table.test.tsx +349 -0
  55. package/src/data/data-table.tsx +359 -42
  56. package/src/data/table.ts +8 -1
  57. package/src/guard/check-source.test.ts +141 -0
  58. package/src/guard/index.ts +102 -8
  59. package/src/index.ts +9 -0
  60. package/src/query/error-message.test.ts +67 -0
  61. package/src/query/error-message.ts +70 -0
  62. package/src/query/index.ts +2 -34
  63. package/src/surfaces.ts +1 -1
@@ -9,3 +9,14 @@ export {
9
9
  DangerZone,
10
10
  type DangerZoneProps,
11
11
  } from './settings-section'
12
+ export {
13
+ QueryState,
14
+ type QueryStateProps,
15
+ type QueryStateLike,
16
+ type QueryStateVariant,
17
+ type QueryEmptyCopy,
18
+ LoadingState,
19
+ type LoadingStateProps,
20
+ ErrorState,
21
+ type ErrorStateProps,
22
+ } from './query-state'
@@ -0,0 +1,152 @@
1
+ /**
2
+ * `QueryState` exists because shipping only `EmptyState` produced a correctness bug in a real
3
+ * consumer: a 500 rendered "No images", a dropped connection rendered "Share not found — it may
4
+ * have been deleted". Every assertion here is aimed at that failure mode rather than at chrome.
5
+ *
6
+ * 1. A FAILED query never renders the empty copy. This is the bug, stated as a test.
7
+ * 2. A failed query WITH cached data keeps the page and adds a banner — a background refetch
8
+ * failing must not blank a screen that already works.
9
+ * 3. `fetchStatus: 'idle'` with no data is EMPTY, not loading (the `enabled: false` case);
10
+ * `'fetching'` with no data is loading.
11
+ * 4. A result missing a branch flag THROWS. `QueryStateLike` is a structural subset on purpose,
12
+ * so the compiler stops policing it — and every field it can lose fails silently in the
13
+ * direction of a false claim about the data.
14
+ */
15
+ import { MantineProvider } from '@mantine/core'
16
+ import { render, screen } from '@testing-library/react'
17
+ import { describe, expect, test } from 'bun:test'
18
+ import type { ReactNode } from 'react'
19
+ import { QueryState } from './query-state'
20
+ import type { QueryStateLike } from './query-state'
21
+
22
+ type Row = { id: number }
23
+
24
+ function result(over: Partial<QueryStateLike<Row[]>>): QueryStateLike<Row[]> {
25
+ return {
26
+ data: undefined,
27
+ isError: false,
28
+ error: null,
29
+ fetchStatus: 'idle',
30
+ refetch: () => undefined,
31
+ ...over,
32
+ }
33
+ }
34
+
35
+ const EMPTY = { title: 'No images match', description: 'Widen the capture dates.' }
36
+
37
+ function renderState(query: QueryStateLike<Row[]>, extra?: ReactNode): HTMLElement {
38
+ const { container } = render(
39
+ <MantineProvider>
40
+ <QueryState query={query} empty={EMPTY} errorTitle="Could not load images">
41
+ {(rows) => <div data-testid="body">{rows.length} rows</div>}
42
+ </QueryState>
43
+ {extra}
44
+ </MantineProvider>,
45
+ )
46
+ return container
47
+ }
48
+
49
+ describe('the branch a 500 must not take', () => {
50
+ test('an errored query renders the server message, never the empty copy', () => {
51
+ renderState(
52
+ result({ isError: true, error: { status: 500, value: { message: 'index is rebuilding' } } }),
53
+ )
54
+ expect(screen.getByText('index is rebuilding')).toBeTruthy()
55
+ expect(screen.queryByText('No images match')).toBeNull()
56
+ expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy()
57
+ })
58
+
59
+ test('an opaque 500 envelope falls back to the errorTitle copy plus the status', () => {
60
+ render(
61
+ <MantineProvider>
62
+ <QueryState
63
+ query={result({ isError: true, error: { status: 500, value: {} } })}
64
+ errorTitle="Could not load images"
65
+ errorFallback="The library did not answer."
66
+ >
67
+ {() => null}
68
+ </QueryState>
69
+ </MantineProvider>,
70
+ )
71
+ expect(screen.getByText('The library did not answer. (HTTP 500)')).toBeTruthy()
72
+ })
73
+
74
+ test('retry calls refetch', () => {
75
+ let calls = 0
76
+ renderState(result({ isError: true, error: new Error('down'), refetch: () => ++calls }))
77
+ ;(screen.getByRole('button', { name: 'Retry' }) as HTMLButtonElement).click()
78
+ expect(calls).toBe(1)
79
+ })
80
+ })
81
+
82
+ describe('cached data survives a failed refresh', () => {
83
+ test('error WITH data renders the body plus a banner, not the error page', () => {
84
+ renderState(result({ data: [{ id: 1 }], isError: true, error: new Error('refresh failed') }))
85
+ expect(screen.getByTestId('body').textContent).toBe('1 rows')
86
+ expect(screen.getByText('Showing cached data')).toBeTruthy()
87
+ })
88
+ })
89
+
90
+ describe('empty vs loading', () => {
91
+ test('no data + idle is EMPTY (the enabled:false case)', () => {
92
+ renderState(result({ fetchStatus: 'idle' }))
93
+ expect(screen.getByText('No images match')).toBeTruthy()
94
+ })
95
+
96
+ test('no data + fetching is LOADING', () => {
97
+ renderState(result({ fetchStatus: 'fetching' }))
98
+ expect(screen.queryByText('No images match')).toBeNull()
99
+ expect(screen.getByLabelText('Loading')).toBeTruthy()
100
+ })
101
+
102
+ test('an empty array is empty; a populated one renders children', () => {
103
+ renderState(result({ data: [] }))
104
+ expect(screen.getByText('No images match')).toBeTruthy()
105
+ })
106
+
107
+ test('isEmpty overrides the default test', () => {
108
+ render(
109
+ <MantineProvider>
110
+ <QueryState query={result({ data: [] })} empty={EMPTY} isEmpty={() => false}>
111
+ {(rows) => <div data-testid="body">{rows.length} rows</div>}
112
+ </QueryState>
113
+ </MantineProvider>,
114
+ )
115
+ expect(screen.getByTestId('body').textContent).toBe('0 rows')
116
+ })
117
+
118
+ test('omitting `empty` renders nothing rather than inventing copy', () => {
119
+ render(
120
+ <MantineProvider>
121
+ <QueryState query={result({ data: [] })}>{() => <div>rows</div>}</QueryState>
122
+ </MantineProvider>,
123
+ )
124
+ expect(screen.queryByText('rows')).toBeNull()
125
+ })
126
+ })
127
+
128
+ describe('a malformed result throws instead of asserting absence', () => {
129
+ const cases: [string, unknown][] = [
130
+ ['missing isError', { data: undefined, error: null, fetchStatus: 'idle', refetch: () => 0 }],
131
+ [
132
+ 'misspelled fetchStatus',
133
+ { data: undefined, isError: false, error: null, fetchStatus: 'pending', refetch: () => 0 },
134
+ ],
135
+ ['missing refetch', { data: undefined, isError: false, error: null, fetchStatus: 'idle' }],
136
+ ['not an object at all', ['a', 'b']],
137
+ ]
138
+
139
+ for (const [name, bad] of cases) {
140
+ test(name, () => {
141
+ expect(() =>
142
+ render(
143
+ <MantineProvider>
144
+ <QueryState query={bad as QueryStateLike<Row[]>} empty={EMPTY}>
145
+ {() => null}
146
+ </QueryState>
147
+ </MantineProvider>,
148
+ ),
149
+ ).toThrow(/QueryState: `query`/)
150
+ })
151
+ }
152
+ })
@@ -0,0 +1,272 @@
1
+ /**
2
+ * QueryState — the one wrapper a page puts around an async result, and the sibling `EmptyState`
3
+ * shipped without.
4
+ *
5
+ * The argument is correctness, not convenience. A framework that ships only the EMPTY branch
6
+ * steers every consumer into rendering "nothing here" for "the server failed": image-share's
7
+ * library rendered *No images* on a 500, and a share detail rendered *Share not found — it may
8
+ * have been deleted* on a dropped connection, until 204 hand-rolled lines were written to stop it.
9
+ * basalt already owned both ends of that file — `EmptyState` and `toErrorMessage` — and nothing in
10
+ * between.
11
+ *
12
+ * Component, not a hook: the product IS the branch precedence below. A hook returning
13
+ * `{ status, data }` would hand every call site the same four-way switch back, which is exactly
14
+ * the re-derivation this exists to delete. `LoadingState` / `ErrorState` ship alongside as the
15
+ * escape hatch for a page that must place its branches in different DOM positions.
16
+ */
17
+ import { Alert, Button, Center, Group, Loader, Stack, Text } from '@mantine/core'
18
+ import type { ReactNode } from 'react'
19
+ import { toErrorMessage } from '../query/error-message'
20
+ import { EmptyState } from './empty-state'
21
+
22
+ /** `'page'` = a full route body (generous padding). `'section'` = a card/panel region (compact). */
23
+ export type QueryStateVariant = 'page' | 'section'
24
+
25
+ /**
26
+ * The structural subset of a TanStack `UseQueryResult` these components read.
27
+ *
28
+ * Typed as a SUBSET rather than as `UseQueryResult<T>` on purpose: a composed, derived or
29
+ * hand-rolled result must be passable without a cast, and
30
+ * these components stay independent of which `@tanstack/react-query` major a consumer pins. This
31
+ * is NOT a claim that the root barrel is free of that package — `./connectivity` imports
32
+ * `onlineManager` as a value and is re-exported from `src/index.ts`. The cost of the subset is
33
+ * that the compiler stops policing the shape — which is why {@link QueryState} asserts it at
34
+ * runtime.
35
+ */
36
+ export type QueryStateLike<TData> = {
37
+ data: TData | undefined
38
+ isError: boolean
39
+ error: unknown
40
+ fetchStatus: 'fetching' | 'paused' | 'idle'
41
+ refetch: () => unknown
42
+ }
43
+
44
+ /** Copy for the empty branch. Rendered through `EmptyState`, so it looks like every other one. */
45
+ export type QueryEmptyCopy = {
46
+ title: string
47
+ /** Optional — a one-word empty panel should not have to invent a sentence. */
48
+ description?: string
49
+ /** Optional glyph slot, rendered faint. */
50
+ icon?: ReactNode
51
+ /** Call-to-action below the copy (e.g. "Reset filters"). */
52
+ action?: ReactNode
53
+ }
54
+
55
+ // ── LoadingState ──────────────────────────────────────────────────────────────────────────────────
56
+
57
+ export type LoadingStateProps = {
58
+ variant?: QueryStateVariant
59
+ /** Accessible name for the spinner. Say what is loading. */
60
+ label?: string
61
+ }
62
+
63
+ /** A spinner on its own — `variant="page"` centres it in a generous block, `'section'` is bare. */
64
+ export function LoadingState({
65
+ variant = 'page',
66
+ label = 'Loading',
67
+ }: LoadingStateProps): ReactNode {
68
+ const loader = <Loader size="sm" aria-label={label} />
69
+ if (variant === 'section') return loader
70
+ return <Center py={64}>{loader}</Center>
71
+ }
72
+
73
+ // ── ErrorState ────────────────────────────────────────────────────────────────────────────────────
74
+
75
+ export type ErrorStateProps = {
76
+ /** Whatever was thrown — the raw Eden/fetch envelope is fine, it gets decoded. */
77
+ error: unknown
78
+ /** Alert heading. Say what failed, e.g. `Could not load images`. */
79
+ title?: string
80
+ /** Shown only when the server body carries no readable message. */
81
+ fallback?: string
82
+ onRetry?: () => void
83
+ /** Disables/spins the retry button while a refetch is in flight. */
84
+ retrying?: boolean
85
+ variant?: QueryStateVariant
86
+ /** Extra controls beside Retry (e.g. a "Back to shares" link). */
87
+ action?: ReactNode
88
+ }
89
+
90
+ /**
91
+ * The error branch: the REAL server message (via `toErrorMessage`) plus a retry. Usable on its own,
92
+ * outside a query — an auth gate or a boundary fallback renders it directly.
93
+ */
94
+ export function ErrorState({
95
+ error,
96
+ title = 'Something went wrong',
97
+ fallback = 'The request failed.',
98
+ onRetry,
99
+ retrying = false,
100
+ variant = 'page',
101
+ action,
102
+ }: ErrorStateProps): ReactNode {
103
+ const alert = (
104
+ <Alert color="red" variant="light" title={title}>
105
+ <Stack gap="sm">
106
+ <Text size="sm">{toErrorMessage(error, fallback)}</Text>
107
+ {(onRetry ?? action) !== undefined && (
108
+ <Group gap="xs">
109
+ {onRetry && (
110
+ <Button size="xs" variant="default" loading={retrying} onClick={onRetry}>
111
+ Retry
112
+ </Button>
113
+ )}
114
+ {action}
115
+ </Group>
116
+ )}
117
+ </Stack>
118
+ </Alert>
119
+ )
120
+ if (variant === 'section') return alert
121
+ return <Stack py="md">{alert}</Stack>
122
+ }
123
+
124
+ // ── QueryState ────────────────────────────────────────────────────────────────────────────────────
125
+
126
+ /** `[]`, `{ data: [], total }` (the common pagination envelope) and null all count as empty. */
127
+ function defaultIsEmpty(data: unknown): boolean {
128
+ if (data === null || data === undefined) return true
129
+ if (Array.isArray(data)) return data.length === 0
130
+ if (typeof data === 'object' && Array.isArray((data as { data?: unknown }).data)) {
131
+ return (data as { data: unknown[] }).data.length === 0
132
+ }
133
+ return false
134
+ }
135
+
136
+ const FETCH_STATUSES = new Set(['fetching', 'paused', 'idle'])
137
+
138
+ /**
139
+ * `QueryStateLike` is a structural subset, so TypeScript cannot protect a hand-composed result the
140
+ * way it protects a real `UseQueryResult` — and every field it drops fails SILENTLY in the exact
141
+ * direction this component exists to prevent. A missing `isError` renders "no data" over a 500. A
142
+ * misspelled `fetchStatus` never leaves the spinner. A missing `refetch` renders a Retry button
143
+ * that does nothing. Each of those is a false claim about the data, so it throws.
144
+ */
145
+ function assertQueryStateLike(query: unknown): asserts query is QueryStateLike<unknown> {
146
+ const bad = (detail: string): never => {
147
+ throw new Error(
148
+ `QueryState: \`query\` ${detail}. It must carry { data, isError, error, fetchStatus, refetch } — ` +
149
+ 'a TanStack UseQueryResult does; a hand-composed one must spell every field, because a ' +
150
+ 'missing branch flag renders a false claim about the data instead of an error.',
151
+ )
152
+ }
153
+ if (query === null || typeof query !== 'object')
154
+ bad(`is ${query === null ? 'null' : typeof query}`)
155
+ const q = query as Record<string, unknown>
156
+ if (!('data' in q)) bad('has no `data` key')
157
+ if (typeof q['isError'] !== 'boolean') bad('has no boolean `isError`')
158
+ if (typeof q['fetchStatus'] !== 'string' || !FETCH_STATUSES.has(q['fetchStatus']))
159
+ bad(`has fetchStatus=${JSON.stringify(q['fetchStatus'])}, not 'fetching' | 'paused' | 'idle'`)
160
+ if (typeof q['refetch'] !== 'function') bad('has no `refetch()`')
161
+ }
162
+
163
+ export type QueryStateProps<TData> = {
164
+ query: QueryStateLike<TData>
165
+ /** A render function gets `data` narrowed to non-undefined; a plain node also works. */
166
+ children: ReactNode | ((data: TData) => ReactNode)
167
+ /** Omit to render nothing when the result is empty. */
168
+ empty?: QueryEmptyCopy
169
+ /** Override the default `[]` / `{ data: [] }` emptiness test — `() => false` disables it. */
170
+ isEmpty?: (data: TData) => boolean
171
+ /** Alert heading on the error branch — say what failed. */
172
+ errorTitle?: string
173
+ /** Shown only when the server body carries no readable message. */
174
+ errorFallback?: string
175
+ /** Extra controls beside Retry on the error branch. */
176
+ errorAction?: ReactNode
177
+ variant?: QueryStateVariant
178
+ /** Replace the default spinner (e.g. with a skeleton grid). */
179
+ loading?: ReactNode
180
+ }
181
+
182
+ /**
183
+ * Renders loading / error-with-retry / empty / children for a query result, in that order.
184
+ * Branch precedence is the whole point, and it is deliberate:
185
+ *
186
+ * - error AND no cached data → the full error state (the page cannot render)
187
+ * - error WITH cached data → children, plus a compact "showing cached data" banner on top
188
+ * (a background refetch failing must not blank a page that already works)
189
+ * - no data, fetch idle → empty (this is an `enabled: false` query, not a pending one)
190
+ * - no data, fetching → `loading` (or the default spinner)
191
+ *
192
+ * @example
193
+ * <QueryState
194
+ * query={q}
195
+ * errorTitle="Could not load images"
196
+ * empty={{ title: 'No images match', description: 'Widen the capture dates.' }}
197
+ * >
198
+ * {(data) => <ImageGrid images={data.rows} />}
199
+ * </QueryState>
200
+ */
201
+ export function QueryState<TData>({
202
+ query,
203
+ children,
204
+ empty,
205
+ isEmpty,
206
+ errorTitle = 'Could not load',
207
+ errorFallback = 'The request failed.',
208
+ errorAction,
209
+ variant = 'page',
210
+ loading,
211
+ }: QueryStateProps<TData>): ReactNode {
212
+ assertQueryStateLike(query)
213
+ const { data, isError, error, fetchStatus } = query
214
+ const retrying = fetchStatus === 'fetching'
215
+ const retry = (): void => void query.refetch()
216
+
217
+ if (isError && data === undefined) {
218
+ return (
219
+ <ErrorState
220
+ error={error}
221
+ title={errorTitle}
222
+ fallback={errorFallback}
223
+ onRetry={retry}
224
+ retrying={retrying}
225
+ variant={variant}
226
+ {...(errorAction !== undefined && { action: errorAction })}
227
+ />
228
+ )
229
+ }
230
+
231
+ if (data === undefined) {
232
+ if (fetchStatus === 'idle' && !isError) return empty ? renderEmpty(empty, variant) : null
233
+ return loading ?? <LoadingState variant={variant} />
234
+ }
235
+
236
+ const emptyNow = isEmpty ? isEmpty(data) : defaultIsEmpty(data)
237
+ const body = emptyNow
238
+ ? empty
239
+ ? renderEmpty(empty, variant)
240
+ : null
241
+ : typeof children === 'function'
242
+ ? children(data)
243
+ : children
244
+
245
+ if (!isError) return body
246
+
247
+ return (
248
+ <Stack gap="sm">
249
+ <ErrorState
250
+ error={error}
251
+ title="Showing cached data"
252
+ fallback="The last refresh failed."
253
+ onRetry={retry}
254
+ retrying={retrying}
255
+ variant="section"
256
+ />
257
+ {body}
258
+ </Stack>
259
+ )
260
+ }
261
+
262
+ function renderEmpty(empty: QueryEmptyCopy, variant: QueryStateVariant): ReactNode {
263
+ return (
264
+ <EmptyState
265
+ title={empty.title}
266
+ variant={variant}
267
+ {...(empty.description !== undefined && { description: empty.description })}
268
+ {...(empty.icon !== undefined && { icon: empty.icon })}
269
+ {...(empty.action !== undefined && { action: empty.action })}
270
+ />
271
+ )
272
+ }