basalt-ui 1.30.2 → 1.32.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 +1 -1
- package/CHANGELOG.md +54 -0
- package/MIGRATING.md +256 -12
- package/agent/rules/basalt-controls.md +19 -19
- package/agent/rules/basalt-mantine.md +5 -5
- package/configs/oxlint-plugin.js +371 -4
- package/configs/oxlint-plugin.test.ts +327 -2
- package/configs/oxlint.json +2 -1
- package/dist/agent-chat/composer.d.ts.map +1 -1
- package/dist/agent-chat/composer.js +41 -43
- package/dist/agent-chat/thread-workspace.d.ts +2 -2
- package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
- package/dist/agent-chat/thread-workspace.js +3 -4
- package/dist/agent-chat/use-keyboard-inset.d.ts +2 -0
- package/dist/agent-chat/use-keyboard-inset.d.ts.map +1 -0
- package/dist/agent-chat/use-keyboard-inset.js +34 -0
- package/dist/charts/cursor/touch-pin.d.ts +45 -0
- package/dist/charts/cursor/touch-pin.d.ts.map +1 -0
- package/dist/charts/cursor/touch-pin.js +79 -0
- package/dist/charts/hooks/use-discrete-cursor.d.ts +66 -0
- package/dist/charts/hooks/use-discrete-cursor.d.ts.map +1 -0
- package/dist/charts/hooks/use-discrete-cursor.js +198 -0
- package/dist/charts/hooks/use-hysteresis.d.ts +18 -0
- package/dist/charts/hooks/use-hysteresis.d.ts.map +1 -0
- package/dist/charts/hooks/use-hysteresis.js +12 -0
- package/dist/charts/hooks/useBandPlot.d.ts +7 -0
- package/dist/charts/hooks/useBandPlot.d.ts.map +1 -1
- package/dist/charts/hooks/useBandPlot.js +62 -18
- package/dist/charts/hooks/useChartCursor.d.ts +40 -11
- package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
- package/dist/charts/hooks/useChartCursor.js +102 -11
- package/dist/charts/index.d.ts +0 -1
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/index.js +0 -10
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
- package/dist/charts/kinds/BandStrip.js +47 -82
- package/dist/charts/kinds/Donut.d.ts +9 -3
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.js +110 -48
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +138 -145
- package/dist/charts/kinds/Heatmap.d.ts +7 -0
- package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
- package/dist/charts/kinds/Heatmap.js +37 -16
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
- package/dist/charts/kinds/MirroredBars.js +68 -111
- package/dist/charts/kinds/band-chrome.d.ts +31 -0
- package/dist/charts/kinds/band-chrome.d.ts.map +1 -0
- package/dist/charts/kinds/band-chrome.js +71 -0
- package/dist/charts/kinds/donut-layout.d.ts +47 -0
- package/dist/charts/kinds/donut-layout.d.ts.map +1 -0
- package/dist/charts/kinds/donut-layout.js +52 -0
- package/dist/charts/layout/auto-margin.d.ts +7 -2
- package/dist/charts/layout/auto-margin.d.ts.map +1 -1
- package/dist/charts/layout/auto-margin.js +12 -3
- package/dist/charts/layout/end-labels.d.ts +64 -0
- package/dist/charts/layout/end-labels.d.ts.map +1 -0
- package/dist/charts/layout/end-labels.js +76 -0
- package/dist/charts/primitives/Axes.d.ts +32 -4
- package/dist/charts/primitives/Axes.d.ts.map +1 -1
- package/dist/charts/primitives/Axes.js +62 -16
- package/dist/charts/primitives/CartesianChart.d.ts +7 -6
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +264 -145
- package/dist/charts/primitives/ChartCard.d.ts +0 -14
- package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
- package/dist/charts/primitives/ChartCard.js +56 -21
- package/dist/charts/primitives/ChartFrame.d.ts +28 -22
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +116 -34
- package/dist/charts/primitives/ChartLegend.d.ts +19 -15
- package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
- package/dist/charts/primitives/ChartLegend.js +86 -73
- package/dist/charts/primitives/ChartTooltip.d.ts +5 -0
- package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
- package/dist/charts/primitives/ChartTooltip.js +20 -4
- package/dist/charts/primitives/Crosshair.js +2 -2
- package/dist/charts/primitives/HoverOverlay.d.ts +29 -9
- package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
- package/dist/charts/primitives/HoverOverlay.js +54 -27
- package/dist/charts/primitives/HoverOverlay.module.css +13 -0
- package/dist/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
- package/dist/charts/primitives/chart-card-context.d.ts +33 -0
- package/dist/charts/primitives/chart-card-context.d.ts.map +1 -0
- package/dist/charts/primitives/chart-card-context.js +18 -0
- package/dist/charts/primitives/chart-frame-layout.d.ts +93 -113
- package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
- package/dist/charts/primitives/chart-frame-layout.js +75 -34
- package/dist/charts/primitives/chart-layout.d.ts +112 -0
- package/dist/charts/primitives/chart-layout.d.ts.map +1 -0
- package/dist/charts/primitives/chart-layout.js +175 -0
- package/dist/charts/primitives/chart-tier.d.ts +13 -11
- package/dist/charts/primitives/chart-tier.d.ts.map +1 -1
- package/dist/charts/primitives/chart-tier.js +42 -13
- package/dist/charts/primitives/legend-disclosure.d.ts +27 -0
- package/dist/charts/primitives/legend-disclosure.d.ts.map +1 -0
- package/dist/charts/primitives/legend-disclosure.js +153 -0
- package/dist/charts/series.d.ts +4 -1
- package/dist/charts/series.d.ts.map +1 -1
- package/dist/charts/utils/ticks.d.ts +27 -8
- package/dist/charts/utils/ticks.d.ts.map +1 -1
- package/dist/charts/utils/ticks.js +35 -11
- package/dist/common/errors.d.ts +11 -1
- package/dist/common/errors.d.ts.map +1 -1
- package/dist/common/errors.js +16 -2
- package/dist/common/hit-floor.d.ts +10 -0
- package/dist/common/hit-floor.d.ts.map +1 -0
- package/dist/common/hit-floor.js +6 -0
- package/dist/{shell → common}/isomorphic-layout-effect.d.ts +4 -3
- package/dist/common/isomorphic-layout-effect.d.ts.map +1 -0
- package/dist/common/use-measured-widths.d.ts +37 -0
- package/dist/common/use-measured-widths.d.ts.map +1 -0
- package/dist/common/use-measured-widths.js +51 -0
- package/dist/common/use-media-query.d.ts +6 -0
- package/dist/common/use-media-query.d.ts.map +1 -0
- package/dist/common/use-media-query.js +40 -0
- package/dist/common/validate.d.ts +9 -0
- package/dist/common/validate.d.ts.map +1 -1
- package/dist/common/validate.js +9 -4
- package/dist/content/article-layout.d.ts +4 -2
- package/dist/content/article-layout.d.ts.map +1 -1
- package/dist/content/article-layout.js +43 -3
- package/dist/content/article-layout.module.css +45 -4
- package/dist/content/article-layout.module.css.d.ts +3 -0
- package/dist/content/code-block.d.ts.map +1 -1
- package/dist/content/code-block.js +40 -2
- package/dist/content/code-block.module.css +32 -2
- package/dist/content/prose.module.css +13 -8
- package/dist/controls/actions.d.ts +29 -6
- package/dist/controls/actions.d.ts.map +1 -1
- package/dist/controls/actions.js +158 -27
- package/dist/controls/actions.module.css +14 -1
- package/dist/controls/actions.module.css.d.ts +1 -0
- package/dist/controls/controls.module.css +13 -31
- package/dist/controls/enum-filter.js +2 -2
- package/dist/controls/filter-context.d.ts +10 -0
- package/dist/controls/filter-context.d.ts.map +1 -1
- package/dist/controls/filter-context.js +2 -0
- package/dist/controls/filter-pill.d.ts.map +1 -1
- package/dist/controls/filter-pill.js +1 -0
- package/dist/controls/index.d.ts +1 -1
- package/dist/controls/index.d.ts.map +1 -1
- package/dist/controls/index.js +0 -2
- package/dist/controls/multi-select-filter.js +2 -2
- package/dist/controls/sync-button.module.css +2 -1
- package/dist/dashboard/section.d.ts.map +1 -1
- package/dist/dashboard/section.js +1 -0
- package/dist/dashboard/section.module.css +1 -26
- package/dist/dashboard/stat-card.d.ts +1 -1
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +4 -1
- package/dist/dashboard/stat-card.module.css +6 -9
- package/dist/dashboard/stat-group.module.css +33 -66
- package/dist/dashboard/widget-grid.d.ts.map +1 -1
- package/dist/dashboard/widget-grid.module.css +11 -38
- package/dist/dashboard/widget-header.d.ts +14 -4
- package/dist/dashboard/widget-header.d.ts.map +1 -1
- package/dist/dashboard/widget-header.js +23 -6
- package/dist/dashboard/widget-header.module.css +145 -133
- package/dist/dashboard/widget-header.module.css.d.ts +2 -0
- package/dist/data/data-table-state.d.ts +83 -0
- package/dist/data/data-table-state.d.ts.map +1 -0
- package/dist/data/data-table-state.js +139 -0
- package/dist/data/data-table.d.ts +56 -2
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +436 -336
- package/dist/data/data-table.module.css +94 -0
- package/dist/data/data-table.module.css.d.ts +11 -0
- package/dist/data/virtual-list.d.ts.map +1 -1
- package/dist/data/virtual-list.js +10 -0
- package/dist/forms/form-layout.d.ts.map +1 -1
- package/dist/forms/form-layout.js +3 -3
- package/dist/forms/form-layout.module.css +20 -5
- package/dist/forms/form-layout.module.css.d.ts +1 -0
- package/dist/guard/index.d.ts +8 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +68 -2
- package/dist/guard/types.d.ts +3 -3
- package/dist/guard/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/notifications/center.d.ts.map +1 -1
- package/dist/provider/index.d.ts +20 -1
- package/dist/provider/index.d.ts.map +1 -1
- package/dist/provider/index.js +30 -8
- package/dist/shell/app-brand.d.ts.map +1 -1
- package/dist/shell/app-brand.js +1 -0
- package/dist/shell/app-brand.module.css +2 -2
- package/dist/shell/app-header.module.css +18 -8
- package/dist/shell/app-main.module.css +54 -3
- package/dist/shell/app-main.module.css.d.ts +1 -0
- package/dist/shell/app-mobile-nav.module.css +16 -4
- package/dist/shell/app-sidebar.module.css +4 -6
- package/dist/shell/aside-docking.d.ts +31 -0
- package/dist/shell/aside-docking.d.ts.map +1 -0
- package/dist/shell/aside-docking.js +19 -0
- package/dist/shell/index.d.ts +1 -0
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +56 -16
- package/dist/shell/mobile-nav-model.d.ts +1 -1
- package/dist/shell/page-aside.d.ts +22 -3
- package/dist/shell/page-aside.d.ts.map +1 -1
- package/dist/shell/page-aside.js +27 -13
- package/dist/shell/page-bar.d.ts.map +1 -1
- package/dist/shell/page-bar.js +10 -5
- package/dist/shell/page-bar.module.css +10 -34
- package/dist/shell/use-breakpoint.d.ts +3 -4
- package/dist/shell/use-breakpoint.d.ts.map +1 -1
- package/dist/shell/use-breakpoint.js +3 -25
- package/dist/shell/use-size-class.d.ts +6 -0
- package/dist/shell/use-size-class.d.ts.map +1 -0
- package/dist/shell/use-size-class.js +19 -0
- package/dist/state/persisted-or-local.d.ts +4 -1
- package/dist/state/persisted-or-local.d.ts.map +1 -1
- package/dist/state/persisted-or-local.js +4 -3
- package/dist/state/persisted.d.ts +10 -0
- package/dist/state/persisted.d.ts.map +1 -1
- package/dist/state/persisted.js +44 -14
- package/dist/styles.css +41 -0
- package/dist/surfaces.d.ts +4 -4
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +11 -3
- package/dist/theme/controls.module.css +7 -0
- package/dist/theme/ctl-theme.d.ts +1 -0
- package/dist/theme/ctl-theme.d.ts.map +1 -1
- package/dist/theme/ctl-theme.js +12 -4
- package/dist/theme/floating.module.css +37 -0
- package/dist/theme/floating.module.css.d.ts +3 -0
- package/dist/theme/index.d.ts +2 -2
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +34 -5
- package/dist/theme/nav-link.module.css +7 -0
- package/dist/theme-lab/derive-controls.d.ts.map +1 -1
- package/dist/tokens/index.d.ts +7 -16
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +23 -28
- package/dist/tokens/palette.d.ts +5 -27
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +8 -30
- package/dist/tokens/size-classes.d.ts +40 -0
- package/dist/tokens/size-classes.d.ts.map +1 -0
- package/dist/tokens/size-classes.js +29 -0
- package/dist/tokens.css +8 -4
- package/llms.txt +3 -3
- package/package.json +18 -17
- package/src/agent-chat/composer.tsx +74 -47
- package/src/agent-chat/thread-workspace.tsx +8 -11
- package/src/agent-chat/use-keyboard-inset.ts +68 -0
- package/src/charts/cursor/touch-pin.ts +138 -0
- package/src/charts/hooks/use-discrete-cursor.ts +335 -0
- package/src/charts/hooks/use-hysteresis.ts +26 -0
- package/src/charts/hooks/useBandPlot.ts +90 -27
- package/src/charts/hooks/useChartCursor.ts +211 -26
- package/src/charts/index.ts +0 -8
- package/src/charts/kinds/BandStrip.tsx +25 -61
- package/src/charts/kinds/Donut.tsx +153 -62
- package/src/charts/kinds/DualPanel.tsx +21 -26
- package/src/charts/kinds/Heatmap.tsx +61 -22
- package/src/charts/kinds/MirroredBars.tsx +23 -65
- package/src/charts/kinds/band-chrome.tsx +91 -0
- package/src/charts/kinds/donut-layout.ts +82 -0
- package/src/charts/layout/auto-margin.ts +24 -5
- package/src/charts/layout/end-labels.ts +135 -0
- package/src/charts/primitives/Axes.tsx +112 -21
- package/src/charts/primitives/CartesianChart.tsx +282 -111
- package/src/charts/primitives/ChartCard.tsx +119 -48
- package/src/charts/primitives/ChartFrame.tsx +204 -60
- package/src/charts/primitives/ChartLegend.tsx +115 -78
- package/src/charts/primitives/ChartTooltip.tsx +24 -3
- package/src/charts/primitives/Crosshair.tsx +2 -2
- package/src/charts/primitives/HoverOverlay.module.css +13 -0
- package/src/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
- package/src/charts/primitives/HoverOverlay.tsx +62 -20
- package/src/charts/primitives/chart-card-context.ts +46 -0
- package/src/charts/primitives/chart-frame-layout.ts +207 -182
- package/src/charts/primitives/chart-layout.ts +318 -0
- package/src/charts/primitives/chart-tier.tsx +66 -21
- package/src/charts/primitives/legend-disclosure.tsx +177 -0
- package/src/charts/series.ts +4 -1
- package/src/charts/utils/ticks.ts +95 -19
- package/src/common/errors.ts +25 -1
- package/src/common/hit-floor.ts +9 -0
- package/src/{shell → common}/isomorphic-layout-effect.ts +4 -3
- package/src/common/use-measured-widths.ts +88 -0
- package/src/common/use-media-query.ts +56 -0
- package/src/common/validate.ts +17 -4
- package/src/content/article-layout.module.css +45 -4
- package/src/content/article-layout.module.css.d.ts +3 -0
- package/src/content/article-layout.tsx +60 -5
- package/src/content/code-block.module.css +32 -2
- package/src/content/code-block.tsx +47 -3
- package/src/content/prose.module.css +13 -8
- package/src/controls/actions.module.css +14 -1
- package/src/controls/actions.module.css.d.ts +1 -0
- package/src/controls/actions.tsx +223 -75
- package/src/controls/controls.module.css +13 -31
- package/src/controls/enum-filter.tsx +2 -2
- package/src/controls/filter-context.tsx +11 -0
- package/src/controls/filter-pill.tsx +1 -0
- package/src/controls/index.ts +0 -1
- package/src/controls/multi-select-filter.tsx +2 -2
- package/src/controls/sync-button.module.css +2 -1
- package/src/dashboard/section.module.css +1 -26
- package/src/dashboard/section.tsx +1 -0
- package/src/dashboard/stat-card.module.css +6 -9
- package/src/dashboard/stat-card.tsx +6 -3
- package/src/dashboard/stat-group.module.css +33 -66
- package/src/dashboard/widget-grid.module.css +11 -38
- package/src/dashboard/widget-grid.tsx +7 -6
- package/src/dashboard/widget-header.module.css +145 -133
- package/src/dashboard/widget-header.module.css.d.ts +2 -0
- package/src/dashboard/widget-header.tsx +45 -11
- package/src/data/data-table-state.ts +218 -0
- package/src/data/data-table.module.css +94 -0
- package/src/data/data-table.module.css.d.ts +11 -0
- package/src/data/data-table.tsx +547 -299
- package/src/data/virtual-list.tsx +31 -0
- package/src/forms/form-layout.module.css +20 -5
- package/src/forms/form-layout.module.css.d.ts +1 -0
- package/src/forms/form-layout.tsx +12 -10
- package/src/guard/index.ts +188 -3
- package/src/guard/types.ts +3 -2
- package/src/index.ts +2 -0
- package/src/notifications/center.tsx +3 -0
- package/src/provider/index.tsx +60 -7
- package/src/shell/app-brand.module.css +2 -2
- package/src/shell/app-brand.tsx +1 -0
- package/src/shell/app-header.module.css +18 -8
- package/src/shell/app-main.module.css +54 -3
- package/src/shell/app-main.module.css.d.ts +1 -0
- package/src/shell/app-mobile-nav.module.css +16 -4
- package/src/shell/app-sidebar.module.css +4 -6
- package/src/shell/aside-docking.ts +54 -0
- package/src/shell/index.tsx +178 -129
- package/src/shell/mobile-nav-model.ts +1 -1
- package/src/shell/page-aside.tsx +70 -21
- package/src/shell/page-bar.module.css +10 -34
- package/src/shell/page-bar.tsx +10 -5
- package/src/shell/use-breakpoint.ts +9 -45
- package/src/shell/use-size-class.ts +31 -0
- package/src/state/persisted-or-local.ts +10 -4
- package/src/state/persisted.ts +76 -17
- package/src/styles.css +41 -0
- package/src/surfaces.ts +9 -1
- package/src/theme/controls.module.css +7 -0
- package/src/theme/ctl-theme.tsx +15 -3
- package/src/theme/floating.module.css +37 -0
- package/src/theme/floating.module.css.d.ts +3 -0
- package/src/theme/index.ts +46 -8
- package/src/theme/nav-link.module.css +7 -0
- package/src/theme-lab/derive-controls.tsx +2 -0
- package/src/tokens/index.ts +20 -30
- package/src/tokens/palette.ts +6 -28
- package/src/tokens/size-classes.ts +46 -0
- package/dist/shell/isomorphic-layout-effect.d.ts.map +0 -1
- /package/dist/{shell → common}/isomorphic-layout-effect.js +0 -0
|
@@ -12,47 +12,123 @@ const X_TICK_LABEL_GAP = 8
|
|
|
12
12
|
* `useBandPlot` and `DualPanel` — the three call sites that used to reimplement this formula.
|
|
13
13
|
*
|
|
14
14
|
* `fontPx` defaults to `VX.axisFont`; a phone-tier chart passes its own smaller tick font
|
|
15
|
-
* (`
|
|
15
|
+
* (`chartMetrics().axisFont`) so the spacing is derived from the size actually painted.
|
|
16
16
|
*/
|
|
17
17
|
export function xLabelPxFor(labels: string[], fontPx: number = VX.axisFont): number {
|
|
18
18
|
return maxTextWidth(labels, fontPx) + X_TICK_LABEL_GAP
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The horizontal room one 45°-rotated x label takes: its projected box (`cos 45°` of the width plus
|
|
23
|
+
* one line box) plus the gap. A tilted label still competes with its neighbour for this much, so
|
|
24
|
+
* thinning by the bare `VX.minPxPerTick` floor let two of them touch as soon as the plot widened.
|
|
25
|
+
*/
|
|
26
|
+
export function rotatedXLabelPx(labels: string[], fontPx: number = VX.axisFont): number {
|
|
27
|
+
return (
|
|
28
|
+
Math.ceil(COS_45 * (maxTextWidth(labels, fontPx) + Math.ceil(fontPx * 1.35))) + X_TICK_LABEL_GAP
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
/**
|
|
22
33
|
* Pick evenly-spaced tick values that fit the available width.
|
|
23
34
|
*
|
|
24
35
|
* `labelPx` is the width one formatted label actually needs (measured, plus the gap it wants from
|
|
25
|
-
* its neighbour) and
|
|
26
|
-
*
|
|
27
|
-
* `Mar 08 14:00`
|
|
28
|
-
* "measure what you paint" law
|
|
36
|
+
* its neighbour) and REPLACES the `VX.minPxPerTick` constant whenever it is supplied, in either
|
|
37
|
+
* direction — a wide label needs more room than the constant assumed (a `formatX` returning
|
|
38
|
+
* `Mar 08 14:00` used to overlap at every width, the one side of the chart that did not follow
|
|
39
|
+
* §1's "measure what you paint" law), and a narrow one (single-digit band labels) can pack in
|
|
40
|
+
* MORE ticks than the constant would allow, which the floor used to suppress (R2C-11). Omit it and
|
|
41
|
+
* the constant is what governs, same as before.
|
|
29
42
|
*
|
|
30
43
|
* The final key is still appended when the step misses the last index — a thinned axis would
|
|
31
44
|
* otherwise paint no label at the right edge at all — but it no longer prints ON TOP of its
|
|
32
45
|
* neighbour. An appended tick lands a PARTIAL step from the last one on the grid, so at any tick
|
|
33
46
|
* count wide enough labels overlap there; measured at `/charts-stress` block (f1), `Mar 13 14:00`
|
|
34
47
|
* and `Mar 14 14:00` printed over each other at 1440px. When that gap is narrower than one label,
|
|
35
|
-
* the grid tick before it is dropped instead.
|
|
36
|
-
*
|
|
48
|
+
* the grid tick before it is dropped instead.
|
|
49
|
+
*
|
|
50
|
+
* `anchorTerminals` (default false) mirrors the SAME flag `autoMargin`/`Axes.tsx` take: when the
|
|
51
|
+
* axis anchors index 0 and the last index at their TEXT edge rather than their centre
|
|
52
|
+
* (`terminalAnchor`), each reaches a FULL label's width toward its neighbour instead of half, so
|
|
53
|
+
* the plain per-tick pitch above (sized for two centred labels) isn't enough at either boundary —
|
|
54
|
+
* `Bars` with 30 daily "Jan DD HH:MM" keys overlapped there at phone width even though the interior
|
|
55
|
+
* pitch was fine (R2C-6). The SAME rule then applies, symmetrically, to the tick right after
|
|
56
|
+
* index 0. 1.5× is a deliberately generous (cheap, simple) stand-in for "one label plus half a
|
|
57
|
+
* label" rather than measuring the split precisely; it's skipped when `labelPx` is only the bare
|
|
58
|
+
* `VX.minPxPerTick` floor, which has no relationship to how wide a label actually is. Index 0
|
|
59
|
+
* always survives (never the one dropped here) and `last` is always (re-)added below, so dropping
|
|
60
|
+
* any OTHER index never breaks the "at least 2 ticks" floor.
|
|
37
61
|
*/
|
|
38
|
-
export function smartTicks(
|
|
62
|
+
export function smartTicks(
|
|
63
|
+
dates: string[],
|
|
64
|
+
xMax: number,
|
|
65
|
+
labelPx?: number,
|
|
66
|
+
anchorTerminals = false,
|
|
67
|
+
): string[] {
|
|
39
68
|
if (dates.length === 0) return []
|
|
40
|
-
|
|
69
|
+
// A non-positive `labelPx` (a caller's own arithmetic gone wrong, or an explicit 0) is not "no
|
|
70
|
+
// measurement" — `labelPx ?? VX.minPxPerTick` let it through as-is, and `xMax / 0` then floors
|
|
71
|
+
// `maxTicks` to `Infinity`, which used to make EVERY tick "fit" and skip thinning entirely.
|
|
72
|
+
const perTick = labelPx !== undefined && labelPx > 0 ? labelPx : VX.minPxPerTick
|
|
41
73
|
const maxTicks = Math.max(2, Math.floor(xMax / perTick))
|
|
42
|
-
if (dates.length <= maxTicks) return dates
|
|
43
|
-
const step = Math.ceil(dates.length / maxTicks)
|
|
44
74
|
const last = dates.length - 1
|
|
75
|
+
// `scalePoint({ padding: 0.5 })` spreads N points over `xMax`, so one index is `xMax / N` wide.
|
|
76
|
+
const pxPerIndex = dates.length > 0 ? xMax / dates.length : 0
|
|
77
|
+
// Only a REAL measured label (not the bare `VX.minPxPerTick` floor, which knows nothing about how
|
|
78
|
+
// wide a label actually is) earns the wider terminal clearance below.
|
|
79
|
+
const anchored = anchorTerminals && labelPx !== undefined && labelPx > 0
|
|
45
80
|
|
|
46
81
|
const keep = new Set<number>()
|
|
47
|
-
|
|
82
|
+
if (dates.length <= maxTicks) {
|
|
83
|
+
// Every date already fits with no thinning — round 3: this used to `return dates` here,
|
|
84
|
+
// skipping the terminal-clearance pass below entirely. A terminal-anchored label still reaches
|
|
85
|
+
// a FULL label's width toward its neighbour (not half, like a centred one), which the plain
|
|
86
|
+
// per-tick spacing above does not itself guarantee, so the same pass has to run here too.
|
|
87
|
+
for (let i = 0; i <= last; i += 1) keep.add(i)
|
|
88
|
+
} else {
|
|
89
|
+
const step = Math.ceil(dates.length / maxTicks)
|
|
90
|
+
for (let i = 0; i <= last; i += step) keep.add(i)
|
|
48
91
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
92
|
+
const BOUNDARY_FACTOR = 1.5
|
|
93
|
+
const boundaryPx = anchored ? perTick * BOUNDARY_FACTOR : perTick
|
|
94
|
+
// The final key is appended unconditionally, even when the step misses it — but only dropping
|
|
95
|
+
// the grid tick right before it when the two would otherwise sit closer than one tick's width.
|
|
96
|
+
// This is the "would an APPEND overlap" concern; it does nothing when the step already lands on
|
|
97
|
+
// `last` (nothing is being appended), which is the gap the anchored pass below closes.
|
|
98
|
+
const lastOnGrid = Math.floor(last / step) * step
|
|
99
|
+
if (lastOnGrid !== last) {
|
|
100
|
+
const gapPx = (last - lastOnGrid) * pxPerIndex
|
|
101
|
+
if (lastOnGrid > 0 && gapPx > 0 && gapPx < boundaryPx) keep.delete(lastOnGrid)
|
|
102
|
+
keep.add(last)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (anchored && keep.size > 1) {
|
|
107
|
+
const BOUNDARY_FACTOR = 1.5
|
|
108
|
+
const boundaryPx = perTick * BOUNDARY_FACTOR
|
|
109
|
+
// START: the kept index right after 0 needs a FULL label's clearance from it, not the plain
|
|
110
|
+
// per-tick pitch a centred label would settle for.
|
|
111
|
+
const sorted = [...keep].toSorted((a, b) => a - b)
|
|
112
|
+
const second = sorted[1]
|
|
113
|
+
if (second !== undefined && second !== last && second * pxPerIndex < boundaryPx) {
|
|
114
|
+
keep.delete(second)
|
|
115
|
+
}
|
|
116
|
+
// END: the same check, mirrored, against whatever is now the second-to-last kept index —
|
|
117
|
+
// computed AFTER the START drop above (not from the original grid), and unconditional of
|
|
118
|
+
// whether `last` was naturally on the grid or had to be appended (round 3's fix: previously this
|
|
119
|
+
// only ran inside the `lastOnGrid !== last` branch above, so a grid that already landed on
|
|
120
|
+
// `last` skipped it — the 13-dates/60px-label/260px-width counter-example this closes: step 4
|
|
121
|
+
// lands on index 12, leaving its neighbour at index 8 only 80px away, under the 90px a
|
|
122
|
+
// full-width terminal label needs).
|
|
123
|
+
const afterHead = [...keep].toSorted((a, b) => a - b)
|
|
124
|
+
const secondLast = afterHead[afterHead.length - 2]
|
|
125
|
+
if (
|
|
126
|
+
secondLast !== undefined &&
|
|
127
|
+
secondLast !== 0 &&
|
|
128
|
+
(last - secondLast) * pxPerIndex < boundaryPx
|
|
129
|
+
) {
|
|
130
|
+
keep.delete(secondLast)
|
|
131
|
+
}
|
|
56
132
|
}
|
|
57
133
|
|
|
58
134
|
return dates.filter((_, i) => keep.has(i))
|
package/src/common/errors.ts
CHANGED
|
@@ -48,10 +48,11 @@ export function deprecatedProp(
|
|
|
48
48
|
oldProp: string,
|
|
49
49
|
newProp: string,
|
|
50
50
|
removeIn: string,
|
|
51
|
+
note?: string,
|
|
51
52
|
): string {
|
|
52
53
|
return message(
|
|
53
54
|
component,
|
|
54
|
-
`prop "${oldProp}" is deprecated — use "${newProp}" instead. "${oldProp}" is removed in ${removeIn}
|
|
55
|
+
`prop "${oldProp}" is deprecated — use "${newProp}" instead. "${oldProp}" is removed in ${removeIn}.${note === undefined ? '' : ` ${note}`}`,
|
|
55
56
|
)
|
|
56
57
|
}
|
|
57
58
|
|
|
@@ -69,6 +70,19 @@ export function ignoredProp(component: string, prop: string, because: string): s
|
|
|
69
70
|
return message(component, `prop "${prop}" is ignored because ${because}`)
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
/**
|
|
74
|
+
* A numeric `height` the caller stated that, once the legend band is taken out, leaves the plot
|
|
75
|
+
* under the floor. The frame keeps the plot at the floor and grows past the number, so the chart
|
|
76
|
+
* is taller than the caller asked for and nothing else says why.
|
|
77
|
+
*/
|
|
78
|
+
export function plotBelowFloor(component: string, height: number, floor: number): string {
|
|
79
|
+
return message(
|
|
80
|
+
component,
|
|
81
|
+
`prop "height" (${height}) leaves less than the ${floor}px plot floor once the legend band is ` +
|
|
82
|
+
'subtracted, so the frame grows past it. Raise the height or drop the legend.',
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
72
86
|
/**
|
|
73
87
|
* An imperative call needs a layer that is not there — the optional peer is not installed, or the
|
|
74
88
|
* mount that subscribes to its event bus is disabled. Both halves are named because the remedy
|
|
@@ -86,6 +100,16 @@ export function duplicateMount(component: string): string {
|
|
|
86
100
|
)
|
|
87
101
|
}
|
|
88
102
|
|
|
103
|
+
/** Two frames inside one `ChartCard` both want the header legend slot (`docs/waves/PLAN.md` wave
|
|
104
|
+
* 3, P2-9) — only the first to mount keeps it, every later one falls back to its own band legend
|
|
105
|
+
* rather than silently overwriting the portal. */
|
|
106
|
+
export function legendSlotContention(component: string): string {
|
|
107
|
+
return message(
|
|
108
|
+
component,
|
|
109
|
+
'more than one chart in this ChartCard wants the header legend slot — only the first keeps it, the rest fall back to their own band legend.',
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
89
113
|
// ── Transport-agnostic error decoding ────────────────────────────────────────────────────────────
|
|
90
114
|
//
|
|
91
115
|
// Deliberately in this module, with NO `@tanstack/react-query` import — `dashboard/query-state.tsx`
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pointer-tier hit floors, in px — WCAG 2.5.8 Target Size Minimum (24) and 2.5.5 Target Size
|
|
3
|
+
* Enhanced (44). One home for the number `--vx-hit` resolves to under `(pointer: coarse)` and for
|
|
4
|
+
* the components that compare a MEASURED px against it (a virtual list's row height, a column-fold
|
|
5
|
+
* toggle's width, the chart legend's dot pitch). Deliberately not re-exported through
|
|
6
|
+
* `common/index.ts`: a CSS-var's numeric value is not a public symbol.
|
|
7
|
+
*/
|
|
8
|
+
export const HIT_FINE = 24
|
|
9
|
+
export const HIT_COARSE = 44
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* nothing on the server" warning. The branch reads a global that cannot change between renders, so
|
|
6
6
|
* the hook identity is stable across every render of every caller.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Lives in `common/` rather than beside the shell because both halves use it — the shell chrome, the
|
|
9
|
+
* provider, and the Mantine-free measured-fold core — and `common/` is the one module both may
|
|
10
|
+
* import from. One module rather than one `const` per file. Not exported from any barrel; import it
|
|
11
|
+
* by path.
|
|
11
12
|
*/
|
|
12
13
|
import { useEffect, useLayoutEffect } from 'react'
|
|
13
14
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useRef } from 'react'
|
|
2
|
+
import type { MutableRefObject } from 'react'
|
|
3
|
+
import { useIsomorphicLayoutEffect } from './isomorphic-layout-effect'
|
|
4
|
+
|
|
5
|
+
/** One measured element's box, cached while it was still visible (a folded child is gone from the DOM). */
|
|
6
|
+
export type MeasuredBox = { width: number; height: number }
|
|
7
|
+
|
|
8
|
+
export interface UseMeasuredWidthsOptions {
|
|
9
|
+
/** The box to observe, resolved on each effect run. */
|
|
10
|
+
resolveRoot: () => HTMLElement | null
|
|
11
|
+
/** Change this string to drop every cached box — a folded child cannot report its unfolded width. */
|
|
12
|
+
signature: string
|
|
13
|
+
/** Skip measuring entirely (the feature is off). Default true. */
|
|
14
|
+
enabled?: boolean
|
|
15
|
+
/** Bump to re-measure on demand beyond a resize (a post-reset commit). */
|
|
16
|
+
nonce?: number
|
|
17
|
+
/** Extra descendants to observe besides the root (an inner table whose min-content can move). */
|
|
18
|
+
extra?: (root: HTMLElement) => (Element | null)[]
|
|
19
|
+
/** Read the current boxes from `root`, fill the cache, and plan — runs on every measure. */
|
|
20
|
+
onMeasure: (root: HTMLElement, boxes: MutableRefObject<Map<string, MeasuredBox>>) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface UseMeasuredWidthsResult {
|
|
24
|
+
boxes: MutableRefObject<Map<string, MeasuredBox>>
|
|
25
|
+
/** Re-run the measurement by hand (a web font settling changes every width without a resize). */
|
|
26
|
+
remeasure: () => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The measured-fold core every fold planner shares: caches each measured box while it is visible,
|
|
31
|
+
* re-reads on a `ResizeObserver` of the root (plus {@link UseMeasuredWidthsOptions.extra}), and
|
|
32
|
+
* clears the cache when the {@link UseMeasuredWidthsOptions.signature} changes. The caller keeps its
|
|
33
|
+
* own element reading and pure plan function in `onMeasure`.
|
|
34
|
+
*
|
|
35
|
+
* A folded child is gone from the DOM but its cached box survives, so a resize never needs it back
|
|
36
|
+
* on screen; the caller's `onMeasure` decides which boxes to cache (e.g. skip an item that is
|
|
37
|
+
* currently folded, whose painted width is the collapsed one).
|
|
38
|
+
*/
|
|
39
|
+
export function useMeasuredWidths(options: UseMeasuredWidthsOptions): UseMeasuredWidthsResult {
|
|
40
|
+
const { resolveRoot, signature, enabled = true, nonce = 0, extra, onMeasure } = options
|
|
41
|
+
const boxes = useRef<Map<string, MeasuredBox>>(new Map())
|
|
42
|
+
const signatureRef = useRef(signature)
|
|
43
|
+
const remeasureRef = useRef<() => void>(() => {})
|
|
44
|
+
|
|
45
|
+
// Read through refs so a fresh closure each render does not re-run the effect.
|
|
46
|
+
const resolveRef = useRef(resolveRoot)
|
|
47
|
+
resolveRef.current = resolveRoot
|
|
48
|
+
const extraRef = useRef(extra)
|
|
49
|
+
extraRef.current = extra
|
|
50
|
+
const onMeasureRef = useRef(onMeasure)
|
|
51
|
+
onMeasureRef.current = onMeasure
|
|
52
|
+
|
|
53
|
+
useIsomorphicLayoutEffect(() => {
|
|
54
|
+
if (signatureRef.current !== signature) {
|
|
55
|
+
signatureRef.current = signature
|
|
56
|
+
boxes.current.clear()
|
|
57
|
+
}
|
|
58
|
+
if (!enabled) {
|
|
59
|
+
// A disabled/detached root leaves no `measure` to re-run — without this a stale closure over
|
|
60
|
+
// the PREVIOUS root's `onMeasure` survives on `remeasureRef` and a later `remeasure()` call
|
|
61
|
+
// (the web-font effect in `controls/actions.tsx`) would measure a root that is no longer live.
|
|
62
|
+
remeasureRef.current = () => {}
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
const root = resolveRef.current()
|
|
66
|
+
if (root === null) {
|
|
67
|
+
remeasureRef.current = () => {}
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
const measure = (): void => {
|
|
71
|
+
onMeasureRef.current(root, boxes)
|
|
72
|
+
}
|
|
73
|
+
remeasureRef.current = measure
|
|
74
|
+
if (typeof ResizeObserver === 'undefined') {
|
|
75
|
+
measure()
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
const observer = new ResizeObserver(measure)
|
|
79
|
+
observer.observe(root)
|
|
80
|
+
for (const el of extraRef.current?.(root) ?? []) {
|
|
81
|
+
if (el !== null) observer.observe(el)
|
|
82
|
+
}
|
|
83
|
+
measure()
|
|
84
|
+
return () => observer.disconnect()
|
|
85
|
+
}, [enabled, signature, nonce])
|
|
86
|
+
|
|
87
|
+
return { boxes, remeasure: () => remeasureRef.current() }
|
|
88
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useMediaQuery` — the one sanctioned raw `window.matchMedia` read
|
|
3
|
+
* (`basalt/raw-breakpoint`'s `RAW_BREAKPOINT_EXEMPT_FILE`). Every other breakpoint/pointer-tier
|
|
4
|
+
* hook (`useSizeClass`, `useCoarsePointer`, the shell's own min-width dock check) is built on this
|
|
5
|
+
* one primitive instead of allocating its own `MediaQueryList`.
|
|
6
|
+
*
|
|
7
|
+
* `useSyncExternalStore`, so SSR and the hydration pass read `serverFallback` and the real match
|
|
8
|
+
* lands one commit later — never a mismatch mid-hydration. One `MediaQueryList` PER QUERY STRING,
|
|
9
|
+
* shared module-wide: `useSizeClass`'s two boundaries and a chart's coarse-pointer read resolve
|
|
10
|
+
* through the same handful of live objects rather than each allocating their own.
|
|
11
|
+
*/
|
|
12
|
+
import { useCallback, useSyncExternalStore } from 'react'
|
|
13
|
+
|
|
14
|
+
// Keyed by the LIVE `window.matchMedia` function, not just the query string: a test that swaps in
|
|
15
|
+
// its own stub (a real pattern in this repo — `provider/host.test.tsx`) gets a fresh inner cache
|
|
16
|
+
// for free, rather than reading a `MediaQueryList` a previous test's stub produced.
|
|
17
|
+
const cache = new WeakMap<typeof window.matchMedia, Map<string, MediaQueryList>>()
|
|
18
|
+
|
|
19
|
+
function mediaQueryList(query: string): MediaQueryList | null {
|
|
20
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return null
|
|
21
|
+
let byQuery = cache.get(window.matchMedia)
|
|
22
|
+
if (byQuery === undefined) {
|
|
23
|
+
byQuery = new Map()
|
|
24
|
+
cache.set(window.matchMedia, byQuery)
|
|
25
|
+
}
|
|
26
|
+
const cached = byQuery.get(query)
|
|
27
|
+
if (cached !== undefined) return cached
|
|
28
|
+
const list = window.matchMedia(query)
|
|
29
|
+
byQuery.set(query, list)
|
|
30
|
+
return list
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A one-off, non-reactive read — for a dev-only check that runs once inside an effect/callback
|
|
34
|
+
* rather than subscribing for the component's lifetime. Prefer `useMediaQuery` for anything that
|
|
35
|
+
* should re-render on a match change. */
|
|
36
|
+
export function readMediaQuery(query: string, fallback: boolean): boolean {
|
|
37
|
+
return mediaQueryList(query)?.matches ?? fallback
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function useMediaQuery(query: string, serverFallback: boolean): boolean {
|
|
41
|
+
const subscribe = useCallback(
|
|
42
|
+
(onStoreChange: () => void) => {
|
|
43
|
+
const list = mediaQueryList(query)
|
|
44
|
+
if (list === null) return () => {}
|
|
45
|
+
list.addEventListener('change', onStoreChange)
|
|
46
|
+
return () => list.removeEventListener('change', onStoreChange)
|
|
47
|
+
},
|
|
48
|
+
[query],
|
|
49
|
+
)
|
|
50
|
+
const getSnapshot = useCallback(
|
|
51
|
+
() => mediaQueryList(query)?.matches ?? serverFallback,
|
|
52
|
+
[query, serverFallback],
|
|
53
|
+
)
|
|
54
|
+
const getServerSnapshot = useCallback(() => serverFallback, [serverFallback])
|
|
55
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
|
56
|
+
}
|
package/src/common/validate.ts
CHANGED
|
@@ -51,16 +51,29 @@ export function useValidateProps(
|
|
|
51
51
|
const messages = Array.isArray(result) ? result : [result]
|
|
52
52
|
for (const raw of messages) {
|
|
53
53
|
if (raw === null || raw === undefined) continue
|
|
54
|
-
|
|
55
|
-
if (reported.has(key)) continue
|
|
56
|
-
reported.add(key)
|
|
57
|
-
console.error(raw)
|
|
54
|
+
reportOnce(component, raw)
|
|
58
55
|
}
|
|
59
56
|
// The deps ARE the caller's contract: this hook cannot know what `check` closes over.
|
|
60
57
|
// oxlint-disable-next-line react-hooks/exhaustive-deps -- the caller owns the dep list
|
|
61
58
|
}, deps)
|
|
62
59
|
}
|
|
63
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The same once-per-`(component, message)` dev diagnostic {@link useValidateProps} logs, for a
|
|
63
|
+
* caller that already owns the effect a check would otherwise race — a decision made synchronously
|
|
64
|
+
* inside a `useLayoutEffect` (e.g. `ChartFrame`'s header-slot claim, P2-9) has no business waiting a
|
|
65
|
+
* further render for a REACTIVE check on state that effect itself just set: that state update lags
|
|
66
|
+
* one commit behind the decision, and a passive-effect check racing it warns about instances that
|
|
67
|
+
* are about to win in the very next render.
|
|
68
|
+
*/
|
|
69
|
+
export function reportOnce(component: string, message: string): void {
|
|
70
|
+
if (!isDev()) return
|
|
71
|
+
const key = `${component} ${message}`
|
|
72
|
+
if (reported.has(key)) return
|
|
73
|
+
reported.add(key)
|
|
74
|
+
console.error(message)
|
|
75
|
+
}
|
|
76
|
+
|
|
64
77
|
/** Test seam. The once-per-message Set is module state, so a suite has to be able to clear it. */
|
|
65
78
|
export function resetValidatedProps(): void {
|
|
66
79
|
reported.clear()
|
|
@@ -17,12 +17,19 @@
|
|
|
17
17
|
grid-template-columns: minmax(0, var(--vx-prose-measure));
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/* The literal below MUST equal `BREAKPOINTS.article` (src/tokens/index.ts) — a `@media`
|
|
20
|
+
/* The literal below MUST equal `BREAKPOINTS.article - 0.1` (src/tokens/index.ts) — a `@media`
|
|
21
21
|
* condition is resolved before custom properties exist, so `max-width: var(--x)` is invalid CSS
|
|
22
|
-
* and a stylesheet with a responsive rule has no choice but to write the number. The
|
|
23
|
-
*
|
|
22
|
+
* and a stylesheet with a responsive rule has no choice but to write the number. The 0.1px offset
|
|
23
|
+
* is `tokens/size-classes.ts`' own `sizeClassMaxEm` convention (Mantine's own): the JS side
|
|
24
|
+
* (`article-layout.tsx`'s `useSizeClass() === 'expanded'`) reads a plain `min-width: 1200px`,
|
|
25
|
+
* inclusive AT 1200 — writing this query as a bare `max-width: 1200px` would ALSO be inclusive at
|
|
26
|
+
* exactly 1200px, so at that one width both conditions would fire at once: JS renders the rail,
|
|
27
|
+
* and this rule then hides it with `display: none`, leaving neither the rail nor the trigger
|
|
28
|
+
* visible. `1199.9px` makes the two conditions strict complements with no such gap. The token is
|
|
29
|
+
* the readable half; `article-layout.breakpoint.test.ts` asserts the two agree, the same
|
|
24
30
|
* "literal must match the token" idiom as `styles.floor.test.ts`. Change both or neither. */
|
|
25
|
-
|
|
31
|
+
/* theme-allow raw-media-query — deliberate literal pinned to BREAKPOINTS.article, outside shell/** */
|
|
32
|
+
@media (max-width: 1199.9px) {
|
|
26
33
|
.root {
|
|
27
34
|
grid-template-columns: minmax(0, var(--vx-prose-measure));
|
|
28
35
|
}
|
|
@@ -34,6 +41,7 @@
|
|
|
34
41
|
|
|
35
42
|
.header {
|
|
36
43
|
grid-column: 1;
|
|
44
|
+
grid-row: 1;
|
|
37
45
|
display: flex;
|
|
38
46
|
flex-direction: column;
|
|
39
47
|
gap: var(--vx-space-article-header-gap);
|
|
@@ -71,11 +79,13 @@
|
|
|
71
79
|
|
|
72
80
|
.content {
|
|
73
81
|
grid-column: 1;
|
|
82
|
+
grid-row: 2;
|
|
74
83
|
min-width: 0;
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
.tocRail {
|
|
78
87
|
grid-column: 2;
|
|
88
|
+
grid-row: 2;
|
|
79
89
|
position: sticky;
|
|
80
90
|
/* The rail sticks inside whatever scrolls it — `AppShell.Main` in a shell, the document without
|
|
81
91
|
* one — and NOTHING overhangs that box: the app header and `PageBar` row 2's band are both shell
|
|
@@ -87,8 +97,39 @@
|
|
|
87
97
|
align-self: start;
|
|
88
98
|
}
|
|
89
99
|
|
|
100
|
+
/* The rail's below-breakpoint replacement (docs/CONTENT-SPEC.md §7): a small sticky trigger that
|
|
101
|
+
* opens the SAME `TableOfContents` in a `Popover` instead of the rail vanishing. Rendered by JS
|
|
102
|
+
* (`useSizeClass() !== 'expanded'`), never both at once with `.tocRail` — no media query needed
|
|
103
|
+
* here, only the sticky/alignment behaviour for whenever it IS mounted. `grid-row: 2` is EXPLICIT,
|
|
104
|
+
* not left to auto-placement: `.content` also being `grid-column: 1` moves the auto-placement
|
|
105
|
+
* cursor's row on past row 2 by the time this item (also column 1) is placed, landing it in its
|
|
106
|
+
* own row BELOW the content instead of at its top — the whole point of this trigger. */
|
|
107
|
+
.tocTrigger {
|
|
108
|
+
grid-column: 1;
|
|
109
|
+
grid-row: 2;
|
|
110
|
+
justify-self: end;
|
|
111
|
+
align-self: start;
|
|
112
|
+
position: sticky;
|
|
113
|
+
top: var(--vx-space-sticky-header-clearance);
|
|
114
|
+
z-index: 1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.tocTriggerLabel {
|
|
118
|
+
font-family: var(--basalt-font-mono);
|
|
119
|
+
font-size: var(--vx-text-micro);
|
|
120
|
+
letter-spacing: 0.06em;
|
|
121
|
+
text-transform: uppercase;
|
|
122
|
+
color: var(--vx-faint);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.tocDropdown {
|
|
126
|
+
min-width: 220px;
|
|
127
|
+
max-width: min(320px, calc(100vw - 32px));
|
|
128
|
+
}
|
|
129
|
+
|
|
90
130
|
.footer {
|
|
91
131
|
grid-column: 1;
|
|
132
|
+
grid-row: 3;
|
|
92
133
|
display: grid;
|
|
93
134
|
grid-template-columns: 1fr 1fr;
|
|
94
135
|
gap: var(--vx-space-article-footer-gap);
|
|
@@ -11,6 +11,9 @@ declare const classes: {
|
|
|
11
11
|
readonly metaRow: string
|
|
12
12
|
readonly content: string
|
|
13
13
|
readonly tocRail: string
|
|
14
|
+
readonly tocTrigger: string
|
|
15
|
+
readonly tocTriggerLabel: string
|
|
16
|
+
readonly tocDropdown: string
|
|
14
17
|
readonly footer: string
|
|
15
18
|
readonly navCell: string
|
|
16
19
|
readonly navCellNext: string
|
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
* `ReadingProgress`) — it does not reimplement them.
|
|
6
6
|
*
|
|
7
7
|
* The header/content/footer all align to the SAME grid column, so the article body reads as one
|
|
8
|
-
* consistent width whether or not the TOC rail is visible
|
|
9
|
-
*
|
|
8
|
+
* consistent width whether or not the TOC rail is visible. Below the 1200px breakpoint the rail
|
|
9
|
+
* is replaced, not dropped: a sticky "On this page" trigger opens the same `TableOfContents` in a
|
|
10
|
+
* `Popover` (docs/CONTENT-SPEC.md §7's originally-documented behavior, `useSizeClass() !==
|
|
11
|
+
* 'expanded'` — the same numeric threshold, resolved in JS instead of `@media`).
|
|
10
12
|
*
|
|
11
13
|
* @example
|
|
12
14
|
* import { ArticleLayout } from 'basalt-ui/content'
|
|
@@ -23,8 +25,9 @@
|
|
|
23
25
|
* </ArticleLayout>
|
|
24
26
|
*/
|
|
25
27
|
import type { CSSProperties, ReactNode } from 'react'
|
|
26
|
-
import { useRef } from 'react'
|
|
27
|
-
import { ScrollArea } from '@mantine/core'
|
|
28
|
+
import { useRef, useState } from 'react'
|
|
29
|
+
import { Button, Popover, ScrollArea } from '@mantine/core'
|
|
30
|
+
import { useSizeClass } from '../shell/use-size-class'
|
|
28
31
|
import classes from './article-layout.module.css'
|
|
29
32
|
import { formatArticleDate } from './article-model'
|
|
30
33
|
import type { ProseDensity } from './prose'
|
|
@@ -115,6 +118,22 @@ export function ArticleLayout({
|
|
|
115
118
|
const articleRef = useRef<HTMLDivElement>(null)
|
|
116
119
|
const rootClass = [classes.root, !toc && classes.noToc, className].filter(Boolean).join(' ')
|
|
117
120
|
const hasMetaRow = meta?.date !== undefined || meta?.readingTime !== undefined
|
|
121
|
+
// `useSizeClass()` is the JS-side twin of the `@media (max-width: 1200px)` rule below — both read
|
|
122
|
+
// `BREAKPOINTS.article`/`SIZE_CLASSES.expanded`, the same 1200 number (`article-layout.module.css`'s
|
|
123
|
+
// own comment). Below it the rail is replaced by a trigger, never both at once — a CSS-only swap
|
|
124
|
+
// has no hook to open a `Popover` from, so this is one of the JS reads that breakpoint doctrine
|
|
125
|
+
// reserves `useSizeClass()` for, not a second raw media query.
|
|
126
|
+
const showTocRail = useSizeClass() === 'expanded'
|
|
127
|
+
const [tocOpen, setTocOpen] = useState(false)
|
|
128
|
+
// Resizing across the breakpoint unmounts the `Popover` without closing it — `tocOpen` lives on
|
|
129
|
+
// THIS component, not the Popover, so crossing back below the breakpoint would otherwise remount
|
|
130
|
+
// it already open with no user action. Adjusted during render (React's own pattern for resetting
|
|
131
|
+
// state when a derived value changes), not an effect, so there is no open-then-close flash.
|
|
132
|
+
const prevShowTocRailRef = useRef(showTocRail)
|
|
133
|
+
if (prevShowTocRailRef.current !== showTocRail) {
|
|
134
|
+
prevShowTocRailRef.current = showTocRail
|
|
135
|
+
if (tocOpen) setTocOpen(false)
|
|
136
|
+
}
|
|
118
137
|
|
|
119
138
|
return (
|
|
120
139
|
<div className={rootClass} {...(style !== undefined && { style })}>
|
|
@@ -140,7 +159,7 @@ export function ArticleLayout({
|
|
|
140
159
|
<Prose density={density}>{children}</Prose>
|
|
141
160
|
</div>
|
|
142
161
|
|
|
143
|
-
{toc && (
|
|
162
|
+
{toc && showTocRail && (
|
|
144
163
|
<div className={classes.tocRail}>
|
|
145
164
|
<ScrollArea.Autosize mah="calc(100vh - 108px)" type="hover">
|
|
146
165
|
<TableOfContents containerRef={articleRef} />
|
|
@@ -148,6 +167,42 @@ export function ArticleLayout({
|
|
|
148
167
|
</div>
|
|
149
168
|
)}
|
|
150
169
|
|
|
170
|
+
{toc &&
|
|
171
|
+
!showTocRail && (
|
|
172
|
+
// `.tocTrigger`'s `grid-row: 2` (article-layout.module.css) is EXPLICIT, matching
|
|
173
|
+
// `.content`'s own row — not left to CSS Grid's auto-placement, which used to land this
|
|
174
|
+
// in its own row below the content instead of at its top (both are `grid-column: 1`, and
|
|
175
|
+
// the auto-placement cursor moves past row 2 once `.content` claims it).
|
|
176
|
+
<Popover
|
|
177
|
+
opened={tocOpen}
|
|
178
|
+
onChange={setTocOpen}
|
|
179
|
+
onDismiss={() => setTocOpen(false)}
|
|
180
|
+
position="bottom-end"
|
|
181
|
+
withinPortal
|
|
182
|
+
shadow="md"
|
|
183
|
+
radius="sm"
|
|
184
|
+
trapFocus
|
|
185
|
+
>
|
|
186
|
+
<Popover.Target>
|
|
187
|
+
<Button
|
|
188
|
+
variant="subtle"
|
|
189
|
+
color="gray"
|
|
190
|
+
size="compact-xs"
|
|
191
|
+
className={classes.tocTrigger}
|
|
192
|
+
classNames={{ label: classes.tocTriggerLabel }}
|
|
193
|
+
onClick={() => setTocOpen((current) => !current)}
|
|
194
|
+
>
|
|
195
|
+
On this page
|
|
196
|
+
</Button>
|
|
197
|
+
</Popover.Target>
|
|
198
|
+
<Popover.Dropdown className={classes.tocDropdown}>
|
|
199
|
+
<ScrollArea.Autosize mah="calc(100vh - 108px)" type="hover">
|
|
200
|
+
<TableOfContents containerRef={articleRef} onNavigate={() => setTocOpen(false)} />
|
|
201
|
+
</ScrollArea.Autosize>
|
|
202
|
+
</Popover.Dropdown>
|
|
203
|
+
</Popover>
|
|
204
|
+
)}
|
|
205
|
+
|
|
151
206
|
{(prev !== undefined || next !== undefined) && (
|
|
152
207
|
<footer className={classes.footer}>
|
|
153
208
|
{prev !== undefined ? (
|
|
@@ -56,6 +56,27 @@
|
|
|
56
56
|
overflow-x: auto;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/* Trailing-edge overflow fade (docs/MATURATION-LEDGER.md) — `code-block.tsx` toggles this
|
|
60
|
+
* attribute from a scroll/resize measurement (`scrollWidth > clientWidth`, not yet at the end), so
|
|
61
|
+
* it never decorates a block that already fits and never lingers once scrolled to the end. The mask
|
|
62
|
+
* fades to TRANSPARENT, not to a solid color — what shows through is `.container`'s own translucent
|
|
63
|
+
* background, already identical to what borders the block everywhere else, so the fade reads as a
|
|
64
|
+
* soft edge rather than a hard cut. Reuses the content inset token rather than a new literal; LTR
|
|
65
|
+
* only, matching the rest of this file (no `dir` handling exists here to fade the other edge from).
|
|
66
|
+
*/
|
|
67
|
+
.body[data-code-overflow] {
|
|
68
|
+
mask-image: linear-gradient(
|
|
69
|
+
to right,
|
|
70
|
+
black calc(100% - var(--vx-space-code-block-content-inset-x) * 2),
|
|
71
|
+
transparent 100%
|
|
72
|
+
);
|
|
73
|
+
-webkit-mask-image: linear-gradient(
|
|
74
|
+
to right,
|
|
75
|
+
black calc(100% - var(--vx-space-code-block-content-inset-x) * 2),
|
|
76
|
+
transparent 100%
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
59
80
|
.body :global(pre.shiki) {
|
|
60
81
|
margin: 0;
|
|
61
82
|
padding: 0;
|
|
@@ -77,11 +98,20 @@ html[data-mantine-color-scheme='light'] .body :global(.shiki span) {
|
|
|
77
98
|
position: absolute;
|
|
78
99
|
top: var(--vx-space-code-block-floating-copy-offset);
|
|
79
100
|
right: var(--vx-space-code-block-floating-copy-offset);
|
|
80
|
-
opacity: 0;
|
|
81
101
|
transition: opacity 120ms ease;
|
|
82
102
|
}
|
|
83
103
|
|
|
84
|
-
|
|
104
|
+
/* Hidden-until-hover only where hover exists (pointer tier, docs/DESIGN-CORE.md § Layout). */
|
|
105
|
+
@media (hover: hover) and (pointer: fine) {
|
|
106
|
+
.floatingCopy {
|
|
107
|
+
opacity: 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.container:hover .floatingCopy {
|
|
111
|
+
opacity: 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
85
115
|
.container:focus-within .floatingCopy {
|
|
86
116
|
opacity: 1;
|
|
87
117
|
}
|