basalt-ui 1.22.0 → 1.23.1

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 (85) hide show
  1. package/AGENTS.md +29 -12
  2. package/CHANGELOG.md +28 -0
  3. package/MIGRATING.md +207 -12
  4. package/README.md +74 -21
  5. package/agent/rules/basalt-app.md +33 -3
  6. package/agent/rules/basalt-charts.md +78 -7
  7. package/agent/rules/basalt-content.md +3 -3
  8. package/agent/rules/basalt-mantine.md +4 -4
  9. package/agent/rules/basalt-tokens.md +35 -6
  10. package/agent/skills/basalt-app/SKILL.md +25 -12
  11. package/agent/skills/basalt-charts/SKILL.md +27 -10
  12. package/agent/templates/CLAUDE-block.md.tpl +3 -2
  13. package/agent/templates/DESIGN.md.tpl +3 -1
  14. package/configs/oxlint-plugin.js +49 -8
  15. package/configs/oxlint-plugin.test.ts +88 -31
  16. package/dist/charts/hooks/useBandPlot.d.ts +138 -0
  17. package/dist/charts/hooks/useBandPlot.d.ts.map +1 -0
  18. package/dist/charts/hooks/useBandPlot.js +127 -0
  19. package/dist/charts/index.d.ts +4 -0
  20. package/dist/charts/index.d.ts.map +1 -1
  21. package/dist/charts/index.js +14 -0
  22. package/dist/charts/kinds/BandStrip.d.ts +127 -0
  23. package/dist/charts/kinds/BandStrip.d.ts.map +1 -0
  24. package/dist/charts/kinds/BandStrip.js +271 -0
  25. package/dist/charts/kinds/BandStrip.test.d.ts +2 -0
  26. package/dist/charts/kinds/BandStrip.test.d.ts.map +1 -0
  27. package/dist/charts/kinds/Bars.d.ts +5 -1
  28. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  29. package/dist/charts/kinds/Bars.js +2 -0
  30. package/dist/charts/kinds/MirroredBars.d.ts +95 -0
  31. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -0
  32. package/dist/charts/kinds/MirroredBars.js +328 -0
  33. package/dist/charts/kinds/MirroredBars.test.d.ts +2 -0
  34. package/dist/charts/kinds/MirroredBars.test.d.ts.map +1 -0
  35. package/dist/charts/kinds/MultiLine.d.ts +5 -1
  36. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  37. package/dist/charts/kinds/MultiLine.js +2 -0
  38. package/dist/charts/kinds/StackedArea.d.ts +5 -1
  39. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  40. package/dist/charts/kinds/StackedArea.js +2 -0
  41. package/dist/charts/kinds/ZonedLine.d.ts +5 -1
  42. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  43. package/dist/charts/kinds/ZonedLine.js +2 -0
  44. package/dist/charts/primitives/CartesianChart.d.ts +18 -2
  45. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  46. package/dist/charts/primitives/CartesianChart.js +5 -2
  47. package/dist/charts/primitives/HatchPattern.d.ts +31 -0
  48. package/dist/charts/primitives/HatchPattern.d.ts.map +1 -0
  49. package/dist/charts/primitives/HatchPattern.js +31 -0
  50. package/dist/cli/index.d.ts +2 -0
  51. package/dist/cli/index.d.ts.map +1 -1
  52. package/dist/cli/index.js +228 -31
  53. package/dist/cli/shipped-versions.test.d.ts +2 -0
  54. package/dist/cli/shipped-versions.test.d.ts.map +1 -0
  55. package/dist/guard/index.d.ts.map +1 -1
  56. package/dist/guard/index.js +60 -13
  57. package/dist/vite.d.ts +42 -3
  58. package/dist/vite.d.ts.map +1 -1
  59. package/dist/vite.js +67 -30
  60. package/llms.txt +2 -2
  61. package/package.json +1 -1
  62. package/src/charts/hooks/useBandPlot.ts +302 -0
  63. package/src/charts/index.ts +14 -0
  64. package/src/charts/kinds/BandStrip.test.tsx +282 -0
  65. package/src/charts/kinds/BandStrip.tsx +457 -0
  66. package/src/charts/kinds/Bars.tsx +7 -1
  67. package/src/charts/kinds/DualPanel.test.tsx +10 -4
  68. package/src/charts/kinds/MirroredBars.test.tsx +232 -0
  69. package/src/charts/kinds/MirroredBars.tsx +491 -0
  70. package/src/charts/kinds/MultiLine.tsx +7 -1
  71. package/src/charts/kinds/StackedArea.tsx +7 -1
  72. package/src/charts/kinds/ZonedLine.tsx +7 -1
  73. package/src/charts/primitives/CartesianChart.test.tsx +86 -4
  74. package/src/charts/primitives/CartesianChart.tsx +28 -3
  75. package/src/charts/primitives/HatchPattern.tsx +55 -0
  76. package/src/cli/help.test.ts +74 -1
  77. package/src/cli/index.ts +436 -52
  78. package/src/cli/shipped-versions.test.ts +83 -0
  79. package/src/cli/tokens-css.test.ts +62 -0
  80. package/src/cli/toolchain-wiring.test.ts +171 -4
  81. package/src/cli/waiver-audit.test.ts +7 -3
  82. package/src/guard/check-source.test.ts +211 -42
  83. package/src/guard/index.ts +149 -23
  84. package/src/vite.test.ts +79 -0
  85. package/src/vite.ts +141 -39
@@ -800,6 +800,79 @@ describe('chart-missing-aria-label', () => {
800
800
  const f = find(text)
801
801
  expect(kinds(f)).not.toContain('chart-missing-aria-label')
802
802
  })
803
+
804
+ // ── tag provenance ─────────────────────────────────────────────────────────
805
+ //
806
+ // The rule keys on a tag NAME, so a consumer's OWN component sharing a shipped kind's name
807
+ // collected an unactionable `ariaLabel` demand (reported against 1.23.0: a hand-composed local
808
+ // `MirroredBars`, 235 lines, nothing to do with the kind). The gate is a one-directional
809
+ // narrowing — skip only what this file DEFINES and does not import from basalt-ui.
810
+
811
+ it('does NOT flag a tag this file DEFINES itself (a local component sharing a kind name)', () => {
812
+ const text = [
813
+ 'function MirroredBars({ data }: Props) {',
814
+ ' return <svg />',
815
+ '}',
816
+ 'export function Panel() {',
817
+ ' return <MirroredBars data={points} />',
818
+ '}',
819
+ ].join('\n')
820
+ expect(kinds(find(text))).not.toContain('chart-missing-aria-label')
821
+ })
822
+
823
+ it('skips a locally defined kind name however it is declared (const/memo, not just function)', () => {
824
+ const text = ['const BandStrip = memo(Inner)', '<BandStrip data={rows} />'].join('\n')
825
+ expect(kinds(find(text))).not.toContain('chart-missing-aria-label')
826
+ })
827
+
828
+ it('STILL flags when the same file also imports that name from basalt-ui (a real shadow)', () => {
829
+ const text = [
830
+ "import { MirroredBars } from 'basalt-ui/charts'",
831
+ 'function Panel() {',
832
+ ' return <MirroredBars data={points} />',
833
+ '}',
834
+ ].join('\n')
835
+ expect(kinds(find(text))).toContain('chart-missing-aria-label')
836
+ })
837
+
838
+ it('STILL flags a tag imported from a consumer BARREL that re-exports the kind', () => {
839
+ const text = ["import { MultiLine } from '../charts'", '<MultiLine data={points} />'].join('\n')
840
+ expect(kinds(find(text))).toContain('chart-missing-aria-label')
841
+ })
842
+
843
+ it('STILL flags a file with no imports and no local definition (coverage is not import-gated)', () => {
844
+ expect(kinds(find('<Donut data={slices} />'))).toContain('chart-missing-aria-label')
845
+ })
846
+
847
+ it('honours an aliased basalt import, since the alias is the tag the JSX writes', () => {
848
+ const text = [
849
+ "import { Bars as BasaltBars } from 'basalt-ui/charts'",
850
+ 'const Bars = () => null',
851
+ '<Bars data={points} />',
852
+ ].join('\n')
853
+ // `Bars` is defined locally here and only `BasaltBars` came from basalt — the local one is skipped.
854
+ expect(kinds(find(text))).not.toContain('chart-missing-aria-label')
855
+ })
856
+ })
857
+
858
+ describe('unframed-chart — the same provenance gate', () => {
859
+ it('does NOT flag a consumer’s OWN ChartLegend taking an items array', () => {
860
+ const text = [
861
+ 'function ChartLegend({ items }: Props) {',
862
+ ' return <ul />',
863
+ '}',
864
+ 'const view = <ChartLegend items={[{ label: "a" }]} />',
865
+ ].join('\n')
866
+ expect(kinds(find(text))).not.toContain('unframed-chart')
867
+ })
868
+
869
+ it('still flags the shipped ChartLegend built from an inline array', () => {
870
+ const text = [
871
+ "import { ChartLegend } from 'basalt-ui/charts'",
872
+ 'const view = <ChartLegend items={[{ label: "a" }]} />',
873
+ ].join('\n')
874
+ expect(kinds(find(text))).toContain('unframed-chart')
875
+ })
803
876
  })
804
877
 
805
878
  // ── 16. card-with-border ─────────────────────────────────────────────────────
@@ -2738,59 +2811,134 @@ describe('inline-spacing — the style-object test', () => {
2738
2811
  })
2739
2812
  })
2740
2813
 
2741
- // ── theme-allow comment shapes ───────────────────────────────────────────────────────────────────
2814
+ // ── theme-allow shape grid ───────────────────────────────────────────────────────────────────────
2742
2815
 
2743
2816
  /**
2744
- * The prefix contract, pinned per comment shape.
2817
+ * The whole shape space of an annotation, pinned cell by cell.
2818
+ *
2819
+ * FIVE holes have been found in this one contract in four rounds — two false positives, one false
2820
+ * negative, and twice the guard and the oxlint plugin disagreeing about what an annotation IS. Each
2821
+ * was found by a consumer writing a shape nobody had thought to test, and round 6's answer (a
2822
+ * thirteen-shape list of "every shape a consumer actually writes") missed the next three anyway,
2823
+ * because a list of observed shapes cannot cover the ones not yet observed.
2824
+ *
2825
+ * So this is a GRID over the four things that actually vary, not a list of anecdotes:
2745
2826
  *
2746
- * Three holes have been found in this rule in three rounds — two false positives and one false
2747
- * NEGATIVE (a docblock documenting a file's waivers disarmed the file). So every shape a consumer
2748
- * actually writes gets a fixture, and the oxlint plugin's copy is pinned identically in
2749
- * `configs/oxlint-plugin.test.ts`: the two halves of one contract must agree on what an annotation
2750
- * IS, and may differ only on which rules each can judge.
2827
+ * | axis | values |
2828
+ * |-|-|
2829
+ * | comment style | `//` · `/* *\/` · `/** *\/` · `{/* *\/}` · `{/** *\/}` · `<!-- -->` · JSON member |
2830
+ * | token position | opener line · gutter line · trailing after code |
2831
+ * | where the closer falls | the token's line · its own line · N lines below · n/a |
2832
+ * | what follows | code · blank then code · comment then code · code on the closer's line |
2833
+ *
2834
+ * `configs/oxlint-plugin.test.ts` pins the same grid, under the same row names, for the subset the
2835
+ * plugin can judge (TS/JSX — it never sees CSS, HTML or JSON). The two halves must agree on every
2836
+ * shared cell; add a row to one and it goes in the other in the same commit.
2837
+ *
2838
+ * The unsupported cells are asserted too. "Unsupported" and "silently broken" read identically to a
2839
+ * consumer, and the whole point of the grid is to make them different things.
2751
2840
  */
2752
- describe('theme-allow comment shapes', () => {
2753
- const REASON = 'deliberate legacy value'
2754
- const waives = (relPath: string, src: string): boolean =>
2755
- checkSource(src, relPath, DEFAULT_GUARD_CONFIG).filter((f) => f.kind === 'raw-hex').length === 0
2841
+ /** Wraps JSX children in a component, so a `{/* … *\/}` child is legal source. */
2842
+ const jsxShape = (body: string): string =>
2843
+ `export const C = () => (\n <div>\n${body}\n </div>\n)\n`
2844
+
2845
+ /** Does the file waive its `raw-hex`? — the one question every grid row asks. */
2846
+ const shapeWaives = (relPath: string, src: string): boolean =>
2847
+ checkSource(src, relPath, DEFAULT_GUARD_CONFIG).filter((f) => f.kind === 'raw-hex').length === 0
2756
2848
 
2849
+ describe('theme-allow shape grid', () => {
2850
+ const REASON = 'deliberate legacy value'
2851
+ const A = `theme-allow raw-hex — ${REASON}`
2852
+ const AF = `theme-allow-file raw-hex — ${REASON}`
2853
+ /** The statement the guard reports `raw-hex` on. */
2854
+ const T = `const a = '#f00'`
2855
+ /** The JSX child the guard reports `raw-hex` on. */
2856
+ const JT = `<span style={{ color: '#f00' }} />`
2857
+ const J = jsxShape
2858
+ const waives = shapeWaives
2859
+
2860
+ // ── SUPPORTED — every cell of the grid that waives ────────────────────────
2757
2861
  it.each([
2758
- ['own line //', PATH, `// theme-allow raw-hex — ${REASON}\nconst a = '#f00'\n`],
2862
+ // `//` — no closer to place.
2863
+ ['// own line', PATH, `// ${A}\n${T}\n`],
2864
+ ['// indented', PATH, `function f() {\n // ${A}\n ${T}\n}\n`],
2865
+ ['// no space after the marker', PATH, `//${A}\n${T}\n`],
2866
+ ['// trailing', PATH, `${T} // ${A}\n`],
2867
+ ['// reason wrapped onto a second // comment', PATH, `// ${A},\n// continued here\n${T}\n`],
2868
+ // `/* */` and `/** */` at statement level, closer in each of its three places.
2869
+ ['/* */ own line', PATH, `/* ${A} */\n${T}\n`],
2870
+ ['/* */ trailing', PATH, `${T} /* ${A} */\n`],
2871
+ ['/** */ own line (docblock opener)', PATH, `/** ${A} */\n${T}\n`],
2872
+ ['/* opener + token, closer on its own line', PATH, `/* ${A}\n*/\n${T}\n`],
2873
+ ['/* gutter token, closer on the token line', PATH, `/*\n ${A} */\n${T}\n`],
2874
+ ['/* gutter token, closer on its own line', PATH, `/*\n ${A}\n*/\n${T}\n`],
2875
+ ['/** star gutter, closer on the token line', PATH, `/**\n * ${A} */\n${T}\n`],
2876
+ ['/** star gutter, closer on its own line', PATH, `/**\n * ${A}\n */\n${T}\n`],
2877
+ // Prose between the token and the closer — any amount of it.
2878
+ ['/** token then 1 prose line', PATH, `/**\n * ${A}\n * more\n */\n${T}\n`],
2879
+ ['/** token then 12 prose lines', PATH, `/**\n * ${A}\n${' * p\n'.repeat(12)} */\n${T}\n`],
2759
2880
  [
2760
- 'indented //',
2881
+ '/** prose first, token on a later gutter line',
2761
2882
  PATH,
2762
- `function f() {\n // theme-allow raw-hex — ${REASON}\n const a = '#f00'\n}\n`,
2883
+ `/**\n * some prose\n * ${A}\n */\n${T}\n`,
2763
2884
  ],
2764
- ['trailing //', PATH, `const a = '#f00' // theme-allow raw-hex — ${REASON}\n`],
2765
- ['trailing block', PATH, `const a = '#f00' /* theme-allow raw-hex — ${REASON} */\n`],
2885
+ // JSX expression containers — the family every disagreement so far has come from.
2886
+ ['{/* */} own line', PATH, J(` {/* ${A} */}\n ${JT}`)],
2887
+ ['{/* */} trailing on the target line', PATH, J(` ${JT} {/* ${A} */}`)],
2888
+ ['{/* opener + token, closer on its own line', PATH, J(` {/* ${A}\n */}\n ${JT}`)],
2889
+ ['{/* gutter token, closer on the token line', PATH, J(` {/*\n ${A} */}\n ${JT}`)],
2766
2890
  [
2767
- 'JSX expression comment',
2891
+ '{/* gutter token, closer on its own line',
2768
2892
  PATH,
2769
- `export const C = () => (\n <div>\n {/* theme-allow raw-hex — ${REASON} */}\n <span style={{ color: '#f00' }} />\n </div>\n)\n`,
2893
+ J(` {/*\n ${A}\n */}\n ${JT}`),
2770
2894
  ],
2895
+ ['{/** star gutter, closer on the token line', PATH, J(` {/**\n * ${A} */}\n ${JT}`)],
2771
2896
  [
2772
- 'JSX expression comment, token on its own wrapped line',
2897
+ '{/** star gutter, closer on its own line',
2773
2898
  PATH,
2774
- `export const C = () => (\n <div>\n {/*\n theme-allow raw-hex — ${REASON} */}\n <span style={{ color: '#f00' }} />\n </div>\n)\n`,
2899
+ J(` {/**\n * ${A}\n */}\n ${JT}`),
2775
2900
  ],
2776
- ['block gutter *', PATH, `/**\n * theme-allow raw-hex — ${REASON}\n */\nconst a = '#f00'\n`],
2777
- ['docblock opener /**', PATH, `/** theme-allow raw-hex — ${REASON} */\nconst a = '#f00'\n`],
2778
- ['css trailing', 'src/a.css', `a { color: #f00; /* theme-allow raw-hex — ${REASON} */ }\n`],
2779
2901
  [
2780
- 'css reflowed onto continuation lines',
2781
- 'src/a.css',
2782
- `a {\n color: var(\n --x,\n #f00\n ); /* theme-allow raw-hex — ${REASON} */\n}\n`,
2902
+ '{/** token then 6 prose lines',
2903
+ PATH,
2904
+ J(` {/**\n * ${A}\n${' * p\n'.repeat(6)} */}\n ${JT}`),
2783
2905
  ],
2906
+ ['{/* target on the closer line', PATH, J(` {/*\n ${A}\n */}${JT}`)],
2907
+ ['{/* closer alone, tab indented', PATH, J(`\t{/*\n\t ${A}\n\t*/}\n\t${JT}`)],
2784
2908
  [
2785
- 'html comment',
2786
- 'index.html',
2787
- `<!-- theme-allow raw-hex — ${REASON} -->\n<b style="color: #f00"></b>\n`,
2909
+ '{/* closer alone, space between */ and }',
2910
+ PATH,
2911
+ J(` {/*\n ${A}\n */ }\n ${JT}`),
2788
2912
  ],
2789
2913
  [
2790
- 'html trailing comment',
2791
- 'index.html',
2792
- `<b style="color: #f00"></b> <!-- theme-allow raw-hex — ${REASON} -->\n`,
2914
+ '{/* closer alone, two annotations in one block',
2915
+ PATH,
2916
+ J(` {/*\n ${A}\n theme-allow raw-color-fn — ${REASON}\n */}\n ${JT}`),
2917
+ ],
2918
+ // A further comment between the annotation and the code is walked through; a blank line is not.
2919
+ ['/** */ then an unrelated // note', PATH, `/** ${A} */\n// an unrelated note\n${T}\n`],
2920
+ [
2921
+ '{/* */} then an unrelated {/* */}',
2922
+ PATH,
2923
+ J(` {/* ${A} */}\n {/* unrelated */}\n ${JT}`),
2793
2924
  ],
2925
+ // The file-declaration form, which is position-independent — pinned in two placements anyway,
2926
+ // because "position-independent" is a claim the parser has to keep making.
2927
+ [
2928
+ 'theme-allow-file, {/* closer on its own line',
2929
+ PATH,
2930
+ J(` {/*\n ${AF}\n */}\n ${JT}`),
2931
+ ],
2932
+ ['theme-allow-file, /** star gutter', PATH, `/**\n * ${AF}\n */\n${T}\n`],
2933
+ // Dialects the plugin never sees — CSS continuation lines, HTML, and the JSON member form.
2934
+ ['css trailing', 'src/a.css', `a { color: #f00; /* ${A} */ }\n`],
2935
+ [
2936
+ 'css reflowed onto continuation lines',
2937
+ 'src/a.css',
2938
+ `a {\n color: var(\n --x,\n #f00\n ); /* ${A} */\n}\n`,
2939
+ ],
2940
+ ['html comment own line', 'index.html', `<!-- ${A} -->\n<b style="color: #f00"></b>\n`],
2941
+ ['html trailing comment', 'index.html', `<b style="color: #f00"></b> <!-- ${A} -->\n`],
2794
2942
  [
2795
2943
  'JSON member form',
2796
2944
  'public/site.webmanifest',
@@ -2800,25 +2948,46 @@ describe('theme-allow comment shapes', () => {
2800
2948
  expect(waives(relPath as string, src as string)).toBe(true)
2801
2949
  })
2802
2950
 
2803
- // The false negative the prefix rule closed: a sentence that MENTIONS the token is not one.
2951
+ // ── UNSUPPORTED — asserted, so a hole can never pass for a design decision ──
2804
2952
  it.each([
2953
+ // A blank line is the separation people use for "this comment is not about the next statement".
2954
+ ['blank line after a // annotation', PATH, `// ${A}\n\n${T}\n`],
2955
+ ['blank line after a {/* */} annotation', PATH, J(` {/* ${A} */}\n\n ${JT}`)],
2805
2956
  [
2806
- 'mid-sentence in a line comment',
2807
- `// we normally write a theme-allow raw-hex here\nconst a = '#f00'\n`,
2957
+ 'blank line after a {/* closer on its own line',
2958
+ PATH,
2959
+ J(` {/*\n ${A}\n */}\n\n ${JT}`),
2808
2960
  ],
2961
+ // Prose that MENTIONS the token is not an annotation — the one false NEGATIVE in the set, and
2962
+ // the reason the token has to START its comment.
2963
+ ['mid-sentence in a line comment', PATH, `// we normally write a ${A} here\n${T}\n`],
2809
2964
  [
2810
- 'mid-sentence in a docblock',
2811
- `/**\n * Each value below is escaped with a theme-allow raw-hex annotation.\n */\nconst a = '#f00'\n`,
2965
+ 'mid-sentence in a docblock gutter',
2966
+ PATH,
2967
+ `/**\n * Each value is escaped with a ${A} annotation.\n */\n${T}\n`,
2968
+ ],
2969
+ [
2970
+ 'mid-sentence in a JSX expression comment',
2971
+ PATH,
2972
+ J(` {/* the shape here is a ${A} comment */}\n ${JT}`),
2973
+ ],
2974
+ ['inside a string literal', PATH, `const doc = 'theme-allow raw-hex'\n${T}\n`],
2975
+ // An annotation reaches the first line below its comment — not an arbitrary line further down.
2976
+ // A multi-line opening tag therefore needs the annotation beside the attribute, not above the
2977
+ // tag. Both halves agree, and both report rather than silently waiving the wrong node.
2978
+ [
2979
+ 'above a multi-line opening tag',
2980
+ PATH,
2981
+ J(` {/* ${A} */}\n <span\n style={{ color: '#f00' }}\n />`),
2812
2982
  ],
2813
- ['inside a string literal', `const doc = 'theme-allow raw-hex'\nconst a = '#f00'\n`],
2814
- ])('%s does NOT waive', (_name, src) => {
2815
- expect(waives(PATH, src as string)).toBe(false)
2983
+ ])('%s does NOT waive', (_name, relPath, src) => {
2984
+ expect(waives(relPath as string, src as string)).toBe(false)
2816
2985
  })
2817
2986
 
2818
2987
  // The narrowness of the `*/}` exception: a `}` that is REAL code keeps its trailing
2819
2988
  // classification, so the annotation does not reach the statement below it.
2820
2989
  it('a trailing annotation on a real closing brace stays on its own line', () => {
2821
- const src = `function f() {\n const a = 1\n} // theme-allow raw-hex — ${REASON}\nconst b = '#0f0'\n`
2990
+ const src = `function f() {\n const a = 1\n} // ${A}\nconst b = '#0f0'\n`
2822
2991
  expect(
2823
2992
  checkSource(src, PATH, DEFAULT_GUARD_CONFIG)
2824
2993
  .filter((f) => f.kind === 'raw-hex')
@@ -2829,7 +2998,7 @@ describe('theme-allow comment shapes', () => {
2829
2998
  // A trailing annotation is scoped to its OWN line — otherwise `const a = '#f00' // theme-allow`
2830
2999
  // would silently waive the statement below it too.
2831
3000
  it('a trailing annotation does not reach the line below', () => {
2832
- const src = `const a = '#f00' // theme-allow raw-hex — ${REASON}\nconst b = '#0f0'\n`
3001
+ const src = `const a = '#f00' // ${A}\nconst b = '#0f0'\n`
2833
3002
  expect(
2834
3003
  checkSource(src, PATH, DEFAULT_GUARD_CONFIG).filter((f) => f.kind === 'raw-hex'),
2835
3004
  ).toHaveLength(1)
@@ -400,9 +400,55 @@ const RAW_CHART_LEGEND_ARRAY = /<ChartLegend\b[^>]*?\bitems\s*=\s*\{\s*\[/g
400
400
  // alternative so their `>` never terminates the tag early. A bare `>` comparison inside a prop
401
401
  // expression still ends the match (accepted limitation of the bounded scan).
402
402
  const CHART_ENTRY_POINT_TAG =
403
- /<(?:MultiLine|Bars|Donut|DualPanel|Heatmap|ZonedLine|StackedArea|LineSparkline|BarSparkline)\b(?:<[^<>]*>)?(?:=>|[^>])*?>/g
403
+ /<(MultiLine|Bars|BandStrip|Donut|DualPanel|Heatmap|MirroredBars|ZonedLine|StackedArea|LineSparkline|BarSparkline)\b(?:<[^<>]*>)?(?:=>|[^>])*?>/g
404
404
  const HAS_ARIA_LABEL_PROP = /\bariaLabel\s*=/
405
405
 
406
+ // ── Tag provenance, shared by the two chart tag rules ────────────────────────────────────────────
407
+ //
408
+ // Both `chart-missing-aria-label` and `unframed-chart` key on a JSX tag NAME, which is the whole of
409
+ // their signal — so a consumer's OWN component that merely shares a shipped kind's name collected
410
+ // the finding too. Reported by a consumer on 1.23.0: a hand-composed local `<MirroredBars>` was
411
+ // told to pass an `ariaLabel` prop it does not accept, by a rule that presents as a correctness
412
+ // finding rather than as a naming one. `shadow-basalt-export` / `ai-sdk-major` scope through
413
+ // `isBasaltScopedFile` (package-level); the analogous file-level signal here is where the name in
414
+ // THIS file came from.
415
+ //
416
+ // The gate is deliberately a one-directional NARROWING: a tag is skipped only when the file DEFINES
417
+ // a component of that name and does not also import it from basalt-ui. Everything else still fires
418
+ // — a tag imported from basalt-ui, one imported from a consumer barrel that re-exports it (`import
419
+ // { MultiLine } from '../charts'`, the shape a downstream component library uses), and one the scan
420
+ // cannot attribute at all. Requiring a POSITIVE basalt import instead would have silently switched
421
+ // both rules off for every barrel-wrapping consumer and for every file with no import statements —
422
+ // a far bigger hole than the false positive it closes.
423
+ const LOCAL_COMPONENT_DEF =
424
+ /(?:^|[\n;}])\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var)\s+([A-Z]\w*)/g
425
+ // `from 'basalt-ui'` and every subpath (`basalt-ui/charts`, `basalt-ui/tokens`, …).
426
+ const BASALT_NAMED_IMPORT =
427
+ /import\s+(?:type\s+)?\{([^}]*)\}\s+from\s+['"]basalt-ui(?:\/[^'"]*)?['"]/g
428
+
429
+ /** Component-shaped names DEFINED in this file — the provable "not the shipped kind" signal. */
430
+ function localComponentNames(codeText: string): ReadonlySet<string> {
431
+ const out = new Set<string>()
432
+ for (const m of codeText.matchAll(LOCAL_COMPONENT_DEF)) out.add(m[1] as string)
433
+ return out
434
+ }
435
+
436
+ /** Names bound in this file by a named import from `basalt-ui` — the local binding, so
437
+ * `{ Bars as BasaltBars }` records `BasaltBars`, which is the name the JSX tag will use. */
438
+ function basaltImportedNames(codeText: string): ReadonlySet<string> {
439
+ const out = new Set<string>()
440
+ for (const m of codeText.matchAll(BASALT_NAMED_IMPORT)) {
441
+ for (const raw of (m[1] as string).split(',')) {
442
+ const spec = raw.trim().replace(/^type\s+/, '')
443
+ if (spec.length === 0) continue
444
+ const parts = spec.split(/\s+as\s+/)
445
+ const bound = (parts[parts.length - 1] as string).trim()
446
+ if (bound.length > 0) out.add(bound)
447
+ }
448
+ }
449
+ return out
450
+ }
451
+
406
452
  // Raw lowercase form-control element — line-scoped, same shape as RAW_VISX_AXIS. `\b` after the
407
453
  // tag name rejects a same-prefixed custom component (`<inputRef`, `<selectAll`).
408
454
  const RAW_FORM_CONTROL = /<(?:input|select|textarea)\b/g
@@ -771,8 +817,45 @@ function parseAllowAnnotation(rest: string, scope: AllowScope): AllowAnnotation
771
817
  /** How far back a trailing CSS annotation reaches for the declaration it terminates. */
772
818
  const MAX_CSS_CONTINUATION_LINES = 8
773
819
 
774
- /** How far a comment-only annotation reaches forward through the rest of its own comment block. */
775
- const MAX_COMMENT_BLOCK_LINES = 8
820
+ /**
821
+ * The 0-based line the annotation's OWN comment closes on.
822
+ *
823
+ * A comment-only annotation waives the first line below its comment that is not itself comment, so
824
+ * the walk needs to know how far the annotation's own comment reaches. It used to substitute a
825
+ * budget — walk forward at most eight lines — which was wrong in two ways nothing could see:
826
+ *
827
+ * 1. **A comment block longer than the budget.** A docblock with ~12 lines of reason between the
828
+ * token and its `*\/` ran the walk out before it reached the code, while the oxlint plugin
829
+ * (which reads the comment NODE, so length is free) waived it. Nothing about the shape said so;
830
+ * the annotation just stopped working past a line count nobody could count.
831
+ * 2. **A blank line inside the block.** Outside a comment a blank line SEPARATES — it is how people
832
+ * say "this comment is not about the next statement" — but a blank gutter line in the middle of
833
+ * a docblock is just prose, and ending the walk there dropped the waiver.
834
+ *
835
+ * Knowing where the comment ends settles both: inside it, keep going; past it, the ordinary
836
+ * comment-run rules apply (see the walk in {@link collectAllowAnnotations}, and
837
+ * {@link isCommentOnly} for the third hole this pass closed).
838
+ *
839
+ * An unterminated block comment reports its own line — the conservative answer for a file that does
840
+ * not parse anyway, and it keeps the walk from running to EOF.
841
+ */
842
+ function commentBlockEnd(
843
+ lines: readonly string[],
844
+ index: number,
845
+ tokenAt: number,
846
+ syntax: GuardSyntax,
847
+ ): number {
848
+ const line = lines[index] ?? ''
849
+ // A line comment closes at its own end of line. CSS has no `//`, so the test is TS-only: there,
850
+ // 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' ? '-->' : '*/'
853
+ if (line.slice(tokenAt).includes(closer)) return index
854
+ for (let ahead = index + 1; ahead < lines.length; ahead++) {
855
+ if ((lines[ahead] ?? '').includes(closer)) return ahead
856
+ }
857
+ return index
858
+ }
776
859
 
777
860
  /**
778
861
  * A comment opener sitting immediately before the annotation token.
@@ -805,6 +888,22 @@ const COMMENT_ONLY_LINE = /^\s*(?:\{\s*\/\*|\/\/|\/\*|\*|<!--)/
805
888
  */
806
889
  const JSX_COMMENT_CLOSE = /\*\/\s*\}\s*$/
807
890
 
891
+ /**
892
+ * Is this line NOTHING but comment? — one definition, used both to classify the annotation's own
893
+ * line (own-line vs trailing) and to walk the lines under it.
894
+ *
895
+ * The two used to be written out separately, and the walk's copy was the one missing the
896
+ * {@link JSX_COMMENT_CLOSE} clause: a `*\/}` alone on its line read as the code `}`, so the walk
897
+ * stopped ON the closer and the annotation waived a brace. Two consumers isolated that
898
+ * independently — `{/* … *\/}` and `{/** … *\/}` — each with a probe pair differing by one newline,
899
+ * while the oxlint plugin (whose test is comment-node-based and never sees the brace) waived both.
900
+ * A shared predicate is what stops the two placements from drifting apart again.
901
+ */
902
+ function isCommentOnly(raw: string, stripped: string): boolean {
903
+ const code = stripped.trim()
904
+ return code === '' || COMMENT_ONLY_LINE.test(raw) || (code === '}' && JSX_COMMENT_CLOSE.test(raw))
905
+ }
906
+
808
907
  /**
809
908
  * Everything allowed between the comment opener (or the start of the line) and the annotation
810
909
  * token, for the token to count as an ANNOTATION rather than prose that mentions one.
@@ -933,7 +1032,12 @@ function collectAllowAnnotations(
933
1032
  }
934
1033
 
935
1034
  /** Place one parsed annotation: file declarations reach everywhere, line ones the 3 placements. */
936
- const record = (index: number, annotation: AllowAnnotation, trailing: boolean): void => {
1035
+ const record = (
1036
+ index: number,
1037
+ annotation: AllowAnnotation,
1038
+ trailing: boolean,
1039
+ tokenAt: number,
1040
+ ): void => {
937
1041
  declared.push({ line: index + 1, annotation })
938
1042
  if (annotation.scope === 'file') {
939
1043
  fileScoped.push(annotation)
@@ -941,16 +1045,20 @@ function collectAllowAnnotations(
941
1045
  }
942
1046
  add(index, annotation)
943
1047
  if (!trailing) {
944
- // The first line below that is not itself comment. A docblock's `*/` (or the rest of a
945
- // wrapped reason) sits between the annotation and the code it is about, so `index + 1` alone
946
- // meant the natural multi-line shape silently waived nothing — argo hit it three times in one
947
- // upgrade, each looking like a correct annotation. A BLANK line still ends the block: that is
948
- // the separation people use to mean "this comment is not about the next statement".
949
- for (let ahead = 1; ahead <= MAX_COMMENT_BLOCK_LINES; ahead++) {
950
- const raw = lines[index + ahead] ?? ''
951
- add(index + ahead, annotation)
952
- if (raw.trim() !== '' && (codeLines[index + ahead] ?? '').trim() !== '') break
1048
+ // The first CODE line below the comment. A docblock's `*/` (or the rest of a wrapped reason)
1049
+ // sits between the annotation and the code it is about, so `index + 1` alone meant the
1050
+ // natural multi-line shape silently waived nothing — argo hit it three times in one upgrade,
1051
+ // each looking like a correct annotation. A BLANK line outside the comment still ends the
1052
+ // block: that is the separation people use to mean "this comment is not about the next
1053
+ // statement". Inside it, a blank gutter line is just prose.
1054
+ const closesAt = commentBlockEnd(lines, index, tokenAt, syntax)
1055
+ for (let ahead = 1; index + ahead < lines.length; ahead++) {
1056
+ const at = index + ahead
1057
+ add(at, annotation)
1058
+ if (at <= closesAt) continue
1059
+ const raw = lines[at] ?? ''
953
1060
  if (raw.trim() === '') break
1061
+ if (!isCommentOnly(raw, codeLines[at] ?? '')) break
954
1062
  }
955
1063
  return
956
1064
  }
@@ -968,14 +1076,17 @@ function collectAllowAnnotations(
968
1076
  for (let i = 0; i < lines.length; i++) {
969
1077
  const line = lines[i] ?? ''
970
1078
  const stripped = codeLines[i] ?? ''
971
- const code = stripped.trim()
972
- const isCommentOnlyLine =
973
- code === '' || COMMENT_ONLY_LINE.test(line) || (code === '}' && JSX_COMMENT_CLOSE.test(line))
1079
+ const isCommentOnlyLine = isCommentOnly(line, stripped)
974
1080
 
975
1081
  // The JSON member form. Markup-only: in TS/CSS that string would be code, not an annotation.
976
1082
  if (syntax === 'markup') {
977
1083
  for (const m of line.matchAll(jsonKey)) {
978
- record(i, parseAllowAnnotation(m[2] ?? '', m[1] === undefined ? 'line' : 'file'), false)
1084
+ record(
1085
+ i,
1086
+ parseAllowAnnotation(m[2] ?? '', m[1] === undefined ? 'line' : 'file'),
1087
+ false,
1088
+ m.index,
1089
+ )
979
1090
  }
980
1091
  }
981
1092
 
@@ -993,6 +1104,7 @@ function collectAllowAnnotations(
993
1104
  i,
994
1105
  parseAllowAnnotation(rest, m[1] === undefined ? 'line' : 'file'),
995
1106
  !isCommentOnlyLine,
1107
+ at,
996
1108
  )
997
1109
  }
998
1110
  }
@@ -1981,10 +2093,25 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
1981
2093
  }
1982
2094
  }
1983
2095
 
2096
+ // Both chart tag rules below gate on tag PROVENANCE (see the block above LOCAL_COMPONENT_DEF).
2097
+ // Computed once, and only if one of the two actually runs.
2098
+ const unframedChartRuns =
2099
+ GUARD_RULES['unframed-chart'].enabled!(cfg) && ruleApplies('unframed-chart', relPath)
2100
+ const ariaLabelRuns =
2101
+ GUARD_RULES['chart-missing-aria-label'].enabled!(cfg) &&
2102
+ ruleApplies('chart-missing-aria-label', relPath)
2103
+ const localNames =
2104
+ unframedChartRuns || ariaLabelRuns ? localComponentNames(codeText) : new Set<string>()
2105
+ const basaltNames =
2106
+ unframedChartRuns || ariaLabelRuns ? basaltImportedNames(codeText) : new Set<string>()
2107
+ const isShippedTag = (tag: string): boolean => basaltNames.has(tag) || !localNames.has(tag)
2108
+
1984
2109
  // unframed-chart — full-text tag-scoped scan (not per-line, see RAW_CHART_LEGEND_ARRAY comment).
1985
2110
  // Scans `codeText` (comment-stripped) so a legend example inside a comment can't match; reports
1986
- // at the line of the `items={[` token itself.
1987
- if (GUARD_RULES['unframed-chart'].enabled!(cfg) && ruleApplies('unframed-chart', relPath)) {
2111
+ // at the line of the `items={[` token itself. Skipped wholesale when `ChartLegend` is this file's
2112
+ // OWN component: a consumer's legend taking an `items` array is not the shipped one that derives
2113
+ // its entries, and the remedy ("pass deriveLegend(series)") is unreachable there.
2114
+ if (unframedChartRuns && isShippedTag('ChartLegend')) {
1988
2115
  for (const m of codeText.matchAll(RAW_CHART_LEGEND_ARRAY)) {
1989
2116
  const lineNo = codeText.slice(0, (m.index ?? 0) + m[0].length).split('\n').length
1990
2117
  push('unframed-chart', lineNo, 'items={[')
@@ -1998,12 +2125,11 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
1998
2125
  // which is what `Finding.text` would then quote back — the closing bracket rather than the tag
1999
2126
  // that is missing the prop. The allow-comment is honored anywhere in the tag's span, so a
2000
2127
  // `theme-allow` that used to sit on the closing line keeps working.
2001
- if (
2002
- GUARD_RULES['chart-missing-aria-label'].enabled!(cfg) &&
2003
- ruleApplies('chart-missing-aria-label', relPath)
2004
- ) {
2128
+ if (ariaLabelRuns) {
2005
2129
  for (const m of codeText.matchAll(CHART_ENTRY_POINT_TAG)) {
2006
2130
  const tagText = m[0]
2131
+ // A tag this file defines itself is not the shipped kind — it does not take `ariaLabel`.
2132
+ if (!isShippedTag(m[1] as string)) continue
2007
2133
  if (HAS_ARIA_LABEL_PROP.test(tagText)) continue
2008
2134
  const startLine = codeText.slice(0, m.index ?? 0).split('\n').length
2009
2135
  const endLine = startLine + (tagText.split('\n').length - 1)
package/src/vite.test.ts CHANGED
@@ -234,6 +234,85 @@ describe('basaltAppPlugin — base handling', () => {
234
234
  const manifest = JSON.parse(runMiddleware(middleware, '/site.webmanifest') ?? '{}')
235
235
  expect(manifest.icons[0].src).toBe('/brand/web-app-manifest-192x192.png')
236
236
  })
237
+
238
+ // ── the consumer-named array form ─────────────────────────────────────────
239
+ //
240
+ // `{ dir }` can move basalt's six filenames but not rename one, so an app whose only icon is
241
+ // `favicon.svg` could pick between a manifest naming two PNGs it does not ship and a manifest
242
+ // with no icons at all. Both are wrong for an installable app, and the second is why rb still
243
+ // kept a hand-written manifest one release after `icons: false` was fixed.
244
+
245
+ // rb's REAL icon set, copied from apps/web/public/manifest.webmanifest — the manifest this
246
+ // option exists to let the plugin generate.
247
+ const RB_ICONS = [
248
+ { src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
249
+ ] as const
250
+
251
+ test("generates rb's hand-written manifest icons member verbatim", () => {
252
+ const plugin = getPlugin({ name: 'rb', themeColor: THEME_COLOR, icons: RB_ICONS })
253
+ const middleware = getDevMiddleware(plugin, '/')
254
+ const manifest = JSON.parse(runMiddleware(middleware, '/site.webmanifest') ?? '{}')
255
+
256
+ expect(manifest.icons).toEqual([
257
+ { src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
258
+ ])
259
+ expect(JSON.stringify(manifest)).not.toContain('.png')
260
+ expect(manifest.name).toBe('rb')
261
+ expect(manifest.display).toBe('standalone')
262
+ })
263
+
264
+ test('a named icon reaches the head only when it asks for a rel', () => {
265
+ const iconLinks = (icons: NonNullable<BasaltAppOptions['icons']>): HtmlTagDescriptor[] => {
266
+ const plugin = getPlugin({ name: 'rb', themeColor: THEME_COLOR, icons })
267
+ resolveConfig(plugin, '/')
268
+ return transformHtml(plugin, HTML_NO_VIEWPORT).filter(
269
+ (tag) => tag.tag === 'link' && String(tag.attrs?.['rel']).includes('icon'),
270
+ )
271
+ }
272
+
273
+ // rb's index.html already links favicon.svg — the manifest gets fixed, the head is left alone.
274
+ expect(iconLinks(RB_ICONS)).toHaveLength(0)
275
+
276
+ const withRel = iconLinks([{ src: 'favicon.svg', type: 'image/svg+xml', rel: 'icon' }])
277
+ expect(withRel).toHaveLength(1)
278
+ expect(withRel[0]?.attrs).toEqual({
279
+ rel: 'icon',
280
+ type: 'image/svg+xml',
281
+ href: '/favicon.svg',
282
+ })
283
+ })
284
+
285
+ test('a named icon src is base-prefixed, with or without its leading slash', () => {
286
+ const plugin = getPlugin({
287
+ name: 'rb',
288
+ themeColor: THEME_COLOR,
289
+ icons: [
290
+ { src: 'favicon.svg', sizes: 'any' },
291
+ { src: '/icon-512.png', sizes: '512x512' },
292
+ ],
293
+ })
294
+ const middleware = getDevMiddleware(plugin, '/myapp/')
295
+ const manifest = JSON.parse(runMiddleware(middleware, '/myapp/site.webmanifest') ?? '{}')
296
+
297
+ expect(manifest.icons.map((icon: { src: string }) => icon.src)).toEqual([
298
+ '/myapp/favicon.svg',
299
+ '/myapp/icon-512.png',
300
+ ])
301
+ })
302
+
303
+ test('an empty array reads as icons: false — no member, not an empty one', () => {
304
+ const plugin = getPlugin({ name: 'rb', themeColor: THEME_COLOR, icons: [] })
305
+ const middleware = getDevMiddleware(plugin, '/')
306
+ const manifest = JSON.parse(runMiddleware(middleware, '/site.webmanifest') ?? '{}')
307
+ expect(manifest.icons).toBeUndefined()
308
+ })
309
+
310
+ test('the array form omits keys the consumer did not write', () => {
311
+ const plugin = getPlugin({ name: 'rb', themeColor: THEME_COLOR, icons: [{ src: '/a.svg' }] })
312
+ const middleware = getDevMiddleware(plugin, '/')
313
+ const manifest = JSON.parse(runMiddleware(middleware, '/site.webmanifest') ?? '{}')
314
+ expect(manifest.icons).toEqual([{ src: '/a.svg' }])
315
+ })
237
316
  })
238
317
 
239
318
  describe('basaltAppPlugin — the anti-FOUC boot rule (V1)', () => {