basalt-ui 1.20.0 → 1.22.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 (89) hide show
  1. package/AGENTS.md +11 -5
  2. package/CHANGELOG.md +33 -0
  3. package/MIGRATING.md +231 -50
  4. package/README.md +57 -10
  5. package/agent/rules/basalt-app.md +49 -2
  6. package/agent/rules/basalt-charts.md +15 -12
  7. package/agent/rules/basalt-content.md +4 -0
  8. package/agent/rules/basalt-router.md +57 -5
  9. package/agent/rules/basalt-state.md +21 -3
  10. package/agent/rules/basalt-tokens.md +78 -16
  11. package/agent/skills/basalt-app/SKILL.md +5 -1
  12. package/agent/skills/basalt-charts/SKILL.md +5 -3
  13. package/agent/templates/CLAUDE-block.md.tpl +11 -5
  14. package/configs/lefthook.yml +48 -15
  15. package/configs/oxlint-plugin.js +218 -69
  16. package/configs/oxlint-plugin.test.ts +331 -9
  17. package/dist/charts/kinds/Bars.d.ts +1 -1
  18. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  19. package/dist/charts/kinds/Donut.d.ts +1 -1
  20. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  21. package/dist/charts/kinds/DualPanel.d.ts +1 -1
  22. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  23. package/dist/charts/kinds/Heatmap.d.ts +1 -1
  24. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  25. package/dist/charts/kinds/MultiLine.d.ts +1 -1
  26. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  27. package/dist/charts/kinds/StackedArea.d.ts +1 -1
  28. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  29. package/dist/charts/kinds/ZonedLine.d.ts +1 -1
  30. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  31. package/dist/cli/index.d.ts +55 -15
  32. package/dist/cli/index.d.ts.map +1 -1
  33. package/dist/cli/index.js +521 -71
  34. package/dist/cli/waiver-audit.test.d.ts +2 -0
  35. package/dist/cli/waiver-audit.test.d.ts.map +1 -0
  36. package/dist/guard/index.d.ts +114 -2
  37. package/dist/guard/index.d.ts.map +1 -1
  38. package/dist/guard/index.js +216 -33
  39. package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
  40. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  41. package/dist/router-tanstack/multi-search-param-store.js +4 -1
  42. package/dist/router-tanstack/search-param-store.d.ts +29 -5
  43. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  44. package/dist/router-tanstack/search-param-store.js +31 -3
  45. package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
  46. package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
  47. package/dist/shell/index.d.ts +12 -5
  48. package/dist/shell/index.d.ts.map +1 -1
  49. package/dist/shell/index.js +24 -6
  50. package/dist/tokens/index.d.ts +3 -1
  51. package/dist/tokens/index.d.ts.map +1 -1
  52. package/dist/tokens/index.js +5 -1
  53. package/dist/tokens/palette.d.ts +2 -2
  54. package/dist/tokens/palette.js +2 -2
  55. package/dist/tokens/text-scale.test.d.ts +2 -0
  56. package/dist/tokens/text-scale.test.d.ts.map +1 -0
  57. package/dist/tokens.css +4 -2
  58. package/dist/vite.d.ts +28 -3
  59. package/dist/vite.d.ts.map +1 -1
  60. package/dist/vite.js +75 -26
  61. package/llms.txt +2 -2
  62. package/package.json +1 -1
  63. package/src/charts/kinds/Bars.tsx +1 -1
  64. package/src/charts/kinds/Donut.tsx +1 -1
  65. package/src/charts/kinds/DualPanel.tsx +2 -2
  66. package/src/charts/kinds/Heatmap.tsx +1 -1
  67. package/src/charts/kinds/MultiLine.tsx +1 -1
  68. package/src/charts/kinds/StackedArea.tsx +1 -1
  69. package/src/charts/kinds/ZonedLine.tsx +1 -1
  70. package/src/cli/check-theme.test.ts +35 -0
  71. package/src/cli/help.test.ts +5 -1
  72. package/src/cli/index.ts +978 -133
  73. package/src/cli/init-sync.test.ts +2 -0
  74. package/src/cli/template-ledger.test.ts +1 -1
  75. package/src/cli/toolchain-wiring.test.ts +352 -1
  76. package/src/cli/waiver-audit.test.ts +211 -0
  77. package/src/guard/check-source.test.ts +638 -1
  78. package/src/guard/index.ts +556 -63
  79. package/src/router-tanstack/multi-search-param-store.ts +16 -1
  80. package/src/router-tanstack/search-param-store.test.ts +135 -0
  81. package/src/router-tanstack/search-param-store.ts +90 -8
  82. package/src/shell/app-mobile-nav.tsx +1 -1
  83. package/src/shell/index.test.tsx +52 -0
  84. package/src/shell/index.tsx +64 -13
  85. package/src/tokens/index.ts +24 -1
  86. package/src/tokens/palette.ts +2 -2
  87. package/src/tokens/text-scale.test.ts +51 -0
  88. package/src/vite.test.ts +158 -6
  89. package/src/vite.ts +176 -34
package/src/vite.test.ts CHANGED
@@ -17,13 +17,20 @@ function resolveConfig(plugin: Plugin, base: string): void {
17
17
  configResolved({ base } as unknown as ResolvedConfig)
18
18
  }
19
19
 
20
+ type HtmlTransformResult = { html: string; tags: HtmlTagDescriptor[] }
21
+
22
+ /** Invokes the plugin's `transformIndexHtml` hook (object form, `order: 'pre'`). */
23
+ function transform(plugin: Plugin, html: string): HtmlTransformResult {
24
+ const hook = plugin.transformIndexHtml as unknown as {
25
+ order: 'pre'
26
+ handler: (html: string) => HtmlTransformResult
27
+ }
28
+ return hook.handler(html)
29
+ }
30
+
20
31
  /** Invokes the plugin's `transformIndexHtml` hook and returns the emitted tags. */
21
32
  function transformHtml(plugin: Plugin, html: string): HtmlTagDescriptor[] {
22
- const transformIndexHtml = plugin.transformIndexHtml as unknown as (html: string) => {
23
- html: string
24
- tags: HtmlTagDescriptor[]
25
- }
26
- return transformIndexHtml(html).tags
33
+ return transform(plugin, html).tags
27
34
  }
28
35
 
29
36
  type FakeReq = { url: string }
@@ -63,6 +70,7 @@ describe('basaltAppPlugin — base handling', () => {
63
70
  const tags = transformHtml(plugin, HTML_NO_VIEWPORT)
64
71
 
65
72
  expect(tags).toEqual([
73
+ { tag: 'meta', attrs: { charset: 'UTF-8' } },
66
74
  {
67
75
  tag: 'meta',
68
76
  attrs: {
@@ -78,7 +86,11 @@ describe('basaltAppPlugin — base handling', () => {
78
86
  tag: 'meta',
79
87
  attrs: { name: 'theme-color', media: '(prefers-color-scheme: dark)', content: '#111111' },
80
88
  },
81
- { tag: 'style', children: 'html{background-color:#111111;color-scheme:dark}' },
89
+ {
90
+ tag: 'style',
91
+ children:
92
+ 'html:not([data-mantine-color-scheme]){background-color:#111111;color-scheme:dark}',
93
+ },
82
94
  { tag: 'link', attrs: { rel: 'shortcut icon', href: '/favicon.ico' } },
83
95
  { tag: 'link', attrs: { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' } },
84
96
  { tag: 'link', attrs: { rel: 'icon', sizes: '96x96', href: '/favicon-96x96.png' } },
@@ -189,4 +201,144 @@ describe('basaltAppPlugin — base handling', () => {
189
201
  false,
190
202
  )
191
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
+
239
+ describe('basaltAppPlugin — the anti-FOUC boot rule (V1)', () => {
240
+ const bootStyleOf = (options: Partial<BasaltAppOptions>): string => {
241
+ const plugin = getPlugin({ name: 'Test App', themeColor: THEME_COLOR, ...options })
242
+ resolveConfig(plugin, '/')
243
+ const style = transformHtml(plugin, HTML_NO_VIEWPORT).find((tag) => tag.tag === 'style')
244
+ return String(style?.children ?? '')
245
+ }
246
+
247
+ test('every mode scopes the rule so Mantine can take it back', () => {
248
+ // The whole defect: an UNLAYERED `html{color-scheme:dark}` outranks Mantine's own layered
249
+ // `:root{color-scheme:var(--mantine-color-scheme)}`, so light mode kept dark native controls
250
+ // forever. Scoping to the absence of the attribute Mantine writes is what expires it.
251
+ for (const colorScheme of ['dark', 'light', 'auto', false] as const) {
252
+ const css = bootStyleOf({ colorScheme })
253
+ expect(css).toContain('html:not([data-mantine-color-scheme])')
254
+ expect(css).not.toMatch(/(^|[};])html\{/)
255
+ }
256
+ })
257
+
258
+ test('default is dark — byte-identical to 1.20.0 apart from the scope', () => {
259
+ expect(bootStyleOf({})).toBe(
260
+ 'html:not([data-mantine-color-scheme]){background-color:#111111;color-scheme:dark}',
261
+ )
262
+ })
263
+
264
+ test('light paints the light surface and declares color-scheme: light', () => {
265
+ expect(bootStyleOf({ colorScheme: 'light' })).toBe(
266
+ 'html:not([data-mantine-color-scheme]){background-color:#ffffff;color-scheme:light}',
267
+ )
268
+ })
269
+
270
+ test('auto declares `light dark` and follows prefers-color-scheme for the paint', () => {
271
+ expect(bootStyleOf({ colorScheme: 'auto' })).toBe(
272
+ 'html:not([data-mantine-color-scheme]){background-color:#ffffff;color-scheme:light dark}' +
273
+ '@media(prefers-color-scheme:dark){html:not([data-mantine-color-scheme])' +
274
+ '{background-color:#111111}}',
275
+ )
276
+ })
277
+
278
+ test('an explicit backgroundColor wins over the auto media pair', () => {
279
+ expect(bootStyleOf({ colorScheme: 'auto', backgroundColor: '#abcdef' })).toBe(
280
+ 'html:not([data-mantine-color-scheme]){background-color:#abcdef;color-scheme:light dark}',
281
+ )
282
+ })
283
+
284
+ test('colorScheme: false is the full opt-out — paint, no color-scheme at all', () => {
285
+ expect(bootStyleOf({ colorScheme: false })).toBe(
286
+ 'html:not([data-mantine-color-scheme]){background-color:#111111}',
287
+ )
288
+ })
289
+
290
+ test('the manifest theme_color follows the boot scheme', () => {
291
+ const read = (options: Partial<BasaltAppOptions>): Record<string, unknown> => {
292
+ const plugin = getPlugin({ name: 'Test App', themeColor: THEME_COLOR, ...options })
293
+ const body = runMiddleware(getDevMiddleware(plugin, '/'), '/site.webmanifest')
294
+ return JSON.parse(body ?? '{}') as Record<string, unknown>
295
+ }
296
+ expect(read({})['theme_color']).toBe('#111111')
297
+ expect(read({ colorScheme: 'auto' })['theme_color']).toBe('#111111')
298
+ expect(read({ colorScheme: 'light' })['theme_color']).toBe('#ffffff')
299
+ expect(read({ colorScheme: 'light' })['background_color']).toBe('#ffffff')
300
+ })
301
+ })
302
+
303
+ describe('basaltAppPlugin — the encoding declaration (V2)', () => {
304
+ const SHELL =
305
+ '<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n' +
306
+ ' <title>App</title>\n </head>\n <body></body>\n</html>'
307
+
308
+ test("the consumer's charset is hoisted out of the html and re-emitted first", () => {
309
+ const plugin = getPlugin({ name: 'Test App', themeColor: THEME_COLOR })
310
+ resolveConfig(plugin, '/')
311
+ const result = transform(plugin, SHELL)
312
+
313
+ expect(result.html).not.toContain('charset')
314
+ expect(result.tags[0]).toEqual({ tag: 'meta', attrs: { charset: 'utf-8' } })
315
+ // The hoist takes the whole line — no orphaned indentation left behind.
316
+ expect(result.html).toContain('<head>\n <title>App</title>')
317
+ })
318
+
319
+ test('a shell that declares no encoding gets one', () => {
320
+ const plugin = getPlugin({ name: 'Test App', themeColor: THEME_COLOR })
321
+ resolveConfig(plugin, '/')
322
+ expect(transform(plugin, HTML_NO_VIEWPORT).tags[0]).toEqual({
323
+ tag: 'meta',
324
+ attrs: { charset: 'UTF-8' },
325
+ })
326
+ })
327
+
328
+ test('the legacy http-equiv form is left completely alone', () => {
329
+ const html =
330
+ '<html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8">' +
331
+ '</head><body></body></html>'
332
+ const plugin = getPlugin({ name: 'Test App', themeColor: THEME_COLOR })
333
+ resolveConfig(plugin, '/')
334
+ const result = transform(plugin, html)
335
+
336
+ expect(result.html).toBe(html)
337
+ expect(result.tags.some((tag) => tag.attrs?.['charset'] !== undefined)).toBe(false)
338
+ })
339
+
340
+ test("the hook runs `pre` so no other plugin's tags can land ahead of the charset", () => {
341
+ const plugin = getPlugin({ name: 'Test App', themeColor: THEME_COLOR })
342
+ expect((plugin.transformIndexHtml as { order?: string }).order).toBe('pre')
343
+ })
192
344
  })
package/src/vite.ts CHANGED
@@ -211,7 +211,21 @@ export type BasaltAppOptions = {
211
211
  * plugin exists. Pass an explicit `{ light, dark }` pair of already-flat CSS colors to override.
212
212
  */
213
213
  themeColor?: 'auto' | { light: string; dark: string }
214
- /** Manifest `background_color` + anti-FOUC paint color. Default: the resolved dark theme color. */
214
+ /**
215
+ * Which scheme the anti-FOUC `<style>` paints before the app boots. Default `'dark'` —
216
+ * `BasaltProvider`'s own default. `'light'` paints the light surface, `'auto'` declares
217
+ * `color-scheme: light dark` and picks the background off `prefers-color-scheme`, and `false`
218
+ * paints the background but declares no `color-scheme` at all (leave native controls to Mantine).
219
+ *
220
+ * Set this to whatever the app passes as `defaultColorScheme` — a mismatch is a visible flash of
221
+ * the wrong surface, not a correctness bug: the boot rule stops applying the moment Mantine
222
+ * writes `data-mantine-color-scheme` on `<html>` (see `BOOT_SCOPE`).
223
+ */
224
+ colorScheme?: 'dark' | 'light' | 'auto' | false
225
+ /**
226
+ * Manifest `background_color` + anti-FOUC paint color. Default: the theme color matching
227
+ * `colorScheme` (the dark one for `'dark'` / `'auto'` / `false`, the light one for `'light'`).
228
+ */
215
229
  backgroundColor?: string
216
230
  /** Manifest `display` mode. Default: `'standalone'`. */
217
231
  display?: 'standalone' | 'minimal-ui' | 'fullscreen' | 'browser'
@@ -228,7 +242,10 @@ export type BasaltAppOptions = {
228
242
  scope?: string
229
243
  /** Manifest `id`. Default: the resolved `startUrl` (default- or explicitly-derived, per above). */
230
244
  id?: string
231
- /** Icon links + manifest icon paths. `false` skips the head `<link>` icons entirely. */
245
+ /**
246
+ * Icon links + manifest icon paths. `false` skips BOTH — the head `<link>` icons and the
247
+ * manifest's `icons` array — so a manifest is never emitted pointing at files that 404.
248
+ */
232
249
  icons?: false | { dir?: string }
233
250
  /** Emits `<meta name="darkreader-lock">` when `'lock'` (default). Pass `false` to omit it. */
234
251
  darkreader?: 'lock' | false
@@ -319,6 +336,91 @@ const VIEWPORT_TAG: HtmlTagDescriptor = {
319
336
  attrs: { name: 'viewport', content: 'width=device-width, initial-scale=1.0, viewport-fit=cover' },
320
337
  }
321
338
 
339
+ // ── The anti-FOUC boot rule ───────────────────────────────────────────────────────────────────
340
+
341
+ /**
342
+ * The selector every anti-FOUC declaration is scoped to.
343
+ *
344
+ * The rule paints the page before any stylesheet lands, so it is UNLAYERED by construction — and an
345
+ * unlayered declaration outranks every layered one regardless of specificity. Through 1.20.0 that
346
+ * meant a flat `html{color-scheme:dark}` permanently beat Mantine's own
347
+ * `@layer mantine{:root{color-scheme:var(--mantine-color-scheme)}}`: a light-scheme consumer got
348
+ * dark scrollbars, dark `<select>` popups and dark date pickers forever, with no opt-out (found
349
+ * independently by two consumers).
350
+ *
351
+ * `MantineProvider` writes `data-mantine-color-scheme` onto `<html>` as soon as it mounts, so
352
+ * scoping the boot rule to its ABSENCE makes it expire exactly when the real theme exists — which
353
+ * is the anti-FOUC contract stated honestly. A cascade layer would also work in principle and is
354
+ * deliberately NOT used: a layer's position is fixed by where its name is first encountered, and in
355
+ * dev Mantine's CSS is injected by JS after this `<style>` is already in the document, so the layer
356
+ * order would depend on bundler mode. Selector scoping depends on nothing.
357
+ */
358
+ const BOOT_SCOPE = 'html:not([data-mantine-color-scheme])'
359
+
360
+ /** The `color-scheme` value each boot mode declares. `false` declares none. */
361
+ const BOOT_COLOR_SCHEME = { dark: 'dark', light: 'light', auto: 'light dark' } as const
362
+
363
+ /**
364
+ * Builds the anti-FOUC stylesheet: paint the surface (and, unless opted out, tell the UA which
365
+ * native-control palette to use) for the frames between first paint and Mantine's stylesheet.
366
+ *
367
+ * `'auto'` with no explicit `backgroundColor` emits a `prefers-color-scheme` pair so the pre-boot
368
+ * paint follows the OS the same way `color-scheme: light dark` does; an explicit `backgroundColor`
369
+ * is used verbatim for both, since the consumer has named one color on purpose.
370
+ */
371
+ function buildBootStyle(input: {
372
+ colorScheme: 'dark' | 'light' | 'auto' | false
373
+ themeColor: { light: string; dark: string }
374
+ backgroundColor: string
375
+ explicitBackground: boolean
376
+ }): string {
377
+ const { colorScheme, themeColor, backgroundColor, explicitBackground } = input
378
+
379
+ if (colorScheme === false) return `${BOOT_SCOPE}{background-color:${backgroundColor}}`
380
+
381
+ const scheme = BOOT_COLOR_SCHEME[colorScheme]
382
+ if (colorScheme !== 'auto' || explicitBackground) {
383
+ return `${BOOT_SCOPE}{background-color:${backgroundColor};color-scheme:${scheme}}`
384
+ }
385
+ return (
386
+ `${BOOT_SCOPE}{background-color:${themeColor.light};color-scheme:${scheme}}` +
387
+ `@media(prefers-color-scheme:dark){${BOOT_SCOPE}{background-color:${themeColor.dark}}}`
388
+ )
389
+ }
390
+
391
+ // ── The encoding declaration ──────────────────────────────────────────────────────────────────
392
+
393
+ /** `<meta charset="…">`, with the value in group 1/2/3 depending on the quoting style. */
394
+ const CHARSET_META_RE =
395
+ /[ \t]*<meta\s+charset\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>=]+))\s*\/?>[ \t]*\r?\n?/i
396
+ /** The legacy `<meta http-equiv="Content-Type" charset=…>` form — left verbatim, never rewritten. */
397
+ const HTTP_EQUIV_CHARSET_RE = /<meta\s[^>]*http-equiv\s*=\s*["']?content-type["']?[^>]*>/i
398
+ const DEFAULT_CHARSET = 'UTF-8'
399
+
400
+ /**
401
+ * Hoists the shell's encoding declaration ahead of everything this plugin injects.
402
+ *
403
+ * Every tag here defaults to `injectTo: 'head-prepend'`, which lands it immediately after `<head>`
404
+ * and therefore AHEAD of the consumer's own `<meta charset>`. On a realistic shell that pushed the
405
+ * declaration to byte 1653 — past the 1024-byte window the HTML spec gives it — and there is no
406
+ * consumer-side fix, because the plugin owns the injection point. So the plugin owns the hoist too:
407
+ * the consumer's tag is removed from the html and re-emitted as this plugin's FIRST head-prepend
408
+ * tag. A shell that declares no encoding at all gets `UTF-8`.
409
+ *
410
+ * The legacy `http-equiv` form is detected and left completely alone — rewriting a consumer's
411
+ * markup into a different (if equivalent) tag is not this plugin's business.
412
+ */
413
+ function hoistCharset(html: string): { html: string; tag: HtmlTagDescriptor | undefined } {
414
+ if (HTTP_EQUIV_CHARSET_RE.test(html)) return { html, tag: undefined }
415
+
416
+ const match = CHARSET_META_RE.exec(html)
417
+ const charset = match?.[1] ?? match?.[2] ?? match?.[3] ?? DEFAULT_CHARSET
418
+ return {
419
+ html: match ? html.replace(CHARSET_META_RE, '') : html,
420
+ tag: { tag: 'meta', attrs: { charset } },
421
+ }
422
+ }
423
+
322
424
  function buildSeoTags(input: {
323
425
  name: string
324
426
  description: string | undefined
@@ -374,7 +476,7 @@ function buildStaticTags(input: {
374
476
  options: BasaltAppOptions
375
477
  shortName: string
376
478
  themeColor: { light: string; dark: string }
377
- backgroundColor: string
479
+ bootStyle: string
378
480
  iconsEnabled: boolean
379
481
  iconsDir: string | undefined
380
482
  darkreader: 'lock' | false
@@ -386,7 +488,7 @@ function buildStaticTags(input: {
386
488
  options,
387
489
  shortName,
388
490
  themeColor,
389
- backgroundColor,
491
+ bootStyle,
390
492
  iconsEnabled,
391
493
  iconsDir,
392
494
  darkreader,
@@ -416,13 +518,11 @@ function buildStaticTags(input: {
416
518
  },
417
519
  )
418
520
 
419
- // Anti-FOUC — BasaltProvider defaults to dark, so this paints the dark surface before any
420
- // stylesheet loads. injectTo defaults to 'head-prepend', so this (and every tag below) lands
421
- // immediately after <head>, ahead of the consumer's own <link rel="stylesheet"> tags.
422
- tags.push({
423
- tag: 'style',
424
- children: `html{background-color:${backgroundColor};color-scheme:dark}`,
425
- })
521
+ // Anti-FOUC — paints the boot surface before any stylesheet loads. injectTo defaults to
522
+ // 'head-prepend', so this (and every tag below) lands immediately after <head> and the hoisted
523
+ // <meta charset>, ahead of the consumer's own <link rel="stylesheet"> tags. Scoped to
524
+ // BOOT_SCOPE so it expires the moment Mantine resolves the real scheme — see buildBootStyle.
525
+ tags.push({ tag: 'style', children: bootStyle })
426
526
 
427
527
  if (iconsEnabled) {
428
528
  tags.push(
@@ -489,8 +589,9 @@ function buildManifestJson(input: {
489
589
  scope: string
490
590
  id: string
491
591
  display: NonNullable<BasaltAppOptions['display']>
492
- themeColor: { light: string; dark: string }
592
+ schemeColor: string
493
593
  backgroundColor: string
594
+ iconsEnabled: boolean
494
595
  iconsDir: string | undefined
495
596
  base: string
496
597
  }): string {
@@ -501,8 +602,9 @@ function buildManifestJson(input: {
501
602
  scope,
502
603
  id,
503
604
  display,
504
- themeColor,
605
+ schemeColor,
505
606
  backgroundColor,
607
+ iconsEnabled,
506
608
  iconsDir,
507
609
  base,
508
610
  } = input
@@ -514,21 +616,31 @@ function buildManifestJson(input: {
514
616
  start_url: startUrl,
515
617
  scope,
516
618
  display,
517
- theme_color: themeColor.dark,
619
+ // A manifest carries ONE theme color, so it follows the boot scheme rather than the dual
620
+ // `<meta name="theme-color">` pair. Unchanged for the default (`'dark'`) and for `'auto'`.
621
+ theme_color: schemeColor,
518
622
  background_color: backgroundColor,
519
- icons: [
520
- {
521
- src: withBase(base, withIconPath(iconsDir, ICON_FILES.manifest192)),
522
- sizes: '192x192',
523
- type: 'image/png',
524
- },
525
- {
526
- src: withBase(base, withIconPath(iconsDir, ICON_FILES.manifest512)),
527
- sizes: '512x512',
528
- type: 'image/png',
529
- purpose: 'any maskable',
530
- },
531
- ],
623
+ // `icons: false` reaches HERE too, not just the head links. The array was unconditional, so
624
+ // `{ manifest: true, icons: false }` shipped a manifest pointing at two PNGs the app never
625
+ // ships — an installable app with broken icons, and the reason rb went hybrid (plugin for the
626
+ // head, a hand-written manifest for the rest). A manifest with NO icons member is valid.
627
+ ...(iconsEnabled
628
+ ? {
629
+ icons: [
630
+ {
631
+ src: withBase(base, withIconPath(iconsDir, ICON_FILES.manifest192)),
632
+ sizes: '192x192',
633
+ type: 'image/png',
634
+ },
635
+ {
636
+ src: withBase(base, withIconPath(iconsDir, ICON_FILES.manifest512)),
637
+ sizes: '512x512',
638
+ type: 'image/png',
639
+ purpose: 'any maskable',
640
+ },
641
+ ],
642
+ }
643
+ : {}),
532
644
  }
533
645
  return JSON.stringify(manifest, null, 2)
534
646
  }
@@ -542,7 +654,17 @@ function createMainPlugin(options: BasaltAppOptions): Plugin {
542
654
  const manifestEnabled = options.manifest !== false
543
655
 
544
656
  const themeColor = resolveThemeColors(options.themeColor)
545
- const backgroundColor = options.backgroundColor ?? themeColor.dark
657
+ // The boot scheme picks which half of the pair is "the" single color (manifest theme_color, the
658
+ // default paint). `'auto'` and `false` keep the historical dark default.
659
+ const colorScheme = options.colorScheme ?? 'dark'
660
+ const schemeColor = colorScheme === 'light' ? themeColor.light : themeColor.dark
661
+ const backgroundColor = options.backgroundColor ?? schemeColor
662
+ const bootStyle = buildBootStyle({
663
+ colorScheme,
664
+ themeColor,
665
+ backgroundColor,
666
+ explicitBackground: options.backgroundColor !== undefined,
667
+ })
546
668
 
547
669
  // Icon hrefs, the manifest link, and the manifest's start_url/scope/icon src all depend on
548
670
  // Vite's resolved `base`, which is only known once `configResolved` fires — so manifestJson and
@@ -569,8 +691,9 @@ function createMainPlugin(options: BasaltAppOptions): Plugin {
569
691
  scope,
570
692
  id,
571
693
  display,
572
- themeColor,
694
+ schemeColor,
573
695
  backgroundColor,
696
+ iconsEnabled,
574
697
  iconsDir,
575
698
  base,
576
699
  })
@@ -579,7 +702,7 @@ function createMainPlugin(options: BasaltAppOptions): Plugin {
579
702
  options,
580
703
  shortName,
581
704
  themeColor,
582
- backgroundColor,
705
+ bootStyle,
583
706
  iconsEnabled,
584
707
  iconsDir,
585
708
  darkreader,
@@ -588,9 +711,20 @@ function createMainPlugin(options: BasaltAppOptions): Plugin {
588
711
  manifestHref,
589
712
  })
590
713
  },
591
- transformIndexHtml(html) {
592
- const tags = hasViewportMeta(html) ? staticTags : [VIEWPORT_TAG, ...staticTags]
593
- return { html, tags }
714
+ transformIndexHtml: {
715
+ // `pre` so this plugin's tags are the FIRST head-prepend contributions Vite collects — that
716
+ // ordering is what puts the hoisted <meta charset> at the very top of <head> rather than
717
+ // behind another plugin's tags. Vite injects all collected tags after every hook has run.
718
+ order: 'pre',
719
+ handler(html) {
720
+ const hoisted = hoistCharset(html)
721
+ const tags = [
722
+ ...(hoisted.tag ? [hoisted.tag] : []),
723
+ ...(hasViewportMeta(html) ? [] : [VIEWPORT_TAG]),
724
+ ...staticTags,
725
+ ]
726
+ return { html: hoisted.html, tags }
727
+ },
594
728
  },
595
729
  generateBundle() {
596
730
  if (!manifestEnabled) return
@@ -666,13 +800,21 @@ function createServiceWorkerPlugin(
666
800
  * manifest `theme_color` both need a FLAT color, so the flattening step is the contract this
667
801
  * plugin owes its output regardless of which shape the palette hands it.
668
802
  *
803
+ * Two behaviours worth knowing before adopting it, both of which the plugin owns because the
804
+ * consumer cannot reach them:
805
+ * • It HOISTS the shell's `<meta charset>` ahead of everything it injects, so the encoding
806
+ * declaration stays inside the spec's first 1024 bytes (see `hoistCharset`).
807
+ * • The anti-FOUC `<style>` expires the moment Mantine resolves the real scheme, and `colorScheme`
808
+ * selects which surface it paints until then (see `BOOT_SCOPE` / `buildBootStyle`).
809
+ *
669
810
  * @example
670
811
  * // vite.config.ts
671
812
  * import { basaltAppPlugin, basaltViteConfig } from 'basalt-ui/vite'
672
813
  *
673
814
  * export default {
674
815
  * ...basaltViteConfig({ port: 5173 }),
675
- * plugins: [react(), ...basaltAppPlugin({ name: 'Argo' })],
816
+ * // colorScheme mirrors <BasaltProvider defaultColorScheme> — omit it for the dark default.
817
+ * plugins: [react(), ...basaltAppPlugin({ name: 'Argo', colorScheme: 'auto' })],
676
818
  * }
677
819
  */
678
820
  export function basaltAppPlugin(options: BasaltAppOptions): PluginOption[] {