basalt-ui 1.23.0 → 1.24.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 (81) hide show
  1. package/AGENTS.md +31 -17
  2. package/CHANGELOG.md +23 -0
  3. package/MIGRATING.md +222 -6
  4. package/README.md +159 -49
  5. package/agent/rules/basalt-app.md +8 -2
  6. package/agent/rules/basalt-charts.md +38 -2
  7. package/agent/rules/basalt-content.md +3 -3
  8. package/agent/rules/basalt-data.md +45 -13
  9. package/agent/rules/basalt-mantine.md +4 -4
  10. package/agent/rules/basalt-query.md +57 -0
  11. package/agent/rules/basalt-tokens.md +18 -5
  12. package/agent/skills/basalt-app/SKILL.md +32 -12
  13. package/agent/skills/basalt-charts/SKILL.md +4 -2
  14. package/agent/templates/CLAUDE-block.md.tpl +3 -2
  15. package/dist/charts/kinds/BandStrip.d.ts +20 -6
  16. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  17. package/dist/charts/kinds/BandStrip.js +59 -6
  18. package/dist/charts/kinds/MirroredBars.d.ts +4 -2
  19. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
  20. package/dist/charts/kinds/MirroredBars.js +13 -0
  21. package/dist/cli/index.d.ts +4 -2
  22. package/dist/cli/index.d.ts.map +1 -1
  23. package/dist/cli/index.js +269 -27
  24. package/dist/dashboard/empty-state.d.ts +6 -2
  25. package/dist/dashboard/empty-state.d.ts.map +1 -1
  26. package/dist/dashboard/empty-state.js +1 -1
  27. package/dist/dashboard/index.d.ts +1 -0
  28. package/dist/dashboard/index.d.ts.map +1 -1
  29. package/dist/dashboard/index.js +8 -0
  30. package/dist/dashboard/query-state.d.ts +96 -0
  31. package/dist/dashboard/query-state.d.ts.map +1 -0
  32. package/dist/dashboard/query-state.js +123 -0
  33. package/dist/dashboard/query-state.test.d.ts +2 -0
  34. package/dist/dashboard/query-state.test.d.ts.map +1 -0
  35. package/dist/data/data-table.d.ts +61 -4
  36. package/dist/data/data-table.d.ts.map +1 -1
  37. package/dist/data/data-table.js +41 -4
  38. package/dist/data/data-table.test.d.ts +2 -0
  39. package/dist/data/data-table.test.d.ts.map +1 -0
  40. package/dist/data/table.d.ts +1 -1
  41. package/dist/data/table.d.ts.map +1 -1
  42. package/dist/guard/index.d.ts.map +1 -1
  43. package/dist/guard/index.js +50 -9
  44. package/dist/index.d.ts +1 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +7 -1
  47. package/dist/query/error-message.d.ts +41 -0
  48. package/dist/query/error-message.d.ts.map +1 -0
  49. package/dist/query/error-message.js +30 -0
  50. package/dist/query/error-message.test.d.ts +2 -0
  51. package/dist/query/error-message.test.d.ts.map +1 -0
  52. package/dist/query/index.d.ts +1 -19
  53. package/dist/query/index.d.ts.map +1 -1
  54. package/dist/query/index.js +2 -14
  55. package/dist/surfaces.d.ts +1 -1
  56. package/dist/surfaces.js +1 -1
  57. package/llms.txt +3 -3
  58. package/package.json +1 -1
  59. package/src/charts/kinds/BandStrip.test.tsx +87 -5
  60. package/src/charts/kinds/BandStrip.tsx +105 -12
  61. package/src/charts/kinds/MirroredBars.test.tsx +39 -0
  62. package/src/charts/kinds/MirroredBars.tsx +38 -2
  63. package/src/cli/check-theme.test.ts +34 -0
  64. package/src/cli/help.test.ts +74 -1
  65. package/src/cli/index.ts +496 -41
  66. package/src/cli/tokens-css.test.ts +129 -0
  67. package/src/cli/toolchain-wiring.test.ts +146 -0
  68. package/src/dashboard/empty-state.tsx +9 -3
  69. package/src/dashboard/index.ts +11 -0
  70. package/src/dashboard/query-state.test.tsx +152 -0
  71. package/src/dashboard/query-state.tsx +272 -0
  72. package/src/data/data-table.test.tsx +158 -0
  73. package/src/data/data-table.tsx +123 -5
  74. package/src/data/table.ts +6 -1
  75. package/src/guard/check-source.test.ts +214 -0
  76. package/src/guard/index.ts +169 -15
  77. package/src/index.ts +9 -0
  78. package/src/query/error-message.test.ts +67 -0
  79. package/src/query/error-message.ts +70 -0
  80. package/src/query/index.ts +2 -34
  81. package/src/surfaces.ts +1 -1
@@ -14,6 +14,7 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSy
14
14
  import { tmpdir } from 'node:os'
15
15
  import { join, resolve } from 'node:path'
16
16
 
17
+ import { DEFAULT_GUARD_CONFIG, GENERATED_HEADER_LINE, checkSource } from '../guard'
17
18
  import { buildPaletteCss } from '../tokens'
18
19
  import { normalizeColorFunctions, run, tokensCss } from './index'
19
20
 
@@ -118,3 +119,131 @@ describe('tokens:css', () => {
118
119
  expect(readdirSync(dir)).toEqual(['package.json'])
119
120
  })
120
121
  })
122
+
123
+ /**
124
+ * `--check` gates the token VALUES and the provenance INVOCATION — only the version token is free.
125
+ *
126
+ * Line 2 of the generated header carries the emitting version, and `--check` used to compare the
127
+ * whole file byte for byte, so every basalt-ui release forced a mandatory no-op commit in every
128
+ * consumer that commits a generated sheet. 1.23.1 fixed that by blanking the whole of line 2 —
129
+ * which is also the line carrying the exact invocation line 1 tells the reader to regenerate with.
130
+ * rollhook's header was rewritten from `--only core --no-legacy-aliases` to `--only all
131
+ * --with-legacy-aliases` and `--check` passed clean.
132
+ *
133
+ * Three-way contract, one test each below: a version-only change passes, a line-2 flag change
134
+ * fails, a token drift fails.
135
+ */
136
+ describe('tokens:css --check and the provenance line', () => {
137
+ /** Capture stdout/stderr so the check's own sentence can be asserted. */
138
+ function check(out: string): { code: number; log: string } {
139
+ const originalLog = console.log
140
+ const originalError = console.error
141
+ let log = ''
142
+ const sink = (...args: unknown[]) => {
143
+ log += `${args.join(' ')}\n`
144
+ }
145
+ console.log = sink
146
+ console.error = sink
147
+ try {
148
+ return { code: tokensCss(['--check', '--out', out], dir), log }
149
+ } finally {
150
+ console.log = originalLog
151
+ console.error = originalError
152
+ }
153
+ }
154
+
155
+ it('passes when only the emitting VERSION on line 2 differs', () => {
156
+ const emitted = emit([])
157
+ const lines = emitted.split('\n')
158
+ lines[1] = (lines[1] as string).replace(/basalt-ui [\d.]+/, 'basalt-ui 0.0.1')
159
+ writeFileSync(resolve(dir, 'out.css'), lines.join('\n'))
160
+
161
+ const { code, log } = check('out.css')
162
+ expect(code).toBe(0)
163
+ expect(log).toContain('is up to date')
164
+ // Parsed, not asserted: the old sentence claimed "an older basalt-ui" without ever reading
165
+ // the token, so `0.0.1-nonsense` got the same claim. Both versions are named now.
166
+ expect(log).toContain('names basalt-ui 0.0.1, an older release than the')
167
+ })
168
+
169
+ it('calls a NEWER on-disk version newer — the old sentence said "older" either way', () => {
170
+ const lines = emit([]).split('\n')
171
+ lines[1] = (lines[1] as string).replace(/basalt-ui [\d.]+ —/, 'basalt-ui 999.0.0 —')
172
+ writeFileSync(resolve(dir, 'out.css'), lines.join('\n'))
173
+
174
+ const { code, log } = check('out.css')
175
+ expect(code).toBe(0)
176
+ expect(log).toContain('names basalt-ui 999.0.0, a newer release than the')
177
+ expect(log).not.toContain('an older release')
178
+ })
179
+
180
+ it('calls a same-triple prerelease merely different rather than ordering it', () => {
181
+ const lines = emit([]).split('\n')
182
+ const current = /basalt-ui (\d+\.\d+\.\d+)/.exec(lines[1] as string)?.[1] as string
183
+ lines[1] = (lines[1] as string).replace(`basalt-ui ${current} —`, `basalt-ui ${current}-rc.1 —`)
184
+ writeFileSync(resolve(dir, 'out.css'), lines.join('\n'))
185
+
186
+ const { code, log } = check('out.css')
187
+ expect(code).toBe(0)
188
+ expect(log).toContain(`names basalt-ui ${current}-rc.1, a different release than the`)
189
+ })
190
+
191
+ it('FAILS when line 2 names different flags — the invocation line 1 points at is gated', () => {
192
+ const emitted = emit([])
193
+ const lines = emitted.split('\n')
194
+ lines[1] = (lines[1] as string).replace(
195
+ '`basalt-ui tokens:css --out out.css`',
196
+ '`basalt-ui tokens:css --only all --with-legacy-aliases --out out.css`',
197
+ )
198
+ writeFileSync(resolve(dir, 'out.css'), lines.join('\n'))
199
+
200
+ const { code, log } = check('out.css')
201
+ expect(code).toBe(1)
202
+ expect(log).toContain('provenance line does not match the command that produced this content')
203
+ expect(log).toContain('--only all --with-legacy-aliases')
204
+ })
205
+
206
+ it('FAILS when the provenance line is deleted outright — a blank line is not a free pass', () => {
207
+ const lines = emit([]).split('\n')
208
+ lines[1] = ''
209
+ writeFileSync(resolve(dir, 'out.css'), lines.join('\n'))
210
+
211
+ expect(check('out.css').code).toBe(1)
212
+ })
213
+
214
+ it('says nothing about provenance when the file is byte-identical', () => {
215
+ emit([])
216
+ const { code, log } = check('out.css')
217
+ expect(code).toBe(0)
218
+ expect(log).not.toContain('provenance line')
219
+ })
220
+
221
+ it('STILL fails when a token value actually moved', () => {
222
+ const emitted = emit([])
223
+ writeFileSync(resolve(dir, 'out.css'), emitted.replace('--vx-neutral', '--vx-neutrall'))
224
+ const { code, log } = check('out.css')
225
+ expect(code).toBe(1)
226
+ expect(log).toContain('differs from what')
227
+ })
228
+
229
+ it('STILL fails on token drift even when the version token ALSO differs', () => {
230
+ const lines = emit([]).replace('--vx-neutral', '--vx-neutrall').split('\n')
231
+ lines[1] = (lines[1] as string).replace(/basalt-ui [\d.]+ —/, 'basalt-ui 0.0.1 —')
232
+ writeFileSync(resolve(dir, 'out.css'), lines.join('\n'))
233
+ expect(check('out.css').code).toBe(1)
234
+ })
235
+
236
+ it('the header itself is unchanged — the `@generated` exemption needs both lines verbatim', () => {
237
+ const lines = emit([]).split('\n')
238
+ expect(lines[0]).toBe(GENERATED_HEADER_LINE)
239
+ expect(lines[1]).toMatch(/^\/\* basalt-ui [\d.]+ — `basalt-ui tokens:css --out out\.css` \*\/$/)
240
+ })
241
+
242
+ it('the emitted header is still guard-exempt — checkSource reports nothing in it', () => {
243
+ // The exemption requires line 1 verbatim AND line 2 parsing as the provenance line. `--check`
244
+ // now reads line 2 too, so the two shapes have to keep agreeing; this is the assertion that
245
+ // notices if the emitter drifts to satisfy the gate.
246
+ const emitted = emit([])
247
+ expect(checkSource(emitted, 'src/tokens.css', DEFAULT_GUARD_CONFIG)).toEqual([])
248
+ })
249
+ })
@@ -1210,3 +1210,149 @@ describe('DESIGN.md carries no version, and sync heals the ones already written'
1210
1210
  expect(read('DESIGN.md')).toBe(owned)
1211
1211
  })
1212
1212
  })
1213
+
1214
+ // ── The ascend direction ──────────────────────────────────────────────────────
1215
+ // Round 9, basalt-ui-obsidian: from `apps/demo` — a package with no `basalt` key of its own —
1216
+ // `check-theme` invented `roots: ["src"]`, scanned 22 of the repo's 44 guarded files, printed
1217
+ // `✓ no off-palette colors`, and reported the invention back as `basalt.roots (src)`. Its
1218
+ // `--audit-allows` then reported `0 live` for a repo carrying one live waiver: a clean slate from
1219
+ // the one command whose entire job is to deny one. `doctor`, standing in the same directory,
1220
+ // already resolved the parent. The descend direction announced itself; only ascend was silent.
1221
+ describe('check-theme / doctor — ascend to the parent config instead of fabricating one', () => {
1222
+ /** Repo root owns the config and names TWO roots; neither package declares anything. */
1223
+ function twoRootRepo(): void {
1224
+ write('.git/HEAD', 'ref: refs/heads/master\n')
1225
+ write(
1226
+ 'package.json',
1227
+ JSON.stringify({
1228
+ name: 'fixture',
1229
+ workspaces: ['packages/*', 'apps/*'],
1230
+ basalt: { roots: ['packages/lib/src', 'apps/demo/src'] },
1231
+ }),
1232
+ )
1233
+ write('packages/lib/package.json', JSON.stringify({ name: 'lib' }))
1234
+ write('apps/demo/package.json', JSON.stringify({ name: 'demo' }))
1235
+ write('apps/demo/src/App.tsx', 'export const App = () => null\n')
1236
+ }
1237
+
1238
+ it('scans the WHOLE declared surface from a package that declares nothing, and says so', () => {
1239
+ twoRootRepo()
1240
+ // The violation sits in the sibling root — the half the fabricated `src` default never read.
1241
+ write('packages/lib/src/Card.tsx', `export const C = () => <div style={{ borderRadius: 8 }} />`)
1242
+
1243
+ const { code, log } = capture(() => checkTheme(join(dir, 'apps/demo')))
1244
+ expect(log).toContain('running in ../.., where it lives')
1245
+ expect(code).toBe(1)
1246
+ expect(log).toContain('raw-surface')
1247
+ })
1248
+
1249
+ it('--audit-allows reports the repo’s real waiver count, not zero', () => {
1250
+ twoRootRepo()
1251
+ write(
1252
+ 'packages/lib/src/Card.tsx',
1253
+ 'export const C = () => <div style={{ borderRadius: 8 }} /> // theme-allow: legacy widget\n',
1254
+ )
1255
+
1256
+ const { code, log } = capture(() => checkTheme(join(dir, 'apps/demo'), ['--audit-allows']))
1257
+ expect(code).toBe(0)
1258
+ expect(log).toContain('1 live, 0 dead')
1259
+ expect(log).toContain('basalt.roots (packages/lib/src, apps/demo/src)')
1260
+ })
1261
+
1262
+ it('doctor and check-theme now answer with the SAME project dir', () => {
1263
+ twoRootRepo()
1264
+ write('packages/lib/src/Card.tsx', 'export const C = () => null\n')
1265
+ write(MANIFEST_PATH, JSON.stringify({ version: 1, files: {}, basaltVersion: CLI_VERSION }))
1266
+ write('.oxlintrc.json', '{ "extends": ["./node_modules/basalt-ui/configs/oxlint.json"] }')
1267
+ installBasalt()
1268
+
1269
+ const { log } = capture(() => doctor(join(dir, 'apps/demo')))
1270
+ expect(log).toContain('reporting on ../.., where it lives')
1271
+ expect(log).toContain(`basalt-ui doctor — ${dir}`)
1272
+ })
1273
+
1274
+ it('never ascends out of the repo — no .git above cwd means no ancestor to read', () => {
1275
+ // No `.git` anywhere: the walk has no bound, so it refuses to walk at all and the built-in
1276
+ // defaults apply exactly as they did before. A standalone consumer is untouched by this.
1277
+ write('package.json', JSON.stringify({ name: 'root', basalt: { roots: ['packages/lib/src'] } }))
1278
+ write('packages/lib/package.json', JSON.stringify({ name: 'lib' }))
1279
+ write('packages/lib/src/App.tsx', 'export const App = () => null\n')
1280
+
1281
+ const { code, log } = capture(() => checkTheme(join(dir, 'packages/lib')))
1282
+ expect(log).not.toContain('where it lives')
1283
+ expect(code).toBe(0)
1284
+ })
1285
+
1286
+ it('BASALT_CWD still wins over the ascend', () => {
1287
+ twoRootRepo()
1288
+ write('packages/lib/src/Card.tsx', 'export const C = () => null\n')
1289
+ write('apps/demo/package.json', JSON.stringify({ name: 'demo', basalt: { roots: ['src'] } }))
1290
+ process.env['BASALT_CWD'] = join(dir, 'apps/demo')
1291
+ try {
1292
+ const { code, log } = capture(() => checkTheme(dir, ['--audit-allows']))
1293
+ expect(code).toBe(0)
1294
+ expect(log).toContain('basalt.roots (src)')
1295
+ } finally {
1296
+ delete process.env['BASALT_CWD']
1297
+ }
1298
+ })
1299
+ })
1300
+
1301
+ // ── The icons check, reachable wherever doctor runs ───────────────────────────
1302
+ // Round 9, rb and argo: the root run — the ONLY invocation that can exit 0 on a monorepo layout —
1303
+ // omitted the icons check entirely, with no `⊘ SKIPPED` line. It read `cwd/public` and `cwd`'s
1304
+ // vite config, and in a monorepo both live in the app package. A check that vanishes without a
1305
+ // word is indistinguishable from one that passed.
1306
+ describe('doctor — the icons check is reachable from the root, or says why not', () => {
1307
+ /** A monorepo whose app package owns the vite config, the public/ tree and nothing else. */
1308
+ function appPackageRepo(viteConfig: string): void {
1309
+ write('.git/HEAD', 'ref: refs/heads/master\n')
1310
+ write('package.json', JSON.stringify({ name: 'fixture', basalt: { roots: ['apps/web/src'] } }))
1311
+ write('apps/web/src/app.tsx', 'export const App = () => null\n')
1312
+ write('apps/web/vite.config.ts', viteConfig)
1313
+ write('.oxlintrc.json', '{ "extends": ["./node_modules/basalt-ui/configs/oxlint.json"] }')
1314
+ write(MANIFEST_PATH, JSON.stringify({ version: 1, files: {}, basaltVersion: CLI_VERSION }))
1315
+ installBasalt()
1316
+ }
1317
+
1318
+ it('reads the app package’s `icons` array from a ROOT-invoked run', () => {
1319
+ appPackageRepo("export default basaltAppPlugin({ icons: [{ src: '/favicon.svg' }] })\n")
1320
+ write('apps/web/public/favicon.svg', '<svg/>')
1321
+
1322
+ const { log } = capture(() => doctor(dir))
1323
+ expect(log).toContain("apps/web/public/ has all 1 icon file(s) basaltAppPlugin's `icons`")
1324
+ })
1325
+
1326
+ it('reports a missing icon from the root too — it is not just reachable, it judges', () => {
1327
+ appPackageRepo("export default basaltAppPlugin({ icons: [{ src: '/favicon.svg' }] })\n")
1328
+ write('apps/web/public/.keep', '')
1329
+
1330
+ const { log } = capture(() => doctor(dir))
1331
+ expect(log).toContain('basaltAppPlugin declares icon(s) that are not in ./apps/web/public/')
1332
+ })
1333
+
1334
+ it('SKIPS loudly when the plugin is configured but has no public/ beside it', () => {
1335
+ appPackageRepo("export default basaltAppPlugin({ icons: [{ src: '/favicon.svg' }] })\n")
1336
+
1337
+ const { code, log } = capture(() => doctor(dir))
1338
+ expect(log).toContain('⊘ SKIPPED — app icons — ./apps/web configures basaltAppPlugin')
1339
+ expect(code).toBe(1)
1340
+ })
1341
+
1342
+ it('states the absence rather than omitting the line when nothing uses the plugin', () => {
1343
+ write('.git/HEAD', 'ref: refs/heads/master\n')
1344
+ healthyFixture()
1345
+
1346
+ const { code, log } = capture(() => doctor(dir))
1347
+ expect(log).toContain('no `basaltAppPlugin(` under this project')
1348
+ expect(code).toBe(0)
1349
+ })
1350
+
1351
+ it('`icons: false` is nothing to check, from the root as from the package', () => {
1352
+ appPackageRepo('export default basaltAppPlugin({ icons: false })\n')
1353
+
1354
+ const { code, log } = capture(() => doctor(dir))
1355
+ expect(log).toContain('basaltAppPlugin is configured with no icons')
1356
+ expect(code).toBe(0)
1357
+ })
1358
+ })
@@ -24,8 +24,12 @@ export type EmptyStateProps = {
24
24
  icon?: ReactNode
25
25
  /** Head-font title (18px, weight 550, ink). */
26
26
  title: string
27
- /** Muted 15px description, capped at ~360px so it reads as a short explanation. */
28
- description: string
27
+ /**
28
+ * Muted 15px description, capped at ~360px so it reads as a short explanation. Optional: a
29
+ * compact panel ("No data yet") should not have to invent a second sentence, and five argo
30
+ * features wrapped this component solely to avoid doing so.
31
+ */
32
+ description?: string
29
33
  /** Optional call-to-action rendered below the description. */
30
34
  action?: ReactNode
31
35
  /** `'page'` (default) = generous padding for a full-page state; `'section'` = compact. */
@@ -60,7 +64,9 @@ export function EmptyState({
60
64
  >
61
65
  {title}
62
66
  </span>
63
- <span style={{ fontSize: VX.text.md, color: VX.muted, maxWidth: 360 }}>{description}</span>
67
+ {description !== undefined && (
68
+ <span style={{ fontSize: VX.text.md, color: VX.muted, maxWidth: 360 }}>{description}</span>
69
+ )}
64
70
  {action}
65
71
  </Stack>
66
72
  )
@@ -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
+ })