basalt-ui 1.19.1 → 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 (129) hide show
  1. package/AGENTS.md +19 -1
  2. package/CHANGELOG.md +39 -0
  3. package/MIGRATING.md +326 -0
  4. package/README.md +87 -9
  5. package/agent/rules/basalt-app.md +84 -0
  6. package/agent/rules/basalt-charts.md +32 -17
  7. package/agent/rules/basalt-content.md +4 -0
  8. package/agent/rules/basalt-mantine.md +25 -10
  9. package/agent/rules/basalt-router.md +79 -15
  10. package/agent/rules/basalt-state.md +21 -3
  11. package/agent/rules/basalt-tokens.md +132 -6
  12. package/agent/skills/basalt-app/SKILL.md +45 -6
  13. package/agent/skills/basalt-charts/SKILL.md +5 -3
  14. package/agent/templates/CLAUDE-block.md.tpl +11 -5
  15. package/configs/check.yml +11 -4
  16. package/configs/lefthook.yml +59 -7
  17. package/configs/oxlint-plugin.js +633 -30
  18. package/configs/oxlint-plugin.test.ts +594 -15
  19. package/configs/oxlint.json +10 -1
  20. package/dist/agent/stick-to-bottom.js +1 -1
  21. package/dist/agent-chat/thread-detail-panel.js +1 -1
  22. package/dist/agent-chat/thread-message.js +2 -2
  23. package/dist/agent-chat/tool-chip.js +1 -1
  24. package/dist/charts/kinds/Bars.d.ts +1 -1
  25. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  26. package/dist/charts/kinds/Donut.d.ts +1 -1
  27. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  28. package/dist/charts/kinds/Donut.js +1 -1
  29. package/dist/charts/kinds/DualPanel.d.ts +1 -1
  30. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  31. package/dist/charts/kinds/Heatmap.d.ts +1 -1
  32. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  33. package/dist/charts/kinds/MultiLine.d.ts +1 -1
  34. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  35. package/dist/charts/kinds/StackedArea.d.ts +1 -1
  36. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  37. package/dist/charts/kinds/ZonedLine.d.ts +1 -1
  38. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  39. package/dist/charts/primitives/ChartLegend.js +2 -2
  40. package/dist/charts/primitives/ChartTooltip.js +1 -1
  41. package/dist/cli/index.d.ts +146 -20
  42. package/dist/cli/index.d.ts.map +1 -1
  43. package/dist/cli/index.js +901 -101
  44. package/dist/cli/toolchain-wiring.test.d.ts +2 -0
  45. package/dist/cli/toolchain-wiring.test.d.ts.map +1 -0
  46. package/dist/cli/waiver-audit.test.d.ts +2 -0
  47. package/dist/cli/waiver-audit.test.d.ts.map +1 -0
  48. package/dist/dashboard/delta-badge.js +2 -2
  49. package/dist/dashboard/stat-card.js +1 -1
  50. package/dist/data/virtual-list.js +5 -5
  51. package/dist/guard/index.d.ts +101 -2
  52. package/dist/guard/index.d.ts.map +1 -1
  53. package/dist/guard/index.js +474 -62
  54. package/dist/guard/types.d.ts +25 -5
  55. package/dist/guard/types.d.ts.map +1 -1
  56. package/dist/register.d.ts +25 -0
  57. package/dist/register.d.ts.map +1 -1
  58. package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
  59. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  60. package/dist/router-tanstack/multi-search-param-store.js +4 -1
  61. package/dist/router-tanstack/search-param-store.d.ts +29 -5
  62. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  63. package/dist/router-tanstack/search-param-store.js +31 -3
  64. package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
  65. package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
  66. package/dist/shell/app-mobile-nav.module.css +11 -3
  67. package/dist/shell/index.d.ts +12 -5
  68. package/dist/shell/index.d.ts.map +1 -1
  69. package/dist/shell/index.js +24 -6
  70. package/dist/surfaces.d.ts +1 -1
  71. package/dist/surfaces.js +1 -1
  72. package/dist/tokens/index.d.ts +5 -3
  73. package/dist/tokens/index.d.ts.map +1 -1
  74. package/dist/tokens/index.js +7 -1
  75. package/dist/tokens/palette.d.ts +35 -27
  76. package/dist/tokens/palette.d.ts.map +1 -1
  77. package/dist/tokens/palette.js +24 -18
  78. package/dist/tokens/text-scale.test.d.ts +2 -0
  79. package/dist/tokens/text-scale.test.d.ts.map +1 -0
  80. package/dist/tokens.css +21 -17
  81. package/dist/vite-color-mix.js +2 -2
  82. package/dist/vite.d.ts +24 -2
  83. package/dist/vite.d.ts.map +1 -1
  84. package/dist/vite.js +54 -13
  85. package/llms.txt +3 -3
  86. package/package.json +4 -3
  87. package/src/agent/stick-to-bottom.tsx +1 -1
  88. package/src/agent-chat/thread-detail-panel.tsx +1 -1
  89. package/src/agent-chat/thread-message.tsx +3 -3
  90. package/src/agent-chat/tool-chip.tsx +1 -1
  91. package/src/charts/kinds/Bars.tsx +1 -1
  92. package/src/charts/kinds/Donut.tsx +3 -2
  93. package/src/charts/kinds/DualPanel.tsx +2 -2
  94. package/src/charts/kinds/Heatmap.tsx +1 -1
  95. package/src/charts/kinds/MultiLine.tsx +1 -1
  96. package/src/charts/kinds/StackedArea.tsx +1 -1
  97. package/src/charts/kinds/ZonedLine.tsx +1 -1
  98. package/src/charts/primitives/ChartLegend.tsx +2 -2
  99. package/src/charts/primitives/ChartTooltip.tsx +1 -1
  100. package/src/cli/index.ts +1614 -164
  101. package/src/cli/init-sync.test.ts +21 -3
  102. package/src/cli/template-ledger.test.ts +1 -1
  103. package/src/cli/tokens-css.test.ts +27 -14
  104. package/src/cli/toolchain-wiring.test.ts +856 -0
  105. package/src/cli/waiver-audit.test.ts +131 -0
  106. package/src/dashboard/delta-badge.tsx +3 -3
  107. package/src/dashboard/stat-card.tsx +1 -1
  108. package/src/data/virtual-list.tsx +8 -8
  109. package/src/guard/check-source.test.ts +1104 -12
  110. package/src/guard/index.ts +1106 -103
  111. package/src/guard/types.ts +29 -4
  112. package/src/register.ts +32 -0
  113. package/src/router-tanstack/multi-search-param-store.ts +16 -1
  114. package/src/router-tanstack/search-param-store.test.ts +135 -0
  115. package/src/router-tanstack/search-param-store.ts +90 -8
  116. package/src/shell/app-mobile-nav.module.css +11 -3
  117. package/src/shell/app-mobile-nav.tsx +1 -1
  118. package/src/shell/index.test.tsx +52 -0
  119. package/src/shell/index.tsx +64 -13
  120. package/src/surfaces.ts +1 -1
  121. package/src/theme/density-relations.test.ts +8 -5
  122. package/src/theme/spacing.test.ts +2 -0
  123. package/src/theme-toggle/index.tsx +2 -2
  124. package/src/tokens/index.ts +28 -3
  125. package/src/tokens/palette.ts +24 -18
  126. package/src/tokens/text-scale.test.ts +51 -0
  127. package/src/vite-color-mix.ts +2 -2
  128. package/src/vite.test.ts +125 -6
  129. package/src/vite.ts +148 -20
@@ -266,17 +266,20 @@ describe("Fix 7 — the knob's resolution is honest at every notch", () => {
266
266
  expect(minRatio).toBeLessThan(0.7)
267
267
  })
268
268
 
269
- test('frozen-vs-baseline ceiling: fewer than 25 of the ~108 values equal their level-0 value at any non-zero level', () => {
270
- // Measured under the 0.1/±3 law: worst case is 22 (at level ±1) — down from 43 under the old
271
- // 0.06/±5 law. The ceiling of 25 leaves margin while still catching a future step reduction
272
- // that reintroduces that dead zone.
269
+ test('frozen-vs-baseline ceiling: fewer than 27 of the ~129 values equal their level-0 value at any non-zero level', () => {
270
+ // Measured under the 0.1/±3 law: worst case is 25 (at level −1) — down from 43 under the old
271
+ // 0.06/±5 law. It is 25 rather than 24 because `mobileNavTabInsetY` (base 2) joined the set:
272
+ // a base that small rounds back to itself at ±1 and ±2 under any multiplier this law can
273
+ // produce, exactly as `mobileNavTabGap` (base 3) already did. That is the rounding law, not a
274
+ // resolution regression — the ceiling of 27 keeps the same margin over the measured worst case
275
+ // while still catching a future step reduction that reintroduces the real dead zone.
273
276
  const baseline = flattenSpacing(0)
274
277
  const keys = Object.keys(baseline)
275
278
  for (const level of ALL_LEVELS) {
276
279
  if (level === 0) continue
277
280
  const values = flattenSpacing(level)
278
281
  const frozen = keys.filter((k) => values[k] === baseline[k]).length
279
- expect(frozen).toBeLessThan(25)
282
+ expect(frozen).toBeLessThan(27)
280
283
  }
281
284
  })
282
285
 
@@ -357,6 +357,8 @@ const SPACE_STEP_SWEEP: ReadonlyArray<
357
357
  // `app-mobile-nav.tsx` hands `mobileNavMenuWidth` to `<Menu width={…}>`.
358
358
  ['mobileNavBarHeight', 56, null],
359
359
  ['mobileNavIconSize', 24, 'space-mobile-nav-icon-size'],
360
+ ['mobileNavTabInsetY', 2, 'space-mobile-nav-tab-inset-y'],
361
+ ['mobileNavTabInsetX', 12, 'space-mobile-nav-tab-inset-x'],
360
362
  ['mobileNavRowHeight', 44, 'space-mobile-nav-row-height'],
361
363
  ['mobileNavMenuWidth', 232, null],
362
364
  ['agentRailInsetX', 10, 'space-agent-rail-inset-x'],
@@ -84,7 +84,7 @@ function SchemeGlyph({ dark, reduceMotion }: { dark: boolean; reduceMotion: bool
84
84
  {dark ? (
85
85
  <motion.span
86
86
  key="moon"
87
- style={{ display: 'inline-flex' }} // theme-allow: framer-motion element — inline-flex glyph wrapper, not a Mantine node
87
+ style={{ display: 'inline-flex' }} // theme-allow inline-display — a framer-motion glyph wrapper, not a Mantine layout node
88
88
  initial={{ opacity: 0, scale: 0.6, rotate: 30 }}
89
89
  animate={{ opacity: 1, scale: 1, rotate: 0 }}
90
90
  exit={{ opacity: 0, scale: 0.6, rotate: -30 }}
@@ -95,7 +95,7 @@ function SchemeGlyph({ dark, reduceMotion }: { dark: boolean; reduceMotion: bool
95
95
  ) : (
96
96
  <motion.span
97
97
  key="sun"
98
- style={{ display: 'inline-flex' }} // theme-allow: framer-motion element — inline-flex glyph wrapper, not a Mantine node
98
+ style={{ display: 'inline-flex' }} // theme-allow inline-display — a framer-motion glyph wrapper, not a Mantine layout node
99
99
  initial={{ opacity: 0, scale: 0.6, rotate: -30 }}
100
100
  animate={{ opacity: 1, scale: 1, rotate: 0 }}
101
101
  exit={{ opacity: 0, scale: 0.6, rotate: 30 }}
@@ -89,8 +89,30 @@ export const alpha = (token: string, a: number): string =>
89
89
  * `theme/index.ts`), so the component surface scales with the user's browser font-size AND with
90
90
  * `--mantine-scale`. `md` is the body step; `lg` is pinned at exactly 16px because it doubles as
91
91
  * the iOS input floor (see the `styles.css` floor — Safari zooms the viewport on focus below 16px).
92
+ *
93
+ * ## Adding a rung
94
+ *
95
+ * The ladder is hand-tuned per ROLE, not generated — but it is not arbitrary either, and the
96
+ * constraint is what keeps a "just add the size I need" request from dissolving it. Every adjacent
97
+ * pair sits between **1.06× and 1.17×**, so a new rung must too:
98
+ *
99
+ * • Below the range, two rungs are visually the same size and the ladder stops being a choice —
100
+ * the guard would then report a size for which two tokens are equally "right".
101
+ * • Above it, the jump reads as a different type system rather than the next step of this one.
102
+ *
103
+ * That test is what admitted `nano` (11/10 = 1.10) and `display` (30/26 = 1.154, the second-widest
104
+ * step, which is the shape a display tier is supposed to have), and what rejected a 20px rung
105
+ * between `xl` (18) and `h2` (21): 21/20 = 1.05 is below the floor, and `h2` is already 1px away
106
+ * from any 20px site. A size the ladder declines to grow a rung for is a size that should snap to
107
+ * its nearest step — not a `theme-allow`.
108
+ *
109
+ * A rung is also a ROLE, not just a number. `nano` and `display` are both deliberately named for
110
+ * where they belong (a label engraved inside a drawn object; a numeral read from across a room)
111
+ * rather than for a position, because the failure mode of a bare `xxs`/`xxl` is that it becomes the
112
+ * escape hatch for every size the ladder was meant to prevent.
92
113
  */
93
114
  const TEXT = {
115
+ nano: 10, // engraved/graphic label INSIDE a drawn object (a plate face, a dial, a chip) — never UI copy or prose
94
116
  micro: 11, // mono uppercase micro-label (table th, Menu.Label, sidebar/section headers, axis ticks)
95
117
  xs: 12.5, // delta badges, tooltip meta, dense chrome, StatCard labels
96
118
  sm: 13.5, // table/stat numerals, chart tooltip, legend
@@ -100,6 +122,7 @@ const TEXT = {
100
122
  h2: 21, // article-density Prose h2 (docs/CONTENT-SPEC.md §5)
101
123
  kpi: 24, // the StatCard hero numeral (density pass: 31 → 24, tighter/less shouty)
102
124
  h1: 26, // article-density Prose h1 (docs/CONTENT-SPEC.md §5)
125
+ display: 30, // a numeral read at arm's length (a keypad readout, a timer, a kiosk figure) — not a heading
103
126
  } as const
104
127
 
105
128
  export const VX = {
@@ -533,6 +556,8 @@ function spaceDecls(space: SpaceValues): string[] {
533
556
  decl('space-app-header-mobile-actions-height', `${space.step.appHeaderMobileActionsHeight}px`),
534
557
  decl('space-mobile-nav-tab-gap', `${space.step.mobileNavTabGap}px`),
535
558
  decl('space-mobile-nav-icon-size', `${space.step.mobileNavIconSize}px`),
559
+ decl('space-mobile-nav-tab-inset-y', `${space.step.mobileNavTabInsetY}px`),
560
+ decl('space-mobile-nav-tab-inset-x', `${space.step.mobileNavTabInsetX}px`),
536
561
  decl('space-mobile-nav-row-height', `${space.step.mobileNavRowHeight}px`),
537
562
  decl('space-agent-rail-inset-x', `${space.step.agentRailInsetX}px`),
538
563
  decl('space-agent-part-gap-top', `${space.step.agentPartGapTop}px`),
@@ -758,13 +783,13 @@ export type BuildPaletteOpts = {
758
783
  */
759
784
  mediaFallback?: boolean
760
785
  /**
761
- * How much of the SPACING set to emit. Default: `'all'` (every one of the 104 `--vx-space-*`
786
+ * How much of the SPACING set to emit. Default: `'all'` (every one of the 108 `--vx-space-*`
762
787
  * declarations the framework's own components read).
763
788
  *
764
789
  * `'core'` keeps the 9 generic anchors — the stack rhythm, the two control heights, the row
765
- * insets — and drops the 95 named for a specific basalt component
790
+ * insets — and drops the 99 named for a specific basalt component
766
791
  * (`--vx-space-agent-transcript-inset`, `--vx-space-toc-sub-indent`, …), taking the emitted set
767
- * from 197 variables to 102. Nothing outside basalt's own React components reads those, so a
792
+ * from 204 variables to 105. Nothing outside basalt's own React components reads those, so a
768
793
  * framework-free consumer is carrying them for nothing.
769
794
  *
770
795
  * This is a SPACING filter only: color, radius, shadow, type and status variables are identical
@@ -97,12 +97,12 @@ export const p = (fam: readonly string[], light = 2, dark = 3): ColorPair => ({
97
97
  */
98
98
  export const SHADOW = {
99
99
  card: {
100
- light: '0 1px 2px rgba(28,25,23,0.05), 0 0 0 1px var(--vx-surface-hairline)',
101
- dark: '0 1px 3px rgba(0,0,0,0.4), inset 0 0 0 1px color-mix(in srgb, #ffffff 4%, transparent)',
100
+ light: '0 1px 2px rgba(28, 25, 23, 0.05), 0 0 0 1px var(--vx-surface-hairline)',
101
+ dark: '0 1px 3px rgba(0, 0, 0, 0.4), inset 0 0 0 1px color-mix(in srgb, #ffffff 4%, transparent)',
102
102
  },
103
103
  ctrl: {
104
- light: '0 1px 2px rgba(28,25,23,0.12)',
105
- dark: '0 1px 2px rgba(0,0,0,0.35)',
104
+ light: '0 1px 2px rgba(28, 25, 23, 0.12)',
105
+ dark: '0 1px 2px rgba(0, 0, 0, 0.35)',
106
106
  },
107
107
  /**
108
108
  * Interactive-control depth — the ONE value every raised Button/ActionIcon/Input variant shares
@@ -132,16 +132,16 @@ export const SHADOW = {
132
132
  * ever needs more, raise the DROP. Do not reintroduce an edge; both shapes have been tried.
133
133
  */
134
134
  raised: {
135
- light: '0 1px 2px rgba(28,25,23,0.10)',
136
- dark: '0 1px 2px rgba(0,0,0,0.35), inset 0 0 0 1px color-mix(in srgb, #ffffff 8%, transparent)',
135
+ light: '0 1px 2px rgba(28, 25, 23, 0.1)',
136
+ dark: '0 1px 2px rgba(0, 0, 0, 0.35), inset 0 0 0 1px color-mix(in srgb, #ffffff 8%, transparent)',
137
137
  },
138
138
  // Floating-layer elevation (menus, popovers, tooltips, modals, drawers) — a REAL drop shadow, a
139
139
  // step above `card`, so detached surfaces read as lifted off the page (`docs/DESIGN-SPEC.md` §5).
140
140
  // Unlike `card`, no embedded ring: floating surfaces carry a real 1px `--vx-surface-border` so
141
141
  // Mantine's rotated-square arrow inherits a proper edge.
142
142
  overlay: {
143
- light: '0 8px 24px rgba(28,25,23,0.10)',
144
- dark: '0 8px 24px rgba(0,0,0,0.5)',
143
+ light: '0 8px 24px rgba(28, 25, 23, 0.1)',
144
+ dark: '0 8px 24px rgba(0, 0, 0, 0.5)',
145
145
  },
146
146
  } as const
147
147
 
@@ -613,8 +613,14 @@ const SPACE_STEP_BASE = {
613
613
  * `env(safe-area-inset-bottom)` to it. JS-consumed (AppShell `footer.height`), no `--vx-*` var,
614
614
  * same shape as `appShellHeaderHeight`. Floored at 48px in `deriveSpacing`. */
615
615
  mobileNavBarHeight: 56,
616
- /** Slot icon box. */
616
+ /** Slot icon box. Also the MIN box of the icon slot itself, so the active pill keeps its shape
617
+ * when a destination ships no icon (an icon-less consumer is a supported configuration). */
617
618
  mobileNavIconSize: 24,
619
+ /** Active-pill inset around the slot icon, vertical. Sub-scale on its own, but it is one half of
620
+ * a pair with `mobileNavTabInsetX` — a step keeps the pill's shape tracking density. */
621
+ mobileNavTabInsetY: 2,
622
+ /** Active-pill inset around the slot icon, horizontal. */
623
+ mobileNavTabInsetX: 12,
618
624
  /** Menu/sheet row height. Floored at 44px in `deriveSpacing` (Apple HIG 44pt / WCAG 2.5.5 AAA). */
619
625
  mobileNavRowHeight: 44,
620
626
  /** More/group `<Menu width={…}>`. JS-consumed, no var — same rationale as
@@ -1059,22 +1065,22 @@ function buildPaletteDataUncached(config: DeriveConfig) {
1059
1065
  const NEUTRAL = {
1060
1066
  line: { light: BP.gray[0], dark: BP.gray[4] }, // primary line
1061
1067
  line2: { light: BP.gray[1], dark: BP.gray[2] }, // secondary line
1062
- axis: { light: inkRgba(inkLight, 0.6), dark: 'rgba(255,255,255,0.6)' },
1063
- axisStroke: { light: inkRgba(inkLight, 0.12), dark: 'rgba(255,255,255,0.12)' },
1064
- grid: { light: inkRgba(inkLight, 0.07), dark: 'rgba(255,255,255,0.06)' },
1065
- crosshair: { light: inkRgba(inkLight, 0.32), dark: 'rgba(255,255,255,0.42)' },
1068
+ axis: { light: inkRgba(inkLight, 0.6), dark: 'rgba(255, 255, 255, 0.6)' },
1069
+ axisStroke: { light: inkRgba(inkLight, 0.12), dark: 'rgba(255, 255, 255, 0.12)' },
1070
+ grid: { light: inkRgba(inkLight, 0.07), dark: 'rgba(255, 255, 255, 0.06)' },
1071
+ crosshair: { light: inkRgba(inkLight, 0.32), dark: 'rgba(255, 255, 255, 0.42)' },
1066
1072
  dotStroke: { light: BP.white, dark: BP.darkGray[1] }, // matches chart-area bg
1067
- tooltipBg: { light: BP.white, dark: 'rgba(39,39,42,0.96)' },
1068
- tooltipText: { light: inkRgba(inkLight, 0.88), dark: 'rgba(255,255,255,0.88)' },
1069
- tooltipMuted: { light: inkRgba(inkLight, 0.5), dark: 'rgba(255,255,255,0.5)' },
1073
+ tooltipBg: { light: BP.white, dark: 'rgba(39, 39, 42, 0.96)' },
1074
+ tooltipText: { light: inkRgba(inkLight, 0.88), dark: 'rgba(255, 255, 255, 0.88)' },
1075
+ tooltipMuted: { light: inkRgba(inkLight, 0.5), dark: 'rgba(255, 255, 255, 0.5)' },
1070
1076
  tooltipBorder: { light: `1px solid ${BP.lightGray[1]}`, dark: 'none' },
1071
1077
  tooltipShadow: {
1072
1078
  light: `0 2px 8px ${inkRgba(inkLight, 0.1)}`,
1073
- dark: '0 2px 8px rgba(0,0,0,0.35)',
1079
+ dark: '0 2px 8px rgba(0, 0, 0, 0.35)',
1074
1080
  },
1075
1081
  // Chart legend text — same DERIVED-ink treatment as axis/grid/tooltip* above (light keys off
1076
1082
  // `inkLight`; dark stays flat white like every other neutral chart-chrome pair here).
1077
- legendText: { light: inkRgba(inkLight, 0.82), dark: 'rgba(255,255,255,0.92)' },
1083
+ legendText: { light: inkRgba(inkLight, 0.82), dark: 'rgba(255, 255, 255, 0.92)' },
1078
1084
  // Base neutral for hairlines / muted / DIMMED text / overlays — apply opacity via alpha().
1079
1085
  // Binds to --mantine-color-dimmed, so it is THE dimmed-text lever. Equal to `INK.muted` (the
1080
1086
  // spec's "muted (secondary text)" row) — kept as its own pair for the pre-existing `--vx-neutral`
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The type ladder's own law — the thing that decides whether a requested size gets a rung.
3
+ *
4
+ * This exists because the ladder is hand-tuned rather than generated, which is exactly the shape
5
+ * that erodes: a consumer reports "no step for 20px", someone adds 20, and now 20 and 21 are the
6
+ * same size and the guard reports a violation with two equally-correct remedies. Pinning the ratio
7
+ * band makes the next request answerable with arithmetic instead of taste.
8
+ */
9
+ import { describe, expect, it } from 'bun:test'
10
+ import { VX } from './index'
11
+
12
+ /** Every adjacent pair of the shipped ladder, ascending. */
13
+ const STEPS = Object.values(VX.text)
14
+ const RATIOS = STEPS.slice(1).map((size, index) => size / STEPS[index]!)
15
+
16
+ /** The band documented on `TEXT` in `tokens/index.ts`. Widen it only with a reason written there. */
17
+ const MIN_RATIO = 1.06
18
+ const MAX_RATIO = 1.17
19
+
20
+ describe('the type ladder', () => {
21
+ it('is strictly ascending, so `Object.values` order IS the ladder order', () => {
22
+ expect(STEPS).toEqual(STEPS.toSorted((a, b) => a - b))
23
+ expect(new Set(STEPS).size).toBe(STEPS.length)
24
+ })
25
+
26
+ it('keeps every adjacent step inside the 1.06–1.17 band', () => {
27
+ const offenders = RATIOS.map((ratio, index) => ({
28
+ pair: `${STEPS[index]} → ${STEPS[index + 1]}`,
29
+ ratio: Number(ratio.toFixed(3)),
30
+ })).filter(({ ratio }) => ratio < MIN_RATIO || ratio > MAX_RATIO)
31
+
32
+ expect(offenders).toEqual([])
33
+ })
34
+
35
+ it('spans the two ends consumers reported as unreachable', () => {
36
+ // A label engraved inside a drawn object, and a numeral read at arm's length. Both arrived from
37
+ // the same consumer round; both are roles the ladder genuinely had no step for.
38
+ expect(VX.text.nano).toBe(10)
39
+ expect(VX.text.display).toBe(30)
40
+ })
41
+
42
+ it('has no rung between xl and h2 — 21/20 = 1.05 is below the band', () => {
43
+ // The third site from that report. `h2` is the remedy, not a waiver: it is 1px away.
44
+ expect(STEPS.filter((size) => size > VX.text.xl && size < VX.text.h2)).toEqual([])
45
+ expect(VX.text.h2 / 20).toBeLessThan(MIN_RATIO)
46
+ })
47
+
48
+ it('pins the iOS input floor, which is a rung with an external constraint', () => {
49
+ expect(VX.text.lg).toBe(16)
50
+ })
51
+ })
@@ -28,8 +28,8 @@
28
28
  * what a color parser must never do.
29
29
  */
30
30
  const NAMED_COLORS: Record<string, string> = {
31
- white: '#ffffff', // theme-allow — CSS keyword definition, not a design color
32
- black: '#000000', // theme-allow — CSS keyword definition, not a design color
31
+ white: '#ffffff', // theme-allow raw-hex — the CSS keyword's definition, not a design color
32
+ black: '#000000', // theme-allow raw-hex — the CSS keyword's definition, not a design color
33
33
  }
34
34
 
35
35
  function normalizeHex(hex: string): string {
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
+ })