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
package/dist/vite.d.ts CHANGED
@@ -33,6 +33,29 @@ export type BasaltViteOptions = {
33
33
  enforcementNotice?: boolean;
34
34
  };
35
35
  export declare function basaltViteConfig(opts: BasaltViteOptions): UserConfig;
36
+ /**
37
+ * One icon a consumer names itself — a manifest `icons` member, plus optionally the head `<link>`
38
+ * that points at the same file.
39
+ *
40
+ * The field names are the manifest's own, verbatim, so what a consumer writes here is what ships in
41
+ * `site.webmanifest`. `src` is root-relative (`'/favicon.svg'` or `'favicon.svg'`, both resolve the
42
+ * same) and is prefixed with Vite's `base` exactly like basalt's built-in set.
43
+ */
44
+ export type BasaltAppIcon = {
45
+ /** Root-relative path under `public/`. Prefixed with Vite's resolved `base`. */
46
+ src: string;
47
+ /** Manifest `sizes`. `'any'` for a scalable SVG; `'192x192'` for a raster. */
48
+ sizes?: string;
49
+ /** MIME type, e.g. `'image/svg+xml'`. */
50
+ type?: string;
51
+ /** Manifest `purpose`, e.g. `'any maskable'`. */
52
+ purpose?: string;
53
+ /**
54
+ * Emit a head `<link rel="…">` for this icon too. Omitted → manifest only, which is what an app
55
+ * that links its own favicon from `index.html` wants.
56
+ */
57
+ rel?: 'icon' | 'shortcut icon' | 'apple-touch-icon' | 'mask-icon';
58
+ };
36
59
  export type BasaltAppOptions = {
37
60
  /** App display name — used verbatim as the manifest `name` and (unless overridden) OG title. */
38
61
  name: string;
@@ -81,12 +104,28 @@ export type BasaltAppOptions = {
81
104
  /** Manifest `id`. Default: the resolved `startUrl` (default- or explicitly-derived, per above). */
82
105
  id?: string;
83
106
  /**
84
- * Icon links + manifest icon paths. `false` skips BOTH — the head `<link>` icons and the
85
- * manifest's `icons` array — so a manifest is never emitted pointing at files that 404.
107
+ * Which icons the plugin knows about — three forms, one question each.
108
+ *
109
+ * - **omitted / `{ dir }`** — basalt's own six-filename set (the realfavicongenerator layout argo
110
+ * ships), optionally under a subdirectory of `public/`. Unchanged default.
111
+ * - **`false`** — no head `<link>` icons and no manifest `icons` member, so a manifest is never
112
+ * emitted pointing at files that 404.
113
+ * - **an array** — the icons this app ACTUALLY has, named by the consumer.
114
+ *
115
+ * The array is the form for every app that was not scaffolded from basalt's icon set, which is
116
+ * most of them. `{ dir }` can only move the six fixed filenames; it cannot rename one, so an app
117
+ * whose single icon is `favicon.svg` had no way to say so and had to keep a hand-written manifest
118
+ * beside the generated head — the last thing standing between rb and deleting a file carrying a
119
+ * permanent `theme-allow-file`. One SVG at `sizes: 'any'` is a complete, installable icon set.
120
+ *
121
+ * Every entry becomes a manifest icon; an entry becomes a head `<link>` only when it names a
122
+ * {@link BasaltAppIcon.rel}. That split is deliberate — an app that already links its favicon
123
+ * from its own `index.html` wants the manifest fixed without a duplicate tag, and an app that
124
+ * wants the plugin to own the head says so per icon. An empty array reads as `false`.
86
125
  */
87
126
  icons?: false | {
88
127
  dir?: string;
89
- };
128
+ } | readonly BasaltAppIcon[];
90
129
  /** Emits `<meta name="darkreader-lock">` when `'lock'` (default). Pass `false` to omit it. */
91
130
  darkreader?: 'lock' | false;
92
131
  /** Site-wide SEO metadata — only the keys provided are emitted, no per-page SEO. */
@@ -1 +1 @@
1
- {"version":3,"file":"vite.d.ts","sourceRoot":"","sources":["../src/vite.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAA6B,YAAY,EAAE,UAAU,EAAE,MAAM,MAAM,CAAA;AAI/E,MAAM,MAAM,iBAAiB,GAAG;IAC9B,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAA;IACZ,6EAA6E;IAC7E,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;IACvB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;IACvB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,kFAAkF;IAClF,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAC5B,CAAA;AA6CD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,iBAAiB,GAAG,UAAU,CAgFpE;AAgBD,MAAM,MAAM,gBAAgB,GAAG;IAC7B,gGAAgG;IAChG,IAAI,EAAE,MAAM,CAAA;IACZ,0DAA0D;IAC1D,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,gGAAgG;IAChG,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,GAAG;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAA;IACrD;;;;;;;;;OASG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,KAAK,CAAA;IAC/C;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,wDAAwD;IACxD,OAAO,CAAC,EAAE,YAAY,GAAG,YAAY,GAAG,YAAY,GAAG,SAAS,CAAA;IAChE;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,mGAAmG;IACnG,EAAE,CAAC,EAAE,MAAM,CAAA;IACX;;;OAGG;IACH,KAAK,CAAC,EAAE,KAAK,GAAG;QAAE,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IAChC,8FAA8F;IAC9F,UAAU,CAAC,EAAE,MAAM,GAAG,KAAK,CAAA;IAC3B,oFAAoF;IACpF,GAAG,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,SAAS,GAAG,qBAAqB,CAAA;KAAE,CAAA;IACvF,8FAA8F;IAC9F,QAAQ,CAAC,EAAE,KAAK,CAAA;IAChB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAClD,CAAA;AA4gBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,gBAAgB,GAAG,YAAY,EAAE,CAMzE"}
1
+ {"version":3,"file":"vite.d.ts","sourceRoot":"","sources":["../src/vite.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAA6B,YAAY,EAAE,UAAU,EAAE,MAAM,MAAM,CAAA;AAI/E,MAAM,MAAM,iBAAiB,GAAG;IAC9B,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAA;IACZ,6EAA6E;IAC7E,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;IACvB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;IACvB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,kFAAkF;IAClF,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAC5B,CAAA;AA6CD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,iBAAiB,GAAG,UAAU,CAgFpE;AAgBD;;;;;;;GAOG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B,gFAAgF;IAChF,GAAG,EAAE,MAAM,CAAA;IACX,8EAA8E;IAC9E,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,yCAAyC;IACzC,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,iDAAiD;IACjD,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,GAAG,eAAe,GAAG,kBAAkB,GAAG,WAAW,CAAA;CAClE,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,gGAAgG;IAChG,IAAI,EAAE,MAAM,CAAA;IACZ,0DAA0D;IAC1D,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,gGAAgG;IAChG,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,GAAG;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAA;IACrD;;;;;;;;;OASG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,KAAK,CAAA;IAC/C;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,wDAAwD;IACxD,OAAO,CAAC,EAAE,YAAY,GAAG,YAAY,GAAG,YAAY,GAAG,SAAS,CAAA;IAChE;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,mGAAmG;IACnG,EAAE,CAAC,EAAE,MAAM,CAAA;IACX;;;;;;;;;;;;;;;;;;;OAmBG;IACH,KAAK,CAAC,EAAE,KAAK,GAAG;QAAE,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,aAAa,EAAE,CAAA;IAC3D,8FAA8F;IAC9F,UAAU,CAAC,EAAE,MAAM,GAAG,KAAK,CAAA;IAC3B,oFAAoF;IACpF,GAAG,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,SAAS,GAAG,qBAAqB,CAAA;KAAE,CAAA;IACvF,8FAA8F;IAC9F,QAAQ,CAAC,EAAE,KAAK,CAAA;IAChB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAClD,CAAA;AA0kBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,gBAAgB,GAAG,YAAY,EAAE,CAMzE"}
package/dist/vite.js CHANGED
@@ -133,6 +133,18 @@ function withIconPath(dir, file) {
133
133
  function withBase(base, path) {
134
134
  return base + path.slice(1);
135
135
  }
136
+ function isIconList(icons) {
137
+ return Array.isArray(icons);
138
+ }
139
+ function resolveIcons(icons) {
140
+ if (icons === false) return { mode: "none" };
141
+ if (icons === void 0) return { mode: "default", dir: void 0 };
142
+ if (!isIconList(icons)) return { mode: "default", dir: icons.dir };
143
+ return icons.length === 0 ? { mode: "none" } : { mode: "explicit", icons };
144
+ }
145
+ function iconHref(base, src) {
146
+ return withBase(base, src.startsWith("/") ? src : `/${src}`);
147
+ }
136
148
  function hasViewportMeta(html) {
137
149
  return /<meta\s+[^>]*name=["']viewport["']/i.test(html);
138
150
  }
@@ -207,8 +219,7 @@ function buildStaticTags(input) {
207
219
  shortName,
208
220
  themeColor,
209
221
  bootStyle,
210
- iconsEnabled,
211
- iconsDir,
222
+ icons,
212
223
  darkreader,
213
224
  manifestEnabled,
214
225
  base,
@@ -234,13 +245,13 @@ function buildStaticTags(input) {
234
245
  }
235
246
  );
236
247
  tags.push({ tag: "style", children: bootStyle });
237
- if (iconsEnabled) {
248
+ if (icons.mode === "default") {
238
249
  tags.push(
239
250
  {
240
251
  tag: "link",
241
252
  attrs: {
242
253
  rel: "shortcut icon",
243
- href: withBase(base, withIconPath(iconsDir, ICON_FILES.favicon))
254
+ href: withBase(base, withIconPath(icons.dir, ICON_FILES.favicon))
244
255
  }
245
256
  },
246
257
  {
@@ -248,7 +259,7 @@ function buildStaticTags(input) {
248
259
  attrs: {
249
260
  rel: "icon",
250
261
  type: "image/svg+xml",
251
- href: withBase(base, withIconPath(iconsDir, ICON_FILES.svg))
262
+ href: withBase(base, withIconPath(icons.dir, ICON_FILES.svg))
252
263
  }
253
264
  },
254
265
  {
@@ -256,7 +267,7 @@ function buildStaticTags(input) {
256
267
  attrs: {
257
268
  rel: "icon",
258
269
  sizes: "96x96",
259
- href: withBase(base, withIconPath(iconsDir, ICON_FILES.png96))
270
+ href: withBase(base, withIconPath(icons.dir, ICON_FILES.png96))
260
271
  }
261
272
  },
262
273
  {
@@ -264,11 +275,25 @@ function buildStaticTags(input) {
264
275
  attrs: {
265
276
  rel: "apple-touch-icon",
266
277
  sizes: "180x180",
267
- href: withBase(base, withIconPath(iconsDir, ICON_FILES.appleTouch))
278
+ href: withBase(base, withIconPath(icons.dir, ICON_FILES.appleTouch))
268
279
  }
269
280
  }
270
281
  );
271
282
  }
283
+ if (icons.mode === "explicit") {
284
+ for (const icon of icons.icons) {
285
+ if (icon.rel === void 0) continue;
286
+ tags.push({
287
+ tag: "link",
288
+ attrs: {
289
+ rel: icon.rel,
290
+ ...icon.type === void 0 ? {} : { type: icon.type },
291
+ ...icon.sizes === void 0 ? {} : { sizes: icon.sizes },
292
+ href: iconHref(base, icon.src)
293
+ }
294
+ });
295
+ }
296
+ }
272
297
  tags.push(
273
298
  { tag: "meta", attrs: { name: "apple-mobile-web-app-title", content: shortName } },
274
299
  { tag: "meta", attrs: { name: "apple-mobile-web-app-capable", content: "yes" } },
@@ -286,6 +311,34 @@ function buildStaticTags(input) {
286
311
  );
287
312
  return tags;
288
313
  }
314
+ function manifestIcons(icons, base) {
315
+ if (icons.mode === "none") return {};
316
+ if (icons.mode === "default") {
317
+ return {
318
+ icons: [
319
+ {
320
+ src: withBase(base, withIconPath(icons.dir, ICON_FILES.manifest192)),
321
+ sizes: "192x192",
322
+ type: "image/png"
323
+ },
324
+ {
325
+ src: withBase(base, withIconPath(icons.dir, ICON_FILES.manifest512)),
326
+ sizes: "512x512",
327
+ type: "image/png",
328
+ purpose: "any maskable"
329
+ }
330
+ ]
331
+ };
332
+ }
333
+ return {
334
+ icons: icons.icons.map((icon) => ({
335
+ src: iconHref(base, icon.src),
336
+ ...icon.sizes === void 0 ? {} : { sizes: icon.sizes },
337
+ ...icon.type === void 0 ? {} : { type: icon.type },
338
+ ...icon.purpose === void 0 ? {} : { purpose: icon.purpose }
339
+ }))
340
+ };
341
+ }
289
342
  function buildManifestJson(input) {
290
343
  const {
291
344
  options,
@@ -296,8 +349,7 @@ function buildManifestJson(input) {
296
349
  display,
297
350
  schemeColor,
298
351
  backgroundColor,
299
- iconsEnabled,
300
- iconsDir,
352
+ icons,
301
353
  base
302
354
  } = input;
303
355
  const manifest = {
@@ -316,21 +368,9 @@ function buildManifestJson(input) {
316
368
  // `{ manifest: true, icons: false }` shipped a manifest pointing at two PNGs the app never
317
369
  // ships — an installable app with broken icons, and the reason rb went hybrid (plugin for the
318
370
  // head, a hand-written manifest for the rest). A manifest with NO icons member is valid.
319
- ...iconsEnabled ? {
320
- icons: [
321
- {
322
- src: withBase(base, withIconPath(iconsDir, ICON_FILES.manifest192)),
323
- sizes: "192x192",
324
- type: "image/png"
325
- },
326
- {
327
- src: withBase(base, withIconPath(iconsDir, ICON_FILES.manifest512)),
328
- sizes: "512x512",
329
- type: "image/png",
330
- purpose: "any maskable"
331
- }
332
- ]
333
- } : {}
371
+ // The `'explicit'` arm is the other half of that fix: honest about icons it cannot see was
372
+ // only worth having once a consumer could tell it which icons it DOES have.
373
+ ...manifestIcons(icons, base)
334
374
  };
335
375
  return JSON.stringify(manifest, null, 2);
336
376
  }
@@ -338,8 +378,7 @@ function createMainPlugin(options) {
338
378
  const shortName = options.shortName ?? options.name;
339
379
  const display = options.display ?? "standalone";
340
380
  const darkreader = options.darkreader ?? "lock";
341
- const iconsEnabled = options.icons !== false;
342
- const iconsDir = options.icons === false ? void 0 : options.icons?.dir;
381
+ const icons = resolveIcons(options.icons);
343
382
  const manifestEnabled = options.manifest !== false;
344
383
  const themeColor = resolveThemeColors(options.themeColor);
345
384
  const colorScheme = options.colorScheme ?? "dark";
@@ -371,8 +410,7 @@ function createMainPlugin(options) {
371
410
  display,
372
411
  schemeColor,
373
412
  backgroundColor,
374
- iconsEnabled,
375
- iconsDir,
413
+ icons,
376
414
  base
377
415
  });
378
416
  staticTags = buildStaticTags({
@@ -380,8 +418,7 @@ function createMainPlugin(options) {
380
418
  shortName,
381
419
  themeColor,
382
420
  bootStyle,
383
- iconsEnabled,
384
- iconsDir,
421
+ icons,
385
422
  darkreader,
386
423
  manifestEnabled,
387
424
  base,
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # basalt-ui v1.22.0
1
+ # basalt-ui v1.23.1
2
2
  # Opinionated framework for Mantine-based React apps — theme, app shell, visx charts, toolchain presets
3
3
  #
4
4
  # basalt-ui is an opinionated framework for Mantine-based React apps.
@@ -19,7 +19,7 @@
19
19
  # ./tokens.css subpath. Guide: docs/FRAMEWORK-FREE.md in the repo.
20
20
 
21
21
  Package: basalt-ui
22
- Version: 1.22.0
22
+ Version: 1.23.1
23
23
 
24
24
  ## basalt-ui
25
25
  Import: import { ... } from 'basalt-ui'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "basalt-ui",
3
- "version": "1.22.0",
3
+ "version": "1.23.1",
4
4
  "description": "Opinionated framework for Mantine-based React apps — theme, app shell, visx charts, toolchain presets",
5
5
  "keywords": [
6
6
  "charts",
@@ -0,0 +1,302 @@
1
+ /**
2
+ * The choreography shared by every BANDED x plot — one slot per datum, drawn as a rect, with no
3
+ * point in between.
4
+ *
5
+ * `CartesianChart` cannot host these: its x scale is `scalePoint` (positions, no width) and it
6
+ * renders `AxisLeftNumeric` unconditionally. `BandStrip` has no numeric y axis at all and
7
+ * `MirroredBars` has two, in two independent scales. Both are therefore
8
+ * `basalt/hand-rolled-plot` shapes — and this module is what stops the second one from being a
9
+ * copy of the first, per `DualPanel`'s own instruction to "promote the shared choreography rather
10
+ * than copying this file".
11
+ *
12
+ * What lives here: width-driven folding, the measured gutters, the band scale and its
13
+ * half-bandwidth cursor correction, the shared cursor itself, and the source/follower tooltip
14
+ * anchor arithmetic. What does NOT: any mark. See `docs/CHARTS-SPEC.md` § "The contract".
15
+ */
16
+
17
+ import { scaleBand } from '@visx/scale'
18
+ import { useCallback, useMemo, useRef } from 'react'
19
+ import type { ReactNode, RefObject } from 'react'
20
+ import { VX } from '../../tokens'
21
+ import type { ChartMargin } from '../../tokens'
22
+ import type { CursorResolution } from '../cursor/resolve'
23
+ import { autoMargin } from '../layout/auto-margin'
24
+ import { maxTextWidth } from '../utils/measure-text'
25
+ import { smartTicks } from '../utils/ticks'
26
+ import { useChartCursor } from './useChartCursor'
27
+ import type { ChartCursor, CursorAnchor } from './useChartCursor'
28
+
29
+ type BandScale = ReturnType<typeof scaleBand<string>>
30
+
31
+ /**
32
+ * Collapse adjacent data into one drawn band at widths where one-band-per-datum sub-pixels.
33
+ *
34
+ * 288 buckets over a 234px plot is a 0.81px pitch: every band draws at a flat 1px and a single
35
+ * catastrophic slot can be overdrawn by its neighbour. Folding is the only honest answer — the
36
+ * alternative is a chart that silently loses its worst column.
37
+ */
38
+ export type BandFold<T> = {
39
+ /**
40
+ * Floor on the px pitch of a drawn band. Default 3 — not 2, because a band also has to be wide
41
+ * enough for a partial-absence split (`absentFraction`) to render as two visibly distinct
42
+ * pieces, and at 2px the fill and the hatch antialias into one smudge.
43
+ */
44
+ minBandPx?: number
45
+ /**
46
+ * Collapse a group of adjacent source data into the one datum drawn in its place.
47
+ *
48
+ * The merge is the CONSUMER's, deliberately: whether a folded slot's value is a max, a sum, or
49
+ * a rate recomputed from summed parts is a question about the measurement, not about the
50
+ * chart — and averaging a fully-down bucket into its clean neighbours describes a bucket that
51
+ * never happened. The kind owns only the arithmetic of how many groups there are.
52
+ *
53
+ * Identity (`getX`) MUST come from `group[0]`, or the axis stops being monotone and the cursor
54
+ * key stops naming a real slot start.
55
+ */
56
+ merge: (group: T[]) => T
57
+ }
58
+
59
+ const DEFAULT_MIN_BAND_PX = 3
60
+
61
+ /**
62
+ * Terminal-gutter cap, as a fraction of the plot width.
63
+ *
64
+ * A gutter that exists only so a centred terminal tick label does not clip is half a label wide —
65
+ * right at 1500px and absurd at 390, where half a `DD.MM HH:MM` label is 48px of a 338px chart
66
+ * spent on empty margin. A gutter measured from an AXIS's own labels is never capped: shrinking
67
+ * that one clips the reading itself.
68
+ */
69
+ const TERMINAL_GUTTER_MAX_FRACTION = { left: 0.14, right: 0.12 }
70
+
71
+ /**
72
+ * The grouping half of {@link BandFold}, exported so a consumer can test their `merge` against the
73
+ * arithmetic that will actually run rather than re-implementing it in the test file — the merge is
74
+ * where the honesty rules live (max vs mean vs a rate recomputed from summed parts), and a test
75
+ * that groups differently from the kind is testing the wrong function.
76
+ */
77
+ export function foldBands<T>(data: readonly T[], cap: number, merge: (group: T[]) => T): T[] {
78
+ if (cap <= 0) return []
79
+ if (data.length <= cap) return [...data]
80
+ const groupSize = Math.ceil(data.length / cap)
81
+ const out: T[] = []
82
+ for (let i = 0; i < data.length; i += groupSize) out.push(merge(data.slice(i, i + groupSize)))
83
+ return out
84
+ }
85
+
86
+ /** Tooltip seams shared by `BandStrip` and `MirroredBars` — the `CartesianTooltipConfig` subset
87
+ * that means anything on a banded plot (there is no per-point `follow` distinction to make that
88
+ * `CartesianChart` doesn't already document identically). */
89
+ export type BandTooltipConfig<T> = {
90
+ /** Default true — the tooltip tracks the pointer. `false` anchors it to the crosshair at the
91
+ * plot's top edge, so a column of charts sharing one cursor lines every tooltip up on one x. */
92
+ follow?: boolean
93
+ /** Right-aligned badge in the tooltip header. */
94
+ label?: (d: T) => { text: string; color: string } | null
95
+ /** Overrides the header's date text — same seam, same reason, as
96
+ * `CartesianTooltipConfig.formatHeader`. */
97
+ formatHeader?: (key: string, d: T) => string
98
+ /** Rows rendered BEFORE the derived rows. `hidden` is the same legend-toggle set the marks
99
+ * draw from, so a hand-authored row cannot desync from it. */
100
+ prependRows?: (d: T, ctx: BandTooltipRowContext) => ReactNode
101
+ /** Rows appended after the derived rows. Same `ctx` as {@link prependRows}. */
102
+ extraRows?: (d: T, ctx: BandTooltipRowContext) => ReactNode
103
+ /**
104
+ * Render this chart's tooltip when it is a cursor FOLLOWER, not only when it is the SOURCE.
105
+ * Default false. A follower has no pointer under it, so it always anchors to the crosshair
106
+ * regardless of `follow`; only the SOURCE is `aria-live`. Costs one `getBoundingClientRect`
107
+ * per hovered frame per opted-in chart — see `CartesianTooltipConfig.onFollow`.
108
+ */
109
+ onFollow?: boolean
110
+ }
111
+
112
+ export type BandTooltipRowContext = { hidden: ReadonlySet<string> }
113
+
114
+ export type UseBandPlotInput<T> = {
115
+ data: readonly T[]
116
+ chartId: string
117
+ getX: (d: T) => string
118
+ formatX: (key: string) => string
119
+ /** The plot rect's width, as handed down by `ChartFrame`. */
120
+ width: number
121
+ fold?: BandFold<T>
122
+ cursorResolution?: CursorResolution
123
+ /**
124
+ * Which of the (possibly folded) keys get a tick. Default `smartTicks`. The seam exists because
125
+ * `smartTicks` appends the final value unconditionally, which on a dense time axis puts the last
126
+ * two labels on top of each other — a chart whose x labels are richer than `DD.MM` needs its own
127
+ * rule.
128
+ */
129
+ xTickValues?: (keys: readonly string[], plotWidth: number) => readonly string[]
130
+ /** Left-axis tick labels, already formatted. Empty for a plot with no left axis. */
131
+ leftLabels?: readonly string[]
132
+ /** Per-side margin overrides — applied last, so the escape hatch always wins. */
133
+ margin?: Partial<ChartMargin>
134
+ tooltip?: BandTooltipConfig<T> | false
135
+ }
136
+
137
+ export type BandPlot<T> = {
138
+ /** The data actually drawn — `data` when it fits, the folded slots when it does not. */
139
+ bands: T[]
140
+ keys: string[]
141
+ margin: ChartMargin
142
+ plotWidth: number
143
+ scale: BandScale
144
+ cursor: ChartCursor<T>
145
+ point: T | null
146
+ /** Plot-local x of the crosshair, or null when no point resolves. */
147
+ crosshairX: number | null
148
+ /** Pitch between adjacent band origins. */
149
+ step: number
150
+ /** Drawn width of one band — the pitch minus a 1px separator, floored at 1. */
151
+ bandWidth: number
152
+ tickValues: string[]
153
+ svgRef: RefObject<SVGSVGElement | null>
154
+ tooltipAnchor: CursorAnchor | null
155
+ showTooltip: boolean
156
+ /** Only the cursor SOURCE announces — N followers on one page would fire N live-region updates
157
+ * per pointer move. */
158
+ ariaLive: boolean
159
+ }
160
+
161
+ /**
162
+ * Wire a banded plot into the framework's measured margins, band scale, shared cursor and tooltip
163
+ * positioning. Returns everything except the marks.
164
+ */
165
+ export function useBandPlot<T>(input: UseBandPlotInput<T>): BandPlot<T> {
166
+ const {
167
+ data,
168
+ chartId,
169
+ getX,
170
+ formatX,
171
+ width,
172
+ fold,
173
+ cursorResolution,
174
+ xTickValues,
175
+ leftLabels,
176
+ margin: marginOverride,
177
+ tooltip,
178
+ } = input
179
+
180
+ const hasLeftAxis = leftLabels !== undefined && leftLabels.length > 0
181
+ const xLabelsAll = useMemo(() => data.map((d) => formatX(getX(d))), [data, getX, formatX])
182
+
183
+ // ── Pass 1: gutters, from the labels that will actually be painted ──────────────────────────
184
+ const margin = useMemo<ChartMargin>(() => {
185
+ const measured = autoMargin({
186
+ ...(leftLabels !== undefined && { left: leftLabels }),
187
+ bottom: xLabelsAll,
188
+ ...(marginOverride !== undefined && { override: marginOverride }),
189
+ })
190
+ if (marginOverride?.left !== undefined && marginOverride.right !== undefined) return measured
191
+
192
+ const halfXLabel = Math.ceil(maxTextWidth(xLabelsAll, VX.axisFont) / 2)
193
+ // A band axis puts its FIRST tick on the plot's left edge, so the left gutter needs the same
194
+ // half-label law `autoMargin` already applies on the right. `autoMargin` cannot know that —
195
+ // its left law measures a left AXIS, which a band plot may not have.
196
+ const left = hasLeftAxis ? measured.left : Math.max(measured.left, halfXLabel)
197
+ return {
198
+ ...measured,
199
+ ...(marginOverride?.left === undefined && {
200
+ left: hasLeftAxis
201
+ ? left
202
+ : capTerminalGutter(left, width, TERMINAL_GUTTER_MAX_FRACTION.left, VX.margin.left),
203
+ }),
204
+ ...(marginOverride?.right === undefined && {
205
+ right: capTerminalGutter(
206
+ measured.right,
207
+ width,
208
+ TERMINAL_GUTTER_MAX_FRACTION.right,
209
+ VX.margin.right,
210
+ ),
211
+ }),
212
+ }
213
+ }, [leftLabels, xLabelsAll, marginOverride, hasLeftAxis, width])
214
+
215
+ const plotWidth = Math.max(width - margin.left - margin.right, 0)
216
+
217
+ // ── Pass 2: the fold, BEFORE the scale — so the domain, and therefore the cursor's key space,
218
+ // is the grid actually drawn rather than the raw one ────────────────────────────────────────
219
+ const minBandPx = fold?.minBandPx ?? DEFAULT_MIN_BAND_PX
220
+ const merge = fold?.merge
221
+ const bands = useMemo(
222
+ () =>
223
+ merge === undefined ? [...data] : foldBands(data, Math.floor(plotWidth / minBandPx), merge),
224
+ [data, plotWidth, minBandPx, merge],
225
+ )
226
+
227
+ const keys = useMemo(() => bands.map(getX), [bands, getX])
228
+ const scale = useMemo(
229
+ () => scaleBand<string>({ domain: keys, range: [0, plotWidth] }),
230
+ [keys, plotWidth],
231
+ )
232
+
233
+ // `+ bandwidth()/2` is mandatory: `useChartCursor`'s nearest-point loop compares the pointer
234
+ // against `xScale(getKey(d))`, and `scaleBand` returns the band's LEFT edge. Passing `scale`
235
+ // raw biases every snap by half a band — at 288 bands, a systematic one-slot-early cursor.
236
+ const bandCenter = useCallback(
237
+ (key: string) => {
238
+ const v = scale(key)
239
+ return v === undefined ? undefined : v + scale.bandwidth() / 2
240
+ },
241
+ [scale],
242
+ )
243
+
244
+ const cursor = useChartCursor<T>({
245
+ data: bands,
246
+ chartId,
247
+ getKey: getX,
248
+ xScale: bandCenter,
249
+ marginLeft: margin.left,
250
+ ...(cursorResolution !== undefined && { resolution: cursorResolution }),
251
+ })
252
+
253
+ const tickValues = useMemo(
254
+ () => [
255
+ ...(xTickValues === undefined ? smartTicks(keys, plotWidth) : xTickValues(keys, plotWidth)),
256
+ ],
257
+ [keys, plotWidth, xTickValues],
258
+ )
259
+
260
+ const svgRef = useRef<SVGSVGElement>(null)
261
+ const point = cursor.point
262
+ const crosshairX = point === null ? null : (bandCenter(getX(point)) ?? null)
263
+
264
+ const cfg = tooltip === false ? undefined : tooltip
265
+ // Identical seam to `CartesianChart`: SOURCE always, FOLLOWER only when opted in. A follower has
266
+ // no pointer under it, so it is always positioned via the anchored (crosshair) path.
267
+ const isFollowerRender = !cursor.isSource && cfg?.onFollow === true
268
+ const showTooltip = tooltip !== false && point !== null && (cursor.isSource || isFollowerRender)
269
+ const useAnchored = cfg?.follow === false || isFollowerRender
270
+ const anchorX = useAnchored ? crosshairX : null
271
+ const svgRect = anchorX === null ? undefined : svgRef.current?.getBoundingClientRect()
272
+ const tooltipAnchor =
273
+ svgRect === undefined || anchorX === null
274
+ ? cursor.anchor
275
+ : { x: svgRect.left + margin.left + anchorX, y: svgRect.top + margin.top }
276
+
277
+ const step = bands.length > 0 ? plotWidth / bands.length : 0
278
+ const bandWidth = Math.max(step - 1, 1)
279
+
280
+ return {
281
+ bands,
282
+ keys,
283
+ margin,
284
+ plotWidth,
285
+ scale,
286
+ cursor,
287
+ point,
288
+ crosshairX,
289
+ step,
290
+ bandWidth,
291
+ tickValues,
292
+ svgRef,
293
+ tooltipAnchor,
294
+ showTooltip,
295
+ ariaLive: cursor.isSource,
296
+ }
297
+ }
298
+
299
+ /** Shrink a half-label gutter on a narrow plot, but never below the token floor. */
300
+ function capTerminalGutter(gutter: number, width: number, fraction: number, floor: number): number {
301
+ return Math.min(gutter, Math.max(floor, Math.round(width * fraction)))
302
+ }
@@ -75,6 +75,7 @@ export {
75
75
  type ChartPendingProps,
76
76
  } from './primitives/ChartPending'
77
77
  export { Crosshair, SeriesDot } from './primitives/Crosshair'
78
+ export { HatchPattern, hatchFill, hatchSizeFor } from './primitives/HatchPattern'
78
79
  export { ChartCard } from './primitives/ChartCard'
79
80
  export { ChartLegend, type LegendEntry } from './primitives/ChartLegend'
80
81
  export {
@@ -91,6 +92,12 @@ export { AreaGradient, areaFillUrl } from './primitives/AreaGradient'
91
92
 
92
93
  // ── Hooks ────────────────────────────────────────────────────────────────
93
94
  export { useChartCursor, type ChartCursor, type CursorAnchor } from './hooks/useChartCursor'
95
+ export {
96
+ foldBands,
97
+ type BandFold,
98
+ type BandTooltipConfig,
99
+ type BandTooltipRowContext,
100
+ } from './hooks/useBandPlot'
94
101
  export type { CursorResolution } from './cursor/resolve'
95
102
  export { useChartSize, type UseChartSizeResult, type ChartSize } from './hooks/useChartSize'
96
103
 
@@ -124,6 +131,13 @@ export { Donut, type DonutProps, type DonutDatum } from './kinds/Donut'
124
131
  export { MultiLine, type MultiLineProps } from './kinds/MultiLine'
125
132
  export { DualPanel, type DualPanelProps } from './kinds/DualPanel'
126
133
  export { Heatmap, type HeatmapProps } from './kinds/Heatmap'
134
+ export {
135
+ BandStrip,
136
+ type BandStripProps,
137
+ type BandStripSeries,
138
+ type BandSpan,
139
+ } from './kinds/BandStrip'
140
+ export { MirroredBars, type MirroredBarsProps, type MirroredBarPane } from './kinds/MirroredBars'
127
141
 
128
142
  // ── Sparklines ───────────────────────────────────────────────────────────
129
143
  export { LineSparkline, BarSparkline } from './sparklines'