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.
- package/AGENTS.md +11 -5
- package/CHANGELOG.md +33 -0
- package/MIGRATING.md +231 -50
- package/README.md +57 -10
- package/agent/rules/basalt-app.md +49 -2
- package/agent/rules/basalt-charts.md +15 -12
- package/agent/rules/basalt-content.md +4 -0
- package/agent/rules/basalt-router.md +57 -5
- package/agent/rules/basalt-state.md +21 -3
- package/agent/rules/basalt-tokens.md +78 -16
- package/agent/skills/basalt-app/SKILL.md +5 -1
- package/agent/skills/basalt-charts/SKILL.md +5 -3
- package/agent/templates/CLAUDE-block.md.tpl +11 -5
- package/configs/lefthook.yml +48 -15
- package/configs/oxlint-plugin.js +218 -69
- package/configs/oxlint-plugin.test.ts +331 -9
- package/dist/charts/kinds/Bars.d.ts +1 -1
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.d.ts +1 -1
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.d.ts +1 -1
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/Heatmap.d.ts +1 -1
- package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.d.ts +1 -1
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.d.ts +1 -1
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.d.ts +1 -1
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/cli/index.d.ts +55 -15
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +521 -71
- package/dist/cli/waiver-audit.test.d.ts +2 -0
- package/dist/cli/waiver-audit.test.d.ts.map +1 -0
- package/dist/guard/index.d.ts +114 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +216 -33
- package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
- package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/multi-search-param-store.js +4 -1
- package/dist/router-tanstack/search-param-store.d.ts +29 -5
- package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/search-param-store.js +31 -3
- package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
- package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
- package/dist/shell/index.d.ts +12 -5
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +24 -6
- package/dist/tokens/index.d.ts +3 -1
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +5 -1
- package/dist/tokens/palette.d.ts +2 -2
- package/dist/tokens/palette.js +2 -2
- package/dist/tokens/text-scale.test.d.ts +2 -0
- package/dist/tokens/text-scale.test.d.ts.map +1 -0
- package/dist/tokens.css +4 -2
- package/dist/vite.d.ts +28 -3
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +75 -26
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/kinds/Bars.tsx +1 -1
- package/src/charts/kinds/Donut.tsx +1 -1
- package/src/charts/kinds/DualPanel.tsx +2 -2
- package/src/charts/kinds/Heatmap.tsx +1 -1
- package/src/charts/kinds/MultiLine.tsx +1 -1
- package/src/charts/kinds/StackedArea.tsx +1 -1
- package/src/charts/kinds/ZonedLine.tsx +1 -1
- package/src/cli/check-theme.test.ts +35 -0
- package/src/cli/help.test.ts +5 -1
- package/src/cli/index.ts +978 -133
- package/src/cli/init-sync.test.ts +2 -0
- package/src/cli/template-ledger.test.ts +1 -1
- package/src/cli/toolchain-wiring.test.ts +352 -1
- package/src/cli/waiver-audit.test.ts +211 -0
- package/src/guard/check-source.test.ts +638 -1
- package/src/guard/index.ts +556 -63
- package/src/router-tanstack/multi-search-param-store.ts +16 -1
- package/src/router-tanstack/search-param-store.test.ts +135 -0
- package/src/router-tanstack/search-param-store.ts +90 -8
- package/src/shell/app-mobile-nav.tsx +1 -1
- package/src/shell/index.test.tsx +52 -0
- package/src/shell/index.tsx +64 -13
- package/src/tokens/index.ts +24 -1
- package/src/tokens/palette.ts +2 -2
- package/src/tokens/text-scale.test.ts +51 -0
- package/src/vite.test.ts +158 -6
- 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
|
-
|
|
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
|
-
{
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
*
|
|
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[] {
|