basalt-ui 1.23.1 → 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 (63) hide show
  1. package/AGENTS.md +11 -6
  2. package/CHANGELOG.md +14 -0
  3. package/MIGRATING.md +138 -4
  4. package/README.md +123 -32
  5. package/agent/rules/basalt-data.md +45 -13
  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 +61 -4
  23. package/dist/data/data-table.d.ts.map +1 -1
  24. package/dist/data/data-table.js +41 -4
  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 +1 -1
  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 +158 -0
  55. package/src/data/data-table.tsx +123 -5
  56. package/src/data/table.ts +6 -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
@@ -42,6 +42,7 @@ import {
42
42
  Text,
43
43
  TextInput,
44
44
  } from '@mantine/core'
45
+ import type { MantineSpacing } from '@mantine/core'
45
46
  import type {
46
47
  Column,
47
48
  ColumnDef,
@@ -49,6 +50,7 @@ import type {
49
50
  ColumnPinningState,
50
51
  FilterFn,
51
52
  PaginationState,
53
+ RowData,
52
54
  SortingState,
53
55
  Table as TanstackTable,
54
56
  Updater,
@@ -65,6 +67,50 @@ import type { CSSProperties, ReactNode } from 'react'
65
67
  import { useCallback, useMemo, useState } from 'react'
66
68
  import { alpha, VX } from '../tokens'
67
69
 
70
+ // ── Column alignment ──────────────────────────────────────────────────────────
71
+
72
+ /** Horizontal alignment of a column's header AND its cells. */
73
+ export type DataTableAlign = 'left' | 'center' | 'right'
74
+
75
+ /**
76
+ * Alignment lives on the column def's `meta`, not on a parallel prop array, so it travels with the
77
+ * column it describes and survives reordering.
78
+ *
79
+ * Declared as a module augmentation of TanStack's own `ColumnMeta` so `meta: { aling: 'right' }` is
80
+ * a TYPE error (excess-property check on the object literal) rather than a silently ignored key —
81
+ * and a wrong VALUE (`'end'`, `'centre'`) throws at render. A misspelled alignment that quietly
82
+ * left-aligns a money column is the class of defect that looks correct in review.
83
+ */
84
+ declare module '@tanstack/react-table' {
85
+ // TData/TValue are TanStack's own parameters — required to match its declaration, unused here.
86
+ // oxlint-disable-next-line no-unused-vars
87
+ interface ColumnMeta<TData extends RowData, TValue> {
88
+ align?: DataTableAlign
89
+ /**
90
+ * Opt OUT of the automatic mono-numeral cell style. The table applies it whenever the cell's
91
+ * raw value is a number — which is right for a plain figure and wrong for a numeric accessor
92
+ * whose `cell` renders its own chrome (a coloured `Text`, a `Badge`, a sparkline), because the
93
+ * `<td>`'s mono font then overrides what the cell asked for.
94
+ */
95
+ numeral?: boolean
96
+ }
97
+ }
98
+
99
+ const ALIGNMENTS: ReadonlySet<string> = new Set<DataTableAlign>(['left', 'center', 'right'])
100
+
101
+ /** Reads `meta.align` off a column, throwing on a value that is not one of the three. */
102
+ function resolveAlign<T>(column: Column<T, unknown>): DataTableAlign | undefined {
103
+ const align = column.columnDef.meta?.align
104
+ if (align === undefined) return undefined
105
+ if (!ALIGNMENTS.has(align)) {
106
+ throw new Error(
107
+ `BasaltDataTable: column "${column.id}" has meta.align=${JSON.stringify(align)} — ` +
108
+ "expected 'left' | 'center' | 'right'.",
109
+ )
110
+ }
111
+ return align
112
+ }
113
+
68
114
  // ── Facets ────────────────────────────────────────────────────────────────────
69
115
 
70
116
  /** A single selectable option inside a {@link DataTableFacet}'s Select/MultiSelect. */
@@ -129,8 +175,8 @@ export type BasaltDataTableProps<T> = {
129
175
  * @default true
130
176
  */
131
177
  enableSorting?: boolean
132
- /** Stripe alternate rows. Forwarded to Mantine `Table`. */
133
- striped?: boolean
178
+ /** Stripe alternate rows. Forwarded to Mantine `Table` — `'odd'`/`'even'` pick the phase. */
179
+ striped?: boolean | 'odd' | 'even'
134
180
  /** Highlight hovered rows. Forwarded to Mantine `Table`. */
135
181
  highlightOnHover?: boolean
136
182
  /** Rendered when `data` is empty. Falls back to a simple message when omitted. */
@@ -257,6 +303,42 @@ export type BasaltDataTableProps<T> = {
257
303
  enablePinning?: boolean
258
304
  /** Initial column-pinning state — which column ids stick to the left/right edge. */
259
305
  initialColumnPinning?: ColumnPinningState
306
+
307
+ // ── Body chrome ─────────────────────────────────────────────────────────────
308
+
309
+ /**
310
+ * Caps the scrolling body (px number or CSS length) so a long table cannot blow a card out
311
+ * vertically — the header stays put and the rows scroll under it. Renders Mantine's
312
+ * `Table.ScrollContainer type="native"`, which is the same node the docs sanction as the raw
313
+ * escape for a bespoke table: the blessed lane and the escape hatch produce identical DOM, so
314
+ * adopting one does not contradict the other.
315
+ *
316
+ * `type="native"` is required rather than preferred: `ScrollArea`'s custom viewport is the
317
+ * positioning context a sticky `<thead>` resolves against, so the default `'scrollarea'` type
318
+ * pins the header to the page viewport instead of the table's box.
319
+ */
320
+ maxHeight?: number | string
321
+ /**
322
+ * `min-width` below which the body scrolls horizontally. Setting either this or `maxHeight`
323
+ * turns on the scroll container; `maxHeight` alone implies `minWidth: 0`.
324
+ */
325
+ minWidth?: number | string
326
+ /** Sticky header row. Pair with `maxHeight` to stick within the table rather than the page. */
327
+ stickyHeader?: boolean
328
+ /** Offset for a sticky header sitting under a fixed app header (px number or CSS length). */
329
+ stickyHeaderOffset?: number | string
330
+ /** Vertical cell padding. Forwarded to Mantine `Table`. */
331
+ verticalSpacing?: MantineSpacing
332
+ /** Horizontal cell padding. Forwarded to Mantine `Table`. */
333
+ horizontalSpacing?: MantineSpacing
334
+ /** Row separators. Forwarded to Mantine `Table`. @default true (Mantine's own default) */
335
+ withRowBorders?: boolean
336
+ /**
337
+ * Outer table border. Turn it OFF for a table that already sits inside a `Card`/`ChartCard`,
338
+ * where the card owns the frame and a second one reads as a nested box.
339
+ * @default true
340
+ */
341
+ withTableBorder?: boolean
260
342
  }
261
343
 
262
344
  // ── Sort indicator ────────────────────────────────────────────────────────────
@@ -390,6 +472,14 @@ export function BasaltDataTable<T>({
390
472
  pageCount,
391
473
  enablePinning = false,
392
474
  initialColumnPinning,
475
+ maxHeight,
476
+ minWidth,
477
+ stickyHeader,
478
+ stickyHeaderOffset,
479
+ verticalSpacing,
480
+ horizontalSpacing,
481
+ withRowBorders,
482
+ withTableBorder = true,
393
483
  }: BasaltDataTableProps<T>) {
394
484
  const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
395
485
  const [globalFilter, setGlobalFilter] = useState(initialGlobalFilter ?? '')
@@ -496,7 +586,12 @@ export function BasaltDataTable<T>({
496
586
  <Table
497
587
  {...(striped !== undefined && { striped })}
498
588
  {...(highlightOnHover !== undefined && { highlightOnHover })}
499
- withTableBorder
589
+ {...(stickyHeader !== undefined && { stickyHeader })}
590
+ {...(stickyHeaderOffset !== undefined && { stickyHeaderOffset })}
591
+ {...(verticalSpacing !== undefined && { verticalSpacing })}
592
+ {...(horizontalSpacing !== undefined && { horizontalSpacing })}
593
+ {...(withRowBorders !== undefined && { withRowBorders })}
594
+ withTableBorder={withTableBorder}
500
595
  >
501
596
  <Table.Thead>
502
597
  {headerGroups.map((headerGroup) => (
@@ -508,6 +603,7 @@ export function BasaltDataTable<T>({
508
603
  const pinnedStyle = enablePinning
509
604
  ? getPinnedCellStyle(table, header.column)
510
605
  : undefined
606
+ const align = resolveAlign(header.column)
511
607
  return (
512
608
  <Table.Th
513
609
  key={header.id}
@@ -533,6 +629,7 @@ export function BasaltDataTable<T>({
533
629
  }
534
630
  style={{
535
631
  ...(canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined),
632
+ ...(align !== undefined && { textAlign: align }),
536
633
  ...pinnedStyle,
537
634
  }}
538
635
  >
@@ -582,11 +679,16 @@ export function BasaltDataTable<T>({
582
679
  const pinnedStyle = enablePinning
583
680
  ? getPinnedCellStyle(table, cell.column)
584
681
  : undefined
682
+ const align = resolveAlign(cell.column)
585
683
  return (
586
684
  <Table.Td
587
685
  key={cell.id}
588
686
  style={{
589
- ...(typeof cell.getValue() === 'number' ? NUMERIC_CELL_STYLE : undefined),
687
+ ...(typeof cell.getValue() === 'number' &&
688
+ cell.column.columnDef.meta?.numeral !== false
689
+ ? NUMERIC_CELL_STYLE
690
+ : undefined),
691
+ ...(align !== undefined && { textAlign: align }),
590
692
  ...pinnedStyle,
591
693
  }}
592
694
  >
@@ -602,6 +704,10 @@ export function BasaltDataTable<T>({
602
704
  </Table>
603
705
  )
604
706
 
707
+ // A capped body or a horizontal floor both need the same native scroll node; pinning keeps its
708
+ // simpler overflow-x Box when neither is set.
709
+ const scrolls = maxHeight !== undefined || minWidth !== undefined
710
+
605
711
  const paginationState = table.getState().pagination
606
712
  const total = table.getRowCount()
607
713
  const rangeStart = total === 0 ? 0 : paginationState.pageIndex * paginationState.pageSize + 1
@@ -661,7 +767,19 @@ export function BasaltDataTable<T>({
661
767
  {toolbarActions}
662
768
  </Group>
663
769
  )}
664
- {enablePinning ? <Box style={{ overflowX: 'auto' }}>{tableNode}</Box> : tableNode}
770
+ {scrolls ? (
771
+ <Table.ScrollContainer
772
+ type="native"
773
+ minWidth={minWidth ?? 0}
774
+ {...(maxHeight !== undefined && { maxHeight })}
775
+ >
776
+ {tableNode}
777
+ </Table.ScrollContainer>
778
+ ) : enablePinning ? (
779
+ <Box style={{ overflowX: 'auto' }}>{tableNode}</Box>
780
+ ) : (
781
+ tableNode
782
+ )}
665
783
  {enablePagination && (
666
784
  <Group justify="space-between" mt="xs" wrap="wrap" gap="xs" align="center">
667
785
  <Text style={RANGE_LABEL_STYLE}>
package/src/data/table.ts CHANGED
@@ -21,7 +21,12 @@
21
21
 
22
22
  // ── BasaltDataTable ───────────────────────────────────────────────────────────
23
23
  export { BasaltDataTable } from './data-table'
24
- export type { BasaltDataTableProps, DataTableFacet, DataTableFacetOption } from './data-table'
24
+ export type {
25
+ BasaltDataTableProps,
26
+ DataTableAlign,
27
+ DataTableFacet,
28
+ DataTableFacetOption,
29
+ } from './data-table'
25
30
 
26
31
  // ── @tanstack/react-table convenience re-exports ──────────────────────────────
27
32
  // These are the handful of table primitives a consumer needs at the call site;
@@ -66,6 +66,58 @@ describe('raw-hex', () => {
66
66
  const f = find(`// use #ff0000 from the palette`)
67
67
  expect(kinds(f)).not.toContain('raw-hex')
68
68
  })
69
+
70
+ /**
71
+ * Round 9, rollhook: `&#123;` / `&#125;` — the escaped braces an Astro template writes to show a
72
+ * literal `${…}` in prose — were read as the hex colors `#123` / `#125`, and 4 findings blocked a
73
+ * release on a page with no color in it at all. The hole was in the KIND, not in the extension:
74
+ * the same string produced the same two findings in `.html`, `.tsx`, `.css` and `.vue`.
75
+ */
76
+ describe('HTML numeric character references', () => {
77
+ const ENTITIES = `<p>image: $&#123;IMAGE_TAG&#125;</p>`
78
+
79
+ for (const rel of [
80
+ 'index.html',
81
+ 'src/App.tsx',
82
+ 'src/a.css',
83
+ 'src/Hero.vue',
84
+ 'src/pages/index.astro',
85
+ ]) {
86
+ it(`does NOT read &#123;/&#125; as a hex color in ${rel}`, () => {
87
+ expect(kinds(find(ENTITIES, rel))).not.toContain('raw-hex')
88
+ })
89
+ }
90
+
91
+ // The exemption is the ENTITY, never the file type — a template that hardcodes a color is
92
+ // exactly what making `.astro`/`.vue` scannable was for.
93
+ it('still flags a real hex in an .astro file', () => {
94
+ const f = find(`<div style="color: #ff0000">x</div>`, 'src/pages/index.astro')
95
+ expect(kinds(f)).toContain('raw-hex')
96
+ expect(f.find((x) => x.kind === 'raw-hex')?.token).toBe('#ff0000')
97
+ })
98
+
99
+ it('still flags a real hex in a .vue file', () => {
100
+ const f = find(`<style>\n .hero { color: #ff0000; }\n</style>`, 'src/Hero.vue')
101
+ expect(kinds(f)).toContain('raw-hex')
102
+ expect(f.find((x) => x.kind === 'raw-hex')?.token).toBe('#ff0000')
103
+ })
104
+
105
+ it('flags a hex after a bare & — an entity needs its terminating semicolon', () => {
106
+ expect(kinds(find(`<p>a&#fff b</p>`, 'index.html'))).toContain('raw-hex')
107
+ })
108
+
109
+ it('flags a plain #123 with no entity around it', () => {
110
+ expect(kinds(find(`const c = '#123'`))).toContain('raw-hex')
111
+ })
112
+
113
+ // The neighbouring raw-text kinds were checked for the same blind spot and do NOT share it: a
114
+ // character reference contains no `(`, so `raw-color-fn`'s `rgba?|hsla?\(` anchor cannot occur
115
+ // inside one, and every other raw-text kind anchors on a property name, `var(`, or a JSX `=`.
116
+ it('leaves the sibling color kinds unchanged — nothing else fires inside an entity', () => {
117
+ const src = `<p>&amp; &mdash; &nbsp; &#8212; &hellip; &#x1F600; &copy;</p>`
118
+ expect(find(src, 'index.html')).toHaveLength(0)
119
+ })
120
+ })
69
121
  })
70
122
 
71
123
  // ── 2. raw-color-fn ──────────────────────────────────────────────────────────
@@ -2580,6 +2632,95 @@ describe('markup files (index.html / *.webmanifest)', () => {
2580
2632
  })
2581
2633
  })
2582
2634
 
2635
+ // ── 29b. single-file components (.astro / .vue) ───────────────────────────────
2636
+
2637
+ /**
2638
+ * Round 9: `.astro` and `.vue` became scannable and fell through to the `ts` dialect, so `<!-- … -->`
2639
+ * was never stripped — a `theme-allow` written in an HTML comment waived nothing there, and a color
2640
+ * inside a commented-out block still reported. They resolve as `sfc` now: BOTH comment dialects,
2641
+ * and the full 25-kind set (not `MARKUP_KINDS` — an `.astro` template is JSX-shaped and a `.vue`
2642
+ * `<script setup>` is real TS).
2643
+ */
2644
+ describe('single-file components (.astro / .vue)', () => {
2645
+ const ASTRO = 'src/pages/index.astro'
2646
+ const VUE = 'src/Hero.vue'
2647
+
2648
+ for (const rel of [ASTRO, VUE]) {
2649
+ it(`strips an HTML comment in ${rel}`, () => {
2650
+ expect(kinds(find(`<!-- <div style="color: #ff0000" /> -->`, rel))).not.toContain('raw-hex')
2651
+ })
2652
+
2653
+ it(`honours a theme-allow inside an HTML comment in ${rel}`, () => {
2654
+ const f = find(
2655
+ `<!-- theme-allow raw-hex — brand asset -->\n<div style="color: #ff0000" />`,
2656
+ rel,
2657
+ )
2658
+ expect(kinds(f)).not.toContain('raw-hex')
2659
+ })
2660
+
2661
+ it(`honours a theme-allow-file inside an HTML comment in ${rel}`, () => {
2662
+ const f = find(
2663
+ `<!-- theme-allow-file raw-hex — brand asset -->\n<b style="color: #ff0000" />`,
2664
+ rel,
2665
+ )
2666
+ expect(kinds(f)).not.toContain('raw-hex')
2667
+ })
2668
+
2669
+ it(`honours a multi-line HTML comment annotation in ${rel}`, () => {
2670
+ const f = find(
2671
+ `<!--\n theme-allow raw-hex — brand asset\n-->\n<b style="color: #ff0000" />`,
2672
+ rel,
2673
+ )
2674
+ expect(kinds(f)).not.toContain('raw-hex')
2675
+ })
2676
+
2677
+ it(`still strips the JS dialect in ${rel} — an SFC carries both`, () => {
2678
+ expect(kinds(find(`// const c = '#ff0000'`, rel))).not.toContain('raw-hex')
2679
+ expect(kinds(find(`/* const c = '#ff0000' */`, rel))).not.toContain('raw-hex')
2680
+ })
2681
+
2682
+ it(`honours a theme-allow in the script fence of ${rel}`, () => {
2683
+ const f = find(`// theme-allow raw-hex — brand asset\nconst c = '#ff0000'`, rel)
2684
+ expect(kinds(f)).not.toContain('raw-hex')
2685
+ })
2686
+
2687
+ it(`keeps the non-markup kinds — an SFC is not MARKUP_KINDS-restricted in ${rel}`, () => {
2688
+ expect(kinds(find(`const s = { border: '1px solid #ccc' }`, rel))).toContain('raw-surface')
2689
+ expect(kinds(find(`<div style={{ padding: 18 }} />`, rel))).toContain('inline-spacing')
2690
+ })
2691
+ }
2692
+
2693
+ it('does not let an HTML comment open a runaway JS block comment', () => {
2694
+ const f = find(`<!-- a /* unclosed -->\n<div style="color: #ff0000" />`, ASTRO)
2695
+ expect(kinds(f)).toContain('raw-hex')
2696
+ })
2697
+
2698
+ /**
2699
+ * Asserted LIMITS, so "unsupported" and "silently broken" don't read the same. Both are
2700
+ * false-negative-only — the direction a release-blocking false positive says to take.
2701
+ */
2702
+ describe('known limits', () => {
2703
+ it('does not fire the kebab-CSS surface kinds inside a <style> fence', () => {
2704
+ const src = `<style>\n .card { border-radius: 8px; }\n</style>`
2705
+ expect(kinds(find(src, ASTRO))).not.toContain('css-raw-surface')
2706
+ // The same text in a real .css file still reports — the limit is the SFC dialect, not the rule.
2707
+ expect(kinds(find(src.replace(/<\/?style>/g, ''), 'src/a.css'))).toContain('css-raw-surface')
2708
+ })
2709
+
2710
+ it('over-strips a `<!--` that lives inside a script string', () => {
2711
+ expect(
2712
+ kinds(find(`const s = '<!--'\nconst c = '#ff0000'\nconst e = '-->'`, ASTRO)),
2713
+ ).not.toContain('raw-hex')
2714
+ })
2715
+
2716
+ it('over-strips after an unquoted `https://` in template prose', () => {
2717
+ expect(
2718
+ kinds(find(`<p>see https://x.test <b style="color: #ff0000" /></p>`, ASTRO)),
2719
+ ).not.toContain('raw-hex')
2720
+ })
2721
+ })
2722
+ })
2723
+
2583
2724
  // ── 30. tokens-only profile ──────────────────────────────────────────────────
2584
2725
 
2585
2726
  describe("profile: 'tokens-only'", () => {
@@ -10,7 +10,32 @@ export type { Finding, GuardConfig, GuardKind, GuardSeverity }
10
10
 
11
11
  // ── Static regex consts ──────────────────────────────────────────────────────────────────────────
12
12
 
13
- const HEX = /#[0-9a-fA-F]{3,8}\b/g
13
+ /**
14
+ * A raw hex color literal.
15
+ *
16
+ * The `(?!(?<=&#)\d+;)` guard drops an HTML NUMERIC CHARACTER REFERENCE. `&#123;` is the escaped
17
+ * `{` and `&#125;` the escaped `}` — the two an Astro/JSX template writes to show a literal brace
18
+ * in prose — and their `#123` / `#125` read as three-digit hex. It is not markup-specific: the same
19
+ * string produced the same two findings in `.html`, `.tsx`, `.css` and `.vue`, so this was a hole
20
+ * in the kind itself that `.astro` merely walked into first (rollhook, round 9).
21
+ *
22
+ * The lookahead is deliberately the FULL reference — `&#`, digits, `;` — not a bare `(?<!&)`. Only
23
+ * the decimal form can collide at all (`&#x1F600;` never matches: `x` is not a hex digit), and a
24
+ * `#` that follows an `&` without terminating in `;` is not an entity, so `color: red&#fff` still
25
+ * flags. Nothing is exempted by file type; a real `#ff0000` in an `.astro` file fires exactly as it
26
+ * does in a `.tsx` one.
27
+ *
28
+ * The sibling raw-text kinds do NOT share this blind spot, and the reason is structural rather than
29
+ * lucky: a character reference contains no `(`, so `raw-color-fn`'s `rgba?|hsla?\(` anchor cannot
30
+ * occur inside one, and every other raw-text kind is anchored on a property name, `var(`, or a JSX
31
+ * `=`. Probed across `.html`/`.astro`/`.tsx` over 20 entity shapes — `raw-hex` was the only kind
32
+ * that fired.
33
+ *
34
+ * Known and NOT covered, because the fix would cost real findings: an all-hex URL fragment or SVG
35
+ * reference (`href="#cafe"`, `fill="url(#abcdef)"`) is indistinguishable from a color by text
36
+ * alone, and both still report. `theme-allow` is the escape there.
37
+ */
38
+ const HEX = /#(?!(?<=&#)\d+;)[0-9a-fA-F]{3,8}\b/g
14
39
 
15
40
  /**
16
41
  * A raw `rgb()`/`rgba()`/`hsl()`/`hsla()` color function.
@@ -531,6 +556,21 @@ export const DEFAULT_GUARD_CONFIG: GuardConfig = {
531
556
  * into an unplanned refactor, which is exactly what the grace minor exists to prevent. The five
532
557
  * entries below all landed together in the round-4 guard minor and all promote one minor later.
533
558
  *
559
+ * **A `SCANNABLE_EXT` widening cannot be expressed here, and 1.23.2's was deliberately not.**
560
+ * Adding `.astro`/`.jsx`/`.vue` widens the FILE SET for all 25 kinds at once — there is no kind to
561
+ * key an entry on, and the nearest one (`raw-hex`, which is what actually fired) is keyed per KIND,
562
+ * not per extension: an entry for it would demote basalt's most load-bearing kind to `warn` in
563
+ * every `.tsx` and `.css` in every consumer for a minor, to buy runway on a file type only one
564
+ * consumer has. `css-raw-surface` is the shape that DOES fit — the new dialect got its own kind
565
+ * because kebab CSS is a distinct pattern set; more files running the same patterns is not.
566
+ *
567
+ * The measurement agreed with the structure. Once the two defects the widening exposed were fixed
568
+ * (`&#123;` read as a hex; `<!-- … -->` never stripped in an SFC), rollhook's marketing site scans
569
+ * 6 files with 0 findings, and no other consumer has a single `.astro`, `.vue` or `.jsx` file — so
570
+ * grace would have covered zero incumbent violations. The doctrine protects code that "passed every
571
+ * previous release"; a violation in a newly-scanned file type is code the consumer is writing now,
572
+ * with `theme-allow`, `basalt.severity` and `exemptRules` all still available.
573
+ *
534
574
  * @example
535
575
  * const GRACE: Partial<Record<GuardKind, string>> = {
536
576
  * 'raw-font-family': 'introduced 1.4.0 — promote to error in 1.5.0',
@@ -846,10 +886,13 @@ function commentBlockEnd(
846
886
  syntax: GuardSyntax,
847
887
  ): number {
848
888
  const line = lines[index] ?? ''
889
+ // An SFC carries both dialects, so the closer is resolved from the opener the token actually sits
890
+ // behind rather than from the extension: whichever of `<!--` / `/*` is NEAREST on its left wins.
891
+ const markup = syntax === 'markup' || (syntax === 'sfc' && opensMarkupComment(line, tokenAt))
849
892
  // A line comment closes at its own end of line. CSS has no `//`, so the test is TS-only: there,
850
893
  // a `//`-looking sequence is text and the conservative same-line answer is the right one anyway.
851
- if (syntax === 'ts' && /\/\/\s*$/.test(line.slice(0, tokenAt))) return index
852
- const closer = syntax === 'markup' ? '-->' : '*/'
894
+ if (!markup && syntax !== 'css' && /\/\/\s*$/.test(line.slice(0, tokenAt))) return index
895
+ const closer = markup ? '-->' : '*/'
853
896
  if (line.slice(tokenAt).includes(closer)) return index
854
897
  for (let ahead = index + 1; ahead < lines.length; ahead++) {
855
898
  if ((lines[ahead] ?? '').includes(closer)) return ahead
@@ -857,6 +900,18 @@ function commentBlockEnd(
857
900
  return index
858
901
  }
859
902
 
903
+ /**
904
+ * In an `sfc` file, is the comment the token sits in an HTML one?
905
+ *
906
+ * Both dialects are legal in the same file, so the extension cannot answer it — the nearest opener
907
+ * on the token's left does. A tie (neither present) reads as the JS dialect, which is what an
908
+ * annotation on a bare frontmatter line is.
909
+ */
910
+ function opensMarkupComment(line: string, tokenAt: number): boolean {
911
+ const before = line.slice(0, tokenAt)
912
+ return before.lastIndexOf('<!--') > before.lastIndexOf('/*')
913
+ }
914
+
860
915
  /**
861
916
  * A comment opener sitting immediately before the annotation token.
862
917
  *
@@ -1132,8 +1187,8 @@ function annotationsCover(annotations: AllowAnnotation[] | undefined, kind: Guar
1132
1187
  return (annotations ?? []).some((a) => annotationCovers(a, kind))
1133
1188
  }
1134
1189
 
1135
- /** The 3 comment dialects the stripper understands, resolved from the file's own extension. */
1136
- type GuardSyntax = 'ts' | 'css' | 'markup'
1190
+ /** The 4 comment dialects the stripper understands, resolved from the file's own extension. */
1191
+ type GuardSyntax = 'ts' | 'css' | 'markup' | 'sfc'
1137
1192
 
1138
1193
  /**
1139
1194
  * CSS (including `.module.css`) has no `//` line-comment syntax at all — an unquoted `//`, e.g.
@@ -1143,13 +1198,50 @@ type GuardSyntax = 'ts' | 'css' | 'markup'
1143
1198
  * `markup` covers the two files whose colors nothing re-derives on a theme change and which the
1144
1199
  * scan never reached — `index.html` and `*.webmanifest` (plus plain `.json`, which a webmanifest
1145
1200
  * often is). Only the color/typography kinds apply there; see `MARKUP_KINDS`.
1201
+ *
1202
+ * `sfc` is the single-file-component dialect — `.astro` and `.vue`, which carry BOTH regions:
1203
+ * `<!-- … -->` in the template and `//` / `/* … *\/` in the frontmatter/`<script>` fence. They used
1204
+ * to fall through to `ts`, so an HTML comment was never stripped: a `theme-allow` written in one
1205
+ * waived nothing, and a color inside a commented-out block still reported (rollhook, round 9 —
1206
+ * `.astro` became scannable in the same minor that exposed it). They are NOT `markup`: an `.astro`
1207
+ * template is JSX-shaped and a `.vue` `<script setup>` is real TS, so restricting them to
1208
+ * {@link MARKUP_KINDS} would drop 22 of the 25 kinds on a file type that can violate all of them.
1209
+ *
1210
+ * `.jsx` needs no entry — it is TS syntax with no HTML-comment region.
1211
+ *
1212
+ * **Two asserted limits, both false-NEGATIVE-only** (pinned in `check-source.test.ts`):
1213
+ *
1214
+ * 1. The kebab-CSS kinds (`css-raw-surface`) do not fire inside a `<style>` fence — that branch
1215
+ * keys on `syntax === 'css'`, and an SFC is one file with three dialects, not three files. The
1216
+ * color kinds, which are what an unguarded template layer actually leaks, do fire there.
1217
+ * 2. Stripping is region-BLIND: `<!-- … -->` is blanked anywhere in the file, and `//` anywhere
1218
+ * outside a string. A `<!--` inside a script string, or an unquoted `https://` in template
1219
+ * prose, over-strips the rest of that construct. Both directions lose findings rather than
1220
+ * inventing them, which is the side of the trade a release-blocking false positive says to take.
1146
1221
  */
1147
1222
  function guardSyntaxFor(relPath: string): GuardSyntax {
1148
1223
  if (relPath.endsWith('.css')) return 'css'
1149
1224
  if (/\.(?:html?|webmanifest|json)$/.test(relPath)) return 'markup'
1225
+ if (/\.(?:astro|vue)$/.test(relPath)) return 'sfc'
1150
1226
  return 'ts'
1151
1227
  }
1152
1228
 
1229
+ /**
1230
+ * The comment-stripped twin of `text`, same length and same newline positions, for any dialect.
1231
+ *
1232
+ * One place rather than the two call sites (`checkSource` and `findAllowAnnotations`) that used to
1233
+ * spell the `markup ? … : …` ternary out independently — adding `sfc` to one and not the other is
1234
+ * exactly how the scan and `--audit-allows` drift apart on what counts as a comment.
1235
+ *
1236
+ * The SFC order is markup-first: an HTML comment may legally contain `//` or an unterminated `/*`,
1237
+ * and blanking it before the TS pass keeps that text from opening a comment that runs to EOF.
1238
+ */
1239
+ function stripGuardComments(text: string, syntax: GuardSyntax): string {
1240
+ if (syntax === 'markup') return stripMarkupComments(text)
1241
+ if (syntax === 'sfc') return stripComments(stripMarkupComments(text), 'ts')
1242
+ return stripComments(text, syntax)
1243
+ }
1244
+
1153
1245
  /** The only kinds meaningful in an HTML document or a JSON manifest — no JSX, no CSS-in-JS. */
1154
1246
  const MARKUP_KINDS: ReadonlySet<GuardKind> = new Set(['raw-hex', 'raw-color-fn', 'raw-font-family'])
1155
1247
 
@@ -1840,7 +1932,7 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
1840
1932
  // stripped text has already blanked out. Same length / same newline positions as `text`, so line
1841
1933
  // numbers computed off either one agree.
1842
1934
  const syntax = guardSyntaxFor(relPath)
1843
- const codeText = syntax === 'markup' ? stripMarkupComments(text) : stripComments(text, syntax)
1935
+ const codeText = stripGuardComments(text, syntax)
1844
1936
  const codeLines = codeText.split('\n')
1845
1937
  const { waivers, fileScoped, declared } = collectAllowAnnotations(
1846
1938
  lines,
@@ -2020,7 +2112,9 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
2020
2112
  if (syntax === 'css' && isSubScaleCssSpacing(line, m.index)) continue
2021
2113
  const hasUnit = /^[a-z%]/.test(line.slice(m.index + m[0].length))
2022
2114
  if (
2023
- syntax === 'ts' &&
2115
+ // An SFC's script fence IS TS, so it takes the same gate — without it a unitless
2116
+ // `padding: 8` in `.astro`/`.vue` frontmatter reports with no style-object behind it.
2117
+ (syntax === 'ts' || syntax === 'sfc') &&
2024
2118
  !hasUnit &&
2025
2119
  !isStyleObjectContext(codeText, lineStarts[i]! + m.index, styleIdentifiers)
2026
2120
  )
@@ -2256,7 +2350,7 @@ export function findAllowAnnotations(
2256
2350
  ): AllowAnnotationSite[] {
2257
2351
  const lines = text.split('\n')
2258
2352
  const syntax = guardSyntaxFor(relPath)
2259
- const codeText = syntax === 'markup' ? stripMarkupComments(text) : stripComments(text, syntax)
2353
+ const codeText = stripGuardComments(text, syntax)
2260
2354
  const { declared } = collectAllowAnnotations(
2261
2355
  lines,
2262
2356
  codeText.split('\n'),
package/src/index.ts CHANGED
@@ -95,6 +95,15 @@ export {
95
95
  type SettingsRowProps,
96
96
  DangerZone,
97
97
  type DangerZoneProps,
98
+ QueryState,
99
+ type QueryStateProps,
100
+ type QueryStateLike,
101
+ type QueryStateVariant,
102
+ type QueryEmptyCopy,
103
+ LoadingState,
104
+ type LoadingStateProps,
105
+ ErrorState,
106
+ type ErrorStateProps,
98
107
  } from './dashboard'
99
108
 
100
109
  // ── Connectivity (auto-mounted by BasaltProvider) ────────────────────────────────────────────────
@@ -0,0 +1,67 @@
1
+ /**
2
+ * `toErrorMessage`'s job is to never hand a human a string that says nothing. The decode ladder
3
+ * was already right; what was missing is the floor under it — an opaque envelope decoded to the
4
+ * literal `"{}"`, and `toErrorMessage(undefined)` returned `JSON.stringify(undefined)`, which is
5
+ * not a string at all despite the `string` return type. Both are the shape a page renders straight
6
+ * into an Alert, so both are asserted here.
7
+ */
8
+ import { describe, expect, test } from 'bun:test'
9
+ import { errorStatus, toErrorMessage } from './error-message'
10
+
11
+ describe('toErrorMessage — decode ladder', () => {
12
+ test('Error instance → message', () => {
13
+ expect(toErrorMessage(new Error('slug already in use'))).toBe('slug already in use')
14
+ })
15
+
16
+ test('{ message } → message', () => {
17
+ expect(toErrorMessage({ message: 'recursive is rejected' })).toBe('recursive is rejected')
18
+ })
19
+
20
+ test('Eden { status, value } envelope → one level down', () => {
21
+ expect(toErrorMessage({ status: 409, value: { message: 'slug taken' } })).toBe('slug taken')
22
+ })
23
+
24
+ test('plain string → itself', () => {
25
+ expect(toErrorMessage('boom')).toBe('boom')
26
+ })
27
+ })
28
+
29
+ describe('toErrorMessage — the unusable floor', () => {
30
+ test('an opaque object falls back instead of rendering "{}"', () => {
31
+ expect(toErrorMessage({}, 'Could not create share')).toBe('Could not create share')
32
+ })
33
+
34
+ test('undefined returns a real string, not the undefined value', () => {
35
+ const message = toErrorMessage(undefined)
36
+ expect(typeof message).toBe('string')
37
+ expect(message).toBe('The request failed.')
38
+ })
39
+
40
+ test('an opaque envelope WITH a status folds the status into the fallback', () => {
41
+ expect(toErrorMessage({ status: 502, value: {} }, 'Could not load images')).toBe(
42
+ 'Could not load images (HTTP 502)',
43
+ )
44
+ })
45
+
46
+ test('a readable body wins over the status — the server message is never replaced', () => {
47
+ expect(toErrorMessage({ status: 403, value: { message: 'forbidden' } }, 'Nope')).toBe(
48
+ 'forbidden',
49
+ )
50
+ })
51
+
52
+ test('whitespace-only decodes as unusable', () => {
53
+ expect(toErrorMessage({ message: ' ' }, 'Fallback')).toBe('Fallback')
54
+ })
55
+ })
56
+
57
+ describe('errorStatus', () => {
58
+ test('reads a numeric status off an envelope', () => {
59
+ expect(errorStatus({ status: 404, value: null })).toBe(404)
60
+ })
61
+
62
+ test('is undefined for anything else', () => {
63
+ expect(errorStatus(new Error('x'))).toBeUndefined()
64
+ expect(errorStatus(null)).toBeUndefined()
65
+ expect(errorStatus({ status: '404' })).toBeUndefined()
66
+ })
67
+ })