basalt-ui 1.21.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 (85) hide show
  1. package/AGENTS.md +11 -1
  2. package/CHANGELOG.md +33 -0
  3. package/MIGRATING.md +205 -9
  4. package/README.md +63 -16
  5. package/agent/rules/basalt-app.md +39 -9
  6. package/agent/rules/basalt-charts.md +41 -6
  7. package/agent/rules/basalt-router.md +1 -1
  8. package/agent/rules/basalt-state.md +4 -4
  9. package/agent/rules/basalt-tokens.md +25 -4
  10. package/agent/skills/basalt-app/SKILL.md +4 -3
  11. package/agent/skills/basalt-charts/SKILL.md +23 -8
  12. package/agent/templates/DESIGN.md.tpl +3 -1
  13. package/configs/oxlint-plugin.js +86 -9
  14. package/configs/oxlint-plugin.test.ts +173 -0
  15. package/dist/charts/hooks/useBandPlot.d.ts +138 -0
  16. package/dist/charts/hooks/useBandPlot.d.ts.map +1 -0
  17. package/dist/charts/hooks/useBandPlot.js +127 -0
  18. package/dist/charts/index.d.ts +4 -0
  19. package/dist/charts/index.d.ts.map +1 -1
  20. package/dist/charts/index.js +14 -0
  21. package/dist/charts/kinds/BandStrip.d.ts +113 -0
  22. package/dist/charts/kinds/BandStrip.d.ts.map +1 -0
  23. package/dist/charts/kinds/BandStrip.js +218 -0
  24. package/dist/charts/kinds/BandStrip.test.d.ts +2 -0
  25. package/dist/charts/kinds/BandStrip.test.d.ts.map +1 -0
  26. package/dist/charts/kinds/Bars.d.ts +5 -1
  27. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  28. package/dist/charts/kinds/Bars.js +2 -0
  29. package/dist/charts/kinds/MirroredBars.d.ts +93 -0
  30. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -0
  31. package/dist/charts/kinds/MirroredBars.js +315 -0
  32. package/dist/charts/kinds/MirroredBars.test.d.ts +2 -0
  33. package/dist/charts/kinds/MirroredBars.test.d.ts.map +1 -0
  34. package/dist/charts/kinds/MultiLine.d.ts +5 -1
  35. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  36. package/dist/charts/kinds/MultiLine.js +2 -0
  37. package/dist/charts/kinds/StackedArea.d.ts +5 -1
  38. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  39. package/dist/charts/kinds/StackedArea.js +2 -0
  40. package/dist/charts/kinds/ZonedLine.d.ts +5 -1
  41. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  42. package/dist/charts/kinds/ZonedLine.js +2 -0
  43. package/dist/charts/primitives/CartesianChart.d.ts +18 -2
  44. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  45. package/dist/charts/primitives/CartesianChart.js +5 -2
  46. package/dist/charts/primitives/HatchPattern.d.ts +31 -0
  47. package/dist/charts/primitives/HatchPattern.d.ts.map +1 -0
  48. package/dist/charts/primitives/HatchPattern.js +31 -0
  49. package/dist/cli/index.d.ts +9 -1
  50. package/dist/cli/index.d.ts.map +1 -1
  51. package/dist/cli/index.js +275 -48
  52. package/dist/cli/shipped-versions.test.d.ts +2 -0
  53. package/dist/cli/shipped-versions.test.d.ts.map +1 -0
  54. package/dist/guard/index.d.ts +67 -0
  55. package/dist/guard/index.d.ts.map +1 -1
  56. package/dist/guard/index.js +70 -11
  57. package/dist/vite.d.ts +44 -2
  58. package/dist/vite.d.ts.map +1 -1
  59. package/dist/vite.js +71 -26
  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 +200 -0
  65. package/src/charts/kinds/BandStrip.tsx +364 -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 +193 -0
  69. package/src/charts/kinds/MirroredBars.tsx +455 -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/check-theme.test.ts +35 -0
  77. package/src/cli/help.test.ts +5 -1
  78. package/src/cli/index.ts +552 -79
  79. package/src/cli/shipped-versions.test.ts +83 -0
  80. package/src/cli/toolchain-wiring.test.ts +362 -6
  81. package/src/cli/waiver-audit.test.ts +88 -4
  82. package/src/guard/check-source.test.ts +293 -0
  83. package/src/guard/index.ts +209 -20
  84. package/src/vite.test.ts +112 -0
  85. package/src/vite.ts +147 -31
package/src/vite.test.ts CHANGED
@@ -201,6 +201,118 @@ describe('basaltAppPlugin — base handling', () => {
201
201
  false,
202
202
  )
203
203
  })
204
+
205
+ // The head half of `icons: false` shipped; the manifest half did not. `{ manifest: true, icons:
206
+ // false }` emitted a site.webmanifest naming two PNGs the app never ships — an installable app
207
+ // with two 404s, which is why rb went hybrid rather than use the plugin's manifest at all.
208
+ test('icons: false reaches the MANIFEST, not just the head links', () => {
209
+ const plugin = getPlugin({ name: 'Test App', themeColor: THEME_COLOR, icons: false })
210
+ const middleware = getDevMiddleware(plugin, '/myapp/')
211
+ const manifest = JSON.parse(runMiddleware(middleware, '/myapp/site.webmanifest') ?? '{}')
212
+
213
+ expect(manifest.icons).toBeUndefined()
214
+ expect(JSON.stringify(manifest)).not.toContain('.png')
215
+ // …and the rest of the manifest is unaffected.
216
+ expect(manifest.start_url).toBe('/myapp/')
217
+ expect(manifest.name).toBe('Test App')
218
+ })
219
+
220
+ test('the default (icons on) still carries both manifest icon entries', () => {
221
+ const plugin = getPlugin({ name: 'Test App', themeColor: THEME_COLOR })
222
+ const middleware = getDevMiddleware(plugin, '/')
223
+ const manifest = JSON.parse(runMiddleware(middleware, '/site.webmanifest') ?? '{}')
224
+ expect(manifest.icons).toHaveLength(2)
225
+ })
226
+
227
+ test('icons.dir still redirects the manifest icon srcs', () => {
228
+ const plugin = getPlugin({
229
+ name: 'Test App',
230
+ themeColor: THEME_COLOR,
231
+ icons: { dir: '/brand' },
232
+ })
233
+ const middleware = getDevMiddleware(plugin, '/')
234
+ const manifest = JSON.parse(runMiddleware(middleware, '/site.webmanifest') ?? '{}')
235
+ expect(manifest.icons[0].src).toBe('/brand/web-app-manifest-192x192.png')
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
+ })
204
316
  })
205
317
 
206
318
  describe('basaltAppPlugin — the anti-FOUC boot rule (V1)', () => {
package/src/vite.ts CHANGED
@@ -198,6 +198,30 @@ const ICON_FILES = {
198
198
  manifest512: 'web-app-manifest-512x512.png',
199
199
  } as const
200
200
 
201
+ /**
202
+ * One icon a consumer names itself — a manifest `icons` member, plus optionally the head `<link>`
203
+ * that points at the same file.
204
+ *
205
+ * The field names are the manifest's own, verbatim, so what a consumer writes here is what ships in
206
+ * `site.webmanifest`. `src` is root-relative (`'/favicon.svg'` or `'favicon.svg'`, both resolve the
207
+ * same) and is prefixed with Vite's `base` exactly like basalt's built-in set.
208
+ */
209
+ export type BasaltAppIcon = {
210
+ /** Root-relative path under `public/`. Prefixed with Vite's resolved `base`. */
211
+ src: string
212
+ /** Manifest `sizes`. `'any'` for a scalable SVG; `'192x192'` for a raster. */
213
+ sizes?: string
214
+ /** MIME type, e.g. `'image/svg+xml'`. */
215
+ type?: string
216
+ /** Manifest `purpose`, e.g. `'any maskable'`. */
217
+ purpose?: string
218
+ /**
219
+ * Emit a head `<link rel="…">` for this icon too. Omitted → manifest only, which is what an app
220
+ * that links its own favicon from `index.html` wants.
221
+ */
222
+ rel?: 'icon' | 'shortcut icon' | 'apple-touch-icon' | 'mask-icon'
223
+ }
224
+
201
225
  export type BasaltAppOptions = {
202
226
  /** App display name — used verbatim as the manifest `name` and (unless overridden) OG title. */
203
227
  name: string
@@ -242,8 +266,27 @@ export type BasaltAppOptions = {
242
266
  scope?: string
243
267
  /** Manifest `id`. Default: the resolved `startUrl` (default- or explicitly-derived, per above). */
244
268
  id?: string
245
- /** Icon links + manifest icon paths. `false` skips the head `<link>` icons entirely. */
246
- icons?: false | { dir?: string }
269
+ /**
270
+ * Which icons the plugin knows about — three forms, one question each.
271
+ *
272
+ * - **omitted / `{ dir }`** — basalt's own six-filename set (the realfavicongenerator layout argo
273
+ * ships), optionally under a subdirectory of `public/`. Unchanged default.
274
+ * - **`false`** — no head `<link>` icons and no manifest `icons` member, so a manifest is never
275
+ * emitted pointing at files that 404.
276
+ * - **an array** — the icons this app ACTUALLY has, named by the consumer.
277
+ *
278
+ * The array is the form for every app that was not scaffolded from basalt's icon set, which is
279
+ * most of them. `{ dir }` can only move the six fixed filenames; it cannot rename one, so an app
280
+ * whose single icon is `favicon.svg` had no way to say so and had to keep a hand-written manifest
281
+ * beside the generated head — the last thing standing between rb and deleting a file carrying a
282
+ * permanent `theme-allow-file`. One SVG at `sizes: 'any'` is a complete, installable icon set.
283
+ *
284
+ * Every entry becomes a manifest icon; an entry becomes a head `<link>` only when it names a
285
+ * {@link BasaltAppIcon.rel}. That split is deliberate — an app that already links its favicon
286
+ * from its own `index.html` wants the manifest fixed without a duplicate tag, and an app that
287
+ * wants the plugin to own the head says so per icon. An empty array reads as `false`.
288
+ */
289
+ icons?: false | { dir?: string } | readonly BasaltAppIcon[]
247
290
  /** Emits `<meta name="darkreader-lock">` when `'lock'` (default). Pass `false` to omit it. */
248
291
  darkreader?: 'lock' | false
249
292
  /** Site-wide SEO metadata — only the keys provided are emitted, no per-page SEO. */
@@ -324,6 +367,39 @@ function withBase(base: string, path: string): string {
324
367
  return base + path.slice(1)
325
368
  }
326
369
 
370
+ /**
371
+ * The three `icons` forms, collapsed to what the two emitters actually need.
372
+ *
373
+ * `'none'` and `'default'` are the pre-existing behaviour verbatim — `'default'` still walks
374
+ * {@link ICON_FILES}, so an app on basalt's own icon layout emits byte-identical head tags and a
375
+ * byte-identical manifest. `'explicit'` is the new form. An EMPTY array collapses to `'none'`
376
+ * rather than to an empty `icons: []` member: a manifest declaring zero icons is a claim, and the
377
+ * honest way to say "this app has no icons" already exists.
378
+ */
379
+ type ResolvedIcons =
380
+ | { mode: 'none' }
381
+ | { mode: 'default'; dir: string | undefined }
382
+ | { mode: 'explicit'; icons: readonly BasaltAppIcon[] }
383
+
384
+ /** `Array.isArray` widens to `any[]` and does not narrow a `readonly T[]` OUT of a union. */
385
+ function isIconList(
386
+ icons: { dir?: string } | readonly BasaltAppIcon[],
387
+ ): icons is readonly BasaltAppIcon[] {
388
+ return Array.isArray(icons)
389
+ }
390
+
391
+ function resolveIcons(icons: BasaltAppOptions['icons']): ResolvedIcons {
392
+ if (icons === false) return { mode: 'none' }
393
+ if (icons === undefined) return { mode: 'default', dir: undefined }
394
+ if (!isIconList(icons)) return { mode: 'default', dir: icons.dir }
395
+ return icons.length === 0 ? { mode: 'none' } : { mode: 'explicit', icons }
396
+ }
397
+
398
+ /** Root-relative `src` (with or without its leading slash) → base-prefixed href. */
399
+ function iconHref(base: string, src: string): string {
400
+ return withBase(base, src.startsWith('/') ? src : `/${src}`)
401
+ }
402
+
327
403
  function hasViewportMeta(html: string): boolean {
328
404
  return /<meta\s+[^>]*name=["']viewport["']/i.test(html)
329
405
  }
@@ -474,8 +550,7 @@ function buildStaticTags(input: {
474
550
  shortName: string
475
551
  themeColor: { light: string; dark: string }
476
552
  bootStyle: string
477
- iconsEnabled: boolean
478
- iconsDir: string | undefined
553
+ icons: ResolvedIcons
479
554
  darkreader: 'lock' | false
480
555
  manifestEnabled: boolean
481
556
  base: string
@@ -486,8 +561,7 @@ function buildStaticTags(input: {
486
561
  shortName,
487
562
  themeColor,
488
563
  bootStyle,
489
- iconsEnabled,
490
- iconsDir,
564
+ icons,
491
565
  darkreader,
492
566
  manifestEnabled,
493
567
  base,
@@ -521,13 +595,13 @@ function buildStaticTags(input: {
521
595
  // BOOT_SCOPE so it expires the moment Mantine resolves the real scheme — see buildBootStyle.
522
596
  tags.push({ tag: 'style', children: bootStyle })
523
597
 
524
- if (iconsEnabled) {
598
+ if (icons.mode === 'default') {
525
599
  tags.push(
526
600
  {
527
601
  tag: 'link',
528
602
  attrs: {
529
603
  rel: 'shortcut icon',
530
- href: withBase(base, withIconPath(iconsDir, ICON_FILES.favicon)),
604
+ href: withBase(base, withIconPath(icons.dir, ICON_FILES.favicon)),
531
605
  },
532
606
  },
533
607
  {
@@ -535,7 +609,7 @@ function buildStaticTags(input: {
535
609
  attrs: {
536
610
  rel: 'icon',
537
611
  type: 'image/svg+xml',
538
- href: withBase(base, withIconPath(iconsDir, ICON_FILES.svg)),
612
+ href: withBase(base, withIconPath(icons.dir, ICON_FILES.svg)),
539
613
  },
540
614
  },
541
615
  {
@@ -543,7 +617,7 @@ function buildStaticTags(input: {
543
617
  attrs: {
544
618
  rel: 'icon',
545
619
  sizes: '96x96',
546
- href: withBase(base, withIconPath(iconsDir, ICON_FILES.png96)),
620
+ href: withBase(base, withIconPath(icons.dir, ICON_FILES.png96)),
547
621
  },
548
622
  },
549
623
  {
@@ -551,12 +625,29 @@ function buildStaticTags(input: {
551
625
  attrs: {
552
626
  rel: 'apple-touch-icon',
553
627
  sizes: '180x180',
554
- href: withBase(base, withIconPath(iconsDir, ICON_FILES.appleTouch)),
628
+ href: withBase(base, withIconPath(icons.dir, ICON_FILES.appleTouch)),
555
629
  },
556
630
  },
557
631
  )
558
632
  }
559
633
 
634
+ // A consumer-named icon reaches the head only when it asks to, so the plugin can fix a manifest
635
+ // without duplicating a `<link>` the app's own index.html already writes.
636
+ if (icons.mode === 'explicit') {
637
+ for (const icon of icons.icons) {
638
+ if (icon.rel === undefined) continue
639
+ tags.push({
640
+ tag: 'link',
641
+ attrs: {
642
+ rel: icon.rel,
643
+ ...(icon.type === undefined ? {} : { type: icon.type }),
644
+ ...(icon.sizes === undefined ? {} : { sizes: icon.sizes }),
645
+ href: iconHref(base, icon.src),
646
+ },
647
+ })
648
+ }
649
+ }
650
+
560
651
  tags.push(
561
652
  { tag: 'meta', attrs: { name: 'apple-mobile-web-app-title', content: shortName } },
562
653
  { tag: 'meta', attrs: { name: 'apple-mobile-web-app-capable', content: 'yes' } },
@@ -579,6 +670,39 @@ function buildStaticTags(input: {
579
670
  return tags
580
671
  }
581
672
 
673
+ /** The manifest's `icons` member, or nothing at all — never an empty array. */
674
+ function manifestIcons(
675
+ icons: ResolvedIcons,
676
+ base: string,
677
+ ): { icons?: ReadonlyArray<Record<string, string>> } {
678
+ if (icons.mode === 'none') return {}
679
+ if (icons.mode === 'default') {
680
+ return {
681
+ icons: [
682
+ {
683
+ src: withBase(base, withIconPath(icons.dir, ICON_FILES.manifest192)),
684
+ sizes: '192x192',
685
+ type: 'image/png',
686
+ },
687
+ {
688
+ src: withBase(base, withIconPath(icons.dir, ICON_FILES.manifest512)),
689
+ sizes: '512x512',
690
+ type: 'image/png',
691
+ purpose: 'any maskable',
692
+ },
693
+ ],
694
+ }
695
+ }
696
+ return {
697
+ icons: icons.icons.map((icon) => ({
698
+ src: iconHref(base, icon.src),
699
+ ...(icon.sizes === undefined ? {} : { sizes: icon.sizes }),
700
+ ...(icon.type === undefined ? {} : { type: icon.type }),
701
+ ...(icon.purpose === undefined ? {} : { purpose: icon.purpose }),
702
+ })),
703
+ }
704
+ }
705
+
582
706
  function buildManifestJson(input: {
583
707
  options: BasaltAppOptions
584
708
  shortName: string
@@ -588,7 +712,7 @@ function buildManifestJson(input: {
588
712
  display: NonNullable<BasaltAppOptions['display']>
589
713
  schemeColor: string
590
714
  backgroundColor: string
591
- iconsDir: string | undefined
715
+ icons: ResolvedIcons
592
716
  base: string
593
717
  }): string {
594
718
  const {
@@ -600,7 +724,7 @@ function buildManifestJson(input: {
600
724
  display,
601
725
  schemeColor,
602
726
  backgroundColor,
603
- iconsDir,
727
+ icons,
604
728
  base,
605
729
  } = input
606
730
  const manifest = {
@@ -615,19 +739,13 @@ function buildManifestJson(input: {
615
739
  // `<meta name="theme-color">` pair. Unchanged for the default (`'dark'`) and for `'auto'`.
616
740
  theme_color: schemeColor,
617
741
  background_color: backgroundColor,
618
- icons: [
619
- {
620
- src: withBase(base, withIconPath(iconsDir, ICON_FILES.manifest192)),
621
- sizes: '192x192',
622
- type: 'image/png',
623
- },
624
- {
625
- src: withBase(base, withIconPath(iconsDir, ICON_FILES.manifest512)),
626
- sizes: '512x512',
627
- type: 'image/png',
628
- purpose: 'any maskable',
629
- },
630
- ],
742
+ // `icons: false` reaches HERE too, not just the head links. The array was unconditional, so
743
+ // `{ manifest: true, icons: false }` shipped a manifest pointing at two PNGs the app never
744
+ // ships — an installable app with broken icons, and the reason rb went hybrid (plugin for the
745
+ // head, a hand-written manifest for the rest). A manifest with NO icons member is valid.
746
+ // The `'explicit'` arm is the other half of that fix: honest about icons it cannot see was
747
+ // only worth having once a consumer could tell it which icons it DOES have.
748
+ ...manifestIcons(icons, base),
631
749
  }
632
750
  return JSON.stringify(manifest, null, 2)
633
751
  }
@@ -636,8 +754,7 @@ function createMainPlugin(options: BasaltAppOptions): Plugin {
636
754
  const shortName = options.shortName ?? options.name
637
755
  const display = options.display ?? 'standalone'
638
756
  const darkreader = options.darkreader ?? 'lock'
639
- const iconsEnabled = options.icons !== false
640
- const iconsDir = options.icons === false ? undefined : options.icons?.dir
757
+ const icons = resolveIcons(options.icons)
641
758
  const manifestEnabled = options.manifest !== false
642
759
 
643
760
  const themeColor = resolveThemeColors(options.themeColor)
@@ -680,7 +797,7 @@ function createMainPlugin(options: BasaltAppOptions): Plugin {
680
797
  display,
681
798
  schemeColor,
682
799
  backgroundColor,
683
- iconsDir,
800
+ icons,
684
801
  base,
685
802
  })
686
803
 
@@ -689,8 +806,7 @@ function createMainPlugin(options: BasaltAppOptions): Plugin {
689
806
  shortName,
690
807
  themeColor,
691
808
  bootStyle,
692
- iconsEnabled,
693
- iconsDir,
809
+ icons,
694
810
  darkreader,
695
811
  manifestEnabled,
696
812
  base,