basalt-ui 1.22.0 → 1.23.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 (79) hide show
  1. package/AGENTS.md +9 -1
  2. package/CHANGELOG.md +19 -0
  3. package/MIGRATING.md +125 -12
  4. package/README.md +45 -11
  5. package/agent/rules/basalt-app.md +26 -2
  6. package/agent/rules/basalt-charts.md +40 -5
  7. package/agent/rules/basalt-tokens.md +17 -1
  8. package/agent/skills/basalt-charts/SKILL.md +23 -8
  9. package/agent/templates/DESIGN.md.tpl +3 -1
  10. package/configs/oxlint-plugin.js +49 -8
  11. package/configs/oxlint-plugin.test.ts +88 -31
  12. package/dist/charts/hooks/useBandPlot.d.ts +138 -0
  13. package/dist/charts/hooks/useBandPlot.d.ts.map +1 -0
  14. package/dist/charts/hooks/useBandPlot.js +127 -0
  15. package/dist/charts/index.d.ts +4 -0
  16. package/dist/charts/index.d.ts.map +1 -1
  17. package/dist/charts/index.js +14 -0
  18. package/dist/charts/kinds/BandStrip.d.ts +113 -0
  19. package/dist/charts/kinds/BandStrip.d.ts.map +1 -0
  20. package/dist/charts/kinds/BandStrip.js +218 -0
  21. package/dist/charts/kinds/BandStrip.test.d.ts +2 -0
  22. package/dist/charts/kinds/BandStrip.test.d.ts.map +1 -0
  23. package/dist/charts/kinds/Bars.d.ts +5 -1
  24. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  25. package/dist/charts/kinds/Bars.js +2 -0
  26. package/dist/charts/kinds/MirroredBars.d.ts +93 -0
  27. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -0
  28. package/dist/charts/kinds/MirroredBars.js +315 -0
  29. package/dist/charts/kinds/MirroredBars.test.d.ts +2 -0
  30. package/dist/charts/kinds/MirroredBars.test.d.ts.map +1 -0
  31. package/dist/charts/kinds/MultiLine.d.ts +5 -1
  32. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  33. package/dist/charts/kinds/MultiLine.js +2 -0
  34. package/dist/charts/kinds/StackedArea.d.ts +5 -1
  35. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  36. package/dist/charts/kinds/StackedArea.js +2 -0
  37. package/dist/charts/kinds/ZonedLine.d.ts +5 -1
  38. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  39. package/dist/charts/kinds/ZonedLine.js +2 -0
  40. package/dist/charts/primitives/CartesianChart.d.ts +18 -2
  41. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  42. package/dist/charts/primitives/CartesianChart.js +5 -2
  43. package/dist/charts/primitives/HatchPattern.d.ts +31 -0
  44. package/dist/charts/primitives/HatchPattern.d.ts.map +1 -0
  45. package/dist/charts/primitives/HatchPattern.js +31 -0
  46. package/dist/cli/index.d.ts +2 -0
  47. package/dist/cli/index.d.ts.map +1 -1
  48. package/dist/cli/index.js +47 -8
  49. package/dist/cli/shipped-versions.test.d.ts +2 -0
  50. package/dist/cli/shipped-versions.test.d.ts.map +1 -0
  51. package/dist/guard/index.d.ts.map +1 -1
  52. package/dist/guard/index.js +32 -11
  53. package/dist/vite.d.ts +42 -3
  54. package/dist/vite.d.ts.map +1 -1
  55. package/dist/vite.js +67 -30
  56. package/llms.txt +2 -2
  57. package/package.json +1 -1
  58. package/src/charts/hooks/useBandPlot.ts +302 -0
  59. package/src/charts/index.ts +14 -0
  60. package/src/charts/kinds/BandStrip.test.tsx +200 -0
  61. package/src/charts/kinds/BandStrip.tsx +364 -0
  62. package/src/charts/kinds/Bars.tsx +7 -1
  63. package/src/charts/kinds/DualPanel.test.tsx +10 -4
  64. package/src/charts/kinds/MirroredBars.test.tsx +193 -0
  65. package/src/charts/kinds/MirroredBars.tsx +455 -0
  66. package/src/charts/kinds/MultiLine.tsx +7 -1
  67. package/src/charts/kinds/StackedArea.tsx +7 -1
  68. package/src/charts/kinds/ZonedLine.tsx +7 -1
  69. package/src/charts/primitives/CartesianChart.test.tsx +86 -4
  70. package/src/charts/primitives/CartesianChart.tsx +28 -3
  71. package/src/charts/primitives/HatchPattern.tsx +55 -0
  72. package/src/cli/index.ts +125 -18
  73. package/src/cli/shipped-versions.test.ts +83 -0
  74. package/src/cli/toolchain-wiring.test.ts +171 -4
  75. package/src/cli/waiver-audit.test.ts +7 -3
  76. package/src/guard/check-source.test.ts +138 -42
  77. package/src/guard/index.ts +83 -17
  78. package/src/vite.test.ts +79 -0
  79. package/src/vite.ts +141 -39
@@ -104,11 +104,15 @@ describe('check-theme --audit-allows — theme-allow annotations', () => {
104
104
  })
105
105
 
106
106
  it('names the scope it audited, so "0 dead" is never read as "0 dead anywhere"', () => {
107
- // The audit reads exactly what check-theme reads. A waiver in a file outside `basalt.roots` is
108
- // invisible to it — not because it is fine, but because nothing scanned it.
107
+ // The audit reads exactly what check-theme reads, and that is WIDER than `basalt.roots` — two
108
+ // consumers reported a live `public/site.webmanifest` waiver under a scope line claiming
109
+ // files outside roots are not audited. The line has to name every class the scan reaches, or
110
+ // it is a second false statement in the sentence that exists to prevent one.
109
111
  fixture("export const c = '#ff0000' // theme-allow raw-hex — brand literal\n")
110
112
  const { log } = audit()
111
- expect(log).toContain('Scope: the 1 file(s) check-theme scans under basalt.roots (src)')
113
+ expect(log).toContain('Scope: the 1 file(s) check-theme scans — everything under basalt.roots')
114
+ expect(log).toContain('sibling index.html and public/ tree')
115
+ expect(log).toContain('basalt.include')
112
116
  })
113
117
 
114
118
  it('marks an unaccountable waiver while still reporting what it covers', () => {
@@ -2738,59 +2738,134 @@ describe('inline-spacing — the style-object test', () => {
2738
2738
  })
2739
2739
  })
2740
2740
 
2741
- // ── theme-allow comment shapes ───────────────────────────────────────────────────────────────────
2741
+ // ── theme-allow shape grid ───────────────────────────────────────────────────────────────────────
2742
2742
 
2743
2743
  /**
2744
- * The prefix contract, pinned per comment shape.
2744
+ * The whole shape space of an annotation, pinned cell by cell.
2745
2745
  *
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.
2746
+ * FIVE holes have been found in this one contract in four rounds — two false positives, one false
2747
+ * negative, and twice the guard and the oxlint plugin disagreeing about what an annotation IS. Each
2748
+ * was found by a consumer writing a shape nobody had thought to test, and round 6's answer (a
2749
+ * thirteen-shape list of "every shape a consumer actually writes") missed the next three anyway,
2750
+ * because a list of observed shapes cannot cover the ones not yet observed.
2751
+ *
2752
+ * So this is a GRID over the four things that actually vary, not a list of anecdotes:
2753
+ *
2754
+ * | axis | values |
2755
+ * |-|-|
2756
+ * | comment style | `//` · `/* *\/` · `/** *\/` · `{/* *\/}` · `{/** *\/}` · `<!-- -->` · JSON member |
2757
+ * | token position | opener line · gutter line · trailing after code |
2758
+ * | where the closer falls | the token's line · its own line · N lines below · n/a |
2759
+ * | what follows | code · blank then code · comment then code · code on the closer's line |
2760
+ *
2761
+ * `configs/oxlint-plugin.test.ts` pins the same grid, under the same row names, for the subset the
2762
+ * plugin can judge (TS/JSX — it never sees CSS, HTML or JSON). The two halves must agree on every
2763
+ * shared cell; add a row to one and it goes in the other in the same commit.
2764
+ *
2765
+ * The unsupported cells are asserted too. "Unsupported" and "silently broken" read identically to a
2766
+ * consumer, and the whole point of the grid is to make them different things.
2751
2767
  */
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
2768
+ /** Wraps JSX children in a component, so a `{/* … *\/}` child is legal source. */
2769
+ const jsxShape = (body: string): string =>
2770
+ `export const C = () => (\n <div>\n${body}\n </div>\n)\n`
2771
+
2772
+ /** Does the file waive its `raw-hex`? — the one question every grid row asks. */
2773
+ const shapeWaives = (relPath: string, src: string): boolean =>
2774
+ checkSource(src, relPath, DEFAULT_GUARD_CONFIG).filter((f) => f.kind === 'raw-hex').length === 0
2756
2775
 
2776
+ describe('theme-allow shape grid', () => {
2777
+ const REASON = 'deliberate legacy value'
2778
+ const A = `theme-allow raw-hex — ${REASON}`
2779
+ const AF = `theme-allow-file raw-hex — ${REASON}`
2780
+ /** The statement the guard reports `raw-hex` on. */
2781
+ const T = `const a = '#f00'`
2782
+ /** The JSX child the guard reports `raw-hex` on. */
2783
+ const JT = `<span style={{ color: '#f00' }} />`
2784
+ const J = jsxShape
2785
+ const waives = shapeWaives
2786
+
2787
+ // ── SUPPORTED — every cell of the grid that waives ────────────────────────
2757
2788
  it.each([
2758
- ['own line //', PATH, `// theme-allow raw-hex — ${REASON}\nconst a = '#f00'\n`],
2789
+ // `//` — no closer to place.
2790
+ ['// own line', PATH, `// ${A}\n${T}\n`],
2791
+ ['// indented', PATH, `function f() {\n // ${A}\n ${T}\n}\n`],
2792
+ ['// no space after the marker', PATH, `//${A}\n${T}\n`],
2793
+ ['// trailing', PATH, `${T} // ${A}\n`],
2794
+ ['// reason wrapped onto a second // comment', PATH, `// ${A},\n// continued here\n${T}\n`],
2795
+ // `/* */` and `/** */` at statement level, closer in each of its three places.
2796
+ ['/* */ own line', PATH, `/* ${A} */\n${T}\n`],
2797
+ ['/* */ trailing', PATH, `${T} /* ${A} */\n`],
2798
+ ['/** */ own line (docblock opener)', PATH, `/** ${A} */\n${T}\n`],
2799
+ ['/* opener + token, closer on its own line', PATH, `/* ${A}\n*/\n${T}\n`],
2800
+ ['/* gutter token, closer on the token line', PATH, `/*\n ${A} */\n${T}\n`],
2801
+ ['/* gutter token, closer on its own line', PATH, `/*\n ${A}\n*/\n${T}\n`],
2802
+ ['/** star gutter, closer on the token line', PATH, `/**\n * ${A} */\n${T}\n`],
2803
+ ['/** star gutter, closer on its own line', PATH, `/**\n * ${A}\n */\n${T}\n`],
2804
+ // Prose between the token and the closer — any amount of it.
2805
+ ['/** token then 1 prose line', PATH, `/**\n * ${A}\n * more\n */\n${T}\n`],
2806
+ ['/** token then 12 prose lines', PATH, `/**\n * ${A}\n${' * p\n'.repeat(12)} */\n${T}\n`],
2759
2807
  [
2760
- 'indented //',
2808
+ '/** prose first, token on a later gutter line',
2761
2809
  PATH,
2762
- `function f() {\n // theme-allow raw-hex — ${REASON}\n const a = '#f00'\n}\n`,
2810
+ `/**\n * some prose\n * ${A}\n */\n${T}\n`,
2763
2811
  ],
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`],
2812
+ // JSX expression containers — the family every disagreement so far has come from.
2813
+ ['{/* */} own line', PATH, J(` {/* ${A} */}\n ${JT}`)],
2814
+ ['{/* */} trailing on the target line', PATH, J(` ${JT} {/* ${A} */}`)],
2815
+ ['{/* opener + token, closer on its own line', PATH, J(` {/* ${A}\n */}\n ${JT}`)],
2816
+ ['{/* gutter token, closer on the token line', PATH, J(` {/*\n ${A} */}\n ${JT}`)],
2766
2817
  [
2767
- 'JSX expression comment',
2818
+ '{/* gutter token, closer on its own line',
2768
2819
  PATH,
2769
- `export const C = () => (\n <div>\n {/* theme-allow raw-hex — ${REASON} */}\n <span style={{ color: '#f00' }} />\n </div>\n)\n`,
2820
+ J(` {/*\n ${A}\n */}\n ${JT}`),
2770
2821
  ],
2822
+ ['{/** star gutter, closer on the token line', PATH, J(` {/**\n * ${A} */}\n ${JT}`)],
2771
2823
  [
2772
- 'JSX expression comment, token on its own wrapped line',
2824
+ '{/** star gutter, closer on its own line',
2773
2825
  PATH,
2774
- `export const C = () => (\n <div>\n {/*\n theme-allow raw-hex — ${REASON} */}\n <span style={{ color: '#f00' }} />\n </div>\n)\n`,
2826
+ J(` {/**\n * ${A}\n */}\n ${JT}`),
2775
2827
  ],
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
2828
  [
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`,
2829
+ '{/** token then 6 prose lines',
2830
+ PATH,
2831
+ J(` {/**\n * ${A}\n${' * p\n'.repeat(6)} */}\n ${JT}`),
2783
2832
  ],
2833
+ ['{/* target on the closer line', PATH, J(` {/*\n ${A}\n */}${JT}`)],
2834
+ ['{/* closer alone, tab indented', PATH, J(`\t{/*\n\t ${A}\n\t*/}\n\t${JT}`)],
2784
2835
  [
2785
- 'html comment',
2786
- 'index.html',
2787
- `<!-- theme-allow raw-hex — ${REASON} -->\n<b style="color: #f00"></b>\n`,
2836
+ '{/* closer alone, space between */ and }',
2837
+ PATH,
2838
+ J(` {/*\n ${A}\n */ }\n ${JT}`),
2788
2839
  ],
2789
2840
  [
2790
- 'html trailing comment',
2791
- 'index.html',
2792
- `<b style="color: #f00"></b> <!-- theme-allow raw-hex — ${REASON} -->\n`,
2841
+ '{/* closer alone, two annotations in one block',
2842
+ PATH,
2843
+ J(` {/*\n ${A}\n theme-allow raw-color-fn — ${REASON}\n */}\n ${JT}`),
2844
+ ],
2845
+ // A further comment between the annotation and the code is walked through; a blank line is not.
2846
+ ['/** */ then an unrelated // note', PATH, `/** ${A} */\n// an unrelated note\n${T}\n`],
2847
+ [
2848
+ '{/* */} then an unrelated {/* */}',
2849
+ PATH,
2850
+ J(` {/* ${A} */}\n {/* unrelated */}\n ${JT}`),
2851
+ ],
2852
+ // The file-declaration form, which is position-independent — pinned in two placements anyway,
2853
+ // because "position-independent" is a claim the parser has to keep making.
2854
+ [
2855
+ 'theme-allow-file, {/* closer on its own line',
2856
+ PATH,
2857
+ J(` {/*\n ${AF}\n */}\n ${JT}`),
2858
+ ],
2859
+ ['theme-allow-file, /** star gutter', PATH, `/**\n * ${AF}\n */\n${T}\n`],
2860
+ // Dialects the plugin never sees — CSS continuation lines, HTML, and the JSON member form.
2861
+ ['css trailing', 'src/a.css', `a { color: #f00; /* ${A} */ }\n`],
2862
+ [
2863
+ 'css reflowed onto continuation lines',
2864
+ 'src/a.css',
2865
+ `a {\n color: var(\n --x,\n #f00\n ); /* ${A} */\n}\n`,
2793
2866
  ],
2867
+ ['html comment own line', 'index.html', `<!-- ${A} -->\n<b style="color: #f00"></b>\n`],
2868
+ ['html trailing comment', 'index.html', `<b style="color: #f00"></b> <!-- ${A} -->\n`],
2794
2869
  [
2795
2870
  'JSON member form',
2796
2871
  'public/site.webmanifest',
@@ -2800,25 +2875,46 @@ describe('theme-allow comment shapes', () => {
2800
2875
  expect(waives(relPath as string, src as string)).toBe(true)
2801
2876
  })
2802
2877
 
2803
- // The false negative the prefix rule closed: a sentence that MENTIONS the token is not one.
2878
+ // ── UNSUPPORTED — asserted, so a hole can never pass for a design decision ──
2804
2879
  it.each([
2880
+ // A blank line is the separation people use for "this comment is not about the next statement".
2881
+ ['blank line after a // annotation', PATH, `// ${A}\n\n${T}\n`],
2882
+ ['blank line after a {/* */} annotation', PATH, J(` {/* ${A} */}\n\n ${JT}`)],
2805
2883
  [
2806
- 'mid-sentence in a line comment',
2807
- `// we normally write a theme-allow raw-hex here\nconst a = '#f00'\n`,
2884
+ 'blank line after a {/* closer on its own line',
2885
+ PATH,
2886
+ J(` {/*\n ${A}\n */}\n\n ${JT}`),
2887
+ ],
2888
+ // Prose that MENTIONS the token is not an annotation — the one false NEGATIVE in the set, and
2889
+ // the reason the token has to START its comment.
2890
+ ['mid-sentence in a line comment', PATH, `// we normally write a ${A} here\n${T}\n`],
2891
+ [
2892
+ 'mid-sentence in a docblock gutter',
2893
+ PATH,
2894
+ `/**\n * Each value is escaped with a ${A} annotation.\n */\n${T}\n`,
2808
2895
  ],
2809
2896
  [
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`,
2897
+ 'mid-sentence in a JSX expression comment',
2898
+ PATH,
2899
+ J(` {/* the shape here is a ${A} comment */}\n ${JT}`),
2900
+ ],
2901
+ ['inside a string literal', PATH, `const doc = 'theme-allow raw-hex'\n${T}\n`],
2902
+ // An annotation reaches the first line below its comment — not an arbitrary line further down.
2903
+ // A multi-line opening tag therefore needs the annotation beside the attribute, not above the
2904
+ // tag. Both halves agree, and both report rather than silently waiving the wrong node.
2905
+ [
2906
+ 'above a multi-line opening tag',
2907
+ PATH,
2908
+ J(` {/* ${A} */}\n <span\n style={{ color: '#f00' }}\n />`),
2812
2909
  ],
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)
2910
+ ])('%s does NOT waive', (_name, relPath, src) => {
2911
+ expect(waives(relPath as string, src as string)).toBe(false)
2816
2912
  })
2817
2913
 
2818
2914
  // The narrowness of the `*/}` exception: a `}` that is REAL code keeps its trailing
2819
2915
  // classification, so the annotation does not reach the statement below it.
2820
2916
  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`
2917
+ const src = `function f() {\n const a = 1\n} // ${A}\nconst b = '#0f0'\n`
2822
2918
  expect(
2823
2919
  checkSource(src, PATH, DEFAULT_GUARD_CONFIG)
2824
2920
  .filter((f) => f.kind === 'raw-hex')
@@ -2829,7 +2925,7 @@ describe('theme-allow comment shapes', () => {
2829
2925
  // A trailing annotation is scoped to its OWN line — otherwise `const a = '#f00' // theme-allow`
2830
2926
  // would silently waive the statement below it too.
2831
2927
  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`
2928
+ const src = `const a = '#f00' // ${A}\nconst b = '#0f0'\n`
2833
2929
  expect(
2834
2930
  checkSource(src, PATH, DEFAULT_GUARD_CONFIG).filter((f) => f.kind === 'raw-hex'),
2835
2931
  ).toHaveLength(1)
@@ -400,7 +400,7 @@ 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
406
  // Raw lowercase form-control element — line-scoped, same shape as RAW_VISX_AXIS. `\b` after the
@@ -771,8 +771,45 @@ function parseAllowAnnotation(rest: string, scope: AllowScope): AllowAnnotation
771
771
  /** How far back a trailing CSS annotation reaches for the declaration it terminates. */
772
772
  const MAX_CSS_CONTINUATION_LINES = 8
773
773
 
774
- /** How far a comment-only annotation reaches forward through the rest of its own comment block. */
775
- const MAX_COMMENT_BLOCK_LINES = 8
774
+ /**
775
+ * The 0-based line the annotation's OWN comment closes on.
776
+ *
777
+ * A comment-only annotation waives the first line below its comment that is not itself comment, so
778
+ * the walk needs to know how far the annotation's own comment reaches. It used to substitute a
779
+ * budget — walk forward at most eight lines — which was wrong in two ways nothing could see:
780
+ *
781
+ * 1. **A comment block longer than the budget.** A docblock with ~12 lines of reason between the
782
+ * token and its `*\/` ran the walk out before it reached the code, while the oxlint plugin
783
+ * (which reads the comment NODE, so length is free) waived it. Nothing about the shape said so;
784
+ * the annotation just stopped working past a line count nobody could count.
785
+ * 2. **A blank line inside the block.** Outside a comment a blank line SEPARATES — it is how people
786
+ * say "this comment is not about the next statement" — but a blank gutter line in the middle of
787
+ * a docblock is just prose, and ending the walk there dropped the waiver.
788
+ *
789
+ * Knowing where the comment ends settles both: inside it, keep going; past it, the ordinary
790
+ * comment-run rules apply (see the walk in {@link collectAllowAnnotations}, and
791
+ * {@link isCommentOnly} for the third hole this pass closed).
792
+ *
793
+ * An unterminated block comment reports its own line — the conservative answer for a file that does
794
+ * not parse anyway, and it keeps the walk from running to EOF.
795
+ */
796
+ function commentBlockEnd(
797
+ lines: readonly string[],
798
+ index: number,
799
+ tokenAt: number,
800
+ syntax: GuardSyntax,
801
+ ): number {
802
+ const line = lines[index] ?? ''
803
+ // A line comment closes at its own end of line. CSS has no `//`, so the test is TS-only: there,
804
+ // a `//`-looking sequence is text and the conservative same-line answer is the right one anyway.
805
+ if (syntax === 'ts' && /\/\/\s*$/.test(line.slice(0, tokenAt))) return index
806
+ const closer = syntax === 'markup' ? '-->' : '*/'
807
+ if (line.slice(tokenAt).includes(closer)) return index
808
+ for (let ahead = index + 1; ahead < lines.length; ahead++) {
809
+ if ((lines[ahead] ?? '').includes(closer)) return ahead
810
+ }
811
+ return index
812
+ }
776
813
 
777
814
  /**
778
815
  * A comment opener sitting immediately before the annotation token.
@@ -805,6 +842,22 @@ const COMMENT_ONLY_LINE = /^\s*(?:\{\s*\/\*|\/\/|\/\*|\*|<!--)/
805
842
  */
806
843
  const JSX_COMMENT_CLOSE = /\*\/\s*\}\s*$/
807
844
 
845
+ /**
846
+ * Is this line NOTHING but comment? — one definition, used both to classify the annotation's own
847
+ * line (own-line vs trailing) and to walk the lines under it.
848
+ *
849
+ * The two used to be written out separately, and the walk's copy was the one missing the
850
+ * {@link JSX_COMMENT_CLOSE} clause: a `*\/}` alone on its line read as the code `}`, so the walk
851
+ * stopped ON the closer and the annotation waived a brace. Two consumers isolated that
852
+ * independently — `{/* … *\/}` and `{/** … *\/}` — each with a probe pair differing by one newline,
853
+ * while the oxlint plugin (whose test is comment-node-based and never sees the brace) waived both.
854
+ * A shared predicate is what stops the two placements from drifting apart again.
855
+ */
856
+ function isCommentOnly(raw: string, stripped: string): boolean {
857
+ const code = stripped.trim()
858
+ return code === '' || COMMENT_ONLY_LINE.test(raw) || (code === '}' && JSX_COMMENT_CLOSE.test(raw))
859
+ }
860
+
808
861
  /**
809
862
  * Everything allowed between the comment opener (or the start of the line) and the annotation
810
863
  * token, for the token to count as an ANNOTATION rather than prose that mentions one.
@@ -933,7 +986,12 @@ function collectAllowAnnotations(
933
986
  }
934
987
 
935
988
  /** Place one parsed annotation: file declarations reach everywhere, line ones the 3 placements. */
936
- const record = (index: number, annotation: AllowAnnotation, trailing: boolean): void => {
989
+ const record = (
990
+ index: number,
991
+ annotation: AllowAnnotation,
992
+ trailing: boolean,
993
+ tokenAt: number,
994
+ ): void => {
937
995
  declared.push({ line: index + 1, annotation })
938
996
  if (annotation.scope === 'file') {
939
997
  fileScoped.push(annotation)
@@ -941,16 +999,20 @@ function collectAllowAnnotations(
941
999
  }
942
1000
  add(index, annotation)
943
1001
  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
1002
+ // The first CODE line below the comment. A docblock's `*/` (or the rest of a wrapped reason)
1003
+ // sits between the annotation and the code it is about, so `index + 1` alone meant the
1004
+ // natural multi-line shape silently waived nothing — argo hit it three times in one upgrade,
1005
+ // each looking like a correct annotation. A BLANK line outside the comment still ends the
1006
+ // block: that is the separation people use to mean "this comment is not about the next
1007
+ // statement". Inside it, a blank gutter line is just prose.
1008
+ const closesAt = commentBlockEnd(lines, index, tokenAt, syntax)
1009
+ for (let ahead = 1; index + ahead < lines.length; ahead++) {
1010
+ const at = index + ahead
1011
+ add(at, annotation)
1012
+ if (at <= closesAt) continue
1013
+ const raw = lines[at] ?? ''
953
1014
  if (raw.trim() === '') break
1015
+ if (!isCommentOnly(raw, codeLines[at] ?? '')) break
954
1016
  }
955
1017
  return
956
1018
  }
@@ -968,14 +1030,17 @@ function collectAllowAnnotations(
968
1030
  for (let i = 0; i < lines.length; i++) {
969
1031
  const line = lines[i] ?? ''
970
1032
  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))
1033
+ const isCommentOnlyLine = isCommentOnly(line, stripped)
974
1034
 
975
1035
  // The JSON member form. Markup-only: in TS/CSS that string would be code, not an annotation.
976
1036
  if (syntax === 'markup') {
977
1037
  for (const m of line.matchAll(jsonKey)) {
978
- record(i, parseAllowAnnotation(m[2] ?? '', m[1] === undefined ? 'line' : 'file'), false)
1038
+ record(
1039
+ i,
1040
+ parseAllowAnnotation(m[2] ?? '', m[1] === undefined ? 'line' : 'file'),
1041
+ false,
1042
+ m.index,
1043
+ )
979
1044
  }
980
1045
  }
981
1046
 
@@ -993,6 +1058,7 @@ function collectAllowAnnotations(
993
1058
  i,
994
1059
  parseAllowAnnotation(rest, m[1] === undefined ? 'line' : 'file'),
995
1060
  !isCommentOnlyLine,
1061
+ at,
996
1062
  )
997
1063
  }
998
1064
  }
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)', () => {