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
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createPersistedState` — the in-tab notification contract.
|
|
3
|
+
*
|
|
4
|
+
* The versioned envelope and the cross-tab path are exercised through every store that persists
|
|
5
|
+
* (`fields.test.tsx`, `search-store.test.ts`); what only shows up here is TWO instances over ONE
|
|
6
|
+
* key, which is ordinary rather than exotic: a page's store and a widget's own state legitimately
|
|
7
|
+
* name the same key. Per-instance listener sets made a write through one invisible to the other in
|
|
8
|
+
* the SAME tab — while the cross-tab path worked, which is what made it read as a caching bug.
|
|
9
|
+
*/
|
|
10
|
+
import { beforeEach, describe, expect, test } from 'bun:test'
|
|
11
|
+
import { act, renderHook } from '@testing-library/react'
|
|
12
|
+
import { createPersistedState, readPersistedValue } from './persisted'
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
localStorage.clear()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('createPersistedState — one key, two instances', () => {
|
|
19
|
+
test('a write through A re-renders a hook on B with the new value', () => {
|
|
20
|
+
const useA = createPersistedState({ key: 'shared-draft', version: 1, initial: 'a' })
|
|
21
|
+
const useB = createPersistedState({ key: 'shared-draft', version: 1, initial: 'a' })
|
|
22
|
+
|
|
23
|
+
const a = renderHook(() => useA())
|
|
24
|
+
const b = renderHook(() => useB())
|
|
25
|
+
expect(b.result.current[0]).toBe('a')
|
|
26
|
+
|
|
27
|
+
act(() => {
|
|
28
|
+
a.result.current[1]('written-through-A')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
expect(b.result.current[0]).toBe('written-through-A')
|
|
32
|
+
expect(a.result.current[0]).toBe('written-through-A')
|
|
33
|
+
expect(readPersistedValue('shared-draft', 1)).toBe('written-through-A')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('the notification is per KEY — a second key is not woken', () => {
|
|
37
|
+
const useShared = createPersistedState({ key: 'k-one', version: 1, initial: 0 })
|
|
38
|
+
const useOther = createPersistedState({ key: 'k-two', version: 1, initial: 0 })
|
|
39
|
+
|
|
40
|
+
let otherRenders = 0
|
|
41
|
+
const shared = renderHook(() => useShared())
|
|
42
|
+
renderHook(() => {
|
|
43
|
+
otherRenders += 1
|
|
44
|
+
return useOther()
|
|
45
|
+
})
|
|
46
|
+
const before = otherRenders
|
|
47
|
+
|
|
48
|
+
act(() => {
|
|
49
|
+
shared.result.current[1](7)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
expect(shared.result.current[0]).toBe(7)
|
|
53
|
+
expect(otherRenders).toBe(before)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('unmounting one instance leaves the other subscribed', () => {
|
|
57
|
+
const useA = createPersistedState({ key: 'shared-unmount', version: 1, initial: 'a' })
|
|
58
|
+
const useB = createPersistedState({ key: 'shared-unmount', version: 1, initial: 'a' })
|
|
59
|
+
|
|
60
|
+
const a = renderHook(() => useA())
|
|
61
|
+
const b = renderHook(() => useB())
|
|
62
|
+
a.unmount()
|
|
63
|
+
|
|
64
|
+
const c = renderHook(() => useA())
|
|
65
|
+
act(() => {
|
|
66
|
+
c.result.current[1]('later')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
expect(b.result.current[0]).toBe('later')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('an object value stays referentially stable between writes (the snapshot cache)', () => {
|
|
73
|
+
const useDraft = createPersistedState<{ title: string }>({
|
|
74
|
+
key: 'shared-object',
|
|
75
|
+
version: 1,
|
|
76
|
+
initial: { title: '' },
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const { result } = renderHook(() => useDraft())
|
|
80
|
+
const first = result.current[0]
|
|
81
|
+
act(() => {
|
|
82
|
+
result.current[1]({ title: 'one' })
|
|
83
|
+
})
|
|
84
|
+
const written = result.current[0]
|
|
85
|
+
|
|
86
|
+
expect(first).not.toBe(written)
|
|
87
|
+
expect(written).toEqual({ title: 'one' })
|
|
88
|
+
// Same raw string → same parsed reference, which is what keeps useSyncExternalStore quiet.
|
|
89
|
+
expect(renderHook(() => useDraft()).result.current[0]).toBe(written)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createPersistedState — Mantine-free, React-only localStorage primitive.
|
|
3
|
+
*
|
|
4
|
+
* Versioned, Standard-Schema-validated state via useSyncExternalStore. SSR-safe.
|
|
5
|
+
* Cross-tab via the 'storage' event. Keys are namespaced 'basalt:*' so they never
|
|
6
|
+
* collide with the localstorage-theme guard pattern.
|
|
7
|
+
*
|
|
8
|
+
* One of the two halves behind the `basalt-ui/state` barrel (`../state.ts`); the field vocabulary
|
|
9
|
+
* and the two store factories built on this primitive live in `./fields`.
|
|
10
|
+
*/
|
|
11
|
+
import { useSyncExternalStore } from 'react'
|
|
12
|
+
import type { StandardSchemaV1 } from '../register'
|
|
13
|
+
|
|
14
|
+
// Shared noop subscribe — passed to useSyncExternalStore on the server so no window access occurs.
|
|
15
|
+
const noopSubscribe =
|
|
16
|
+
(_cb: () => void): (() => void) =>
|
|
17
|
+
() => {}
|
|
18
|
+
|
|
19
|
+
export type PersistedStateOptions<T> = {
|
|
20
|
+
/** localStorage key (will be namespaced as `basalt:<key>`). */
|
|
21
|
+
readonly key: string
|
|
22
|
+
/** Envelope version — increment when the shape changes. */
|
|
23
|
+
readonly version: number
|
|
24
|
+
/** Value to use when nothing is persisted, after migration fails, or on SSR. */
|
|
25
|
+
readonly initial: T
|
|
26
|
+
/** Migrate a previous-version persisted value forward to the current shape. */
|
|
27
|
+
readonly migrate?: (persisted: unknown, fromVersion: number) => T
|
|
28
|
+
/**
|
|
29
|
+
* Standard-Schema validate the (post-migrate) value. Invalid result OR a Promise (async schema)
|
|
30
|
+
* falls back to `initial` — sync storage can't await, so async validators are treated as invalid.
|
|
31
|
+
*/
|
|
32
|
+
readonly schema?: StandardSchemaV1<unknown, T>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Every subscriber for ONE storage key, shared by every `createPersistedState` instance over that
|
|
37
|
+
* key — plus the single `storage` event listener the key needs.
|
|
38
|
+
*
|
|
39
|
+
* Module-level rather than per-instance because two instances of the same key are ordinary, not a
|
|
40
|
+
* mistake: a page's `createSearchStore` and a widget's `createPersistedState` legitimately name the
|
|
41
|
+
* same key, and a consumer re-exporting a store from two modules ends up there by accident. With
|
|
42
|
+
* per-instance listener sets a write through one instance updated localStorage and notified only
|
|
43
|
+
* its own hooks, so the other instance's components kept rendering the stale value until something
|
|
44
|
+
* else re-rendered them — in the SAME tab, while the cross-tab path (the `storage` event) worked.
|
|
45
|
+
*
|
|
46
|
+
* The map is keyed by the NAMESPACED key and holds no window reference; a channel is created on
|
|
47
|
+
* first subscribe, which only happens in the browser (`subscribe` is never passed on the server).
|
|
48
|
+
*/
|
|
49
|
+
type KeyChannel = {
|
|
50
|
+
readonly listeners: Set<() => void>
|
|
51
|
+
handler: ((e: StorageEvent) => void) | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const channels = new Map<string, KeyChannel>()
|
|
55
|
+
|
|
56
|
+
/** Wake every hook on a key, whichever instance holds it. */
|
|
57
|
+
function notify(storageKey: string): void {
|
|
58
|
+
const channel = channels.get(storageKey)
|
|
59
|
+
if (channel === undefined) return
|
|
60
|
+
for (const listener of channel.listeners) listener()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function channelFor(storageKey: string): KeyChannel {
|
|
64
|
+
const existing = channels.get(storageKey)
|
|
65
|
+
if (existing !== undefined) return existing
|
|
66
|
+
const created: KeyChannel = { listeners: new Set(), handler: null }
|
|
67
|
+
channels.set(storageKey, created)
|
|
68
|
+
return created
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The envelope stored in localStorage: `{ v: number, value: unknown }`. */
|
|
72
|
+
type Envelope = { v: number; value: unknown }
|
|
73
|
+
|
|
74
|
+
function isEnvelope(raw: unknown): raw is Envelope {
|
|
75
|
+
return (
|
|
76
|
+
typeof raw === 'object' &&
|
|
77
|
+
raw !== null &&
|
|
78
|
+
'v' in raw &&
|
|
79
|
+
typeof (raw as Record<string, unknown>)['v'] === 'number' &&
|
|
80
|
+
'value' in raw
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Parse a raw localStorage string (or null) into a value, falling back to `initial` on any miss. */
|
|
85
|
+
function parseStorage<T>(raw: string | null, opts: PersistedStateOptions<T>): T {
|
|
86
|
+
if (raw === null) return opts.initial
|
|
87
|
+
try {
|
|
88
|
+
const parsed: unknown = JSON.parse(raw)
|
|
89
|
+
if (!isEnvelope(parsed)) return opts.initial
|
|
90
|
+
|
|
91
|
+
let value: unknown = parsed.value
|
|
92
|
+
|
|
93
|
+
if (parsed.v !== opts.version) {
|
|
94
|
+
value = opts.migrate ? opts.migrate(parsed.value, parsed.v) : opts.initial
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (opts.schema) {
|
|
98
|
+
const result = opts.schema['~standard'].validate(value)
|
|
99
|
+
// Async schema — can't await in sync storage path; fall back to initial
|
|
100
|
+
if (result instanceof Promise) return opts.initial
|
|
101
|
+
if (result.issues !== undefined) return opts.initial
|
|
102
|
+
// Narrowed to SuccessResult<T> — value is T
|
|
103
|
+
return result.value
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return value as T
|
|
107
|
+
} catch {
|
|
108
|
+
return opts.initial
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function writeEnvelope(storageKey: string, version: number, value: unknown): void {
|
|
113
|
+
try {
|
|
114
|
+
const envelope: Envelope = { v: version, value }
|
|
115
|
+
window.localStorage.setItem(storageKey, JSON.stringify(envelope))
|
|
116
|
+
} catch {
|
|
117
|
+
// Silently fail (storage full, private browsing, etc.)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function writeStorage<T>(opts: PersistedStateOptions<T>, next: T): void {
|
|
122
|
+
writeEnvelope(`basalt:${opts.key}`, opts.version, next)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Versioned, Standard-Schema-validated localStorage state via useSyncExternalStore (no zustand).
|
|
127
|
+
* SSR-safe (getServerSnapshot returns `initial`). Cross-tab via the storage event.
|
|
128
|
+
*
|
|
129
|
+
* Returns a factory hook — call it once per module, then use the returned hook in components.
|
|
130
|
+
* Common case is 3 lines.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* export const useFilterDraft = createPersistedState({ key: 'filters', version: 1, initial: DEFAULT, schema: FilterSchema })
|
|
134
|
+
* // in a component:
|
|
135
|
+
* const [draft, setDraft] = useFilterDraft()
|
|
136
|
+
*/
|
|
137
|
+
export function createPersistedState<T>(
|
|
138
|
+
opts: PersistedStateOptions<T>,
|
|
139
|
+
): () => readonly [T, (next: T) => void] {
|
|
140
|
+
const storageKey = `basalt:${opts.key}`
|
|
141
|
+
|
|
142
|
+
const subscribe = (cb: () => void): (() => void) => {
|
|
143
|
+
// The subscriber set is the KEY's, not this instance's — see `KeyChannel`.
|
|
144
|
+
const channel = channelFor(storageKey)
|
|
145
|
+
channel.listeners.add(cb)
|
|
146
|
+
|
|
147
|
+
// ONE 'storage' event listener per key, registered lazily on the first subscriber and removed
|
|
148
|
+
// when the last one leaves (cleanup semantics preserved).
|
|
149
|
+
if (channel.handler === null) {
|
|
150
|
+
channel.handler = (e: StorageEvent): void => {
|
|
151
|
+
if (e.key === storageKey) notify(storageKey)
|
|
152
|
+
}
|
|
153
|
+
window.addEventListener('storage', channel.handler)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return () => {
|
|
157
|
+
channel.listeners.delete(cb)
|
|
158
|
+
if (channel.listeners.size === 0 && channel.handler !== null) {
|
|
159
|
+
window.removeEventListener('storage', channel.handler)
|
|
160
|
+
channel.handler = null
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Snapshot cache — useSyncExternalStore requires getSnapshot to return a referentially STABLE
|
|
166
|
+
// value while the store is unchanged. parseStorage() allocates a fresh object/array on every
|
|
167
|
+
// call, so returning it raw makes React see an ever-changing snapshot for object/array state
|
|
168
|
+
// and loop until "Maximum update depth exceeded". We cache the parsed value keyed on the raw
|
|
169
|
+
// localStorage string: same string → same reference; a write (this tab or another) changes the
|
|
170
|
+
// string, so the next read re-parses exactly once. Primitive state was unaffected — this fixes
|
|
171
|
+
// the object/array case (chat history, form drafts).
|
|
172
|
+
let cachedRaw: string | null = null
|
|
173
|
+
let cachedValue: T = opts.initial
|
|
174
|
+
let primed = false
|
|
175
|
+
|
|
176
|
+
const getSnapshot = (): T => {
|
|
177
|
+
let raw: string | null
|
|
178
|
+
try {
|
|
179
|
+
raw = window.localStorage.getItem(storageKey)
|
|
180
|
+
} catch {
|
|
181
|
+
raw = null
|
|
182
|
+
}
|
|
183
|
+
if (primed && raw === cachedRaw) return cachedValue
|
|
184
|
+
cachedRaw = raw
|
|
185
|
+
cachedValue = parseStorage(raw, opts)
|
|
186
|
+
primed = true
|
|
187
|
+
return cachedValue
|
|
188
|
+
}
|
|
189
|
+
const getServerSnapshot = (): T => opts.initial
|
|
190
|
+
|
|
191
|
+
const setState = (next: T): void => {
|
|
192
|
+
writeStorage(opts, next)
|
|
193
|
+
// Notify every in-tab listener on this KEY — the 'storage' event fires in OTHER tabs only, and
|
|
194
|
+
// a second instance of the same key has its own hooks to wake.
|
|
195
|
+
notify(storageKey)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Detect SSR once at creation time — the environment doesn't change between renders.
|
|
199
|
+
const isServer = typeof window === 'undefined'
|
|
200
|
+
|
|
201
|
+
return function usePersistedState(): readonly [T, (next: T) => void] {
|
|
202
|
+
const value = useSyncExternalStore<T>(
|
|
203
|
+
// SSR guard — subscribe must not reference `window` on the server.
|
|
204
|
+
isServer ? noopSubscribe : subscribe,
|
|
205
|
+
isServer ? getServerSnapshot : getSnapshot,
|
|
206
|
+
getServerSnapshot,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
return [value, setState] as const
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── readPersistedValue — plain function, no React required ─────────────────────────────────
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Read a value previously written by `createPersistedState` under the namespaced key
|
|
217
|
+
* `basalt:<key>`. Parses the versioned envelope shape `{ v, value }`. Returns `null` on
|
|
218
|
+
* miss, corruption, version mismatch, or SSR — no fallback is applied (call sites supply
|
|
219
|
+
* their own default).
|
|
220
|
+
*
|
|
221
|
+
* Primary use: `validateSearch` in TanStack Router (which runs outside React, so it can't
|
|
222
|
+
* call `usePersistedState`). Combine with a URL-param check to create a search-param store
|
|
223
|
+
* backed by localStorage.
|
|
224
|
+
*
|
|
225
|
+
* @param key — un-namespaced key (the same one passed to `createPersistedState`)
|
|
226
|
+
* @param version — if provided, the envelope version must match; stale envelopes return `null`
|
|
227
|
+
*
|
|
228
|
+
* @example
|
|
229
|
+
* const stored = readPersistedValue('dashboard-range', 1)
|
|
230
|
+
* // stored is '1d' | '7d' | '30d' | null
|
|
231
|
+
*/
|
|
232
|
+
export function readPersistedValue(key: string, version?: number): unknown | null {
|
|
233
|
+
const storageKey = `basalt:${key}`
|
|
234
|
+
try {
|
|
235
|
+
const raw = localStorage.getItem(storageKey)
|
|
236
|
+
if (raw === null) return null
|
|
237
|
+
const envelope: unknown = JSON.parse(raw)
|
|
238
|
+
if (!isEnvelope(envelope)) return null
|
|
239
|
+
if (version !== undefined && envelope.v !== version) return null
|
|
240
|
+
return envelope.value
|
|
241
|
+
} catch {
|
|
242
|
+
return null
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Write a value under the namespaced key `basalt:<key>` in the same versioned envelope
|
|
248
|
+
* `createPersistedState` writes, and wake every hook on that key — the write half of
|
|
249
|
+
* `readPersistedValue`, and plain in the same way: no React required.
|
|
250
|
+
*
|
|
251
|
+
* The one call site is a store field's `clear()`, which has to DELETE its key from the store's
|
|
252
|
+
* record from an event handler, where the hook's setter is not in reach. Not exported from the
|
|
253
|
+
* `basalt-ui/state` barrel: the two store factories are the API, this is their seam.
|
|
254
|
+
*
|
|
255
|
+
* @internal
|
|
256
|
+
*/
|
|
257
|
+
export function writePersistedValue(key: string, version: number, value: unknown): void {
|
|
258
|
+
const storageKey = `basalt:${key}`
|
|
259
|
+
writeEnvelope(storageKey, version, value)
|
|
260
|
+
notify(storageKey)
|
|
261
|
+
}
|
package/src/state.ts
CHANGED
|
@@ -1,246 +1,58 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* `basalt-ui/state` — the headless state surface: no Mantine, no router, no JSX.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* Cross-tab via the 'storage' event. Keys are namespaced 'basalt:*' so they never
|
|
6
|
-
* collide with the localstorage-theme guard pattern.
|
|
7
|
-
*/
|
|
8
|
-
import { useSyncExternalStore } from 'react'
|
|
9
|
-
import type { StandardSchemaV1 } from './register'
|
|
10
|
-
|
|
11
|
-
// Shared noop subscribe — passed to useSyncExternalStore on the server so no window access occurs.
|
|
12
|
-
const noopSubscribe =
|
|
13
|
-
(_cb: () => void): (() => void) =>
|
|
14
|
-
() => {}
|
|
15
|
-
|
|
16
|
-
// ── useOnlineStatus ────────────────────────────────────────────────────────────────────────────────
|
|
17
|
-
|
|
18
|
-
function subscribeOnline(cb: () => void): () => void {
|
|
19
|
-
window.addEventListener('online', cb)
|
|
20
|
-
window.addEventListener('offline', cb)
|
|
21
|
-
return () => {
|
|
22
|
-
window.removeEventListener('online', cb)
|
|
23
|
-
window.removeEventListener('offline', cb)
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const getOnlineSnapshot = (): boolean => navigator.onLine
|
|
28
|
-
const getOnlineServerSnapshot = (): boolean => true
|
|
29
|
-
|
|
30
|
-
// Detect SSR once at module load — mirrors createPersistedState's isServer pattern.
|
|
31
|
-
const isOnlineServer = typeof window === 'undefined'
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Returns `true` when the browser reports an active network connection, `false` otherwise.
|
|
35
|
-
* Backed by `useSyncExternalStore`: subscribes to window `online`/`offline` events.
|
|
36
|
-
* SSR-safe — `getServerSnapshot` returns `true` (optimistic: assume online on the server).
|
|
4
|
+
* Two halves, one barrel:
|
|
37
5
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
6
|
+
* - `./state/persisted` — `createPersistedState` / `readPersistedValue`, the versioned localStorage
|
|
7
|
+
* primitive every other basalt module persists through.
|
|
8
|
+
* - `./state/fields` — the store field vocabulary (`field.*`, `FieldHandle`, the lanes) and
|
|
9
|
+
* `createLocalStore`, the router-free store. `createSearchStore` (`basalt-ui/router-tanstack`)
|
|
10
|
+
* is the router-coupled factory over the same fields.
|
|
41
11
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* if (!isOnline) return <OfflineBanner />
|
|
12
|
+
* The split is a file boundary, not a surface boundary: this barrel is what `package.json`'s
|
|
13
|
+
* `./state` export resolves to, and its named exports are pinned by `scripts/export-surface.json`.
|
|
45
14
|
*/
|
|
46
|
-
export function useOnlineStatus(): boolean {
|
|
47
|
-
return useSyncExternalStore(
|
|
48
|
-
isOnlineServer ? noopSubscribe : subscribeOnline,
|
|
49
|
-
isOnlineServer ? getOnlineServerSnapshot : getOnlineSnapshot,
|
|
50
|
-
getOnlineServerSnapshot,
|
|
51
|
-
)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export type PersistedStateOptions<T> = {
|
|
55
|
-
/** localStorage key (will be namespaced as `basalt:<key>`). */
|
|
56
|
-
readonly key: string
|
|
57
|
-
/** Envelope version — increment when the shape changes. */
|
|
58
|
-
readonly version: number
|
|
59
|
-
/** Value to use when nothing is persisted, after migration fails, or on SSR. */
|
|
60
|
-
readonly initial: T
|
|
61
|
-
/** Migrate a previous-version persisted value forward to the current shape. */
|
|
62
|
-
readonly migrate?: (persisted: unknown, fromVersion: number) => T
|
|
63
|
-
/**
|
|
64
|
-
* Standard-Schema validate the (post-migrate) value. Invalid result OR a Promise (async schema)
|
|
65
|
-
* falls back to `initial` — sync storage can't await, so async validators are treated as invalid.
|
|
66
|
-
*/
|
|
67
|
-
readonly schema?: StandardSchemaV1<unknown, T>
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** The envelope stored in localStorage: `{ v: number, value: unknown }`. */
|
|
71
|
-
type Envelope = { v: number; value: unknown }
|
|
72
15
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
} catch {
|
|
117
|
-
// Silently fail (storage full, private browsing, etc.)
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Versioned, Standard-Schema-validated localStorage state via useSyncExternalStore (no zustand).
|
|
123
|
-
* SSR-safe (getServerSnapshot returns `initial`). Cross-tab via the storage event.
|
|
124
|
-
*
|
|
125
|
-
* Returns a factory hook — call it once per module, then use the returned hook in components.
|
|
126
|
-
* Common case is 3 lines.
|
|
127
|
-
*
|
|
128
|
-
* @example
|
|
129
|
-
* export const useFilterDraft = createPersistedState({ key: 'filters', version: 1, initial: DEFAULT, schema: FilterSchema })
|
|
130
|
-
* // in a component:
|
|
131
|
-
* const [draft, setDraft] = useFilterDraft()
|
|
132
|
-
*/
|
|
133
|
-
export function createPersistedState<T>(
|
|
134
|
-
opts: PersistedStateOptions<T>,
|
|
135
|
-
): () => readonly [T, (next: T) => void] {
|
|
136
|
-
const storageKey = `basalt:${opts.key}`
|
|
137
|
-
|
|
138
|
-
// One Set of subscriber callbacks — mirrors notifications/store.ts single-listener-to-Set pattern.
|
|
139
|
-
const listeners = new Set<() => void>()
|
|
140
|
-
|
|
141
|
-
// ONE module-scoped 'storage' event listener per key, registered lazily on first subscriber.
|
|
142
|
-
// Removed when the last subscriber unsubscribes (cleanup semantics preserved).
|
|
143
|
-
let storageHandler: ((e: StorageEvent) => void) | null = null
|
|
144
|
-
|
|
145
|
-
const subscribe = (cb: () => void): (() => void) => {
|
|
146
|
-
listeners.add(cb)
|
|
147
|
-
|
|
148
|
-
// Attach the shared window listener on the first subscriber.
|
|
149
|
-
if (storageHandler === null) {
|
|
150
|
-
storageHandler = (e: StorageEvent): void => {
|
|
151
|
-
if (e.key === storageKey) {
|
|
152
|
-
for (const listener of listeners) listener()
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
window.addEventListener('storage', storageHandler)
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
return () => {
|
|
159
|
-
listeners.delete(cb)
|
|
160
|
-
// Detach the shared window listener when the last subscriber unsubscribes.
|
|
161
|
-
if (listeners.size === 0 && storageHandler !== null) {
|
|
162
|
-
window.removeEventListener('storage', storageHandler)
|
|
163
|
-
storageHandler = null
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Snapshot cache — useSyncExternalStore requires getSnapshot to return a referentially STABLE
|
|
169
|
-
// value while the store is unchanged. parseStorage() allocates a fresh object/array on every
|
|
170
|
-
// call, so returning it raw makes React see an ever-changing snapshot for object/array state
|
|
171
|
-
// and loop until "Maximum update depth exceeded". We cache the parsed value keyed on the raw
|
|
172
|
-
// localStorage string: same string → same reference; a write (this tab or another) changes the
|
|
173
|
-
// string, so the next read re-parses exactly once. Primitive state was unaffected — this fixes
|
|
174
|
-
// the object/array case (chat history, form drafts).
|
|
175
|
-
let cachedRaw: string | null = null
|
|
176
|
-
let cachedValue: T = opts.initial
|
|
177
|
-
let primed = false
|
|
178
|
-
|
|
179
|
-
const getSnapshot = (): T => {
|
|
180
|
-
let raw: string | null
|
|
181
|
-
try {
|
|
182
|
-
raw = window.localStorage.getItem(storageKey)
|
|
183
|
-
} catch {
|
|
184
|
-
raw = null
|
|
185
|
-
}
|
|
186
|
-
if (primed && raw === cachedRaw) return cachedValue
|
|
187
|
-
cachedRaw = raw
|
|
188
|
-
cachedValue = parseStorage(raw, opts)
|
|
189
|
-
primed = true
|
|
190
|
-
return cachedValue
|
|
191
|
-
}
|
|
192
|
-
const getServerSnapshot = (): T => opts.initial
|
|
193
|
-
|
|
194
|
-
const setState = (next: T): void => {
|
|
195
|
-
writeStorage(opts, next)
|
|
196
|
-
// Notify in-tab listeners (the 'storage' event only fires in OTHER tabs)
|
|
197
|
-
for (const cb of listeners) cb()
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// Detect SSR once at creation time — the environment doesn't change between renders.
|
|
201
|
-
const isServer = typeof window === 'undefined'
|
|
202
|
-
|
|
203
|
-
return function usePersistedState(): readonly [T, (next: T) => void] {
|
|
204
|
-
const value = useSyncExternalStore<T>(
|
|
205
|
-
// SSR guard — subscribe must not reference `window` on the server.
|
|
206
|
-
isServer ? noopSubscribe : subscribe,
|
|
207
|
-
isServer ? getServerSnapshot : getSnapshot,
|
|
208
|
-
getServerSnapshot,
|
|
209
|
-
)
|
|
210
|
-
|
|
211
|
-
return [value, setState] as const
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// ── readPersistedValue — plain function, no React required ─────────────────────────────────
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* Read a value previously written by `createPersistedState` under the namespaced key
|
|
219
|
-
* `basalt:<key>`. Parses the versioned envelope shape `{ v, value }`. Returns `null` on
|
|
220
|
-
* miss, corruption, version mismatch, or SSR — no fallback is applied (call sites supply
|
|
221
|
-
* their own default).
|
|
222
|
-
*
|
|
223
|
-
* Primary use: `validateSearch` in TanStack Router (which runs outside React, so it can't
|
|
224
|
-
* call `usePersistedState`). Combine with a URL-param check to create a search-param store
|
|
225
|
-
* backed by localStorage.
|
|
226
|
-
*
|
|
227
|
-
* @param key — un-namespaced key (the same one passed to `createPersistedState`)
|
|
228
|
-
* @param version — if provided, the envelope version must match; stale envelopes return `null`
|
|
229
|
-
*
|
|
230
|
-
* @example
|
|
231
|
-
* const stored = readPersistedValue('dashboard-range', 1)
|
|
232
|
-
* // stored is '1d' | '7d' | '30d' | null
|
|
233
|
-
*/
|
|
234
|
-
export function readPersistedValue(key: string, version?: number): unknown | null {
|
|
235
|
-
const storageKey = `basalt:${key}`
|
|
236
|
-
try {
|
|
237
|
-
const raw = localStorage.getItem(storageKey)
|
|
238
|
-
if (raw === null) return null
|
|
239
|
-
const envelope: unknown = JSON.parse(raw)
|
|
240
|
-
if (!isEnvelope(envelope)) return null
|
|
241
|
-
if (version !== undefined && envelope.v !== version) return null
|
|
242
|
-
return envelope.value
|
|
243
|
-
} catch {
|
|
244
|
-
return null
|
|
245
|
-
}
|
|
246
|
-
}
|
|
16
|
+
export {
|
|
17
|
+
createPersistedState,
|
|
18
|
+
type PersistedStateOptions,
|
|
19
|
+
readPersistedValue,
|
|
20
|
+
} from './state/persisted'
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
field,
|
|
24
|
+
createLocalStore,
|
|
25
|
+
type LocalStore,
|
|
26
|
+
// The field vocabulary
|
|
27
|
+
type FieldLane,
|
|
28
|
+
type ResolvedLane,
|
|
29
|
+
type ResolveLane,
|
|
30
|
+
type RangeValue,
|
|
31
|
+
type RangeParams,
|
|
32
|
+
type RangePresets,
|
|
33
|
+
type RangeWindow,
|
|
34
|
+
type RangeWindows,
|
|
35
|
+
type FieldFallback,
|
|
36
|
+
type FieldSetOptions,
|
|
37
|
+
type EnumField,
|
|
38
|
+
type MultiField,
|
|
39
|
+
type RangeField,
|
|
40
|
+
type NumberField,
|
|
41
|
+
type BooleanField,
|
|
42
|
+
type StringField,
|
|
43
|
+
type AnyField,
|
|
44
|
+
type FieldValue,
|
|
45
|
+
type FieldOption,
|
|
46
|
+
type FieldHandle,
|
|
47
|
+
type SearchValues,
|
|
48
|
+
type StoredValues,
|
|
49
|
+
// @internal — the seam `createSearchStore` is built on, not a consumer API.
|
|
50
|
+
type FieldCodec,
|
|
51
|
+
type StoreEntry,
|
|
52
|
+
type StoreCoreOptions,
|
|
53
|
+
type StoreCore,
|
|
54
|
+
type FieldUse,
|
|
55
|
+
type FieldWrite,
|
|
56
|
+
resolveFieldCodec,
|
|
57
|
+
createStoreCore,
|
|
58
|
+
} from './state/fields'
|
package/src/styles.css
CHANGED
|
@@ -68,6 +68,19 @@
|
|
|
68
68
|
font-size: max(16px, var(--input-fz, 1rem)) !important;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/* `data-numeric` — one typographic law, every element. A number read as a MEASUREMENT (a preset
|
|
72
|
+
like `7d`/`30d`, a KPI value, a table cell, a count tag) is mono with tabular figures, so
|
|
73
|
+
digits line up column-wise and a changing value does not reflow its neighbours. Declared here,
|
|
74
|
+
unscoped, because the attribute is the contract: `theme/segmented-control.module.css` and
|
|
75
|
+
`controls/controls.module.css` were each carrying their own copy, which is why a plain
|
|
76
|
+
`<Text data-numeric>` in a consumer silently rendered in the sans face. Those two module rules
|
|
77
|
+
STAY — they additionally set `font-size` for their own component, which is a component
|
|
78
|
+
decision, not a law. */
|
|
79
|
+
[data-numeric] {
|
|
80
|
+
font-family: var(--basalt-font-mono);
|
|
81
|
+
font-variant-numeric: tabular-nums;
|
|
82
|
+
}
|
|
83
|
+
|
|
71
84
|
/* Focus ring = the accent as INK, not as a fill. Mantine hardcodes the ring to
|
|
72
85
|
`var(--mantine-primary-color-filled)` — the SURFACE token, which is deliberately deep in both
|
|
73
86
|
schemes so a white label sits on it (see ACCENT in tokens/palette.ts). A ring is read against
|