basalt-ui 1.20.0 → 1.21.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 (87) hide show
  1. package/AGENTS.md +9 -5
  2. package/CHANGELOG.md +19 -0
  3. package/MIGRATING.md +147 -49
  4. package/README.md +42 -8
  5. package/agent/rules/basalt-app.md +41 -0
  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 +73 -16
  11. package/agent/skills/basalt-app/SKILL.md +4 -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 +181 -68
  16. package/configs/oxlint-plugin.test.ts +215 -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 +48 -14
  32. package/dist/cli/index.d.ts.map +1 -1
  33. package/dist/cli/index.js +328 -66
  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 +47 -2
  37. package/dist/guard/index.d.ts.map +1 -1
  38. package/dist/guard/index.js +176 -31
  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 +24 -2
  59. package/dist/vite.d.ts.map +1 -1
  60. package/dist/vite.js +54 -13
  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/index.ts +607 -128
  71. package/src/cli/init-sync.test.ts +2 -0
  72. package/src/cli/template-ledger.test.ts +1 -1
  73. package/src/cli/toolchain-wiring.test.ts +162 -0
  74. package/src/cli/waiver-audit.test.ts +131 -0
  75. package/src/guard/check-source.test.ts +441 -1
  76. package/src/guard/index.ts +430 -60
  77. package/src/router-tanstack/multi-search-param-store.ts +16 -1
  78. package/src/router-tanstack/search-param-store.test.ts +135 -0
  79. package/src/router-tanstack/search-param-store.ts +90 -8
  80. package/src/shell/app-mobile-nav.tsx +1 -1
  81. package/src/shell/index.test.tsx +52 -0
  82. package/src/shell/index.tsx +64 -13
  83. package/src/tokens/index.ts +24 -1
  84. package/src/tokens/palette.ts +2 -2
  85. package/src/tokens/text-scale.test.ts +51 -0
  86. package/src/vite.test.ts +125 -6
  87. package/src/vite.ts +148 -20
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' } },
@@ -190,3 +202,110 @@ describe('basaltAppPlugin — base handling', () => {
190
202
  )
191
203
  })
192
204
  })
205
+
206
+ describe('basaltAppPlugin — the anti-FOUC boot rule (V1)', () => {
207
+ const bootStyleOf = (options: Partial<BasaltAppOptions>): string => {
208
+ const plugin = getPlugin({ name: 'Test App', themeColor: THEME_COLOR, ...options })
209
+ resolveConfig(plugin, '/')
210
+ const style = transformHtml(plugin, HTML_NO_VIEWPORT).find((tag) => tag.tag === 'style')
211
+ return String(style?.children ?? '')
212
+ }
213
+
214
+ test('every mode scopes the rule so Mantine can take it back', () => {
215
+ // The whole defect: an UNLAYERED `html{color-scheme:dark}` outranks Mantine's own layered
216
+ // `:root{color-scheme:var(--mantine-color-scheme)}`, so light mode kept dark native controls
217
+ // forever. Scoping to the absence of the attribute Mantine writes is what expires it.
218
+ for (const colorScheme of ['dark', 'light', 'auto', false] as const) {
219
+ const css = bootStyleOf({ colorScheme })
220
+ expect(css).toContain('html:not([data-mantine-color-scheme])')
221
+ expect(css).not.toMatch(/(^|[};])html\{/)
222
+ }
223
+ })
224
+
225
+ test('default is dark — byte-identical to 1.20.0 apart from the scope', () => {
226
+ expect(bootStyleOf({})).toBe(
227
+ 'html:not([data-mantine-color-scheme]){background-color:#111111;color-scheme:dark}',
228
+ )
229
+ })
230
+
231
+ test('light paints the light surface and declares color-scheme: light', () => {
232
+ expect(bootStyleOf({ colorScheme: 'light' })).toBe(
233
+ 'html:not([data-mantine-color-scheme]){background-color:#ffffff;color-scheme:light}',
234
+ )
235
+ })
236
+
237
+ test('auto declares `light dark` and follows prefers-color-scheme for the paint', () => {
238
+ expect(bootStyleOf({ colorScheme: 'auto' })).toBe(
239
+ 'html:not([data-mantine-color-scheme]){background-color:#ffffff;color-scheme:light dark}' +
240
+ '@media(prefers-color-scheme:dark){html:not([data-mantine-color-scheme])' +
241
+ '{background-color:#111111}}',
242
+ )
243
+ })
244
+
245
+ test('an explicit backgroundColor wins over the auto media pair', () => {
246
+ expect(bootStyleOf({ colorScheme: 'auto', backgroundColor: '#abcdef' })).toBe(
247
+ 'html:not([data-mantine-color-scheme]){background-color:#abcdef;color-scheme:light dark}',
248
+ )
249
+ })
250
+
251
+ test('colorScheme: false is the full opt-out — paint, no color-scheme at all', () => {
252
+ expect(bootStyleOf({ colorScheme: false })).toBe(
253
+ 'html:not([data-mantine-color-scheme]){background-color:#111111}',
254
+ )
255
+ })
256
+
257
+ test('the manifest theme_color follows the boot scheme', () => {
258
+ const read = (options: Partial<BasaltAppOptions>): Record<string, unknown> => {
259
+ const plugin = getPlugin({ name: 'Test App', themeColor: THEME_COLOR, ...options })
260
+ const body = runMiddleware(getDevMiddleware(plugin, '/'), '/site.webmanifest')
261
+ return JSON.parse(body ?? '{}') as Record<string, unknown>
262
+ }
263
+ expect(read({})['theme_color']).toBe('#111111')
264
+ expect(read({ colorScheme: 'auto' })['theme_color']).toBe('#111111')
265
+ expect(read({ colorScheme: 'light' })['theme_color']).toBe('#ffffff')
266
+ expect(read({ colorScheme: 'light' })['background_color']).toBe('#ffffff')
267
+ })
268
+ })
269
+
270
+ describe('basaltAppPlugin — the encoding declaration (V2)', () => {
271
+ const SHELL =
272
+ '<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n' +
273
+ ' <title>App</title>\n </head>\n <body></body>\n</html>'
274
+
275
+ test("the consumer's charset is hoisted out of the html and re-emitted first", () => {
276
+ const plugin = getPlugin({ name: 'Test App', themeColor: THEME_COLOR })
277
+ resolveConfig(plugin, '/')
278
+ const result = transform(plugin, SHELL)
279
+
280
+ expect(result.html).not.toContain('charset')
281
+ expect(result.tags[0]).toEqual({ tag: 'meta', attrs: { charset: 'utf-8' } })
282
+ // The hoist takes the whole line — no orphaned indentation left behind.
283
+ expect(result.html).toContain('<head>\n <title>App</title>')
284
+ })
285
+
286
+ test('a shell that declares no encoding gets one', () => {
287
+ const plugin = getPlugin({ name: 'Test App', themeColor: THEME_COLOR })
288
+ resolveConfig(plugin, '/')
289
+ expect(transform(plugin, HTML_NO_VIEWPORT).tags[0]).toEqual({
290
+ tag: 'meta',
291
+ attrs: { charset: 'UTF-8' },
292
+ })
293
+ })
294
+
295
+ test('the legacy http-equiv form is left completely alone', () => {
296
+ const html =
297
+ '<html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8">' +
298
+ '</head><body></body></html>'
299
+ const plugin = getPlugin({ name: 'Test App', themeColor: THEME_COLOR })
300
+ resolveConfig(plugin, '/')
301
+ const result = transform(plugin, html)
302
+
303
+ expect(result.html).toBe(html)
304
+ expect(result.tags.some((tag) => tag.attrs?.['charset'] !== undefined)).toBe(false)
305
+ })
306
+
307
+ test("the hook runs `pre` so no other plugin's tags can land ahead of the charset", () => {
308
+ const plugin = getPlugin({ name: 'Test App', themeColor: THEME_COLOR })
309
+ expect((plugin.transformIndexHtml as { order?: string }).order).toBe('pre')
310
+ })
311
+ })
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'
@@ -319,6 +333,91 @@ const VIEWPORT_TAG: HtmlTagDescriptor = {
319
333
  attrs: { name: 'viewport', content: 'width=device-width, initial-scale=1.0, viewport-fit=cover' },
320
334
  }
321
335
 
336
+ // ── The anti-FOUC boot rule ───────────────────────────────────────────────────────────────────
337
+
338
+ /**
339
+ * The selector every anti-FOUC declaration is scoped to.
340
+ *
341
+ * The rule paints the page before any stylesheet lands, so it is UNLAYERED by construction — and an
342
+ * unlayered declaration outranks every layered one regardless of specificity. Through 1.20.0 that
343
+ * meant a flat `html{color-scheme:dark}` permanently beat Mantine's own
344
+ * `@layer mantine{:root{color-scheme:var(--mantine-color-scheme)}}`: a light-scheme consumer got
345
+ * dark scrollbars, dark `<select>` popups and dark date pickers forever, with no opt-out (found
346
+ * independently by two consumers).
347
+ *
348
+ * `MantineProvider` writes `data-mantine-color-scheme` onto `<html>` as soon as it mounts, so
349
+ * scoping the boot rule to its ABSENCE makes it expire exactly when the real theme exists — which
350
+ * is the anti-FOUC contract stated honestly. A cascade layer would also work in principle and is
351
+ * deliberately NOT used: a layer's position is fixed by where its name is first encountered, and in
352
+ * dev Mantine's CSS is injected by JS after this `<style>` is already in the document, so the layer
353
+ * order would depend on bundler mode. Selector scoping depends on nothing.
354
+ */
355
+ const BOOT_SCOPE = 'html:not([data-mantine-color-scheme])'
356
+
357
+ /** The `color-scheme` value each boot mode declares. `false` declares none. */
358
+ const BOOT_COLOR_SCHEME = { dark: 'dark', light: 'light', auto: 'light dark' } as const
359
+
360
+ /**
361
+ * Builds the anti-FOUC stylesheet: paint the surface (and, unless opted out, tell the UA which
362
+ * native-control palette to use) for the frames between first paint and Mantine's stylesheet.
363
+ *
364
+ * `'auto'` with no explicit `backgroundColor` emits a `prefers-color-scheme` pair so the pre-boot
365
+ * paint follows the OS the same way `color-scheme: light dark` does; an explicit `backgroundColor`
366
+ * is used verbatim for both, since the consumer has named one color on purpose.
367
+ */
368
+ function buildBootStyle(input: {
369
+ colorScheme: 'dark' | 'light' | 'auto' | false
370
+ themeColor: { light: string; dark: string }
371
+ backgroundColor: string
372
+ explicitBackground: boolean
373
+ }): string {
374
+ const { colorScheme, themeColor, backgroundColor, explicitBackground } = input
375
+
376
+ if (colorScheme === false) return `${BOOT_SCOPE}{background-color:${backgroundColor}}`
377
+
378
+ const scheme = BOOT_COLOR_SCHEME[colorScheme]
379
+ if (colorScheme !== 'auto' || explicitBackground) {
380
+ return `${BOOT_SCOPE}{background-color:${backgroundColor};color-scheme:${scheme}}`
381
+ }
382
+ return (
383
+ `${BOOT_SCOPE}{background-color:${themeColor.light};color-scheme:${scheme}}` +
384
+ `@media(prefers-color-scheme:dark){${BOOT_SCOPE}{background-color:${themeColor.dark}}}`
385
+ )
386
+ }
387
+
388
+ // ── The encoding declaration ──────────────────────────────────────────────────────────────────
389
+
390
+ /** `<meta charset="…">`, with the value in group 1/2/3 depending on the quoting style. */
391
+ const CHARSET_META_RE =
392
+ /[ \t]*<meta\s+charset\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>=]+))\s*\/?>[ \t]*\r?\n?/i
393
+ /** The legacy `<meta http-equiv="Content-Type" charset=…>` form — left verbatim, never rewritten. */
394
+ const HTTP_EQUIV_CHARSET_RE = /<meta\s[^>]*http-equiv\s*=\s*["']?content-type["']?[^>]*>/i
395
+ const DEFAULT_CHARSET = 'UTF-8'
396
+
397
+ /**
398
+ * Hoists the shell's encoding declaration ahead of everything this plugin injects.
399
+ *
400
+ * Every tag here defaults to `injectTo: 'head-prepend'`, which lands it immediately after `<head>`
401
+ * and therefore AHEAD of the consumer's own `<meta charset>`. On a realistic shell that pushed the
402
+ * declaration to byte 1653 — past the 1024-byte window the HTML spec gives it — and there is no
403
+ * consumer-side fix, because the plugin owns the injection point. So the plugin owns the hoist too:
404
+ * the consumer's tag is removed from the html and re-emitted as this plugin's FIRST head-prepend
405
+ * tag. A shell that declares no encoding at all gets `UTF-8`.
406
+ *
407
+ * The legacy `http-equiv` form is detected and left completely alone — rewriting a consumer's
408
+ * markup into a different (if equivalent) tag is not this plugin's business.
409
+ */
410
+ function hoistCharset(html: string): { html: string; tag: HtmlTagDescriptor | undefined } {
411
+ if (HTTP_EQUIV_CHARSET_RE.test(html)) return { html, tag: undefined }
412
+
413
+ const match = CHARSET_META_RE.exec(html)
414
+ const charset = match?.[1] ?? match?.[2] ?? match?.[3] ?? DEFAULT_CHARSET
415
+ return {
416
+ html: match ? html.replace(CHARSET_META_RE, '') : html,
417
+ tag: { tag: 'meta', attrs: { charset } },
418
+ }
419
+ }
420
+
322
421
  function buildSeoTags(input: {
323
422
  name: string
324
423
  description: string | undefined
@@ -374,7 +473,7 @@ function buildStaticTags(input: {
374
473
  options: BasaltAppOptions
375
474
  shortName: string
376
475
  themeColor: { light: string; dark: string }
377
- backgroundColor: string
476
+ bootStyle: string
378
477
  iconsEnabled: boolean
379
478
  iconsDir: string | undefined
380
479
  darkreader: 'lock' | false
@@ -386,7 +485,7 @@ function buildStaticTags(input: {
386
485
  options,
387
486
  shortName,
388
487
  themeColor,
389
- backgroundColor,
488
+ bootStyle,
390
489
  iconsEnabled,
391
490
  iconsDir,
392
491
  darkreader,
@@ -416,13 +515,11 @@ function buildStaticTags(input: {
416
515
  },
417
516
  )
418
517
 
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
- })
518
+ // Anti-FOUC — paints the boot surface before any stylesheet loads. injectTo defaults to
519
+ // 'head-prepend', so this (and every tag below) lands immediately after <head> and the hoisted
520
+ // <meta charset>, ahead of the consumer's own <link rel="stylesheet"> tags. Scoped to
521
+ // BOOT_SCOPE so it expires the moment Mantine resolves the real scheme — see buildBootStyle.
522
+ tags.push({ tag: 'style', children: bootStyle })
426
523
 
427
524
  if (iconsEnabled) {
428
525
  tags.push(
@@ -489,7 +586,7 @@ function buildManifestJson(input: {
489
586
  scope: string
490
587
  id: string
491
588
  display: NonNullable<BasaltAppOptions['display']>
492
- themeColor: { light: string; dark: string }
589
+ schemeColor: string
493
590
  backgroundColor: string
494
591
  iconsDir: string | undefined
495
592
  base: string
@@ -501,7 +598,7 @@ function buildManifestJson(input: {
501
598
  scope,
502
599
  id,
503
600
  display,
504
- themeColor,
601
+ schemeColor,
505
602
  backgroundColor,
506
603
  iconsDir,
507
604
  base,
@@ -514,7 +611,9 @@ function buildManifestJson(input: {
514
611
  start_url: startUrl,
515
612
  scope,
516
613
  display,
517
- theme_color: themeColor.dark,
614
+ // A manifest carries ONE theme color, so it follows the boot scheme rather than the dual
615
+ // `<meta name="theme-color">` pair. Unchanged for the default (`'dark'`) and for `'auto'`.
616
+ theme_color: schemeColor,
518
617
  background_color: backgroundColor,
519
618
  icons: [
520
619
  {
@@ -542,7 +641,17 @@ function createMainPlugin(options: BasaltAppOptions): Plugin {
542
641
  const manifestEnabled = options.manifest !== false
543
642
 
544
643
  const themeColor = resolveThemeColors(options.themeColor)
545
- const backgroundColor = options.backgroundColor ?? themeColor.dark
644
+ // The boot scheme picks which half of the pair is "the" single color (manifest theme_color, the
645
+ // default paint). `'auto'` and `false` keep the historical dark default.
646
+ const colorScheme = options.colorScheme ?? 'dark'
647
+ const schemeColor = colorScheme === 'light' ? themeColor.light : themeColor.dark
648
+ const backgroundColor = options.backgroundColor ?? schemeColor
649
+ const bootStyle = buildBootStyle({
650
+ colorScheme,
651
+ themeColor,
652
+ backgroundColor,
653
+ explicitBackground: options.backgroundColor !== undefined,
654
+ })
546
655
 
547
656
  // Icon hrefs, the manifest link, and the manifest's start_url/scope/icon src all depend on
548
657
  // Vite's resolved `base`, which is only known once `configResolved` fires — so manifestJson and
@@ -569,7 +678,7 @@ function createMainPlugin(options: BasaltAppOptions): Plugin {
569
678
  scope,
570
679
  id,
571
680
  display,
572
- themeColor,
681
+ schemeColor,
573
682
  backgroundColor,
574
683
  iconsDir,
575
684
  base,
@@ -579,7 +688,7 @@ function createMainPlugin(options: BasaltAppOptions): Plugin {
579
688
  options,
580
689
  shortName,
581
690
  themeColor,
582
- backgroundColor,
691
+ bootStyle,
583
692
  iconsEnabled,
584
693
  iconsDir,
585
694
  darkreader,
@@ -588,9 +697,20 @@ function createMainPlugin(options: BasaltAppOptions): Plugin {
588
697
  manifestHref,
589
698
  })
590
699
  },
591
- transformIndexHtml(html) {
592
- const tags = hasViewportMeta(html) ? staticTags : [VIEWPORT_TAG, ...staticTags]
593
- return { html, tags }
700
+ transformIndexHtml: {
701
+ // `pre` so this plugin's tags are the FIRST head-prepend contributions Vite collects — that
702
+ // ordering is what puts the hoisted <meta charset> at the very top of <head> rather than
703
+ // behind another plugin's tags. Vite injects all collected tags after every hook has run.
704
+ order: 'pre',
705
+ handler(html) {
706
+ const hoisted = hoistCharset(html)
707
+ const tags = [
708
+ ...(hoisted.tag ? [hoisted.tag] : []),
709
+ ...(hasViewportMeta(html) ? [] : [VIEWPORT_TAG]),
710
+ ...staticTags,
711
+ ]
712
+ return { html: hoisted.html, tags }
713
+ },
594
714
  },
595
715
  generateBundle() {
596
716
  if (!manifestEnabled) return
@@ -666,13 +786,21 @@ function createServiceWorkerPlugin(
666
786
  * manifest `theme_color` both need a FLAT color, so the flattening step is the contract this
667
787
  * plugin owes its output regardless of which shape the palette hands it.
668
788
  *
789
+ * Two behaviours worth knowing before adopting it, both of which the plugin owns because the
790
+ * consumer cannot reach them:
791
+ * • It HOISTS the shell's `<meta charset>` ahead of everything it injects, so the encoding
792
+ * declaration stays inside the spec's first 1024 bytes (see `hoistCharset`).
793
+ * • The anti-FOUC `<style>` expires the moment Mantine resolves the real scheme, and `colorScheme`
794
+ * selects which surface it paints until then (see `BOOT_SCOPE` / `buildBootStyle`).
795
+ *
669
796
  * @example
670
797
  * // vite.config.ts
671
798
  * import { basaltAppPlugin, basaltViteConfig } from 'basalt-ui/vite'
672
799
  *
673
800
  * export default {
674
801
  * ...basaltViteConfig({ port: 5173 }),
675
- * plugins: [react(), ...basaltAppPlugin({ name: 'Argo' })],
802
+ * // colorScheme mirrors <BasaltProvider defaultColorScheme> — omit it for the dark default.
803
+ * plugins: [react(), ...basaltAppPlugin({ name: 'Argo', colorScheme: 'auto' })],
676
804
  * }
677
805
  */
678
806
  export function basaltAppPlugin(options: BasaltAppOptions): PluginOption[] {