basalt-ui 1.34.1 → 1.36.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 +21 -21
- package/CHANGELOG.md +24 -0
- package/MIGRATING.md +89 -12
- package/agent/rules/basalt-controls.md +1 -1
- package/agent/rules/basalt-mantine.md +1 -1
- package/configs/oxlint-plugin.js +51 -4
- package/configs/oxlint-plugin.test.ts +58 -15
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +2 -1
- package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
- package/dist/charts/primitives/ChartLegend.js +2 -1
- package/dist/charts/primitives/chart-tier.d.ts +0 -2
- package/dist/charts/primitives/chart-tier.d.ts.map +1 -1
- package/dist/charts/primitives/chart-tier.js +0 -5
- package/dist/common/index.d.ts +1 -0
- package/dist/common/index.d.ts.map +1 -1
- package/dist/common/index.js +2 -0
- package/dist/common/use-media-query.d.ts +7 -0
- package/dist/common/use-media-query.d.ts.map +1 -1
- package/dist/common/use-media-query.js +4 -0
- package/dist/common/vx-families.d.ts +1 -1
- package/dist/{forms → controls}/form-layout.d.ts +1 -1
- package/dist/controls/form-layout.d.ts.map +1 -0
- package/dist/{forms → controls}/form-layout.js +2 -2
- package/dist/controls/index.d.ts +1 -0
- package/dist/controls/index.d.ts.map +1 -1
- package/dist/controls/index.js +10 -0
- package/dist/forms/index.d.ts +37 -7
- package/dist/forms/index.d.ts.map +1 -1
- package/dist/forms/index.js +10 -2
- package/dist/guard/index.d.ts +3 -1
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +16 -12
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/provider/index.d.ts +5 -4
- package/dist/provider/index.d.ts.map +1 -1
- package/dist/shell/app-mobile-nav.d.ts.map +1 -1
- package/dist/shell/app-mobile-nav.js +19 -10
- package/dist/shell/app-sidebar-account.d.ts.map +1 -1
- package/dist/shell/app-sidebar-account.js +1 -0
- package/dist/shell/app-sidebar.d.ts.map +1 -1
- package/dist/shell/app-sidebar.js +11 -6
- package/dist/shell/app-sidebar.module.css +39 -0
- package/dist/shell/app-sidebar.module.css.d.ts +3 -0
- package/dist/shell/index.d.ts +12 -5
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/nav-types.d.ts +19 -3
- package/dist/shell/nav-types.d.ts.map +1 -1
- package/dist/shell/sidebar-block-model.d.ts +3 -3
- package/dist/shell/sidebar-block-model.d.ts.map +1 -1
- package/dist/shell/sidebar-block-model.js +6 -2
- package/dist/shell/sidebar-blocks.d.ts +2 -1
- package/dist/shell/sidebar-blocks.d.ts.map +1 -1
- package/dist/shell/sidebar-blocks.js +13 -5
- package/dist/shell/use-size-class.d.ts.map +1 -1
- package/dist/surfaces.d.ts +4 -4
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +13 -5
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +5 -7
- package/llms.txt +5 -5
- package/package.json +1 -1
- package/src/charts/primitives/ChartFrame.tsx +2 -1
- package/src/charts/primitives/ChartLegend.tsx +2 -1
- package/src/charts/primitives/chart-tier.tsx +0 -6
- package/src/common/index.ts +1 -0
- package/src/common/use-media-query.ts +10 -0
- package/src/common/vx-families.ts +1 -1
- package/src/{forms → controls}/form-layout.tsx +7 -5
- package/src/controls/index.ts +16 -0
- package/src/forms/index.ts +36 -9
- package/src/guard/index.ts +35 -29
- package/src/index.ts +1 -0
- package/src/provider/index.tsx +5 -4
- package/src/shell/app-mobile-nav.tsx +34 -15
- package/src/shell/app-sidebar-account.tsx +3 -0
- package/src/shell/app-sidebar.module.css +39 -0
- package/src/shell/app-sidebar.module.css.d.ts +3 -0
- package/src/shell/app-sidebar.tsx +13 -6
- package/src/shell/index.tsx +12 -5
- package/src/shell/nav-types.ts +19 -3
- package/src/shell/sidebar-block-model.ts +13 -5
- package/src/shell/sidebar-blocks.tsx +30 -5
- package/src/shell/use-size-class.ts +6 -4
- package/src/surfaces.ts +13 -5
- package/src/tokens/index.ts +12 -15
- package/dist/forms/form-layout.d.ts.map +0 -1
- /package/dist/{forms → controls}/form-layout.module.css +0 -0
- /package/dist/{forms → controls}/form-layout.module.css.d.ts +0 -0
- /package/src/{forms → controls}/form-layout.module.css +0 -0
- /package/src/{forms → controls}/form-layout.module.css.d.ts +0 -0
package/src/common/index.ts
CHANGED
|
@@ -19,4 +19,5 @@ export {
|
|
|
19
19
|
} from './errors'
|
|
20
20
|
export { useValidateProps, assertRequiredProps, resetValidatedProps } from './validate'
|
|
21
21
|
export { scrollParentOf, SCROLLPORT_ATTRIBUTE } from './scroll-parent'
|
|
22
|
+
export { useCoarsePointer } from './use-media-query'
|
|
22
23
|
export { MOTION_DURATION, MOTION_EASE_STANDARD, MOTION_SPRING } from './motion'
|
|
@@ -54,3 +54,13 @@ export function useMediaQuery(query: string, serverFallback: boolean): boolean {
|
|
|
54
54
|
const getServerSnapshot = useCallback(() => serverFallback, [serverFallback])
|
|
55
55
|
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
|
56
56
|
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether the primary pointer is coarse — the JS twin of `--vx-hit`'s `(pointer: coarse)` query.
|
|
60
|
+
* For a branch CSS cannot express (mount one of two components, attach a keydown listener); styling
|
|
61
|
+
* stays `@media (pointer: coarse)` in a CSS module. Server and hydration snapshot is `false`; a
|
|
62
|
+
* `createRoot` app reads the real pointer on its first render.
|
|
63
|
+
*/
|
|
64
|
+
export function useCoarsePointer(): boolean {
|
|
65
|
+
return useMediaQuery('(pointer: coarse)', false)
|
|
66
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The families every emitted `--vx-*` name lives in (`basaltVxFamilies`) — basalt-owned:
|
|
5
|
-
* `unknown-vx-token` judges every name inside one, and `groupTokens`/`seriesTokens`
|
|
5
|
+
* `unknown-vx-token` judges every name inside one, and `groupTokens`/`seriesTokens` throw on a
|
|
6
6
|
* consumer ref there.
|
|
7
7
|
*/
|
|
8
8
|
export const BASALT_VX_FAMILIES: readonly string[] = [
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ./
|
|
3
|
-
* Mantine-coupled.
|
|
2
|
+
* ./controls — the form layout primitives: `FormSection`, `FormRow`, `FormGroup`, `FormActions`.
|
|
3
|
+
* Mantine-coupled. They need no `@mantine/form` (layout only), which is why they live here and not
|
|
4
|
+
* in `./forms`, whose barrel value-imports it: importing a row from there made a consumer install a
|
|
5
|
+
* peer it never used. `./forms` re-exports them as deprecated aliases until 1.37.0.
|
|
4
6
|
*
|
|
5
7
|
* The gap they close (audit B #5 / §4): `./forms` shipped no layout at all, so the only row
|
|
6
8
|
* primitive in the package was `SettingsRow` over in `./dashboard` — which is the SETTINGS-page
|
|
@@ -22,11 +24,11 @@ import type { ReactNode } from 'react'
|
|
|
22
24
|
import { cx } from '../common/props'
|
|
23
25
|
import type { BasaltProps, SlotStylesProps } from '../common/props'
|
|
24
26
|
import { assertRequiredProps } from '../common/validate'
|
|
25
|
-
import { BarActionSlot } from '
|
|
26
|
-
import type { SlotActions } from '
|
|
27
|
+
import { BarActionSlot } from './actions'
|
|
28
|
+
import type { SlotActions } from './actions'
|
|
27
29
|
import { WidgetHeader } from '../dashboard'
|
|
28
30
|
import type { WidgetHeaderTitleProps } from '../dashboard'
|
|
29
|
-
import { useFormState } from '
|
|
31
|
+
import { useFormState } from '../forms/form-state'
|
|
30
32
|
import classes from './form-layout.module.css'
|
|
31
33
|
|
|
32
34
|
// ── the two fragments FormRow and FormGroup both paint ────────────────────────
|
package/src/controls/index.ts
CHANGED
|
@@ -53,3 +53,19 @@ export { ViewTabs, type ViewTabsOption, type ViewTabsProps } from './view-tabs'
|
|
|
53
53
|
// docs. The filters above render their own panel forms; there is nothing extra to import for them.
|
|
54
54
|
export { PanelRow, type PanelRowProps } from './panel-row'
|
|
55
55
|
export { SliderControl, type SliderControlProps } from './slider-control'
|
|
56
|
+
|
|
57
|
+
// ── The form layout (moved from ./forms in 1.36.0) ────────────────────────────────────────────
|
|
58
|
+
// Layout needs no `@mantine/form`, and `./forms`' barrel value-imports it — so a consumer rendering
|
|
59
|
+
// a `FormRow` had to install a peer it never used. `./forms` re-exports these until 1.37.0.
|
|
60
|
+
export {
|
|
61
|
+
FormSection,
|
|
62
|
+
FormRow,
|
|
63
|
+
FormGroup,
|
|
64
|
+
FormActions,
|
|
65
|
+
type FormSectionProps,
|
|
66
|
+
type FormSectionSlot,
|
|
67
|
+
type FormRowProps,
|
|
68
|
+
type FormRowSlot,
|
|
69
|
+
type FormGroupProps,
|
|
70
|
+
type FormActionsProps,
|
|
71
|
+
} from './form-layout'
|
package/src/forms/index.ts
CHANGED
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
* `basalt-ui`. Same reason `./charts` and `./tokens` stay off the root barrel, one optional peer
|
|
7
7
|
* over. Import everything below from `basalt-ui/forms`.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
* (`FormSection` / `FormRow` / `FormGroup` / `FormActions`), the submit lifecycle
|
|
9
|
+
* Four pieces: the form (`useBasaltForm` + `inputProps`/`fieldKey`), the submit lifecycle
|
|
11
10
|
* (`useFormSubmit` + `FormStateProvider`), the list field (`useFieldArray`) and the draft
|
|
12
|
-
* (`useFormDraft`).
|
|
11
|
+
* (`useFormDraft`). The layout (`FormSection` / `FormRow` / `FormGroup` / `FormActions`) moved to
|
|
12
|
+
* `basalt-ui/controls` in 1.36.0 — it needs no `@mantine/form` — and stays here as deprecated
|
|
13
|
+
* aliases until 1.37.0, as do the `useForm`/`schemaResolver` re-exports (import from
|
|
14
|
+
* `@mantine/form`).
|
|
13
15
|
*
|
|
14
16
|
* Optional peer: @mantine/form ^9.3.0 — install with: bun add @mantine/form
|
|
15
17
|
*/
|
|
@@ -22,16 +24,31 @@ export type { UseBasaltFormOptions } from './create-form'
|
|
|
22
24
|
// Two calls, never one object: `key` inside a spread is a React 19 warning. See field.ts.
|
|
23
25
|
export { inputProps, fieldKey } from './field'
|
|
24
26
|
|
|
25
|
-
// ── layout —
|
|
26
|
-
export {
|
|
27
|
+
// ── layout — moved to ./controls (1.36.0), deprecated aliases until 1.37.0 ─────
|
|
28
|
+
export {
|
|
29
|
+
/** @deprecated Import from 'basalt-ui/controls' — removed in 1.37.0. */
|
|
30
|
+
FormSection,
|
|
31
|
+
/** @deprecated Import from 'basalt-ui/controls' — removed in 1.37.0. */
|
|
32
|
+
FormRow,
|
|
33
|
+
/** @deprecated Import from 'basalt-ui/controls' — removed in 1.37.0. */
|
|
34
|
+
FormGroup,
|
|
35
|
+
/** @deprecated Import from 'basalt-ui/controls' — removed in 1.37.0. */
|
|
36
|
+
FormActions,
|
|
37
|
+
} from '../controls/form-layout'
|
|
27
38
|
export type {
|
|
39
|
+
/** @deprecated Import from 'basalt-ui/controls' — removed in 1.37.0. */
|
|
28
40
|
FormSectionProps,
|
|
41
|
+
/** @deprecated Import from 'basalt-ui/controls' — removed in 1.37.0. */
|
|
29
42
|
FormSectionSlot,
|
|
43
|
+
/** @deprecated Import from 'basalt-ui/controls' — removed in 1.37.0. */
|
|
30
44
|
FormRowProps,
|
|
45
|
+
/** @deprecated Import from 'basalt-ui/controls' — removed in 1.37.0. */
|
|
31
46
|
FormRowSlot,
|
|
47
|
+
/** @deprecated Import from 'basalt-ui/controls' — removed in 1.37.0. */
|
|
32
48
|
FormGroupProps,
|
|
49
|
+
/** @deprecated Import from 'basalt-ui/controls' — removed in 1.37.0. */
|
|
33
50
|
FormActionsProps,
|
|
34
|
-
} from '
|
|
51
|
+
} from '../controls/form-layout'
|
|
35
52
|
|
|
36
53
|
// ── submit lifecycle ──────────────────────────────────────────────────────────
|
|
37
54
|
export { useFormSubmit, isFieldErrorEnvelope } from './use-form-submit'
|
|
@@ -51,6 +68,16 @@ export type { FormErrorSummaryProps } from './FormErrorSummary'
|
|
|
51
68
|
export { useFormDraft, DEFAULT_AUTOSAVE_DEBOUNCE_MS } from './use-form-draft'
|
|
52
69
|
export type { UseFormDraftOptions, UseFormDraftReturn } from './use-form-draft'
|
|
53
70
|
|
|
54
|
-
// ── @mantine/form re-exports
|
|
55
|
-
export {
|
|
56
|
-
|
|
71
|
+
// ── @mantine/form re-exports — deprecated, zero consumers import them from here ──
|
|
72
|
+
export {
|
|
73
|
+
/** @deprecated Import from '@mantine/form' — removed in 1.37.0. */
|
|
74
|
+
useForm,
|
|
75
|
+
/** @deprecated Import from '@mantine/form' — removed in 1.37.0. */
|
|
76
|
+
schemaResolver,
|
|
77
|
+
} from '@mantine/form'
|
|
78
|
+
export type {
|
|
79
|
+
/** @deprecated Import from '@mantine/form' — removed in 1.37.0. */
|
|
80
|
+
UseFormReturnType,
|
|
81
|
+
/** @deprecated Import from '@mantine/form' — removed in 1.37.0. */
|
|
82
|
+
UseFormInput,
|
|
83
|
+
} from '@mantine/form'
|
package/src/guard/index.ts
CHANGED
|
@@ -334,7 +334,7 @@ const KNOWN_VX_NAMES: ReadonlySet<string> = new Set(BASALT_VX_NAMES)
|
|
|
334
334
|
* emitted set, so `--vx-surface-2` and a bare root like `--vx-space` are both findings.
|
|
335
335
|
*
|
|
336
336
|
* That judgement is honest only because no consumer ref may point into these families:
|
|
337
|
-
* `groupTokens`/`seriesTokens`
|
|
337
|
+
* `groupTokens`/`seriesTokens` throw on a name inside one. A name in any OTHER family (argo's
|
|
338
338
|
* `--vx-activity-*`, rb's `--vx-confidence-*`) may be a consumer group declared in a file this scan
|
|
339
339
|
* cannot see, and is never judged.
|
|
340
340
|
*/
|
|
@@ -738,8 +738,8 @@ const PROSE_CONTEXT_TAG = /<(?:Prose|ArticleLayout|Modal|Drawer)(?![\w])/
|
|
|
738
738
|
* does not need to: both mean "a raw control here is not homeless".
|
|
739
739
|
*
|
|
740
740
|
* The three subtree homes are the plugin's `BASALT_HOST_TAGS`, mirrored here so one law does not
|
|
741
|
-
* read differently in the two lanes. The plugin gates them on the tag's basalt IMPORT; a
|
|
742
|
-
*
|
|
741
|
+
* read differently in the two lanes. The plugin gates them on the tag's basalt IMPORT; a regex walk
|
|
742
|
+
* has no import graph, so this lane matches by name and accepts the missed warn on a
|
|
743
743
|
* consumer's own `PanelRow` — the same direction every approximation in this scan already leans.
|
|
744
744
|
*
|
|
745
745
|
* The name set is the plugin's `CONTROL_HOST_TAGS` ∪ `BASALT_HOST_TAGS`; `oxlint-plugin.test.ts` ›
|
|
@@ -749,16 +749,31 @@ const CONTROL_HOST_TAG =
|
|
|
749
749
|
/<(?:SettingsRow|FormRow|FormGroup|Modal|Drawer|Popover\.Dropdown|Menu\.Dropdown|Composer|FilterSet|PageAside|PanelRow)(?![\w])/
|
|
750
750
|
|
|
751
751
|
/**
|
|
752
|
-
*
|
|
753
|
-
*
|
|
754
|
-
*
|
|
755
|
-
*
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
752
|
+
* Open and close tags of the same host names, for the ancestry walk in {@link insideHostTag}. The
|
|
753
|
+
* opening alternative tolerates `=>` inside props (`onChange={(v) => …}`) like
|
|
754
|
+
* {@link RAW_SELECTION_CONTROL_TAG} does, and a `/>` ending marks a self-closing host, which opens
|
|
755
|
+
* nothing.
|
|
756
|
+
*/
|
|
757
|
+
const CONTROL_HOST_TAG_SCAN = new RegExp(
|
|
758
|
+
`<(/?)(?:${/\(\?:([^)]*)\)/.exec(CONTROL_HOST_TAG.source)?.[1] ?? ''})(?![\\w])(?:=>|[^>])*?>`,
|
|
759
|
+
'g',
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Whether `offset` sits inside a host tag — one opened before it and not yet closed. The text lane
|
|
764
|
+
* has no AST, but the nesting of host tags in plain JSX is countable: +1 per opening tag, −1 per
|
|
765
|
+
* closing one, self-closing tags neither. That is what the plugin's `control-outside-home` answers
|
|
766
|
+
* by walking ancestors, so a control 40 lines under its `<SettingsRow>` is as homed here as there,
|
|
767
|
+
* and one after the row closed is not — the cases a fixed line window got wrong in both directions.
|
|
760
768
|
*/
|
|
761
|
-
|
|
769
|
+
function insideHostTag(codeText: string, offset: number): boolean {
|
|
770
|
+
let depth = 0
|
|
771
|
+
for (const m of codeText.slice(0, offset).matchAll(CONTROL_HOST_TAG_SCAN)) {
|
|
772
|
+
if (m[1] === '/') depth = Math.max(0, depth - 1)
|
|
773
|
+
else if (!m[0].endsWith('/>')) depth += 1
|
|
774
|
+
}
|
|
775
|
+
return depth > 0
|
|
776
|
+
}
|
|
762
777
|
|
|
763
778
|
/**
|
|
764
779
|
* A file whose BASENAME declares it is an overlay's or a form's own body — the cross-file half of
|
|
@@ -768,7 +783,7 @@ const CONTROL_HOST_WINDOW_LINES = 12
|
|
|
768
783
|
*
|
|
769
784
|
* Law C1's cross-file case is advisory by declaration (`docs/CONTROLS-SPEC.md` §6, "Honest
|
|
770
785
|
* coverage"), and this kind was paying for it: a `<Select>` inside `edit-session-modal.tsx` whose
|
|
771
|
-
* `<Modal>` is rendered by the parent route is outside
|
|
786
|
+
* `<Modal>` is rendered by the parent route is outside any
|
|
772
787
|
* ancestry walk, because the host tag is not in the file at all. argo carried 9 of them.
|
|
773
788
|
*
|
|
774
789
|
* KEBAB and PascalCase both count, because a repo picks one file-naming law and the convention has
|
|
@@ -878,6 +893,8 @@ export type GraceEntry = { since: string; promote: string; why: string }
|
|
|
878
893
|
|
|
879
894
|
/**
|
|
880
895
|
* Kinds still inside their grace minor — reported, not fatal, until the next minor promotes them.
|
|
896
|
+
* Empty since 1.36.0: `unknown-vx-token`, the last tenant, promoted (its grace ran one minor past
|
|
897
|
+
* 1.35.0 to keep the factory throw out of the a11y release).
|
|
881
898
|
* See {@link GuardSeverity} for the doctrine and why basalt-ui in particular needs it.
|
|
882
899
|
*
|
|
883
900
|
* Adding a kind here is part of shipping it; removing the entry IS the promotion, and belongs in
|
|
@@ -914,17 +931,7 @@ export type GraceEntry = { since: string; promote: string; why: string }
|
|
|
914
931
|
* 'raw-font-family': { since: '1.4.0', promote: '1.5.0', why: 'introduced 1.4.0 — …' },
|
|
915
932
|
* }
|
|
916
933
|
*/
|
|
917
|
-
export const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, GraceEntry>> = {
|
|
918
|
-
'unknown-vx-token': {
|
|
919
|
-
since: '1.34.0',
|
|
920
|
-
promote: '1.35.0',
|
|
921
|
-
why:
|
|
922
|
-
'New kind at 1.33.0 (consumer-loop r2 B3): obsidian lost its 44px touch floor to a removed ' +
|
|
923
|
-
'`var(--vx-space-touch-target)` that nothing reported. WIDENED at 1.34.0 (image-gen r4: ' +
|
|
924
|
-
'`var(--vx-surface-2)` rendered transparent, unreported) to every unemitted name inside a ' +
|
|
925
|
-
'basalt-owned family, so the grace restarts: warn through 1.34.x, error at 1.35.0.',
|
|
926
|
-
},
|
|
927
|
-
}
|
|
934
|
+
export const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, GraceEntry>> = {}
|
|
928
935
|
|
|
929
936
|
/** A kind's effective severity: consumer override first, then the grace table, then `error`. */
|
|
930
937
|
function severityOf(kind: GuardKind, cfg: GuardConfig): GuardSeverity {
|
|
@@ -2232,7 +2239,7 @@ export const GUARD_RULES = {
|
|
|
2232
2239
|
},
|
|
2233
2240
|
'raw-selection-control': {
|
|
2234
2241
|
kind: 'raw-selection-control',
|
|
2235
|
-
pattern: RAW_SELECTION_CONTROL_TAG, // handled inline (full-text tag-scoped scan + host
|
|
2242
|
+
pattern: RAW_SELECTION_CONTROL_TAG, // handled inline (full-text tag-scoped scan + host ancestry)
|
|
2236
2243
|
// JSX-tag-shaped (`<Select …>`) — never appears in CSS text.
|
|
2237
2244
|
appliesTo: (relPath) => !relPath.endsWith('.css'),
|
|
2238
2245
|
message:
|
|
@@ -2255,7 +2262,7 @@ export const GUARD_RULES = {
|
|
|
2255
2262
|
'unknown-vx-token': {
|
|
2256
2263
|
kind: 'unknown-vx-token',
|
|
2257
2264
|
pattern: VX_VAR_REF, // handled inline (basalt-owned families); entry keeps the registry complete
|
|
2258
|
-
message: `var() names a --vx-* token basalt does not emit, so it resolves to its fallback (or to nothing) with no error anywhere. Judged: every name inside a basalt-owned family (${[...BASALT_VX_FAMILY_SET].join(', ')}) — pick the token from basalt-ui/tokens.css; your own series belong in a family of their own (groupTokens
|
|
2265
|
+
message: `var() names a --vx-* token basalt does not emit, so it resolves to its fallback (or to nothing) with no error anywhere. Judged: every name inside a basalt-owned family (${[...BASALT_VX_FAMILY_SET].join(', ')}) — pick the token from basalt-ui/tokens.css; your own series belong in a family of their own (groupTokens throws on one inside basalt's, in dev builds). Removed names and their replacements: ${Object.entries(
|
|
2259
2266
|
REMOVED_VX_NAMES,
|
|
2260
2267
|
)
|
|
2261
2268
|
.map(([from, to]) => `${from} → ${to ?? '(deleted, no replacement)'}`)
|
|
@@ -2760,7 +2767,7 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
|
|
|
2760
2767
|
}
|
|
2761
2768
|
}
|
|
2762
2769
|
|
|
2763
|
-
// raw-selection-control — full-text tag scan plus the host
|
|
2770
|
+
// raw-selection-control — full-text tag scan plus the host ancestry walk (see insideHostTag).
|
|
2764
2771
|
// Three file-level exemptions mirror the plugin rule's, so the two lanes agree on the same file:
|
|
2765
2772
|
// a file that DEFINES a basalt control (and imports none — see CONTROL_OWNER_DEF) cannot be told
|
|
2766
2773
|
// to use one, a file importing `@mantine/form` is a form — C1's third home, whose inputs are not
|
|
@@ -2774,8 +2781,7 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
|
|
|
2774
2781
|
) {
|
|
2775
2782
|
for (const m of codeText.matchAll(RAW_SELECTION_CONTROL_TAG)) {
|
|
2776
2783
|
const startLine = codeText.slice(0, m.index ?? 0).split('\n').length
|
|
2777
|
-
|
|
2778
|
-
if (CONTROL_HOST_TAG.test(codeLines.slice(windowStart, startLine).join('\n'))) continue
|
|
2784
|
+
if (insideHostTag(codeText, m.index ?? 0)) continue
|
|
2779
2785
|
const endLine = startLine + (m[0].split('\n').length - 1)
|
|
2780
2786
|
if (isAllowedInRange(startLine, endLine, 'raw-selection-control')) continue
|
|
2781
2787
|
findings.push({
|
package/src/index.ts
CHANGED
package/src/provider/index.tsx
CHANGED
|
@@ -113,10 +113,11 @@ export type BasaltProviderProps = {
|
|
|
113
113
|
connectivity?: Omit<ConnectivityProviderProps, 'children'>
|
|
114
114
|
/**
|
|
115
115
|
* The size class `useSizeClass()` reports on the server and during hydration, before `matchMedia`
|
|
116
|
-
* can answer. Default `'compact'` (mobile-first).
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
116
|
+
* can answer. Default `'compact'` (mobile-first). SSR only: a client-rendered (`createRoot`) app
|
|
117
|
+
* reads `matchMedia` on its first render and never sees this value. An SSR app that knows the
|
|
118
|
+
* device class (UA hint, cookie) passes it so the first frame already matches. An SSR desktop app
|
|
119
|
+
* SHOULD pass it: without it the first paint is `'compact'`, and `PageAside`/`ThreadWorkspace`
|
|
120
|
+
* remount after hydration when the real class lands.
|
|
120
121
|
*/
|
|
121
122
|
sizeClassHint?: SizeClass
|
|
122
123
|
} & Omit<MantineProviderProps, 'children' | 'theme' | 'defaultColorScheme' | 'cssVariablesResolver'>
|
|
@@ -33,6 +33,7 @@ import type {
|
|
|
33
33
|
SidebarBlock,
|
|
34
34
|
SidebarBlockItem,
|
|
35
35
|
SidebarItem,
|
|
36
|
+
SidebarCustomBlock,
|
|
36
37
|
SidebarListBlock,
|
|
37
38
|
SidebarProgressBlock,
|
|
38
39
|
} from './nav-types'
|
|
@@ -127,6 +128,14 @@ type ActionRow = {
|
|
|
127
128
|
onClick?: ((e: MouseEvent<HTMLElement>) => void) | undefined
|
|
128
129
|
/** Mirrors `SettingsMenuItem.active` — a trailing check + `aria-current` in every row form. */
|
|
129
130
|
active?: boolean
|
|
131
|
+
/** Mirrors `SettingsMenuItem.current` — `'page'` swaps the check for the nav active style. */
|
|
132
|
+
current?: 'selection' | 'page'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** `aria-current` for an action row — `"page"` for a destination, `"true"` for a chosen value. */
|
|
136
|
+
function rowCurrent(row: ActionRow): 'page' | 'true' | undefined {
|
|
137
|
+
if (!row.active) return undefined
|
|
138
|
+
return row.current === 'page' ? 'page' : 'true'
|
|
130
139
|
}
|
|
131
140
|
|
|
132
141
|
/**
|
|
@@ -198,6 +207,7 @@ function settingsRows(items: SettingsMenuItem[] | undefined): ActionRow[] {
|
|
|
198
207
|
icon: item.icon,
|
|
199
208
|
onClick: item.onClick,
|
|
200
209
|
...(item.active !== undefined && { active: item.active }),
|
|
210
|
+
...(item.current !== undefined && { current: item.current }),
|
|
201
211
|
}))
|
|
202
212
|
}
|
|
203
213
|
|
|
@@ -248,10 +258,10 @@ const menuActionRow = (row: ActionRow, sectionStart = false) => (
|
|
|
248
258
|
key={row.key}
|
|
249
259
|
className={sectionStart ? `${classes.menuItem} ${classes.menuSectionStart}` : classes.menuItem}
|
|
250
260
|
leftSection={row.icon}
|
|
251
|
-
rightSection={row.active ? <IconCheck /> : undefined}
|
|
261
|
+
rightSection={row.active && row.current !== 'page' ? <IconCheck /> : undefined}
|
|
252
262
|
{...(row.danger ? { color: 'red' } : {})}
|
|
253
263
|
onClick={(event: MouseEvent<HTMLElement>) => row.onClick?.(event)}
|
|
254
|
-
aria-current={row
|
|
264
|
+
aria-current={rowCurrent(row)}
|
|
255
265
|
>
|
|
256
266
|
{row.label}
|
|
257
267
|
</Menu.Item>
|
|
@@ -353,12 +363,13 @@ export function MobileNav({
|
|
|
353
363
|
* applies — the count that picked `menu` vs `sheet` and the rows that render must agree.
|
|
354
364
|
*/
|
|
355
365
|
const moreBlocks = (blocks ?? []).filter(
|
|
356
|
-
(block): block is SidebarListBlock | SidebarProgressBlock =>
|
|
357
|
-
block.kind !== '
|
|
358
|
-
|
|
359
|
-
|
|
366
|
+
(block): block is SidebarListBlock | SidebarProgressBlock | SidebarCustomBlock =>
|
|
367
|
+
sidebarBlockMobile(block) === 'more' && (block.kind !== 'list' || block.items.length > 0),
|
|
368
|
+
)
|
|
369
|
+
// The blocks that open a nested sheet — a list's rows, or a custom block's node.
|
|
370
|
+
const sheetBlocks = moreBlocks.filter(
|
|
371
|
+
(block): block is SidebarListBlock | SidebarCustomBlock => block.kind !== 'progress',
|
|
360
372
|
)
|
|
361
|
-
const listBlocks = moreBlocks.filter((block): block is SidebarListBlock => block.kind === 'list')
|
|
362
373
|
|
|
363
374
|
/**
|
|
364
375
|
* Whether the More surface's UNLABELLED tail (blocks + account + settings) opens its own band —
|
|
@@ -369,10 +380,10 @@ export function MobileNav({
|
|
|
369
380
|
const tailRule = (slot: SurfaceSlot): boolean =>
|
|
370
381
|
moreBlocks.length + extraRows.length > 0 && slotHasRows(slot)
|
|
371
382
|
|
|
372
|
-
/** A list block raises its nested sheet; a progress block just fires its own handler. */
|
|
383
|
+
/** A list or custom block raises its nested sheet; a progress block just fires its own handler. */
|
|
373
384
|
const activateBlock = (block: (typeof moreBlocks)[number]) => {
|
|
374
385
|
close()
|
|
375
|
-
if (block.kind
|
|
386
|
+
if (block.kind !== 'progress') {
|
|
376
387
|
// ONE FRAME, deliberately. `close()` starts the Menu's `returnFocus` (it restores focus to
|
|
377
388
|
// the More tab) while the nested Drawer traps focus on mount — in the same handler the two
|
|
378
389
|
// race, and focus lands wherever the loser wrote last, which is how a tap on a block row
|
|
@@ -614,13 +625,14 @@ export function MobileNav({
|
|
|
614
625
|
classNames={{ root: classes.row }}
|
|
615
626
|
label={row.label}
|
|
616
627
|
leftSection={row.icon}
|
|
617
|
-
rightSection={row.active ? <IconCheck /> : undefined}
|
|
628
|
+
rightSection={row.active && row.current !== 'page' ? <IconCheck /> : undefined}
|
|
618
629
|
{...(row.danger ? { color: 'red' } : {})}
|
|
619
630
|
onClick={(event: MouseEvent<HTMLElement>) => {
|
|
620
631
|
row.onClick?.(event)
|
|
621
632
|
close()
|
|
622
633
|
}}
|
|
623
|
-
|
|
634
|
+
active={row.active === true && row.current === 'page'}
|
|
635
|
+
aria-current={rowCurrent(row)}
|
|
624
636
|
/>
|
|
625
637
|
)
|
|
626
638
|
|
|
@@ -773,7 +785,7 @@ export function MobileNav({
|
|
|
773
785
|
* exit transition would play against a blank sheet; a closed Mantine Drawer renders nothing, so N
|
|
774
786
|
* instances cost N nothings.
|
|
775
787
|
*/
|
|
776
|
-
const blockSheet = (block: SidebarListBlock) => (
|
|
788
|
+
const blockSheet = (block: SidebarListBlock | SidebarCustomBlock) => (
|
|
777
789
|
<Drawer
|
|
778
790
|
key={`block-${block.key}`}
|
|
779
791
|
opened={openBlockKey === block.key}
|
|
@@ -799,7 +811,11 @@ export function MobileNav({
|
|
|
799
811
|
}}
|
|
800
812
|
>
|
|
801
813
|
<ScrollArea.Autosize mah="62dvh" type="scroll" offsetScrollbars="present" scrollbarSize={8}>
|
|
802
|
-
|
|
814
|
+
{block.kind === 'list' ? (
|
|
815
|
+
<Stack gap={1}>{block.items.map(blockItemRow)}</Stack>
|
|
816
|
+
) : (
|
|
817
|
+
block.node
|
|
818
|
+
)}
|
|
803
819
|
</ScrollArea.Autosize>
|
|
804
820
|
</Drawer>
|
|
805
821
|
)
|
|
@@ -815,13 +831,16 @@ export function MobileNav({
|
|
|
815
831
|
if (slot.kind === 'menu') return menuSlot(slot)
|
|
816
832
|
return sheetSlot(slot)
|
|
817
833
|
})}
|
|
818
|
-
{
|
|
834
|
+
{sheetBlocks.map(blockSheet)}
|
|
819
835
|
</nav>
|
|
820
836
|
)
|
|
821
837
|
}
|
|
822
838
|
|
|
823
839
|
/** `Awaiting action · 3` — the row's whole job is to say how much is behind it. */
|
|
824
|
-
function blockRowLabel(
|
|
840
|
+
function blockRowLabel(
|
|
841
|
+
block: SidebarListBlock | SidebarProgressBlock | SidebarCustomBlock,
|
|
842
|
+
): string {
|
|
843
|
+
if (block.kind === 'custom') return block.label ?? ''
|
|
825
844
|
return block.kind === 'list'
|
|
826
845
|
? `${block.label} · ${block.count ?? block.items.length}`
|
|
827
846
|
: `${block.label} · ${block.value} of ${block.total}`
|
|
@@ -101,6 +101,9 @@ export function SidebarAccount(props: BasaltAccountProps & BasaltProps) {
|
|
|
101
101
|
return (
|
|
102
102
|
<UnstyledButton
|
|
103
103
|
className={cx(classes.accountRow, className)}
|
|
104
|
+
// The collapsed rail hides `.accountText` (display:none), which drops "Sign in" from the
|
|
105
|
+
// accessibility tree — the label keeps the button nameable with only the glyph showing.
|
|
106
|
+
aria-label="Sign in"
|
|
104
107
|
onClick={actions?.onSignIn}
|
|
105
108
|
{...(style !== undefined && { style })}
|
|
106
109
|
>
|
|
@@ -249,6 +249,21 @@
|
|
|
249
249
|
color: var(--vx-ink);
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
/* A settings entry that IS the current page (`SettingsMenuItem.current: 'page'`) — the nav row's
|
|
253
|
+
* active treatment (theme/nav-link.module.css): accent fill, weight 600, accent icon. */
|
|
254
|
+
.footerBtn[aria-current='page'],
|
|
255
|
+
.settingsMenuItem[aria-current='page'] {
|
|
256
|
+
background-color: color-mix(in srgb, var(--vx-accent) 12%, transparent);
|
|
257
|
+
color: var(--vx-ink);
|
|
258
|
+
font-weight: 600;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.footerBtn[aria-current='page'] > svg,
|
|
262
|
+
.footerBtn[aria-current='page'] .footerIconSlot > svg,
|
|
263
|
+
.settingsMenuItem[aria-current='page'] [data-position='left'] {
|
|
264
|
+
color: var(--vx-accent);
|
|
265
|
+
}
|
|
266
|
+
|
|
252
267
|
.footerText {
|
|
253
268
|
flex: 1;
|
|
254
269
|
text-align: left;
|
|
@@ -399,6 +414,25 @@
|
|
|
399
414
|
color: var(--vx-ink);
|
|
400
415
|
}
|
|
401
416
|
|
|
417
|
+
/* A custom block's rail trigger (`rail: 'icon'`) — a footer-row-sized icon target. */
|
|
418
|
+
.blockRailTrigger {
|
|
419
|
+
display: flex;
|
|
420
|
+
align-items: center;
|
|
421
|
+
justify-content: center;
|
|
422
|
+
padding: var(--vx-space-row-inset-y) var(--vx-space-row-inset-x);
|
|
423
|
+
border-radius: var(--vx-radius-ctrl);
|
|
424
|
+
color: var(--vx-faint);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
.blockRailTrigger:hover {
|
|
428
|
+
background-color: color-mix(in srgb, var(--vx-ink) 6%, transparent);
|
|
429
|
+
color: var(--vx-ink);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
.blockRailPopover {
|
|
433
|
+
min-width: 220px;
|
|
434
|
+
}
|
|
435
|
+
|
|
402
436
|
.blockIcon {
|
|
403
437
|
position: relative;
|
|
404
438
|
display: flex;
|
|
@@ -717,6 +751,11 @@
|
|
|
717
751
|
display: none;
|
|
718
752
|
}
|
|
719
753
|
|
|
754
|
+
.root[data-collapsed] .block[data-rail='icon'] {
|
|
755
|
+
display: flex;
|
|
756
|
+
justify-content: center;
|
|
757
|
+
}
|
|
758
|
+
|
|
720
759
|
.root[data-collapsed] .block[data-rail='dot'] .blockHeader {
|
|
721
760
|
justify-content: center;
|
|
722
761
|
padding: 0;
|
|
@@ -42,5 +42,8 @@ declare const classes: {
|
|
|
42
42
|
readonly footerLinks: string
|
|
43
43
|
readonly footerIconSlot: string
|
|
44
44
|
readonly footerVersion: string
|
|
45
|
+
readonly settingsMenuItem: string
|
|
46
|
+
readonly blockRailTrigger: string
|
|
47
|
+
readonly blockRailPopover: string
|
|
45
48
|
}
|
|
46
49
|
export default classes
|
|
@@ -183,6 +183,12 @@ function hasActiveDescendant(item: SidebarItem): boolean {
|
|
|
183
183
|
return item.children?.some((c) => hasActiveDescendant(c)) ?? false
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/** `aria-current` for a settings entry: `"page"` for a destination, `"true"` for a chosen value. */
|
|
187
|
+
function settingsCurrent(entry: SettingsMenuItem): 'page' | 'true' | undefined {
|
|
188
|
+
if (!entry.active) return undefined
|
|
189
|
+
return entry.current === 'page' ? 'page' : 'true'
|
|
190
|
+
}
|
|
191
|
+
|
|
186
192
|
/**
|
|
187
193
|
* Renders a nav link body without the Tooltip/Box wrapper.
|
|
188
194
|
*
|
|
@@ -488,7 +494,7 @@ export function AppSidebar(props: AppSidebarProps) {
|
|
|
488
494
|
className={classes.footerBtn}
|
|
489
495
|
onClick={entry.onClick}
|
|
490
496
|
aria-label={entry.label}
|
|
491
|
-
aria-current={entry
|
|
497
|
+
aria-current={settingsCurrent(entry)}
|
|
492
498
|
>
|
|
493
499
|
{/* A FIXED slot, so rows align on one icon column whether or not each ships an icon —
|
|
494
500
|
and the gear fallback is functional, not decorative: in the collapsed rail the label
|
|
@@ -501,7 +507,7 @@ export function AppSidebar(props: AppSidebarProps) {
|
|
|
501
507
|
</Text>
|
|
502
508
|
{/* The rail ring rides the FIRST row only — one progress block is one mark. */}
|
|
503
509
|
{index === 0 ? ringMark : null}
|
|
504
|
-
{entry.active ? <IconCheck /> : null}
|
|
510
|
+
{entry.active && entry.current !== 'page' ? <IconCheck /> : null}
|
|
505
511
|
</UnstyledButton>
|
|
506
512
|
))}
|
|
507
513
|
{versionLabel !== undefined && (
|
|
@@ -526,10 +532,11 @@ export function AppSidebar(props: AppSidebarProps) {
|
|
|
526
532
|
{settingsItems.map((entry) => (
|
|
527
533
|
<Menu.Item
|
|
528
534
|
key={entry.key}
|
|
535
|
+
className={classes.settingsMenuItem}
|
|
529
536
|
leftSection={entry.icon}
|
|
530
|
-
rightSection={entry.active ? <IconCheck /> : undefined}
|
|
537
|
+
rightSection={entry.active && entry.current !== 'page' ? <IconCheck /> : undefined}
|
|
531
538
|
onClick={entry.onClick}
|
|
532
|
-
aria-current={entry
|
|
539
|
+
aria-current={settingsCurrent(entry)}
|
|
533
540
|
>
|
|
534
541
|
{entry.label}
|
|
535
542
|
</Menu.Item>
|
|
@@ -576,7 +583,7 @@ export function AppSidebar(props: AppSidebarProps) {
|
|
|
576
583
|
<NavSection key={section.label} section={section} collapsed={collapsed} />
|
|
577
584
|
))}
|
|
578
585
|
{navBlocks.map((block) => (
|
|
579
|
-
<SidebarBlockView key={block.key} block={block} />
|
|
586
|
+
<SidebarBlockView key={block.key} block={block} collapsed={collapsed} />
|
|
580
587
|
))}
|
|
581
588
|
</Stack>
|
|
582
589
|
</ScrollArea>
|
|
@@ -584,7 +591,7 @@ export function AppSidebar(props: AppSidebarProps) {
|
|
|
584
591
|
{(bottomBlocks.length > 0 || settingsRow !== null || account !== undefined) && (
|
|
585
592
|
<Stack gap={0} className={classes.footer}>
|
|
586
593
|
{bottomBlocks.map((block) => (
|
|
587
|
-
<SidebarBlockView key={block.key} block={block} />
|
|
594
|
+
<SidebarBlockView key={block.key} block={block} collapsed={collapsed} />
|
|
588
595
|
))}
|
|
589
596
|
{settingsRow}
|
|
590
597
|
{account !== undefined && (
|
package/src/shell/index.tsx
CHANGED
|
@@ -130,10 +130,16 @@ export type SettingsMenuItem = {
|
|
|
130
130
|
label: string
|
|
131
131
|
icon?: ReactNode
|
|
132
132
|
onClick?: (e: MouseEvent) => void
|
|
133
|
-
/** This entry is the current
|
|
134
|
-
*
|
|
135
|
-
* More-sheet row) and the row carries `aria-current="true"`. */
|
|
133
|
+
/** This entry is the current one (e.g. the active theme radio, the open dev tool, the open
|
|
134
|
+
* settings page) — how it shows is `current`. */
|
|
136
135
|
active?: boolean
|
|
136
|
+
/**
|
|
137
|
+
* What `active` means. `'selection'` (default) is a chosen value: a trailing check glyph in every
|
|
138
|
+
* form (flat footer row, gear `Menu.Item`, the mobile More-sheet row) and `aria-current="true"`.
|
|
139
|
+
* `'page'` is a navigation destination: no check, the nav-item active style, and
|
|
140
|
+
* `aria-current="page"` — what a router-backed settings entry wants.
|
|
141
|
+
*/
|
|
142
|
+
current?: 'selection' | 'page'
|
|
137
143
|
}
|
|
138
144
|
|
|
139
145
|
export type BasaltShellProps = BasaltProps & {
|
|
@@ -167,8 +173,9 @@ export type BasaltShellProps = BasaltProps & {
|
|
|
167
173
|
* Because they are data, basalt owns the projections the two `ReactNode` slots could not express:
|
|
168
174
|
* a `list` with a `count` becomes a dot on its icon in the collapsed rail, a `progress` block
|
|
169
175
|
* becomes a ring on the settings row, and a block with `mobile: 'more'` becomes one More-sheet
|
|
170
|
-
* row opening a nested sheet of its items. A `kind: 'custom'` block is desktop-only, exactly
|
|
171
|
-
* `sidebarNavExtra` was
|
|
176
|
+
* row opening a nested sheet of its items. A `kind: 'custom'` block is desktop-only and hidden in the rail, exactly
|
|
177
|
+
* as `sidebarNavExtra` was, unless it opts in with `icon` + `label` (a rail icon opening it in a
|
|
178
|
+
* popover) and `mobile: 'more'` (a More row opening it in a sheet).
|
|
172
179
|
*/
|
|
173
180
|
sidebarBlocks?: SidebarBlock[]
|
|
174
181
|
/**
|