basalt-ui 1.25.0 → 1.27.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 +8 -6
- package/CHANGELOG.md +26 -0
- package/MIGRATING.md +815 -23
- package/README.md +45 -32
- package/agent/rules/basalt-batteries.md +205 -0
- package/agent/rules/basalt-charts.md +125 -555
- package/agent/rules/basalt-controls.md +160 -0
- package/agent/rules/basalt-mantine.md +146 -439
- package/agent/rules/basalt-state.md +118 -96
- package/agent/rules/basalt-tokens.md +126 -288
- package/agent/skills/basalt-app/SKILL.md +70 -195
- package/agent/skills/basalt-charts/SKILL.md +54 -234
- package/agent/skills/basalt-design/SKILL.md +56 -248
- package/agent/templates/CLAUDE-block.md.tpl +31 -40
- package/agent/templates/DESIGN.md.tpl +22 -51
- package/configs/oxlint-plugin.js +1465 -36
- package/configs/oxlint-plugin.test.ts +1190 -11
- package/configs/oxlint.json +11 -3
- package/dist/charts/cursor/resolve.d.ts +18 -0
- package/dist/charts/cursor/resolve.d.ts.map +1 -1
- package/dist/charts/cursor/resolve.js +15 -6
- package/dist/charts/cursor/scope.d.ts +6 -3
- package/dist/charts/cursor/scope.d.ts.map +1 -1
- package/dist/charts/cursor/store.d.ts +5 -1
- package/dist/charts/cursor/store.d.ts.map +1 -1
- package/dist/charts/cursor/store.js +4 -4
- package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
- package/dist/charts/hooks/useChartCursor.js +9 -8
- package/dist/charts/index.d.ts +2 -2
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.d.ts +34 -9
- package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.js +29 -114
- package/dist/charts/primitives/ChartCard.test.d.ts +2 -0
- package/dist/charts/primitives/ChartCard.test.d.ts.map +1 -0
- package/dist/charts/sparklines/BarSparkline.d.ts +26 -2
- package/dist/charts/sparklines/BarSparkline.d.ts.map +1 -1
- package/dist/charts/sparklines/BarSparkline.js +18 -4
- package/dist/cli/check-coverage.test.d.ts +2 -0
- package/dist/cli/check-coverage.test.d.ts.map +1 -0
- package/dist/cli/index.d.ts +67 -4
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +225 -7
- package/dist/connectivity/connectivity-indicator.d.ts +4 -1
- package/dist/connectivity/connectivity-indicator.d.ts.map +1 -1
- package/dist/connectivity/connectivity-indicator.js +1 -1
- package/dist/content/index.d.ts +5 -3
- package/dist/content/index.d.ts.map +1 -1
- package/dist/content/index.js +4 -2
- package/dist/content/prose.module.css +7 -28
- package/dist/controls/actions.d.ts +169 -0
- package/dist/controls/actions.d.ts.map +1 -0
- package/dist/controls/actions.js +287 -0
- package/dist/controls/actions.module.css +62 -0
- package/dist/controls/actions.module.css.d.ts +13 -0
- package/dist/controls/actions.test.d.ts +2 -0
- package/dist/controls/actions.test.d.ts.map +1 -0
- package/dist/controls/actions.type-guard.test.d.ts +2 -0
- package/dist/controls/actions.type-guard.test.d.ts.map +1 -0
- package/dist/controls/compare-filter.d.ts +41 -0
- package/dist/controls/compare-filter.d.ts.map +1 -0
- package/dist/controls/compare-filter.js +23 -0
- package/dist/controls/control-group.d.ts +19 -0
- package/dist/controls/control-group.d.ts.map +1 -0
- package/dist/controls/control-group.js +20 -0
- package/dist/controls/control-group.test.d.ts +2 -0
- package/dist/controls/control-group.test.d.ts.map +1 -0
- package/dist/controls/controls.module.css +542 -0
- package/dist/controls/controls.module.css.d.ts +35 -0
- package/dist/controls/controls.router.test.d.ts +2 -0
- package/dist/controls/controls.router.test.d.ts.map +1 -0
- package/dist/controls/enum-filter.d.ts +39 -0
- package/dist/controls/enum-filter.d.ts.map +1 -0
- package/dist/controls/enum-filter.js +93 -0
- package/dist/controls/filter-context.d.ts +60 -0
- package/dist/controls/filter-context.d.ts.map +1 -0
- package/dist/controls/filter-context.js +69 -0
- package/dist/controls/filter-pill.d.ts +28 -0
- package/dist/controls/filter-pill.d.ts.map +1 -0
- package/dist/controls/filter-pill.js +70 -0
- package/dist/controls/filter-set.d.ts +13 -0
- package/dist/controls/filter-set.d.ts.map +1 -0
- package/dist/controls/filter-set.js +125 -0
- package/dist/controls/filter-set.test.d.ts +2 -0
- package/dist/controls/filter-set.test.d.ts.map +1 -0
- package/dist/controls/filter-sheet.d.ts +117 -0
- package/dist/controls/filter-sheet.d.ts.map +1 -0
- package/dist/controls/filter-sheet.js +146 -0
- package/dist/controls/glyphs.d.ts +27 -0
- package/dist/controls/glyphs.d.ts.map +1 -0
- package/dist/controls/glyphs.js +49 -0
- package/dist/controls/index.d.ts +20 -0
- package/dist/controls/index.d.ts.map +1 -0
- package/dist/controls/index.js +46 -0
- package/dist/controls/multi-select-filter.d.ts +23 -0
- package/dist/controls/multi-select-filter.d.ts.map +1 -0
- package/dist/controls/multi-select-filter.js +89 -0
- package/dist/controls/number-filter.d.ts +26 -0
- package/dist/controls/number-filter.d.ts.map +1 -0
- package/dist/controls/number-filter.js +143 -0
- package/dist/controls/peer-boundary.test.d.ts +2 -0
- package/dist/controls/peer-boundary.test.d.ts.map +1 -0
- package/dist/controls/props-contract.test.d.ts +2 -0
- package/dist/controls/props-contract.test.d.ts.map +1 -0
- package/dist/controls/range-filter.d.ts +43 -0
- package/dist/controls/range-filter.d.ts.map +1 -0
- package/dist/controls/range-filter.js +135 -0
- package/dist/controls/range-filter.type-guard.test.d.ts +2 -0
- package/dist/controls/range-filter.type-guard.test.d.ts.map +1 -0
- package/dist/controls/search-filter.d.ts +10 -0
- package/dist/controls/search-filter.d.ts.map +1 -0
- package/dist/controls/search-filter.js +81 -0
- package/dist/controls/select-filter.d.ts +74 -0
- package/dist/controls/select-filter.d.ts.map +1 -0
- package/dist/controls/select-filter.js +10 -0
- package/dist/controls/sync-button.d.ts +38 -0
- package/dist/controls/sync-button.d.ts.map +1 -0
- package/dist/controls/sync-button.js +119 -0
- package/dist/controls/sync-button.module.css +63 -0
- package/dist/controls/sync-button.module.css.d.ts +11 -0
- package/dist/controls/sync-button.test.d.ts +2 -0
- package/dist/controls/sync-button.test.d.ts.map +1 -0
- package/dist/controls/toggle-filter.d.ts +9 -0
- package/dist/controls/toggle-filter.d.ts.map +1 -0
- package/dist/controls/toggle-filter.js +42 -0
- package/dist/controls/view-tabs.d.ts +23 -0
- package/dist/controls/view-tabs.d.ts.map +1 -0
- package/dist/controls/view-tabs.js +59 -0
- package/dist/controls-dates/date-range-picker.d.ts +4 -0
- package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
- package/dist/controls-dates/date-range-picker.js +18 -0
- package/dist/controls-dates/index.d.ts +9 -0
- package/dist/controls-dates/index.d.ts.map +1 -0
- package/dist/controls-dates/index.js +4 -0
- package/dist/dashboard/delta-badge.d.ts +6 -12
- package/dist/dashboard/delta-badge.d.ts.map +1 -1
- package/dist/dashboard/delta-badge.js +1 -50
- package/dist/dashboard/index.d.ts +1 -1
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/index.js +3 -1
- package/dist/dashboard/settings-section.d.ts +6 -4
- package/dist/dashboard/settings-section.d.ts.map +1 -1
- package/dist/dashboard/settings-section.js +23 -16
- package/dist/dashboard/settings-section.module.css +10 -0
- package/dist/dashboard/settings-section.module.css.d.ts +1 -0
- package/dist/dashboard/settings-section.test.d.ts +2 -0
- package/dist/dashboard/settings-section.test.d.ts.map +1 -0
- package/dist/dashboard/stat-card.d.ts +95 -9
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +108 -49
- package/dist/dashboard/stat-card.module.css +153 -0
- package/dist/dashboard/stat-card.module.css.d.ts +15 -0
- package/dist/data/data-table.d.ts +37 -15
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +96 -58
- package/dist/guard/grace.test.d.ts +20 -0
- package/dist/guard/grace.test.d.ts.map +1 -0
- package/dist/guard/index.d.ts +66 -3
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +78 -7
- package/dist/guard/types.d.ts +3 -3
- package/dist/guard/types.d.ts.map +1 -1
- package/dist/index.d.ts +13 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -10
- package/dist/nav/types.d.ts +78 -2
- package/dist/nav/types.d.ts.map +1 -1
- package/dist/notifications/bell.d.ts +4 -1
- package/dist/notifications/bell.d.ts.map +1 -1
- package/dist/notifications/index.d.ts +1 -1
- package/dist/router-tanstack/field.d.ts +14 -0
- package/dist/router-tanstack/field.d.ts.map +1 -0
- package/dist/router-tanstack/field.js +4 -0
- package/dist/router-tanstack/index.d.ts +6 -2
- package/dist/router-tanstack/index.d.ts.map +1 -1
- package/dist/router-tanstack/index.js +4 -0
- package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
- package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/multi-search-param-store.js +19 -27
- package/dist/router-tanstack/search-param-store.d.ts +3 -87
- package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/search-param-store.js +16 -45
- package/dist/router-tanstack/search-store.d.ts +84 -0
- package/dist/router-tanstack/search-store.d.ts.map +1 -0
- package/dist/router-tanstack/search-store.js +271 -0
- package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
- package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
- package/dist/router-tanstack/search-store.test.d.ts +2 -0
- package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
- package/dist/router-tanstack/use-nav.d.ts +0 -2
- package/dist/router-tanstack/use-nav.d.ts.map +1 -1
- package/dist/router-tanstack/use-nav.js +34 -12
- package/dist/router-tanstack/use-nav.test.d.ts +2 -0
- package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
- package/dist/section/index.d.ts +2 -0
- package/dist/section/index.d.ts.map +1 -0
- package/dist/section/index.js +4 -0
- package/dist/section/section.d.ts +24 -0
- package/dist/section/section.d.ts.map +1 -0
- package/dist/section/section.js +126 -0
- package/dist/section/section.module.css +104 -0
- package/dist/section/section.module.css.d.ts +11 -0
- package/dist/section/section.test.d.ts +2 -0
- package/dist/section/section.test.d.ts.map +1 -0
- package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
- package/dist/shell/app-breadcrumbs.js +18 -15
- package/dist/shell/app-header.module.css +77 -37
- package/dist/shell/app-header.module.css.d.ts +2 -1
- package/dist/shell/app-mobile-nav.d.ts +9 -2
- package/dist/shell/app-mobile-nav.d.ts.map +1 -1
- package/dist/shell/app-mobile-nav.js +98 -11
- package/dist/shell/app-mobile-nav.module.css +8 -0
- package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
- package/dist/shell/app-sidebar.d.ts +55 -28
- package/dist/shell/app-sidebar.d.ts.map +1 -1
- package/dist/shell/app-sidebar.js +116 -67
- package/dist/shell/app-sidebar.module.css +303 -20
- package/dist/shell/app-sidebar.module.css.d.ts +19 -1
- package/dist/shell/index.d.ts +55 -21
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +40 -18
- package/dist/shell/mobile-nav-model.d.ts +13 -2
- package/dist/shell/mobile-nav-model.d.ts.map +1 -1
- package/dist/shell/mobile-nav-model.js +15 -1
- package/dist/shell/nav-count-badge.d.ts +1 -1
- package/dist/shell/page-bar.d.ts +51 -0
- package/dist/shell/page-bar.d.ts.map +1 -0
- package/dist/shell/page-bar.js +154 -0
- package/dist/shell/page-bar.module.css +178 -0
- package/dist/shell/page-bar.module.css.d.ts +17 -0
- package/dist/shell/page-bar.test.d.ts +2 -0
- package/dist/shell/page-bar.test.d.ts.map +1 -0
- package/dist/shell/sidebar-block-model.d.ts +48 -0
- package/dist/shell/sidebar-block-model.d.ts.map +1 -0
- package/dist/shell/sidebar-block-model.js +39 -0
- package/dist/shell/sidebar-blocks.d.ts +32 -0
- package/dist/shell/sidebar-blocks.d.ts.map +1 -0
- package/dist/shell/sidebar-blocks.js +169 -0
- package/dist/shell/sidebar-search.d.ts +13 -1
- package/dist/shell/sidebar-search.d.ts.map +1 -1
- package/dist/shell/sidebar-search.js +34 -1
- package/dist/shell/sidebar-search.module.css +26 -0
- package/dist/shell/sidebar-search.module.css.d.ts +2 -0
- package/dist/state/fields.d.ts +565 -0
- package/dist/state/fields.d.ts.map +1 -0
- package/dist/state/fields.js +405 -0
- package/dist/state/fields.test.d.ts +2 -0
- package/dist/state/fields.test.d.ts.map +1 -0
- package/dist/state/persisted.d.ts +60 -0
- package/dist/state/persisted.d.ts.map +1 -0
- package/dist/state/persisted.js +123 -0
- package/dist/state/persisted.test.d.ts +2 -0
- package/dist/state/persisted.test.d.ts.map +1 -0
- package/dist/state.d.ts +11 -56
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +14 -120
- package/dist/styles.css +13 -0
- package/dist/surfaces.d.ts +106 -24
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +156 -19
- package/dist/theme/controls.module.css +16 -1
- package/dist/theme/ctl-theme.d.ts +32 -0
- package/dist/theme/ctl-theme.d.ts.map +1 -0
- package/dist/theme/ctl-theme.js +54 -0
- package/dist/theme/ctl-theme.test.d.ts +2 -0
- package/dist/theme/ctl-theme.test.d.ts.map +1 -0
- package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
- package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
- package/dist/theme/divider-law.test.d.ts +2 -0
- package/dist/theme/divider-law.test.d.ts.map +1 -0
- package/dist/theme/icon-slot.d.ts +44 -0
- package/dist/theme/icon-slot.d.ts.map +1 -0
- package/dist/theme/icon-slot.js +21 -0
- package/dist/theme/icon-slot.module.css +47 -0
- package/dist/theme/icon-slot.module.css.d.ts +8 -0
- package/dist/theme/icon-slot.test.d.ts +2 -0
- package/dist/theme/icon-slot.test.d.ts.map +1 -0
- package/dist/theme/index.d.ts +1 -0
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +82 -5
- package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
- package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
- package/dist/theme/segmented-control.module.css +20 -0
- package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
- package/dist/tokens/index.d.ts +12 -0
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +38 -8
- package/dist/tokens/palette.d.ts +97 -61
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +76 -38
- package/dist/tokens.css +10 -2
- package/dist/widget-header/delta-badge.d.ts +13 -0
- package/dist/widget-header/delta-badge.d.ts.map +1 -0
- package/dist/widget-header/delta-badge.js +29 -0
- package/dist/widget-header/delta-badge.test.d.ts +2 -0
- package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
- package/dist/widget-header/index.d.ts +3 -0
- package/dist/widget-header/index.d.ts.map +1 -0
- package/dist/widget-header/index.js +6 -0
- package/dist/widget-header/no-mantine.test.d.ts +2 -0
- package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
- package/dist/widget-header/widget-header.d.ts +67 -0
- package/dist/widget-header/widget-header.d.ts.map +1 -0
- package/dist/widget-header/widget-header.js +89 -0
- package/dist/widget-header/widget-header.module.css +350 -0
- package/dist/widget-header/widget-header.module.css.d.ts +25 -0
- package/dist/widget-header/widget-header.test.d.ts +2 -0
- package/dist/widget-header/widget-header.test.d.ts.map +1 -0
- package/llms.txt +30 -17
- package/package.json +14 -1
- package/src/charts/cursor/resolve.test.ts +29 -1
- package/src/charts/cursor/resolve.ts +38 -6
- package/src/charts/cursor/scope.tsx +6 -3
- package/src/charts/cursor/store.test.ts +19 -1
- package/src/charts/cursor/store.ts +10 -5
- package/src/charts/hooks/useChartCursor.ts +16 -8
- package/src/charts/index.ts +2 -2
- package/src/charts/primitives/CartesianChart.test.tsx +124 -0
- package/src/charts/primitives/ChartCard.test.tsx +66 -0
- package/src/charts/primitives/ChartCard.tsx +86 -146
- package/src/charts/sparklines/BarSparkline.tsx +43 -5
- package/src/cli/check-coverage.test.ts +297 -0
- package/src/cli/check-theme.test.ts +38 -14
- package/src/cli/index.ts +392 -16
- package/src/cli/init-sync.test.ts +97 -0
- package/src/connectivity/connectivity-indicator.tsx +16 -9
- package/src/content/index.ts +16 -3
- package/src/content/prose.module.css +7 -28
- package/src/content/prose.module.css.test.ts +8 -24
- package/src/controls/actions.module.css +62 -0
- package/src/controls/actions.module.css.d.ts +13 -0
- package/src/controls/actions.test.tsx +252 -0
- package/src/controls/actions.tsx +655 -0
- package/src/controls/actions.type-guard.test.ts +63 -0
- package/src/controls/compare-filter.tsx +64 -0
- package/src/controls/control-group.test.tsx +229 -0
- package/src/controls/control-group.tsx +63 -0
- package/src/controls/controls.module.css +542 -0
- package/src/controls/controls.module.css.d.ts +35 -0
- package/src/controls/controls.router.test.tsx +1053 -0
- package/src/controls/enum-filter.tsx +170 -0
- package/src/controls/filter-context.tsx +158 -0
- package/src/controls/filter-pill.tsx +120 -0
- package/src/controls/filter-set.test.tsx +472 -0
- package/src/controls/filter-set.tsx +225 -0
- package/src/controls/filter-sheet.tsx +285 -0
- package/src/controls/glyphs.tsx +81 -0
- package/src/controls/index.ts +49 -0
- package/src/controls/multi-select-filter.tsx +140 -0
- package/src/controls/number-filter.tsx +291 -0
- package/src/controls/peer-boundary.test.ts +85 -0
- package/src/controls/props-contract.test.tsx +137 -0
- package/src/controls/range-filter.tsx +237 -0
- package/src/controls/range-filter.type-guard.test.tsx +122 -0
- package/src/controls/search-filter.tsx +123 -0
- package/src/controls/select-filter.tsx +89 -0
- package/src/controls/sync-button.module.css +63 -0
- package/src/controls/sync-button.module.css.d.ts +11 -0
- package/src/controls/sync-button.test.tsx +183 -0
- package/src/controls/sync-button.tsx +197 -0
- package/src/controls/toggle-filter.tsx +60 -0
- package/src/controls/view-tabs.tsx +110 -0
- package/src/controls-dates/date-range-picker.tsx +42 -0
- package/src/controls-dates/index.ts +8 -0
- package/src/dashboard/delta-badge.tsx +4 -74
- package/src/dashboard/index.ts +6 -1
- package/src/dashboard/settings-section.module.css +10 -0
- package/src/dashboard/settings-section.module.css.d.ts +1 -0
- package/src/dashboard/settings-section.test.tsx +56 -0
- package/src/dashboard/settings-section.tsx +33 -30
- package/src/dashboard/stat-card.module.css +153 -0
- package/src/dashboard/stat-card.module.css.d.ts +15 -0
- package/src/dashboard/stat-card.test.tsx +298 -1
- package/src/dashboard/stat-card.tsx +289 -67
- package/src/data/data-table.test.tsx +96 -11
- package/src/data/data-table.tsx +228 -84
- package/src/guard/check-source.test.ts +159 -13
- package/src/guard/grace.test.ts +158 -0
- package/src/guard/index.ts +185 -29
- package/src/guard/types.ts +4 -2
- package/src/index.ts +51 -5
- package/src/nav/types.ts +84 -2
- package/src/notifications/bell.tsx +6 -3
- package/src/notifications/index.ts +1 -1
- package/src/router-tanstack/field.ts +35 -0
- package/src/router-tanstack/index.ts +28 -2
- package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
- package/src/router-tanstack/multi-search-param-store.ts +46 -77
- package/src/router-tanstack/search-param-store.ts +42 -159
- package/src/router-tanstack/search-store.router.test.tsx +882 -0
- package/src/router-tanstack/search-store.test.ts +578 -0
- package/src/router-tanstack/search-store.ts +546 -0
- package/src/router-tanstack/use-nav.test.tsx +187 -0
- package/src/router-tanstack/use-nav.ts +72 -14
- package/src/section/index.ts +1 -0
- package/src/section/section.module.css +104 -0
- package/src/section/section.module.css.d.ts +11 -0
- package/src/section/section.test.tsx +204 -0
- package/src/section/section.tsx +220 -0
- package/src/shell/app-breadcrumbs.tsx +44 -31
- package/src/shell/app-header.module.css +77 -37
- package/src/shell/app-header.module.css.d.ts +2 -1
- package/src/shell/app-mobile-nav.module.css +8 -0
- package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
- package/src/shell/app-mobile-nav.tsx +147 -8
- package/src/shell/app-sidebar-account.tsx +2 -2
- package/src/shell/app-sidebar.module.css +303 -20
- package/src/shell/app-sidebar.module.css.d.ts +19 -1
- package/src/shell/app-sidebar.test.tsx +456 -41
- package/src/shell/app-sidebar.tsx +276 -140
- package/src/shell/index.test.tsx +291 -5
- package/src/shell/index.tsx +137 -38
- package/src/shell/mobile-nav-model.test.ts +228 -7
- package/src/shell/mobile-nav-model.ts +53 -3
- package/src/shell/nav-count-badge.tsx +1 -1
- package/src/shell/page-bar.module.css +178 -0
- package/src/shell/page-bar.module.css.d.ts +17 -0
- package/src/shell/page-bar.test.tsx +316 -0
- package/src/shell/page-bar.tsx +315 -0
- package/src/shell/sidebar-block-model.ts +83 -0
- package/src/shell/sidebar-blocks.tsx +301 -0
- package/src/shell/sidebar-search.module.css +26 -0
- package/src/shell/sidebar-search.module.css.d.ts +2 -0
- package/src/shell/sidebar-search.tsx +71 -2
- package/src/state/fields.test.tsx +512 -0
- package/src/state/fields.ts +1150 -0
- package/src/state/persisted.test.ts +91 -0
- package/src/state/persisted.ts +261 -0
- package/src/state.ts +52 -240
- package/src/styles.css +13 -0
- package/src/surfaces.ts +204 -36
- package/src/theme/controls.module.css +16 -1
- package/src/theme/ctl-theme.test.tsx +275 -0
- package/src/theme/ctl-theme.tsx +138 -0
- package/src/theme/ctl-tier-coverage.test.ts +170 -0
- package/src/theme/density-relations.test.ts +106 -51
- package/src/theme/divider-law.test.ts +259 -0
- package/src/theme/icon-slot.module.css +47 -0
- package/src/theme/icon-slot.module.css.d.ts +8 -0
- package/src/theme/icon-slot.test.tsx +275 -0
- package/src/theme/icon-slot.tsx +59 -0
- package/src/theme/index.ts +165 -3
- package/src/theme/layout-rhythm-css.test.ts +72 -0
- package/src/theme/radius.test.ts +11 -1
- package/src/theme/segmented-control.module.css +20 -0
- package/src/theme/shadow-surfaces.test.ts +20 -7
- package/src/theme/spacing.test.ts +75 -27
- package/src/theme/use-basalt-spacing.ts +2 -2
- package/src/tokens/density.test.ts +7 -25
- package/src/tokens/index.ts +48 -8
- package/src/tokens/palette.ts +107 -73
- package/src/widget-header/delta-badge.test.tsx +60 -0
- package/src/widget-header/delta-badge.tsx +57 -0
- package/src/widget-header/index.ts +2 -0
- package/src/widget-header/no-mantine.test.ts +33 -0
- package/src/widget-header/widget-header.module.css +350 -0
- package/src/widget-header/widget-header.module.css.d.ts +25 -0
- package/src/widget-header/widget-header.test.tsx +187 -0
- package/src/widget-header/widget-header.tsx +213 -0
- package/agent/rules/basalt-agent.md +0 -452
- package/agent/rules/basalt-app.md +0 -246
- package/agent/rules/basalt-commands.md +0 -320
- package/agent/rules/basalt-content.md +0 -409
- package/agent/rules/basalt-data.md +0 -329
- package/agent/rules/basalt-forms.md +0 -186
- package/agent/rules/basalt-notifications.md +0 -264
- package/agent/rules/basalt-query.md +0 -187
- package/agent/rules/basalt-router.md +0 -336
- package/dist/content/article-filter-bar.d.ts +0 -40
- package/dist/content/article-filter-bar.d.ts.map +0 -1
- package/dist/content/article-filter-bar.js +0 -56
- package/dist/shell/page-header.d.ts +0 -13
- package/dist/shell/page-header.d.ts.map +0 -1
- package/dist/shell/page-header.js +0 -23
- package/src/content/article-filter-bar.tsx +0 -88
- package/src/shell/page-header.tsx +0 -35
|
@@ -1,560 +1,130 @@
|
|
|
1
1
|
---
|
|
2
2
|
source: basalt-ui
|
|
3
|
-
description: visx chart
|
|
3
|
+
description: visx chart doctrine — compose CartesianChart (or ChartFrame for a non-single-plot shape), derive legends and tooltip rows from `series`, keep every color in `--vx-*`. Enforced by basalt/hand-rolled-plot, basalt/chart-legend-literal, basalt/chart-in-raw-surface and the two @visx boundary rules.
|
|
4
4
|
paths:
|
|
5
|
-
- '
|
|
6
|
-
- 'apps/**/charts/**'
|
|
7
|
-
- 'packages/**/charts/**'
|
|
5
|
+
- '**/charts/**'
|
|
8
6
|
---
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
## The
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
`
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
`
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
`
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
a
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
same machinery, different assembly, because their contract isn't a single plot rect with one or
|
|
134
|
-
two numeric y axes.
|
|
135
|
-
|
|
136
|
-
2. **ChartCard** wrapper — never a raw `<Card>`. Gives title + info-tooltip + extra slot, consistent
|
|
137
|
-
margin.
|
|
138
|
-
3. **`series` is the single source of truth.** Legend entries and tooltip rows are DERIVED from it
|
|
139
|
-
(`deriveLegend` / `deriveTooltipRows`) — a chart cannot show a row or a legend key it doesn't
|
|
140
|
-
draw. Never hand-author a legend or a tooltip row in parallel. Corollary for mark renderers: draw
|
|
141
|
-
`ctx.visible` (the `PlotContext` field), never the `series` prop directly — `visible` is `series`
|
|
142
|
-
minus whatever the legend has toggled off, so drawing `series` would repaint a mark the reader
|
|
143
|
-
just hid.
|
|
144
|
-
4. **`ChartTooltipFloat`** + `TooltipHeader` + `TooltipRow` + `TooltipBody` — never `@visx/tooltip`
|
|
145
|
-
directly. `CartesianChart` wires this internally for a single-plot chart; a non-cartesian shape
|
|
146
|
-
composes it directly (see #1). Portals to `document.body` (SSR-guarded: renders nothing when
|
|
147
|
-
`document` is undefined), so it can be authored anywhere in the tree, including inside an
|
|
148
|
-
`<svg>`. A plain `<div>` there mounts in the SVG namespace — accepting every prop,
|
|
149
|
-
throwing nothing, typechecking, passing lint — and never paint; one consumer shipped eight
|
|
150
|
-
authored tooltip rows no one had ever seen. The portal also un-breaks `position: fixed` under a
|
|
151
|
-
transformed ancestor.
|
|
152
|
-
5. **AxisLeftNumeric** / **AxisRightNumeric** + **AxisBottomDate** — never raw
|
|
153
|
-
`<AxisLeft>`/`<AxisBottom>`/`<AxisRight>` (they miss theme tokens + smart ticks). Enforced by
|
|
154
|
-
`basalt-ui check-theme` (`raw-visx-axis` guard fails the build on a raw axis in a `/charts/`
|
|
155
|
-
file; escape via `theme-allow`), not just convention. `AxisBottomDate` takes an optional
|
|
156
|
-
`tickFormat` (`TickFormatter<string>`, defaults to `fmtAxisDate` — `DD.MM`) — the only supported
|
|
157
|
-
exit for a sub-day window, where the default collapses every tick to the same label and a raw
|
|
158
|
-
`<AxisBottom>` isn't an option. `CartesianChart`'s own `formatX` prop is the higher-level way to
|
|
159
|
-
set this without touching the axis component at all — and every cartesian kind (`Bars`/
|
|
160
|
-
`MultiLine`/`StackedArea`/`ZonedLine`/`DualPanel`) forwards its own `formatX?: (key) => string`
|
|
161
|
-
now too, not just `CartesianChart` itself. Without it, the only route to a custom x label was
|
|
162
|
-
pre-formatting it into the domain key, which makes one string serve as display value, scale
|
|
163
|
-
identity, AND cursor key simultaneously — and a truncating formatter then collapses two points
|
|
164
|
-
onto one domain value, silently dropping one from the plot (see #6 below for why that's the
|
|
165
|
-
cursor's problem too). `Heatmap` is deliberately excluded: its existing `colLabel`/`rowLabel`
|
|
166
|
-
already are that seam, and a second prop over one concern would fork them.
|
|
167
|
-
**Which x ticks get painted is a separate seam**, `xTickValues?: (keys, xMax) => readonly
|
|
168
|
-
string[]`, on `CartesianChart` and forwarded by `Bars`/`MultiLine`/`StackedArea`/`ZonedLine`
|
|
169
|
-
plus both band kinds. Resolution order is explicit VALUES → explicit COUNT (`xTicks`, unchanged)
|
|
170
|
-
→ as many as fit (`smartTicks`); omit both and nothing moves. Reach for it on a dense time axis,
|
|
171
|
-
because **a count cannot express one**: `smartTicks`/`smartTicksEvery` append the final key
|
|
172
|
-
unconditionally, so any count that does not land exactly on the last index paints two labels on
|
|
173
|
-
top of each other at the right edge — at every count, not at an unlucky one.
|
|
174
|
-
**The x axis is CATEGORICAL, and this is not inferable from the API.** `CartesianChart` builds
|
|
175
|
-
its x scale as `scalePoint<string>`, so N points are N evenly spaced positions whatever the
|
|
176
|
-
values behind the keys — there is no linear or time x scale. Correct and invisible for a domain
|
|
177
|
-
that is already a regular grid (five-minute buckets ARE evenly spaced); wrong for an event-shaped
|
|
178
|
-
series (speed-test runs, deploys, sessions). Two silent consequences: on a page sharing one
|
|
179
|
-
cursor, event points draw at equal spacing, so the crosshair marks the right point at a different
|
|
180
|
-
screen x than a regularly-sampled sibling — the correlation stays legible in the numbers and not
|
|
181
|
-
in the geometry; and because the domain value must be unique, two events at the same instant
|
|
182
|
-
collapse onto one position and one stops being drawn. `getX` returning a date string reads like a
|
|
183
|
-
time axis and is not one. Tracked as issue #52; a chart whose x is a measured quantity currently
|
|
184
|
-
needs a `basalt/hand-rolled-plot` file declaration.
|
|
185
|
-
|
|
186
|
-
6. **The cursor is shared by default.** No provider needed — `useChartCursor` reads a module-level
|
|
187
|
-
external store (`useSyncExternalStore`), so every `CartesianChart`/`ChartFrame`-composed chart on
|
|
188
|
-
the page shares one cursor out of the box. `ChartCursorScope` **isolates** a subtree onto a
|
|
189
|
-
private store instead: it opts a subtree OUT of sharing, never into it. Reach for it only when a
|
|
190
|
-
group must not follow the rest of the page.
|
|
191
|
-
|
|
192
|
-
```tsx
|
|
193
|
-
<ChartCursorScope>
|
|
194
|
-
<ChartCard>…</ChartCard>
|
|
195
|
-
<ChartCard>…</ChartCard>
|
|
196
|
-
</ChartCursorScope>
|
|
197
|
-
```
|
|
198
|
-
|
|
199
|
-
**Resolution is domain-aware, not string-equal**, which is what makes sharing safe as a default.
|
|
200
|
-
Exact-string matching would desync any chart that folds or downsamples its own domain: it stops
|
|
201
|
-
owning most of the keys its siblings broadcast, so the shared crosshair lands on some hovers and
|
|
202
|
-
not others, with no rule a reader can infer — worse than no shared cursor at all.
|
|
203
|
-
`useChartCursor` resolves a broadcast key against a chart's own points by, in order: exact
|
|
204
|
-
match → nearest parsed-numeric/date match within the chart's own step → `null`. A chart that folds its calendar
|
|
205
|
-
into weekly buckets still tracks a hover from a sibling plotting daily points, with no extra
|
|
206
|
-
wiring — `resolveKey` no longer exists because there is nothing left for it to patch.
|
|
207
|
-
|
|
208
|
-
`CursorResolution = 'nearest' | 'leading'` (exported from `basalt-ui/charts`) picks WHICH own
|
|
209
|
-
point wins that resolution, reachable as `cursorResolution` on `CartesianChart`/every cartesian
|
|
210
|
-
kind/`DualPanel` and as `useChartCursor`'s `resolution` option — default `'nearest'`, unchanged.
|
|
211
|
-
Reach for `'leading'` when `getX` returns a bucket's LEADING EDGE (a weekly series keyed by its
|
|
212
|
-
Monday, a monthly series keyed by its 1st): under `'nearest'`, a hover landing in the back half
|
|
213
|
-
of a bucket resolves to the FOLLOWING bucket instead of the one it's actually inside, so the
|
|
214
|
-
shared crosshair sits one column right of the data being pointed at — reproducibly, for every
|
|
215
|
-
back-half hover.
|
|
216
|
-
|
|
217
|
-
7. **Keyboard-operable by construction.** The hover overlay `CartesianChart` renders is focusable;
|
|
218
|
-
`←`/`→` scrub the shared cursor one point at a time, `Escape` clears it. The tooltip is
|
|
219
|
-
`aria-live="polite"`. This comes for free from composing `CartesianChart`/`ChartFrame` — nothing
|
|
220
|
-
to wire per chart. That's exactly why `ChartFrame`'s outer container uses `role="group"` on its
|
|
221
|
-
`ariaLabel`, **never** `role="img"`: per the ARIA spec every descendant of a `role="img"`
|
|
222
|
-
element is presentational, which would erase the hover overlay's `role="slider"` from the
|
|
223
|
-
accessibility tree entirely — the label still announces, but the keyboard-scrubbable control
|
|
224
|
-
underneath it becomes unreachable, silently, with no error anywhere. jsdom does not implement
|
|
225
|
-
that pruning, so no `getByRole` test can ever catch this regression; it is a structural rule, not
|
|
226
|
-
a test-covered one. Do not "simplify" `ChartFrame`'s container role back to `img` — it looks like
|
|
227
|
-
a no-op refactor and it is not.
|
|
228
|
-
8. **Theme-aware colors** via `VX.*` tokens + `alpha()`. **Never** a raw hex literal in a chart file.
|
|
229
|
-
**Never** `localStorage.getItem('theme')` — the scheme resolves via CSS vars (see Dark/light
|
|
230
|
-
below).
|
|
231
|
-
|
|
232
|
-
**Exemption:** sparklines (`LineSparkline`, `BarSparkline` — tiny inline charts with no
|
|
233
|
-
legend/tooltip) don't have to compose ChartCard/`CartesianChart`'s legend/tooltip — but still use
|
|
234
|
-
`VX.*` tokens.
|
|
235
|
-
|
|
236
|
-
## Legend interaction
|
|
237
|
-
|
|
238
|
-
At ≥2 entries, the derived legend is a toggle by default: clicking an entry hides that series from
|
|
239
|
-
the plot, the tooltip, AND the auto y-domain, together — a stacked chart's axis actually shrinks
|
|
240
|
-
when a band is hidden, it doesn't just leave a dead gap. `legend={{ toggle: false }}` opts a chart
|
|
241
|
-
out. A single-entry legend never toggles (hiding the only series a chart draws is never useful).
|
|
242
|
-
This is exactly why marks must draw `ctx.visible` and never `series` (see "Every chart has" #3) —
|
|
243
|
-
`visible` is what actually reflects a hidden key; the raw `series` prop never changes.
|
|
244
|
-
|
|
245
|
-
## Tooltip config
|
|
246
|
-
|
|
247
|
-
`CartesianChart`'s `tooltip` prop (`CartesianTooltipConfig<T>`, or `false` to disable the tooltip
|
|
248
|
-
and its crosshair dots entirely):
|
|
249
|
-
|
|
250
|
-
- `label?: (d: T) => { text; color } | null` — a right-aligned badge in the header (a status label,
|
|
251
|
-
a zone name).
|
|
252
|
-
- `prependRows?: (d: T, ctx: { visible; hidden }) => ReactNode` — rows rendered BEFORE the derived
|
|
253
|
-
per-series rows (a total, a context line). `ctx` is the same `visible`/`hidden` the plot itself
|
|
254
|
-
draws from, so a hand-authored row tracks legend toggling instead of desyncing from it.
|
|
255
|
-
- `extraRows?: (d: T, ctx: { visible; hidden }) => ReactNode` — rows appended after the derived
|
|
256
|
-
rows. Same `ctx` as `prependRows`.
|
|
257
|
-
- `follow?: boolean` — default `true`, the tooltip tracks the pointer. `follow: false` anchors it to
|
|
258
|
-
the crosshair at the plot's top edge instead, which reads better across a column of charts sharing
|
|
259
|
-
one cursor (every tooltip lines up on the same x). Anchoring costs one `getBoundingClientRect` per
|
|
260
|
-
hovered frame, which is why following is the default.
|
|
261
|
-
- `formatHeader?: (key, d) => string` — overrides the tooltip header's date text (also
|
|
262
|
-
`TooltipHeader`'s own `format` prop directly, and the identical seam on `DualPanel`'s
|
|
263
|
-
`formatHeader`, so the two never diverge). Default: today's `fmtTooltipDate` behavior, unchanged.
|
|
264
|
-
The seam exists because `fmtTooltipDate` regexes `YYYY-MM-DD` out of the domain key and builds a
|
|
265
|
-
LOCAL `Date` from it — a UTC ISO key then names a different day than `formatX`, the tooltip badge,
|
|
266
|
-
and every sibling chart, all of which resolve locally; the only prior workaround was carrying a
|
|
267
|
-
local-offset ISO key. Receives the raw `getX` key alongside the hovered datum.
|
|
268
|
-
|
|
269
|
-
Per-series, `SeriesStyle.tooltip: false` means "draw the mark and legend it, but never give it a
|
|
270
|
-
tooltip row" — it lives on the series, not on the kind. `ChartSeries.formatValue` is
|
|
271
|
-
`(v: number, d: T) => string` — a row can cite the hovered datum, not just the plotted number (e.g.
|
|
272
|
-
`97.5 kg (92.5 × 3)`). `Bars` has its own per-key version of the opt-out: `BarsBar.tooltip: false` /
|
|
273
|
-
`BarsLine.tooltip: false` draws and legends a bar series without ever listing it as a tooltip row.
|
|
274
|
-
|
|
275
|
-
A **stacked** chart needs `cursorValue` too: `CartesianChart`'s crosshair dot defaults to
|
|
276
|
-
`series.getValue(point)`, which is correct for an unstacked line but puts a stacked band's dot at
|
|
277
|
-
its own raw value — somewhere inside the fill, not on the edge the reader is tracking. Pass
|
|
278
|
-
`cursorValue={(point, series, visible) => …}` to place it at the cumulative band top instead;
|
|
279
|
-
`StackedArea` is the reference implementation (`kinds/StackedArea.tsx`).
|
|
280
|
-
|
|
281
|
-
## Pending state (`isPending`)
|
|
282
|
-
|
|
283
|
-
"Nothing to draw" is three states, not two: **measured-and-empty**, **measured-and-absent** (a
|
|
284
|
-
genuine coverage gap), and **not-asked-yet** (the query hasn't resolved). The `data ?? []` idiom
|
|
285
|
-
collapses the third into the second — an in-flight query renders as a fully-hatched "not measured"
|
|
286
|
-
window, a positive claim the series was watched and carried nothing. Pass `isPending` instead of
|
|
287
|
-
faking an empty array:
|
|
288
|
-
|
|
289
|
-
```tsx
|
|
290
|
-
import { Bars } from 'basalt-ui/charts'
|
|
291
|
-
;<Bars
|
|
292
|
-
data={data ?? []}
|
|
293
|
-
isPending={query.isPending}
|
|
294
|
-
chartId="load"
|
|
295
|
-
getX={(d) => d.date}
|
|
296
|
-
getValue={(d, key) => d[key]}
|
|
297
|
-
positiveBars={[{ key: 'load', label: 'Load', color: VX.accent }]}
|
|
298
|
-
/>
|
|
299
|
-
```
|
|
300
|
-
|
|
301
|
-
All seven kinds accept `isPending` and forward it to `ChartFrame` — `ZonedLine`/`Bars`/
|
|
302
|
-
`StackedArea`/`MultiLine` forward it through `CartesianChart`, which forwards it on to `ChartFrame`
|
|
303
|
-
in turn; `DualPanel`/`Heatmap`/`Donut` compose `ChartFrame` directly and forward it there. Every
|
|
304
|
-
path lands on the same `ChartFrame`, so there's one pending renderer, not seven. `ChartFrame` with
|
|
305
|
-
`isPending` renders `ChartPending` over the plot rect in place of `children`, suppresses the legend
|
|
306
|
-
entirely (a legend naming a "not measured" series with nothing to point at is its own small lie),
|
|
307
|
-
and sets `aria-busy="true"` on the outer container.
|
|
308
|
-
|
|
309
|
-
`ChartPending` reserves the plot's exact footprint and draws **nothing** that could be mistaken for
|
|
310
|
-
a measurement — no axes, no gridlines, no hatching, no marks, no animation (the motion doctrine
|
|
311
|
-
bans idle pulsing) — just a faint, static, centered label (`ChartPendingProps.label`, default
|
|
312
|
-
`'Loading…'`).
|
|
313
|
-
|
|
314
|
-
`ChartCenter` (also exported from `./charts`) is the centering primitive `ChartPending` is built
|
|
315
|
-
on — it exists only because chart files can't import Mantine's `Center`/`Flex`. Reach for it
|
|
316
|
-
directly in a bespoke chart file the same way; it's deliberately minimal (`width`, `height`,
|
|
317
|
-
`children`, nothing else), not a general layout system.
|
|
318
|
-
|
|
319
|
-
## Kinds — the recurring shapes
|
|
320
|
-
|
|
321
|
-
basalt-ui ships these kinds (declarative props, generic over your point type via `getX` and
|
|
322
|
-
per-series `getValue` accessors on `ChartSeries<T>`):
|
|
323
|
-
|
|
324
|
-
- **`ZonedLine`** — a single-series line with zone bands, thresholds, x-zones, and reference lines.
|
|
325
|
-
Composes `CartesianChart`.
|
|
326
|
-
- **`Bars`** — 1+ stacked positive/negative bar series, optional line overlays, zones, ref lines,
|
|
327
|
-
dual-axis config. Composes `CartesianChart`.
|
|
328
|
-
- **`StackedArea`** — opaque stacked bands, auto cumulative-top y-domain. Composes `CartesianChart`.
|
|
329
|
-
- **`MultiLine`** — N series on a shared y-axis (or two, via `y2`): legend-hover dimming, per-series
|
|
330
|
-
synced dots, dashed companion (MA) lines, per-point markers (PR stars / status dots), zones +
|
|
331
|
-
refLines, fixed or auto domain (covers z-score/σ via a fixed symmetric domain + zero refLine).
|
|
332
|
-
Composes `CartesianChart`. `ChartSeries.getMarker` returns `{ color?, r?, fillOpacity?, ring? }`
|
|
333
|
-
— `ring` defaults `true` (today's punched-out stroke, unchanged), `ring: false` omits the stroke
|
|
334
|
-
entirely, `fillOpacity` defaults `1`. Existed because a consumer's plain `fillOpacity: 0.7` dots
|
|
335
|
-
were unreproducible after moving their chart onto the kind — the old shape had no seam for either.
|
|
336
|
-
- **`DualPanel`** — top line-pane + bottom signed-histogram pane sharing one x-scale and one cursor;
|
|
337
|
-
optional fill-between two top lines, zones, refLines, per-point markers on the top pane
|
|
338
|
-
(`ChartSeries.getMarker`). Composes `ChartFrame` directly (two panes, not one plot rect). The
|
|
339
|
-
bottom pane's tooltip row is `formatBar` (separate from `formatBottom`'s tick labels); its domain
|
|
340
|
-
is configurable via `bottomYDomain`/`bottomMaxAbsFloor`.
|
|
341
|
-
- **`Heatmap`** — category×category intensity grid (`color-mix` alpha), per-cell tooltip, optional
|
|
342
|
-
gradient legend strip. Composes `ChartFrame` directly; self-measures, no separate responsive
|
|
343
|
-
wrapper.
|
|
344
|
-
- **`Donut`** — proportional donut with an optional center-content overlay. Composes `ChartFrame`
|
|
345
|
-
directly (radial, not cartesian).
|
|
346
|
-
- **`BandStrip`** — 1-D categorical bands over a shared x axis, **no y dimension at all**: one rect
|
|
347
|
-
per slot, `getBand(d) => { state, fill?, absentFraction?, marker? }`. `series` IS the state set —
|
|
348
|
-
it drives the legend, each band's fill and the one derived tooltip row, so a strip cannot name a
|
|
349
|
-
state it does not draw. `absentFraction` paints the share of a folded slot nothing measured as a
|
|
350
|
-
hatch; `marker` carries a fact that must not be readable off the `fill` ramp. `cursorResolution`
|
|
351
|
-
defaults `'leading'` here (a band IS a bucket), not `'nearest'`. Composes `ChartFrame` directly —
|
|
352
|
-
`CartesianChart` renders `AxisLeftNumeric` unconditionally, so this shape could never use it.
|
|
353
|
-
`BandStripSeries.formatValue` is `(d) => string | null` — `null` renders an em dash, an absent
|
|
354
|
-
READING, which `''` cannot say (`''` is a state whose label is the whole row).
|
|
355
|
-
- **`MirroredBars`** — two BAR panes over one x scale, mirrored around one baseline, each pane in
|
|
356
|
-
its own domain. `up`/`down` take `{ key, max?, autoMaxFloor?, ticks?, format }`; `upFraction`
|
|
357
|
-
(default 0.35) is the up pane's share of the band height, which is where the shared baseline sits;
|
|
358
|
-
`getAbsentFraction` hatches an unmeasured slot and `getBarOpacity` dims one. A sibling of
|
|
359
|
-
`DualPanel`, not an extension: `DualPanel`'s top pane is a LINE pane and its bottom takes one
|
|
360
|
-
SIGNED `getBar` over a symmetric domain. Two independent magnitudes are not one signed quantity.
|
|
361
|
-
Composes `ChartFrame` directly.
|
|
362
|
-
|
|
363
|
-
Both band kinds fold their domain by width (`fold: { minBandPx?, merge }`), and `foldBands` is
|
|
364
|
-
exported so a consumer can test their merge against the grouping that will actually run.
|
|
365
|
-
`HatchPattern` / `hatchFill` / `hatchSizeFor` ship the absence fill.
|
|
366
|
-
|
|
367
|
-
**`merge` does not run on a sub-cap dataset.** `foldBands` returns `[...data]` unchanged when
|
|
368
|
-
`data.length <= cap`, which on a wide viewport is the common case — so any bookkeeping the merge
|
|
369
|
-
adds (how many source buckets a slot folded, how many were unmeasured) is absent exactly where you
|
|
370
|
-
are least likely to look for it. Put that bookkeeping on the datum at CONSTRUCTION and have `merge`
|
|
371
|
-
sum it; do not compute it from `group.length` inside the merge. Summing is also the composable
|
|
372
|
-
form, and it keeps "every source bucket accounted for exactly once" true by construction.
|
|
373
|
-
|
|
374
|
-
**A band state naming no `series` entry throws in dev.** `series` IS the state set, so an
|
|
375
|
-
unresolvable `state` has no legend entry, no colour and no tooltip row — and the old behaviour,
|
|
376
|
-
skipping the band, drew a coverage GAP, which on a measured/not-measured strip is a false claim
|
|
377
|
-
about the data rather than an "unknown". Where the key comes from decides the treatment:
|
|
378
|
-
|
|
379
|
-
| Key comes off | Dev | Production |
|
|
380
|
-
| ------------------------------------------------------------- | ---------------------------------------- | ---------------------------------------------------------------------- |
|
|
381
|
-
| the DATUM — `BandSpan.state`, `marker.state` | throws, naming the key and the valid set | dashed neutral outline band + an `Unknown state` / `<key>` tooltip row |
|
|
382
|
-
| a PROP — `absentState`, `MirroredBars`' `up.key` / `down.key` | throws | throws |
|
|
383
|
-
|
|
384
|
-
A feed that grows a state basalt has never seen must degrade rather than take a dashboard down; a
|
|
385
|
-
typo, which is the same input, still fails loudly wherever it is written. The dashed neutral outline
|
|
386
|
-
is used by no legend entry and no state fill, so it can never be mistaken for data. Pane keys are
|
|
387
|
-
props, never data-driven — an unresolvable `up.key` used to hide the pane AND its axis, which reads
|
|
388
|
-
as "that half measured zero".
|
|
389
|
-
|
|
390
|
-
`ZonedLine` and `MultiLine` also accept `xZones?: XZoneSpec[]` — the vertical counterpart to a
|
|
391
|
-
kind's horizontal zone bands, for marking a time window rather than a value range. Each
|
|
392
|
-
`{ from?, to?, fill, align? }` bound is a `getX` **domain key** (the label string the kind's
|
|
393
|
-
`scalePoint<string>` runs over), not a date or timestamp. An omitted bound is the plot edge; a key
|
|
394
|
-
absent from the scale's domain skips that band entirely rather than clamping to an edge. `align`
|
|
395
|
-
defaults `'center'` (a present bound resolves to the point's own center, today's behaviour);
|
|
396
|
-
`align: 'edge'` widens by half a step at each present bound instead — a two-key band covers both
|
|
397
|
-
terminal slots in full, and `from === to` renders one step wide instead of being skipped as
|
|
398
|
-
degenerate. An edge-aligned bound is clamped into the plot range at the first/last sample.
|
|
399
|
-
|
|
400
|
-
How to add a chart:
|
|
401
|
-
|
|
402
|
-
1. **Fits an existing kind?** Use it. Pass `data`, `chartId`, `getX`, `series` (or
|
|
403
|
-
`positiveBars`/`getValue` for `Bars`), an `AxisConfig` per axis (`y`/`y2`), and the declarative
|
|
404
|
-
zones/thresholds/refLines arrays.
|
|
405
|
-
2. **Second instance of a new recurring shape?** Extract a kind and migrate both call sites
|
|
406
|
-
(Rule of Three: don't extract on the first, don't wait past the third). Bespoke escape hatches
|
|
407
|
-
(`tooltip.extraRows`, etc.) are fine but must not grow into god-object configs.
|
|
408
|
-
3. **Genuinely unique (e.g. a dual-panel MACD, or a dual-axis line pair no kind's config surface
|
|
409
|
-
covers)?** Stay bespoke — compose `CartesianChart` (single-plot) or `ChartFrame` (multi-pane/
|
|
410
|
-
non-cartesian) directly, drawing only marks in the `children` render prop. Keep it in the page's
|
|
411
|
-
chart file, not in a shared kind. See `apps/playground/src/demo/ChartsPage.tsx`'s
|
|
412
|
-
`SessionsRevenueChart` (a dual-axis line pair via `CartesianChart` + `y`/`y2`) and
|
|
413
|
-
`ChannelVolumeChart` (a role-grouped, `maxRows`-capped legend with a threshold-as-legend-entry
|
|
414
|
-
trick) for two real bespoke compositions.
|
|
415
|
-
|
|
416
|
-
**Anti-pattern:** a single `<Chart type="..." config={...} />` that switches by kind. Prefer N small
|
|
417
|
-
kinds.
|
|
418
|
-
|
|
419
|
-
## Axis config (`AxisConfig<T>`)
|
|
420
|
-
|
|
421
|
-
One object per axis on `CartesianChart` and every kind that composes it — this collapses the
|
|
422
|
-
`yDomain` / `yAutoMaxFloor` / `yAutoMinCeil` / `yAutoPad` / `numTicksY` /
|
|
423
|
-
`formatYTick` prop soup:
|
|
424
|
-
|
|
425
|
-
```tsx
|
|
426
|
-
y={{
|
|
427
|
-
domain: 'auto', // 'auto' (default) | [min, max] | (data, visible) => [min, max]
|
|
428
|
-
autoMaxFloor: 100, // when 'auto': raw upper bound is at least this, clamped BEFORE padding
|
|
429
|
-
autoMinCeil: 0, // when 'auto': lower bound is at most this (Infinity = pad from raw min)
|
|
430
|
-
autoPad: 1.1, // when 'auto': padding multiplier away from the data
|
|
431
|
-
ticks: 5,
|
|
432
|
-
format: (v) => `${v}%`,
|
|
433
|
-
grid: true, // horizontal grid rules; default on for `y`, off for `y2`
|
|
434
|
-
nice: false, // default false — opt in to d3's scale.nice() domain rounding
|
|
435
|
-
}}
|
|
436
|
-
```
|
|
437
|
-
|
|
438
|
-
**Behavior change (2026-08-19) — the one item in this doc that can move an existing chart's
|
|
439
|
-
rendering:** `autoMaxFloor` now clamps the raw upper bound BEFORE padding, mirroring `autoMinCeil`,
|
|
440
|
-
which has always clamped first and padded second — two different laws used to live in one function.
|
|
441
|
-
The old order padded the raw max, then applied the floor last, so when the floor won it landed
|
|
442
|
-
exactly on the axis top with zero headroom: a target line sitting at precisely the floor value was
|
|
443
|
-
glued to the plot edge. Measured case: dataMax 3.2, pad 1.1, floor 6 → axis top was 6.0, is now
|
|
444
|
-
6.6. A consumer relying on the old ordering will see their axis top move — lower the floor or pin
|
|
445
|
-
`domain` explicitly to opt back out.
|
|
446
|
-
|
|
447
|
-
`nice` defaults `false` deliberately: flipping the default would move the domain of every
|
|
448
|
-
already-migrated chart, so it's opt-in per axis.
|
|
449
|
-
|
|
450
|
-
**Passing `y2` is what makes a chart dual-axis** — it draws the right axis and widens the right
|
|
451
|
-
margin by measurement; nothing else flips it on. A series opts into that axis with `axis: 'right'`
|
|
452
|
-
on its `ChartSeries` entry (`SeriesStyle.axis`, default `'left'`).
|
|
453
|
-
|
|
454
|
-
`domain` as a function is the seam a stacked chart uses: it receives `(data, visible)`, so a
|
|
455
|
-
domain summed from the VISIBLE series shrinks correctly when the legend toggles a band off
|
|
456
|
-
(`StackedArea`'s `yConfig` is the reference — summing `props.series` there instead would leave a
|
|
457
|
-
permanent gap above the stack once a band is hidden).
|
|
458
|
-
|
|
459
|
-
## Series color
|
|
460
|
-
|
|
461
|
-
App-specific series colors are _your_ domain data, not framework data. Declare them once:
|
|
462
|
-
|
|
463
|
-
```ts
|
|
464
|
-
import { defineSeries, groupTokens } from 'basalt-ui/tokens'
|
|
465
|
-
|
|
466
|
-
const SERIES = defineSeries({
|
|
467
|
-
load: { light: '#…', dark: '#…' },
|
|
468
|
-
recovery: { light: '#…', dark: '#…' },
|
|
469
|
-
})
|
|
470
|
-
const tokens = groupTokens('series', SERIES) // → { load: 'var(--vx-series-load)', ... }
|
|
471
|
-
```
|
|
472
|
-
|
|
473
|
-
Emit the CSS via `buildPaletteCss({ groups: { 'series-': SERIES } })`. A hue keeps its identity but
|
|
474
|
-
shifts shade across schemes — that's why each entry is a `{ light, dark }` pair, not a single value.
|
|
475
|
-
**Adding a new color** means adding a pair to your series map and rebuilding the palette CSS — never
|
|
476
|
-
inline a hex in a chart.
|
|
477
|
-
|
|
478
|
-
A series that's invisible in the plot (flat at the domain floor, filtered to zero) can still carry
|
|
479
|
-
an explanation: `SeriesStyle.note` (and so `LegendEntry.note`, via `deriveLegend`) renders a short
|
|
480
|
-
muted qualifier after the legend label — e.g. _"Low cloud — 0% all night"_. Keep it to a clause.
|
|
481
|
-
|
|
482
|
-
`SeriesStyle.strokeOpacity` dims the plotted stroke (e.g. a faint moving-average companion) — the
|
|
483
|
-
legend swatch honors it too, parity with `fillOpacity`. The tooltip-row swatch and the crosshair
|
|
484
|
-
dot deliberately do NOT honor it: a sub-1 opacity on a 12px value-readout chip reads as a rendering
|
|
485
|
-
bug, not as data.
|
|
486
|
-
|
|
487
|
-
## Dark/light mode
|
|
488
|
-
|
|
489
|
-
Theme reactivity is **pure CSS**: the `--vx-*` variables are redeclared under the light/dark color
|
|
490
|
-
scheme, so toggling the scheme restyles every chart with no React re-render. Charts read `VX.*` (var
|
|
491
|
-
refs) directly. Don't branch on color scheme in JS; never read `localStorage.getItem('theme')`.
|
|
492
|
-
|
|
493
|
-
## Area gradients
|
|
494
|
-
|
|
495
|
-
Use the `AreaGradient` primitive (a vertical `<linearGradient>` of `color-mix` stops over a `--vx-*`
|
|
496
|
-
color) with the global strength knobs (`--vx-area-top` / `--vx-area-bottom`). Default the fill **on**
|
|
497
|
-
for plain metric lines, **off** when the chart already carries zone/threshold fills (avoid double-fill
|
|
498
|
-
clutter). Keep stacked-area bands opaque — fading them leaks lower bands.
|
|
499
|
-
|
|
500
|
-
## Responsive sizing
|
|
501
|
-
|
|
502
|
-
There is exactly one responsive path now — `ChartFrame` (composed either directly, or via
|
|
503
|
-
`CartesianChart`, which composes it internally). `Heatmap` self-measures
|
|
504
|
-
the same way every other kind does, instead of taking `width`/`height` from a separate wrapper.
|
|
505
|
-
Every kind and `CartesianChart` itself resolve their size the same three ways, tried in this order:
|
|
506
|
-
|
|
507
|
-
```tsx
|
|
508
|
-
<Bars height={260} … /> // fixed height (most common)
|
|
509
|
-
<Bars aspectRatio={16 / 9} … /> // height = round(measured width / ratio)
|
|
510
|
-
<Bars fill … /> // fills the parent flex/grid cell's measured height
|
|
511
|
-
```
|
|
512
|
-
|
|
513
|
-
Reach for `useChartSize` directly only when composing `ChartFrame`/`CartesianChart` yourself in a
|
|
514
|
-
bespoke chart and you need the measured `{ width, height }` for something beyond what those
|
|
515
|
-
primitives already do with it — `ChartFrame` already calls it internally for you in the common case.
|
|
516
|
-
`UseChartSizeResult`: `{ ref, width, height }`; attach `ref` to the container you want measured.
|
|
517
|
-
|
|
518
|
-
## Margin
|
|
519
|
-
|
|
520
|
-
Margins are **measured**, not hand-picked. `CartesianChart` runs every configured axis's tick
|
|
521
|
-
labels through `autoMargin`/`measureText` (`basalt-ui/charts`) before laying out the plot, so
|
|
522
|
-
`VX.margin` is a **floor**, not the value — a wide tick label (a long right-axis dollar format, a
|
|
523
|
-
rotated x label) widens its own gutter automatically instead of clipping or needing a hand-tuned
|
|
524
|
-
margin. Passing `y2` to get a dual-axis chart no longer needs a manual margin nudge at all:
|
|
525
|
-
|
|
526
|
-
```tsx
|
|
527
|
-
// now: nothing — passing y2 is what widens the right margin, measured from what's actually painted
|
|
528
|
-
<CartesianChart data={data} chartId="x" getX={…} series={SERIES} y={{…}} y2={{…}} />
|
|
529
|
-
```
|
|
530
|
-
|
|
531
|
-
The escape hatch is `margin={{ left: n }}` (`Partial<ChartMargin>`) on `CartesianChart` — a per-side
|
|
532
|
-
override applied LAST, after measurement, so it always wins.
|
|
533
|
-
|
|
534
|
-
**A band plot has one extra law, because a band axis puts its FIRST tick on the plot's left edge.**
|
|
535
|
-
With no left axis to measure, `autoMargin` stops at `VX.margin.left` and half the first x label
|
|
536
|
-
hangs off the canvas — so `BandStrip` floors its left gutter at half the widest x label, mirroring
|
|
537
|
-
what `autoMargin` already does on the right. Both terminal gutters are then capped (14% of width
|
|
538
|
-
left, 12% right): uncapped, half a `DD.MM HH:MM` label is 48px of a 338px chart. `MirroredBars`
|
|
539
|
-
probes real tick labels from both panes, so it takes the plain measured margin and neither the
|
|
540
|
-
floor nor the cap applies.
|
|
541
|
-
|
|
542
|
-
**The height law is the number you actually have to compute.** A band row is
|
|
543
|
-
`height − legend − margin.top − margin.bottom`: `ChartFrame` reserves the measured legend band out
|
|
544
|
-
of the plot rect first, then the kind takes the margins out of what is left. There is no
|
|
545
|
-
`bandHeight` prop — to get a 44px band, hand the frame `44 + margins + legend`. `margin={{ top: 0 }}`
|
|
546
|
-
reclaims the whole top gutter, which a 1-D strip draws nothing in; the bottom is not reclaimable,
|
|
547
|
-
`autoMargin` floors it at `VX.margin.bottom`. Derive the constants from `VX.margin` rather than
|
|
548
|
-
restating them — a consumer that copies `30` into three chart files has three silent-drift sites the
|
|
549
|
-
moment the density scale is retuned.
|
|
550
|
-
|
|
551
|
-
`chartMargin(opts?)` (`basalt-ui/tokens`, also re-exported from `basalt-ui/charts`) still exists,
|
|
552
|
-
but only matters for a bespoke chart that composes `ChartFrame` directly (not `CartesianChart`) and
|
|
553
|
-
needs a right-axis-sized margin without running its own measurement pass — `DualPanel` and `Heatmap`
|
|
554
|
-
don't use it today (neither renders a measured dual numeric axis), so treat it as a fallback, not the
|
|
555
|
-
default path. It returns `VX.margin` widened for `{ rightAxis: true }`, or any side overridden
|
|
556
|
-
directly; returns a new object per call, so memoize it at the call site if you do reach for it.
|
|
557
|
-
|
|
558
|
-
## Rule of thumb
|
|
559
|
-
|
|
560
|
-
> If the new chart doesn't fit the primitives, add a kind — don't loosen the primitives.
|
|
8
|
+
<!-- basalt:coverage -->
|
|
9
|
+
<!-- GENERATED from src/surfaces.ts — `basalt-ui check-coverage --write`. Do not hand-edit. -->
|
|
10
|
+
<!-- backed by: guard kinds — chart-missing-aria-label, raw-color-fn, raw-hex, raw-visx-axis, unframed-chart · oxlint rules — basalt/chart-in-raw-surface, basalt/chart-legend-literal, basalt/hand-rolled-plot, basalt/visx-boundary, basalt/visx-tooltip -->
|
|
11
|
+
<!-- not guarded: — -->
|
|
12
|
+
<!-- /basalt:coverage -->
|
|
13
|
+
|
|
14
|
+
# Basalt Charts — the primitive contract
|
|
15
|
+
|
|
16
|
+
basalt-ui owns the chart doctrine: charts are assembled from low-level visx primitives, so without a
|
|
17
|
+
shared assembly every chart drifts from every other one. **This file is the contract; the API is not
|
|
18
|
+
here** — read the shipped types and JSDoc, and `llms.txt` at the install directory, for props.
|
|
19
|
+
|
|
20
|
+
## The one mandatory primitive
|
|
21
|
+
|
|
22
|
+
**Every single-plot cartesian chart composes `CartesianChart`, and composes nothing else from its job
|
|
23
|
+
list by hand.** It owns the measured margins, both y scales and their domains, the x scale and tick
|
|
24
|
+
thinning, the grid, zones, the axes, the page-shared cursor, the crosshair and its per-series dots,
|
|
25
|
+
the hover/keyboard overlay and the derived tooltip. The caller supplies `series` and a `children`
|
|
26
|
+
render prop that draws **only marks**.
|
|
27
|
+
|
|
28
|
+
Reaching past it for an axis, a tooltip or a margin means the chart has drifted. `basalt/hand-rolled-plot`
|
|
29
|
+
enforces this per NODE: a chart-assembly primitive rendered in a file that does not compose
|
|
30
|
+
`CartesianChart` is a finding, waived one node at a time with `theme-allow hand-rolled-plot — <why>`.
|
|
31
|
+
|
|
32
|
+
**Five shipped shapes are the declared exceptions**, by shape, not by preference: `DualPanel` (two
|
|
33
|
+
panes), `MirroredBars` (two bar panes on one baseline), `BandStrip` (no y dimension at all), `Donut`
|
|
34
|
+
(radial) and `Heatmap` (a matrix). They compose `ChartFrame` + `useChartCursor` + `autoMargin` +
|
|
35
|
+
`ChartTooltipFloat` directly. The first three carry a `theme-allow-file hand-rolled-plot — <why>`
|
|
36
|
+
declaration; `Donut` and `Heatmap` render no assembly primitive, so nothing fires and there is
|
|
37
|
+
nothing to waive. A genuinely non-single-plot bespoke shape declares itself the same way — file
|
|
38
|
+
scope, spelled, with a reason. The module that DEFINES `CartesianChart` is exempt definitionally: a
|
|
39
|
+
rule saying "compose X" cannot fire inside X.
|
|
40
|
+
|
|
41
|
+
**A documented limit:** the rule keys on those visx primitives, so a chart drawn with DOM nodes or a
|
|
42
|
+
hand-assembled `<svg>` is structurally invisible to it. Compose the primitive because it is the
|
|
43
|
+
contract, not because lint will catch you.
|
|
44
|
+
|
|
45
|
+
## `series` is the single source of truth
|
|
46
|
+
|
|
47
|
+
Legend entries and tooltip rows are **DERIVED** from `series` (`deriveLegend` / `deriveTooltipRows`,
|
|
48
|
+
or just let `ChartFrame`/`CartesianChart` do it), so a chart cannot show a row or a key it does not
|
|
49
|
+
draw. A hand-written array literal passed to `ChartLegend`'s `items` — or a `.map()` over some OTHER
|
|
50
|
+
array, because deriving from AN array is not deriving from THE series — is
|
|
51
|
+
`basalt/chart-legend-literal`.
|
|
52
|
+
|
|
53
|
+
**Corollary for mark renderers: draw `ctx.visible`, never the `series` prop.** `visible` is `series`
|
|
54
|
+
minus whatever the legend has toggled off, and the legend toggles by default at two or more entries
|
|
55
|
+
— hiding a series drops it from the plot, the tooltip AND the auto y-domain together. Drawing
|
|
56
|
+
`series` repaints the mark the reader just hid.
|
|
57
|
+
|
|
58
|
+
## Boundaries (three independent rules, no escape hatch)
|
|
59
|
+
|
|
60
|
+
- **`@visx/*` only inside a `charts/` path segment** (`basalt/visx-boundary`, shipped AND repo-local).
|
|
61
|
+
Need a raw visx primitive? Import it from `basalt-ui/charts`, which re-exports the curated set, and
|
|
62
|
+
keep your bespoke chart file under a `charts/` directory.
|
|
63
|
+
- **`@visx/tooltip` is banned everywhere**, charts included (`basalt/visx-tooltip`) — use
|
|
64
|
+
`ChartTooltipFloat` + `TooltipHeader`/`TooltipRow`/`TooltipBody`. It portals to `document.body`, so
|
|
65
|
+
it can be authored anywhere including inside an `<svg>`; a plain `<div>` there mounts in the SVG
|
|
66
|
+
namespace, typechecks, lints, throws nothing, and never paints.
|
|
67
|
+
- **basalt's own `charts/` and `tokens/` import zero `@mantine/*`** (`basalt/token-layer-boundary`,
|
|
68
|
+
repo-local by design). That is a framework invariant, not an obligation on your app's own
|
|
69
|
+
`charts/`-named directory: it keeps the token layer upstream of Mantine and lets
|
|
70
|
+
`basalt-ui/charts` + `basalt-ui/tokens` resolve with no Mantine installed.
|
|
71
|
+
- Because Mantine layout primitives are unreachable inside a chart file, `inline-display` and
|
|
72
|
+
`raw-html-layout` deliberately do NOT fire there — the finding would be unactionable. Raw `<div>`
|
|
73
|
+
is fine in a chart file; `VX.*` tokens are still mandatory. Reach for `ChartCenter` to center.
|
|
74
|
+
- **Maps are not charts.** No map library is a `@visx/*` package, basalt ships no map kind, and the
|
|
75
|
+
boundary says nothing about one — build it wherever the rest of the app's non-chart UI lives, and
|
|
76
|
+
style its chrome with `--vx-*` so it matches.
|
|
77
|
+
|
|
78
|
+
## Color, scheme and margins
|
|
79
|
+
|
|
80
|
+
- **Never a raw hex in a chart file**, and never `localStorage.getItem('theme')`. Theme reactivity is
|
|
81
|
+
**pure CSS**: the `--vx-*` variables are redeclared per scheme, so toggling restyles every chart
|
|
82
|
+
with no React re-render. Don't branch on the color scheme in JS.
|
|
83
|
+
- **Series color is consumer domain data** — one guard-exempt file per app, `defineSeries` →
|
|
84
|
+
`seriesTokens`/`groupTokens` → `buildPaletteCss` (basalt-tokens.md). A hue keeps its identity and
|
|
85
|
+
shifts shade across schemes, which is why every entry is a `{ light, dark }` pair.
|
|
86
|
+
- **Margins are MEASURED, not hand-picked.** Every configured axis's tick labels run through
|
|
87
|
+
`autoMargin`/`measureText` before layout, so `VX.margin` is a floor and a wide label widens its own
|
|
88
|
+
gutter. Passing `y2` is what makes a chart dual-axis — the right margin widens by measurement, with
|
|
89
|
+
no manual nudge. `margin={{ side: n }}` is the per-side override, applied last.
|
|
90
|
+
- **Derive constants from `VX.margin`** rather than copying a number into three chart files — those
|
|
91
|
+
are three silent-drift sites the moment the density scale is retuned.
|
|
92
|
+
- **Never fake an empty dataset for a pending query.** "Nothing to draw" is three states, and
|
|
93
|
+
`data ?? []` collapses "not asked yet" into "measured and absent", which is a positive claim about
|
|
94
|
+
data nobody fetched. Pass `isPending`; every kind forwards it to one pending renderer that reserves
|
|
95
|
+
the plot's footprint and draws nothing that could be mistaken for a measurement.
|
|
96
|
+
- **`ChartFrame`'s container is `role="group"`, never `role="img"`.** Every descendant of a
|
|
97
|
+
`role="img"` element is presentational, which erases the hover overlay's `role="slider"` from the
|
|
98
|
+
accessibility tree — silently, and jsdom does not implement the pruning, so no `getByRole` test can
|
|
99
|
+
catch the regression. Do not "simplify" it back.
|
|
100
|
+
|
|
101
|
+
## Adding a chart
|
|
102
|
+
|
|
103
|
+
1. **Fits a shipped kind?** Use it. The kinds are declarative — `data`, `chartId`, `getX`, `series`,
|
|
104
|
+
one `AxisConfig` per axis, zones/thresholds/refLines as plain arrays.
|
|
105
|
+
2. **Second instance of a shape with no kind?** Extract a kind and migrate both call sites (Rule of
|
|
106
|
+
Three: not on the first, not past the third). **Prove a new kind by porting real call sites** and
|
|
107
|
+
shipping the "still cannot express" list with it — a demo page is written against the API that
|
|
108
|
+
already exists.
|
|
109
|
+
3. **Genuinely unique?** Stay bespoke, composing `CartesianChart` (or `ChartFrame`) directly in the
|
|
110
|
+
page's chart file, not in a shared kinds directory.
|
|
111
|
+
|
|
112
|
+
Sparklines are the one exemption from the composition contract (no legend, no tooltip, no
|
|
113
|
+
`CartesianChart`) — they still read `VX.*`.
|
|
114
|
+
|
|
115
|
+
**Anti-pattern:** a single `<Chart type="…" config={…} />` that switches by kind. Prefer N small
|
|
116
|
+
kinds, and if a chart doesn't fit the primitives, **add a kind — don't loosen the primitives.**
|
|
117
|
+
|
|
118
|
+
## The x axis is categorical, and the API does not say so
|
|
119
|
+
|
|
120
|
+
`CartesianChart` builds x as a point scale over domain KEYS: N points are N evenly spaced positions
|
|
121
|
+
whatever the values behind them. Correct and invisible for a regular grid; wrong for an event-shaped
|
|
122
|
+
series, where two consequences are silent — equal spacing means a shared crosshair marks the right
|
|
123
|
+
point at a different screen x than a regularly-sampled sibling, and two events at the same instant
|
|
124
|
+
collapse onto one position with one of them dropped. `getX` returning a date string reads like a time
|
|
125
|
+
axis and is not one. A chart whose x is a measured quantity needs a file declaration today.
|
|
126
|
+
|
|
127
|
+
The cursor is likewise **shared page-wide by default** with no provider, resolved domain-aware
|
|
128
|
+
(nearest parsed date/number within a chart's own step, not string equality) so a chart that folds its
|
|
129
|
+
domain still tracks a sibling's hover. `ChartCursorScope` ISOLATES a subtree — it opts out of
|
|
130
|
+
sharing, never into it.
|