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.
- package/AGENTS.md +19 -1
- package/CHANGELOG.md +39 -0
- package/MIGRATING.md +326 -0
- package/README.md +87 -9
- package/agent/rules/basalt-app.md +84 -0
- package/agent/rules/basalt-charts.md +32 -17
- package/agent/rules/basalt-content.md +4 -0
- package/agent/rules/basalt-mantine.md +25 -10
- package/agent/rules/basalt-router.md +79 -15
- package/agent/rules/basalt-state.md +21 -3
- package/agent/rules/basalt-tokens.md +132 -6
- package/agent/skills/basalt-app/SKILL.md +45 -6
- package/agent/skills/basalt-charts/SKILL.md +5 -3
- package/agent/templates/CLAUDE-block.md.tpl +11 -5
- package/configs/check.yml +11 -4
- package/configs/lefthook.yml +59 -7
- package/configs/oxlint-plugin.js +633 -30
- package/configs/oxlint-plugin.test.ts +594 -15
- package/configs/oxlint.json +10 -1
- package/dist/agent/stick-to-bottom.js +1 -1
- package/dist/agent-chat/thread-detail-panel.js +1 -1
- package/dist/agent-chat/thread-message.js +2 -2
- package/dist/agent-chat/tool-chip.js +1 -1
- 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/Donut.js +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/charts/primitives/ChartLegend.js +2 -2
- package/dist/charts/primitives/ChartTooltip.js +1 -1
- package/dist/cli/index.d.ts +146 -20
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +901 -101
- package/dist/cli/toolchain-wiring.test.d.ts +2 -0
- package/dist/cli/toolchain-wiring.test.d.ts.map +1 -0
- package/dist/cli/waiver-audit.test.d.ts +2 -0
- package/dist/cli/waiver-audit.test.d.ts.map +1 -0
- package/dist/dashboard/delta-badge.js +2 -2
- package/dist/dashboard/stat-card.js +1 -1
- package/dist/data/virtual-list.js +5 -5
- package/dist/guard/index.d.ts +101 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +474 -62
- package/dist/guard/types.d.ts +25 -5
- package/dist/guard/types.d.ts.map +1 -1
- package/dist/register.d.ts +25 -0
- package/dist/register.d.ts.map +1 -1
- 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/app-mobile-nav.module.css +11 -3
- 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/surfaces.d.ts +1 -1
- package/dist/surfaces.js +1 -1
- package/dist/tokens/index.d.ts +5 -3
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +7 -1
- package/dist/tokens/palette.d.ts +35 -27
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +24 -18
- 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 +21 -17
- package/dist/vite-color-mix.js +2 -2
- package/dist/vite.d.ts +24 -2
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +54 -13
- package/llms.txt +3 -3
- package/package.json +4 -3
- package/src/agent/stick-to-bottom.tsx +1 -1
- package/src/agent-chat/thread-detail-panel.tsx +1 -1
- package/src/agent-chat/thread-message.tsx +3 -3
- package/src/agent-chat/tool-chip.tsx +1 -1
- package/src/charts/kinds/Bars.tsx +1 -1
- package/src/charts/kinds/Donut.tsx +3 -2
- 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/charts/primitives/ChartLegend.tsx +2 -2
- package/src/charts/primitives/ChartTooltip.tsx +1 -1
- package/src/cli/index.ts +1614 -164
- package/src/cli/init-sync.test.ts +21 -3
- package/src/cli/template-ledger.test.ts +1 -1
- package/src/cli/tokens-css.test.ts +27 -14
- package/src/cli/toolchain-wiring.test.ts +856 -0
- package/src/cli/waiver-audit.test.ts +131 -0
- package/src/dashboard/delta-badge.tsx +3 -3
- package/src/dashboard/stat-card.tsx +1 -1
- package/src/data/virtual-list.tsx +8 -8
- package/src/guard/check-source.test.ts +1104 -12
- package/src/guard/index.ts +1106 -103
- package/src/guard/types.ts +29 -4
- package/src/register.ts +32 -0
- 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.module.css +11 -3
- 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/surfaces.ts +1 -1
- package/src/theme/density-relations.test.ts +8 -5
- package/src/theme/spacing.test.ts +2 -0
- package/src/theme-toggle/index.tsx +2 -2
- package/src/tokens/index.ts +28 -3
- package/src/tokens/palette.ts +24 -18
- package/src/tokens/text-scale.test.ts +51 -0
- package/src/vite-color-mix.ts +2 -2
- package/src/vite.test.ts +125 -6
- 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
|
|
270
|
-
// Measured under the 0.1/±3 law: worst case is
|
|
271
|
-
// 0.06/±5 law.
|
|
272
|
-
//
|
|
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(
|
|
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
|
|
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
|
|
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 }}
|
package/src/tokens/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
package/src/tokens/palette.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
+
})
|
package/src/vite-color-mix.ts
CHANGED
|
@@ -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
|
-
|
|
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' } },
|
|
@@ -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
|
+
})
|