basalt-ui 1.26.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 +1 -1
- package/CHANGELOG.md +8 -0
- package/MIGRATING.md +299 -33
- package/agent/rules/basalt-controls.md +17 -12
- package/agent/rules/basalt-state.md +5 -0
- package/configs/oxlint-plugin.js +251 -66
- package/configs/oxlint-plugin.test.ts +243 -0
- package/configs/oxlint.json +5 -5
- package/dist/controls/enum-filter.d.ts +11 -2
- package/dist/controls/enum-filter.d.ts.map +1 -1
- package/dist/controls/enum-filter.js +4 -2
- package/dist/controls/filter-context.d.ts +7 -2
- package/dist/controls/filter-context.d.ts.map +1 -1
- package/dist/controls/index.d.ts +1 -0
- package/dist/controls/index.d.ts.map +1 -1
- package/dist/controls/index.js +2 -0
- package/dist/controls/multi-select-filter.js +2 -2
- 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/range-filter.d.ts +19 -5
- package/dist/controls/range-filter.d.ts.map +1 -1
- package/dist/controls/range-filter.js +22 -7
- 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.js +1 -1
- package/dist/controls/toggle-filter.js +1 -1
- 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/stat-card.d.ts +52 -1
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/dashboard/stat-card.js +42 -15
- package/dist/dashboard/stat-card.module.css +71 -0
- package/dist/dashboard/stat-card.module.css.d.ts +4 -0
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +8 -0
- package/dist/guard/grace.test.d.ts.map +1 -1
- package/dist/guard/index.d.ts +1 -1
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +6 -10
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/router-tanstack/field.d.ts +1 -1
- package/dist/router-tanstack/field.d.ts.map +1 -1
- package/dist/router-tanstack/index.d.ts +1 -1
- package/dist/router-tanstack/index.d.ts.map +1 -1
- package/dist/router-tanstack/search-store.d.ts +4 -1
- package/dist/router-tanstack/search-store.d.ts.map +1 -1
- package/dist/router-tanstack/search-store.js +91 -7
- package/dist/shell/page-bar.d.ts +1 -1
- package/dist/state/fields.d.ts +209 -36
- package/dist/state/fields.d.ts.map +1 -1
- package/dist/state/fields.js +68 -32
- package/dist/state/persisted.d.ts +12 -0
- package/dist/state/persisted.d.ts.map +1 -1
- package/dist/state/persisted.js +36 -18
- package/dist/state/persisted.test.d.ts +2 -0
- package/dist/state/persisted.test.d.ts.map +1 -0
- package/dist/state.d.ts +1 -1
- package/dist/state.d.ts.map +1 -1
- package/dist/surfaces.d.ts +1 -1
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +4 -3
- package/dist/widget-header/widget-header.d.ts +31 -1
- package/dist/widget-header/widget-header.d.ts.map +1 -1
- package/dist/widget-header/widget-header.js +13 -1
- package/dist/widget-header/widget-header.module.css +15 -0
- package/dist/widget-header/widget-header.module.css.d.ts +1 -0
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/src/controls/controls.router.test.tsx +416 -0
- package/src/controls/enum-filter.tsx +14 -3
- package/src/controls/filter-context.tsx +7 -2
- package/src/controls/index.ts +1 -0
- package/src/controls/multi-select-filter.tsx +2 -2
- package/src/controls/number-filter.tsx +291 -0
- package/src/controls/props-contract.test.tsx +31 -0
- package/src/controls/range-filter.tsx +64 -11
- package/src/controls/range-filter.type-guard.test.tsx +122 -0
- package/src/controls/search-filter.tsx +1 -1
- package/src/controls/toggle-filter.tsx +1 -1
- package/src/dashboard/index.ts +6 -1
- package/src/dashboard/stat-card.module.css +71 -0
- package/src/dashboard/stat-card.module.css.d.ts +4 -0
- package/src/dashboard/stat-card.test.tsx +231 -0
- package/src/dashboard/stat-card.tsx +81 -0
- package/src/data/data-table.tsx +8 -0
- package/src/guard/check-source.test.ts +35 -2
- package/src/guard/grace.test.ts +14 -5
- package/src/guard/index.ts +30 -17
- package/src/index.ts +5 -0
- package/src/router-tanstack/field.ts +4 -0
- package/src/router-tanstack/index.ts +4 -0
- package/src/router-tanstack/search-store.router.test.tsx +232 -11
- package/src/router-tanstack/search-store.test.ts +160 -0
- package/src/router-tanstack/search-store.ts +177 -11
- package/src/shell/page-bar.tsx +1 -1
- package/src/state/fields.test.tsx +254 -0
- package/src/state/fields.ts +328 -70
- package/src/state/persisted.test.ts +91 -0
- package/src/state/persisted.ts +76 -26
- package/src/state.ts +6 -0
- package/src/surfaces.ts +4 -3
- package/src/widget-header/widget-header.module.css +15 -0
- package/src/widget-header/widget-header.module.css.d.ts +1 -0
- package/src/widget-header/widget-header.tsx +46 -1
package/AGENTS.md
CHANGED
|
@@ -25,7 +25,7 @@ Toolchain: Bun runtime, oxlint + oxfmt, conventional commits (empty scope), `mas
|
|
|
25
25
|
| `basalt-ui/agent` | headless | Headless streaming-chat layer: useAgentStream, aiSdkTransport (recommended default) + edenTransport, isResumable/ResumableAgentTransport (stream-resumption seam), PartList, coalesceParts, the ForeignPart/definePartRenderers/narrowAgentPart open part-registry seam, plus the multi-thread createThreadsStore + useAgentThreadRuns + outcome-resolver seam (Mantine-free) |
|
|
26
26
|
| `basalt-ui/agent-chat` | mantine-coupled | Mantine-styled thread-chat components over basalt-ui/agent: ThreadWorkspace, ThreadFeed (variant/renderRow), ThreadFeedRow (inline-expanding Slack row, lazily mounted + kept mounted), ThreadOutcomeCard, ThreadDetailPanel, Composer, ThreadTranscript (open part-renderer registry via its renderers/fallbackRenderer props, per-message MessageAffordances, groupConsecutive, and an optional virtualize/height windowing mode whose VirtualizeOptions carry overscan/estimateSize/initialScroll — a virtualized transcript opens scrolled to the newest message unless initialScroll is "start"), threadPartRenderers, ToolChip (Mantine-coupled). motion is required, not optional — ThreadFeed/ThreadDetailPanel import motion/react eagerly, so this subpath fails to resolve without it installed even though peerDependenciesMeta marks it optional (npm has no per-subpath optionality). remend is genuinely optional here — ThreadTranscript reaches it only through the lazy dynamic import() inside content/markdown.tsx, and @tanstack/react-virtual the same way through the lazy import() behind ThreadTranscript virtualize (absent peer degrades to an unwindowed, height-bound pane). |
|
|
27
27
|
| `basalt-ui/content` | mantine-coupled | Prose (article/chat typography), CodeBlock (shiki, optional peer), Callout, TableOfContents, ReadingProgress, Markdown (react-markdown + remark-gfm, optional peers; `streaming` is a rendering mode ONLY — `contentTrust` is the independent security input, and any surface rendering agent/model output must pin `contentTrust="untrusted"`, the sole input to the image-origin allowlist; a `fenceRenderers` registry — settledOnly/FenceRenderer/FenceRenderers/FenceRenderContext; `sanitizeSchema`, an additions-only SanitizeSchemaExtension merged over BASALT_SANITIZE_SCHEMA via mergeSanitizeSchema; the remend streaming-repair pass is now a lazy optional peer), MermaidDiagram (beautiful-mermaid, optional peer), mdxComponents/createMdxComponents, ArticleLayout (docs-page frame), ArticleCard/ArticleGrid (overview cards), Article model (sortArticles/filterArticles/formatArticleDate), FilterSet/ViewTabs/MultiSelectFilter re-exported from ./controls (they replaced the controlled ArticleFilterBar), toArticleActions (Spotlight projector, @mantine/spotlight type-only), GuideLink/GuideDrawer (contextual-help drawer) — the content/prose surface |
|
|
28
|
-
| `basalt-ui/controls` | mantine-coupled | The control tier (docs/CONTROLS-SPEC.md §3): FilterSet (nowrap row + measured +N fold + the mobile Filters (n) sheet), RangeFilter/CompareFilter/SelectFilter/MultiSelectFilter/SearchFilter/ToggleFilter (each bound to a FieldHandle — no value/onChange/size, law C2/C5), ViewTabs, and the action/sync family (ActionGroup, OverflowMenu, SyncButton, BarAction/GlobalAction). Every control owns its own desktop/mobile swap in CSS (C9) and renders size="ctl" internally. Resolves and renders with NO @mantine/dates installed — the custom date picker is injected through RangeFilter.customPicker from ./controls-dates.
|
|
28
|
+
| `basalt-ui/controls` | mantine-coupled | The control tier (docs/CONTROLS-SPEC.md §3): FilterSet (nowrap row + measured +N fold + the mobile Filters (n) sheet), RangeFilter/CompareFilter/SelectFilter/MultiSelectFilter/NumberFilter/SearchFilter/ToggleFilter (each bound to a FieldHandle — no value/onChange/size, law C2/C5; NumberFilter is the field.number lane, a radio list over `options` or a stepper without), ViewTabs, and the action/sync family (ActionGroup, OverflowMenu, SyncButton, BarAction/GlobalAction). Every control owns its own desktop/mobile swap in CSS (C9) and renders size="ctl" internally. Resolves and renders with NO @mantine/dates installed — the custom date picker is injected through RangeFilter.customPicker from ./controls-dates. |
|
|
29
29
|
| `basalt-ui/controls-dates` | mantine-coupled | DateRangePicker — the @mantine/dates implementation of RangeFilter's customPicker seam. Its own subpath because @mantine/dates is an optional peer and basaltViteConfig pre-bundles the whole @mantine scope, so a consumer without the peer (linewatch) must never resolve it: nothing under src/controls may import it, statically or lazily (docs/CONTROLS-SPEC.md §3). |
|
|
30
30
|
| `basalt-ui/state` | headless | createPersistedState (versioned localStorage) + the store field vocabulary (field.enum/multi/range/number/boolean/string, FieldHandle, lanes) + createLocalStore, the router-free store — Mantine-free state primitives |
|
|
31
31
|
| `basalt-ui/connectivity` | mantine-coupled | ConnectivityProvider (aggregates browser online/offline, React Query onlineManager, SSE, and health-check pings into one status), useConnectivity, and ConnectivityIndicator — auto-mounted by BasaltProvider |
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
# [1.27.0](https://github.com/jkrumm/basalt-ui/compare/v1.26.0...v1.27.0) (2026-08-27)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* close the gaps the argo and linewatch migrations found ([79d9a6c](https://github.com/jkrumm/basalt-ui/commit/79d9a6ce69101309d8949f70a9bba2ed25dc513e))
|
|
7
|
+
* dogfood NumberFilter and StatCard unit/breakdown in the playground ([7a690be](https://github.com/jkrumm/basalt-ui/commit/7a690bea7998a9d2e9b71d304817476f45066b04))
|
|
8
|
+
|
|
1
9
|
# [1.26.0](https://github.com/jkrumm/basalt-ui/compare/v1.25.0...v1.26.0) (2026-08-27)
|
|
2
10
|
|
|
3
11
|
|
package/MIGRATING.md
CHANGED
|
@@ -32,6 +32,262 @@ at 1.3.0, `./agent-chat` at 1.10.0.
|
|
|
32
32
|
|
|
33
33
|
## Unreleased
|
|
34
34
|
|
|
35
|
+
**Nothing removed. Two exports added (`NumberFilter`, and three members on the number handle),
|
|
36
|
+
one member added to every `FieldHandle` (`clear()`), four `StatCard` props added, and enforcement
|
|
37
|
+
tightened: five oxlint rules plus one guard kind became `error`.** The store half of this minor also
|
|
38
|
+
fixed four inference and notification defects consumers hit while porting, plus two ways a fallback
|
|
39
|
+
got pinned into localStorage — see § Stores below. Every level change honours the `theme-allow`
|
|
40
|
+
grammar unchanged; only the severity of an unwaived finding moves.
|
|
41
|
+
|
|
42
|
+
### Stores — the `custom` flag, patched writes, lazy fallbacks, derived windows
|
|
43
|
+
|
|
44
|
+
**Additive, except one type that got narrower on purpose.** `field.range({ presets, fallback })`
|
|
45
|
+
written INLINE inside `createSearchStore({ fields })` used to infer `custom` as `boolean` — the
|
|
46
|
+
contextual `AnyField` return type won over the argument — so every value and search type carried a
|
|
47
|
+
`'custom'` preset the field itself rejects at runtime, and `RangeFilter field={…}` needed a cast or
|
|
48
|
+
an explicit `custom: false`. `field.range` is now three overloads: an omitted or `false` flag is
|
|
49
|
+
`false`, a literal `true` is `true`, a value typed `boolean` stays widened.
|
|
50
|
+
|
|
51
|
+
- **Delete the workaround, not the field.** An explicit `custom: false` still works (same
|
|
52
|
+
overload); a cast on `<RangeFilter field={…} />` can go, and `RangeFilterProps<P, C>` is generic
|
|
53
|
+
over the flag so a preset-only handle binds directly.
|
|
54
|
+
- **What can newly type-error:** code that read `'custom'` out of a range whose field never declared
|
|
55
|
+
it — `if (search.range === 'custom')` on a preset-only field, or a `RangeValue<P | 'custom'>`
|
|
56
|
+
annotation over one. That branch was always dead at runtime; the type just stopped pretending.
|
|
57
|
+
- **…and one more, in a consumer's own WRAPPER.** A prop typed `FieldHandle<RangeField<P>>` — `C`
|
|
58
|
+
defaulting to `boolean` — no longer accepts a handle from a `field.range` without `custom: true`,
|
|
59
|
+
because that handle's setter is now `RangeValue<P>`-typed and a setter is contravariant. Pin
|
|
60
|
+
`FieldHandle<RangeField<P, false>>` for the preset-only shape, `RangeField<P, true>` for a
|
|
61
|
+
picker-backed one, or make the wrapper generic over the flag
|
|
62
|
+
(`<P extends string, C extends boolean>` + `FieldHandle<RangeField<P, C>>`), which is what
|
|
63
|
+
`RangeFilter` does. An existing `as FieldHandle<RangeField<P>>` cast keeps compiling.
|
|
64
|
+
|
|
65
|
+
**`toWindow()` resolves derived presets.** `field.range({ window: { '3m': (now) => ({ from, to }) } })`
|
|
66
|
+
makes `toWindow({ preset: '3m' })` return `{ from, to }` (ISO dates, resolved at call time with the
|
|
67
|
+
current `Date`) while `3m` stays one preset in the URL. A preset with no resolver keeps
|
|
68
|
+
`{ window: preset }`, in the same field — which is what retires the last hand-rolled
|
|
69
|
+
`presetToParams` a consumer kept beside the store for `3m` / `6m` / `1y` / `ytd`.
|
|
70
|
+
|
|
71
|
+
**The resolved presets are gone from the return TYPE too**, which is the half that decides whether
|
|
72
|
+
the switch is deleted or the cast just moves: the field now carries its resolver keys (`RangeField`'s
|
|
73
|
+
fifth type parameter, inferred from `window`), and `toWindow` returns
|
|
74
|
+
`{ window: Exclude<P, W> } | { from; to }`. A `resolveWindow` whose target is
|
|
75
|
+
`{ window: '7d' | '30d' | '90d' | 'all' } | { from; to }` becomes a one-line delegation with no
|
|
76
|
+
`as`. One guard survives on a `custom: true` field: a `'custom'` preset that arrives WITHOUT dates
|
|
77
|
+
resolves to `{ window: 'custom' }`, so `'custom'` stays in the union by construction.
|
|
78
|
+
|
|
79
|
+
**A field setter takes an optional second argument.** `set(next, { patch })` merges extra search
|
|
80
|
+
params into the SAME navigate — URL lane only, for keys the store does not own (`{ patch: {
|
|
81
|
+
detailDate: undefined } }` clears a sibling param the page put there). The field's own params always
|
|
82
|
+
win over the patch, so it cannot corrupt the value being set. A local/memory-lane write has no
|
|
83
|
+
navigate to merge into and ignores it.
|
|
84
|
+
|
|
85
|
+
Two limits are now enforced rather than documented. A patch key **another field of the same store
|
|
86
|
+
owns** is refused — it would reach the URL while the store's own write path never touched the mirror,
|
|
87
|
+
so the next paramless visit and every `linkSearch` link resolve the OLD value: `createSearchStore`
|
|
88
|
+
throws in dev, and in production keeps the write and logs it once. Write that field through its own
|
|
89
|
+
setter instead. And a patched write **from a route that does not validate the field** persists (A1)
|
|
90
|
+
but drops the patch with it — there is no navigate to merge into — which now warns once per field in
|
|
91
|
+
dev instead of doing nothing.
|
|
92
|
+
|
|
93
|
+
**A fallback may be a thunk.** `field.*({ fallback: () => T })` (every kind — `field.number`'s codec
|
|
94
|
+
froze its thunk at store definition in the first cut of this minor and does not now) is resolved at
|
|
95
|
+
READ time and re-resolved while nothing is written, and is never persisted on its own — a write
|
|
96
|
+
stores what the control produced, as before. **Local and memory lanes only:** `createSearchStore` now
|
|
97
|
+
THROWS at definition for a thunk on a URL-lane field, because `validateSearch` would evaluate it on
|
|
98
|
+
every navigation and pin the result into the URL. Move the field off the URL lane (`{ url: false }`,
|
|
99
|
+
or `createLocalStore`) or pass a value.
|
|
100
|
+
|
|
101
|
+
**`FieldHandle` gained `clear()`, and a reset now UNSETS.** `useReset()` used to write
|
|
102
|
+
`entry.codec.fallback` into the mirror for every persisted field, and every control's reset called
|
|
103
|
+
`setValue(field.fallback)` — both of which pin a RESOLVED thunk: pressing `Reset all` over
|
|
104
|
+
`field.string({ fallback: () => todayIso() }, { url: false })` stored today's date, so tomorrow the
|
|
105
|
+
field read a value nobody chose and counted as active in `Filters (n)`. Both paths now delete the
|
|
106
|
+
key instead (the memory lane always did), and `clear()` is the per-field door: the persist lane
|
|
107
|
+
deletes its key, the memory lane drops its value, the URL lane navigates back to the fallback
|
|
108
|
+
params.
|
|
109
|
+
|
|
110
|
+
Nothing to change at a call site — every shipped control's reset calls it. A consumer that
|
|
111
|
+
hand-rolled a reset should swap `setValue(field.fallback)` for `field.clear()`; a hand-built
|
|
112
|
+
`FieldHandle` (a facet column, a test double) must add the member, which is the one type-error this
|
|
113
|
+
adds.
|
|
114
|
+
|
|
115
|
+
**`createPersistedState` notifies across instances (bug fix).** Two instances over one key — a
|
|
116
|
+
page's store and a widget's own state naming the same key, or a store re-exported from two modules —
|
|
117
|
+
kept per-instance listener sets, so a write through one left the other's components rendering the
|
|
118
|
+
stale value **in the same tab** while the cross-tab `storage` path worked. Subscribers are now
|
|
119
|
+
registered per storage key. No API change; if you were re-rendering something by hand to work around
|
|
120
|
+
this, delete it.
|
|
121
|
+
|
|
122
|
+
**`RangeFilter` warns once in dev** when the field declares `custom: true` and no `customPicker` was
|
|
123
|
+
injected: the custom window is then unreachable (no popover picker, no `Custom range…` row in the
|
|
124
|
+
sheet). Preset-only remains a legal configuration — the picker is injected precisely because
|
|
125
|
+
`@mantine/dates` may be absent — so this is a warning, not a type error.
|
|
126
|
+
|
|
127
|
+
### Controls — `NumberFilter`, the `field.number` lane
|
|
128
|
+
|
|
129
|
+
**One export added, nothing removed.** `field.number` has existed since the store landed and had no
|
|
130
|
+
control, so both consumers that needed one wrote around it in opposite directions: linewatch kept a
|
|
131
|
+
raw `SegmentedControl` over `minDuration` (a `control-outside-home` warn, law C1), and argo widened
|
|
132
|
+
`nights` into a string enum — which puts `'3'` in the URL and makes every downstream comparison a
|
|
133
|
+
parse.
|
|
134
|
+
|
|
135
|
+
```tsx
|
|
136
|
+
import { NumberFilter } from 'basalt-ui/controls'
|
|
137
|
+
|
|
138
|
+
// A preset set — a pill plus a radio list, the same body every enum filter renders.
|
|
139
|
+
<NumberFilter field={booking.field.nights} label="Nights"
|
|
140
|
+
options={[{ value: 1, label: '1 night' }, { value: 7, label: 'A week' }]} />
|
|
141
|
+
|
|
142
|
+
// No `options` → a pill whose popover holds a `ctl` NumberInput; the sheet renders it full-width.
|
|
143
|
+
<NumberFilter field={lines.field.minDuration} label="Min duration" step={30} />
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Three properties worth knowing before porting a call site:
|
|
147
|
+
|
|
148
|
+
- **The URL keeps a NUMBER.** `options` takes `{ value: number; label: string }`, and a numeral
|
|
149
|
+
STRING there is a type error — the whole reason to stop widening a threshold into an enum.
|
|
150
|
+
- **The stepper applies on blur or Enter, never per keystroke.** A number is typed digit by digit, so
|
|
151
|
+
a live write would navigate on `4`, `42`, `420` — three loader runs for one intended threshold,
|
|
152
|
+
two of them values nobody meant. `SearchFilter` debounces; a number has an explicit commit point.
|
|
153
|
+
- **`min`/`max`/`int` come off the HANDLE, not the call site.** They are deliberately not props: the
|
|
154
|
+
field is what validates the URL, and a second copy at the call site is a second answer to the same
|
|
155
|
+
question. The number handle republishes all three (see § Stores — the number handle below), so the
|
|
156
|
+
stepper stops at the field's limit and an `int` field refuses decimals outright. A TYPED value is
|
|
157
|
+
clamped to those bounds when it commits, so the box reads what the store holds even when the store
|
|
158
|
+
does not move — a second out-of-range `9999` against `max: 600` over a stored `600` writes nothing,
|
|
159
|
+
and the readout still corrects itself to `600`. The codec's clamp stays the backstop for a value
|
|
160
|
+
that never came through the box at all — a hand-typed URL, a stale deep link.
|
|
161
|
+
|
|
162
|
+
### Stores — the number handle republishes its bounds
|
|
163
|
+
|
|
164
|
+
**Three members added to `FieldHandle<NumberField>`, nothing removed.** `min`, `max` and `int` now
|
|
165
|
+
sit on the handle, filled from the field declaration (`NumberHandleExtras`, the shape
|
|
166
|
+
`RangeHandleExtras`/`toWindow` already had). A control never sees the field descriptor — only the
|
|
167
|
+
handle — so before this the only way one could learn its own limits was for the call site to pass
|
|
168
|
+
them a second time.
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
const nights = store.field.nights // field.number({ fallback: 2, min: 1, max: 14, int: true })
|
|
172
|
+
nights.min // 1 — `undefined` when the field declared none
|
|
173
|
+
nights.max // 14
|
|
174
|
+
nights.int // true — `false`, never `undefined`, on a number handle
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Non-number handles carry all three as `undefined`, the same way `toWindow` is `undefined` off a
|
|
178
|
+
range handle. That is a type-level fact, not just a runtime one, and it matters for one collision in
|
|
179
|
+
particular: `StringField` has its own `max`, which is NOT republished — a string handle's `max` is
|
|
180
|
+
`undefined`.
|
|
181
|
+
|
|
182
|
+
Nothing about the clamp changed. The codec has always clamped a number to the declared bounds on
|
|
183
|
+
write, and still does; the handle is a readout of that law, which is what lets `NumberFilter` bound
|
|
184
|
+
its stepper instead of letting the user watch the correction happen.
|
|
185
|
+
|
|
186
|
+
### `StatCard` — `unit`, `breakdown` and the delta's own format
|
|
187
|
+
|
|
188
|
+
**Four props added, nothing removed or renamed; every one defaults to today's rendering when
|
|
189
|
+
omitted.**
|
|
190
|
+
`value` is a pre-formatted `string`, so a card had exactly one text channel under the hero row
|
|
191
|
+
(`subtitle`) and three consumers wanting a unit AND a basis AND a split hand-rolled the card instead
|
|
192
|
+
— which is the fork `shadow-basalt-export` reports as a `HeroCard`.
|
|
193
|
+
|
|
194
|
+
| Prop | Type | Renders |
|
|
195
|
+
| ------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
|
|
196
|
+
| `unit` | `string` | after the value on the hero row — muted, mono, `--vx-text-sm` |
|
|
197
|
+
| `breakdown` | `readonly { label: string; value: string; tone?: StatCardTone }[]` | compact rows under the hero, one `controlHeightTag` line each |
|
|
198
|
+
| `deltaFormat` | `(delta: number) => string` | the delta chip's label. Default unchanged: `Math.abs(delta).toFixed(1) + '%'` |
|
|
199
|
+
| `deltaGlyph` | `boolean` | the chip's ▲/▼. Default `true`; a zero delta never shows one |
|
|
200
|
+
|
|
201
|
+
`unit` is forwarded to `WidgetHeader`, which gained the same prop, so `ChartCard` and `Section`
|
|
202
|
+
headers get it too. It is NOT the basis: `412` + `TSS` is the pair, and `7-day rolling` is still
|
|
203
|
+
`subtitle`. A thousands separator, a currency symbol or a `%` still belong in `value`.
|
|
204
|
+
|
|
205
|
+
`breakdown` draws **no hairlines** — §2.1 puts a horizontal rule between OPTION rows and nowhere
|
|
206
|
+
else, and `theme/divider-law.test.ts` inventories every one basalt draws. The rows separate by
|
|
207
|
+
weight (muted label, mono ink value) instead. Keep it to two or three: past that the card is a
|
|
208
|
+
table, and a table is `BasaltDataTable` in a `Section`. A row's `tone` reads the same per-scheme
|
|
209
|
+
`--vx-status-*` solid the card's own rail does, and omitting it is untinted, never `'good'`.
|
|
210
|
+
|
|
211
|
+
`deltaFormat` exists because **a delta is not always a percentage, and `StatCard` claimed it was**:
|
|
212
|
+
`delta={0.3}` on a pace or speed card rendered `▲0.3%`, a wrong unit on a KPI — worse than no chip —
|
|
213
|
+
so the one consumer that needed it kept the card hand-rolled and took the `shadow-basalt-export` warn
|
|
214
|
+
instead. `DeltaBadge` had `format` all along; neither `WidgetHeader` nor `StatCard` forwarded it. Both now do.
|
|
215
|
+
`ChartCard`/`Section` are unchanged: each declares its own header props and forwards them one by
|
|
216
|
+
one, so a delta-bearing `ChartCard` or `Section` still prints the percentage — ask if you need it
|
|
217
|
+
there.
|
|
218
|
+
|
|
219
|
+
```tsx
|
|
220
|
+
// A percentage, unchanged — no prop, no move.
|
|
221
|
+
<StatCard title="Volume" value="1,204" delta={4.2} deltaPeriod="WoW" />
|
|
222
|
+
|
|
223
|
+
// An absolute delta. The formatter gets the SIGNED number, so it may print the sign itself;
|
|
224
|
+
// `deltaGlyph={false}` then stops the ▼ saying the same thing twice. No `deltaLabel` prop exists —
|
|
225
|
+
// the function IS the escape hatch, and `delta` stays the number that drives the tone.
|
|
226
|
+
<StatCard title="Pace" value="5:31" unit="/km" delta={-12} deltaGlyph={false}
|
|
227
|
+
deltaFormat={(s) => `${s < 0 ? '−' : '+'}0:${String(Math.abs(s)).padStart(2, '0')} /km`} />
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Guards — the 1.27.0 promotions, one narrowing and one new exemption
|
|
231
|
+
|
|
232
|
+
**Five plugin rules and one guard kind became `error` in the shipped preset.** Their
|
|
233
|
+
`PLUGIN_RULE_GRACE` / `GRACE_PERIOD_KINDS` entries are deleted, which IS the promotion (C16). A
|
|
234
|
+
`theme-allow` written against any of them still works unchanged; only the severity of an UNWAIVED
|
|
235
|
+
finding moves.
|
|
236
|
+
|
|
237
|
+
| Promoted to `error` | Waiver, if the finding is deliberate |
|
|
238
|
+
| -------------------------------- | --------------------------------------------------------------------------------------------- |
|
|
239
|
+
| `basalt/control-size-literal` | `theme-allow control-size-literal — <why>` above the node, or `theme-allow-file` for the file |
|
|
240
|
+
| `basalt/in-body-page-title` | `theme-allow in-body-page-title — <why>` — ONE id, so it waives the guard kind's lane too |
|
|
241
|
+
| `in-body-page-title` (kind) | the same annotation; the two lanes share the id by construction |
|
|
242
|
+
| `basalt/responsive-twin` | `theme-allow responsive-twin — <why>`; the real fix is deleting one mount |
|
|
243
|
+
| `basalt/search-literal-link` | `theme-allow search-literal-link — <why>`; the real fix is `search: <store>.linkSearch` |
|
|
244
|
+
| `basalt/use-search-from-literal` | `theme-allow use-search-from-literal — <why>`; the real fix is the param as a prop |
|
|
245
|
+
|
|
246
|
+
The commonest in-body-title case that is NOT a defect: **a shell-less surface naming itself** — an
|
|
247
|
+
auth gate, an error boundary, a print view. There is no breadcrumb to carry the name, so write
|
|
248
|
+
`theme-allow in-body-page-title — shell-less surface`.
|
|
249
|
+
|
|
250
|
+
**`control-size-literal` also gained one exemption in the same minor, and it removes a waiver rather
|
|
251
|
+
than adding one: a slot owned by `ChartCard` no longer reports.** `ChartCard` lives inside the
|
|
252
|
+
Mantine-free chart layer, so its `actions` slot writes `data-basalt-tier` by hand and cannot mount
|
|
253
|
+
the tier theme at all — a `Switch` there with no `size` renders at Mantine's default, not at `ctl`.
|
|
254
|
+
The rule's message ("the HOME sets the tier, drop the prop") was simply false in that one slot, and
|
|
255
|
+
because the rule fires on the `size` ATTRIBUTE it could not tell a correct `size="ctl"` from the
|
|
256
|
+
`size="xs"` it exists to catch. Every other home still reports, and the owner test reads the
|
|
257
|
+
IMPORTED name so `ChartCard as Card` is exempt too. `hand-rolled-filter` is deliberately NOT
|
|
258
|
+
exempted there: a raw `Select` in `ChartCard.actions` is still a filter that should take a `field`.
|
|
259
|
+
A HOISTED binding is exempt only when EVERY basalt home it was handed to is `ChartCard` — one
|
|
260
|
+
`const acts = <Button size="xs"/>` given to both `<ChartCard actions>` and `<Section actions>` still
|
|
261
|
+
renders in a tiered slot, and keying the exemption on a single owner made the verdict depend on which
|
|
262
|
+
attribute came later in the file.
|
|
263
|
+
|
|
264
|
+
**`basalt/control-outside-home` and `raw-selection-control` did NOT promote. They are re-dated to
|
|
265
|
+
1.28.0, and the reason is a measurement rather than caution:** the wave-7 consumer run left 9 warns
|
|
266
|
+
in argo, and every one is a control inside a modal/form module whose `<Modal>` is rendered by the
|
|
267
|
+
PARENT route — law C1's cross-file case, which is advisory by declaration because no scan of one
|
|
268
|
+
file can see it. Both lanes now exempt a file whose BASENAME matches
|
|
269
|
+
`*-{modal,drawer,popover,panel,form}.tsx`, which is the convention those nine already follow.
|
|
270
|
+
Outside the convention, an overlay body declares itself:
|
|
271
|
+
`theme-allow-file control-outside-home — overlay`. The trade is stated plainly: a whole file goes
|
|
272
|
+
unscanned on a naming convention, so a `Select` that genuinely belongs in a page bar goes unreported
|
|
273
|
+
if it lives in `filters-panel.tsx` — the same bargain the `@mantine/form` exemption already buys,
|
|
274
|
+
and a smaller one than promoting a rule with 9 known false positives.
|
|
275
|
+
|
|
276
|
+
**`basalt/shadow-basalt-export` narrowed, and it stays a permanent advisory `warn`.** An ALIAS hit is
|
|
277
|
+
now skipped when the file imports the basalt export it renames **and REFERENCES that binding as a
|
|
278
|
+
value**: a `HeroCard` that composes `StatCard` is a wrapper, not a fork, and it was already following
|
|
279
|
+
the advice the message gives. Composition, not import — three shapes stay reported, and they are the
|
|
280
|
+
ones a fork actually writes: a type-only `import type { StatCard }` (or an inline `type` specifier)
|
|
281
|
+
feeding `ComponentProps<typeof StatCard>`, a VALUE import used only in a type position, and a dead
|
|
282
|
+
import left behind after the body was re-rolled. None of them render anything. A reference does not
|
|
283
|
+
have to be a JSX tag — `component={StatCard}` and `createElement(StatCard)` compose it too. The
|
|
284
|
+
provenance test reads the IMPORTED name, so `import { StatCard as Base }` counts. The name-COLLISION
|
|
285
|
+
half is deliberately NOT exempted the same way — a local `StatCard` beside an
|
|
286
|
+
`import { StatCard as Base }` kept the name AND a piece of the original, which is the fork shape this
|
|
287
|
+
rule most wants to see.
|
|
288
|
+
|
|
289
|
+
## 1.26.0 — the control tier, the page bar and the store
|
|
290
|
+
|
|
35
291
|
**One export removed and two deprecated — see § Stores below; two shell PROPS removed — see
|
|
36
292
|
§ Sidebar. Two other behaviour changes: the grace ledgers changed shape, and nine
|
|
37
293
|
long-stale entries (D4, `docs/archive/CONTROLS-SYNTHESIS.md`) are promoted.** C16
|
|
@@ -48,7 +304,8 @@ happened to the nine rows below.
|
|
|
48
304
|
(the table below) or moved to the new, permanent `PLUGIN_RULE_ADVISORY` ledger
|
|
49
305
|
(`shadow-basalt-export`). Both ledgers now hold only the wave-6 control guards listed under
|
|
50
306
|
§ Guards — six plugin rules and two guard kinds, each `{ since: '1.26.0', promote: '1.27.0' }` —
|
|
51
|
-
which the C16 gate
|
|
307
|
+
which the C16 gate forced to promote or be deleted at 1.27.0. Five rules and one kind promoted there;
|
|
308
|
+
the C1 pair was re-dated to 1.28.0 against a measurement (see `## Unreleased` above). A theme-allow escape written against
|
|
52
309
|
any of these still works unchanged; only the SEVERITY of an unwaived finding moves from `warn` to
|
|
53
310
|
`error`.
|
|
54
311
|
|
|
@@ -357,22 +614,23 @@ A consumer that uses `DateRangePicker` installs `@mantine/dates` and imports
|
|
|
357
614
|
|
|
358
615
|
### Guards — the control rules (docs/CONTROLS-SPEC.md §6)
|
|
359
616
|
|
|
360
|
-
**Nothing removed. Eight new oxlint rules and two new guard kinds
|
|
361
|
-
|
|
617
|
+
**Nothing removed. Eight new oxlint rules and two new guard kinds. `Ships` below is the level as of
|
|
618
|
+
THIS minor** — eight land `error`, and the C1 pair (`control-outside-home` and its text lane) is the
|
|
619
|
+
only grace left, re-dated to 1.28.0 on a measurement (see § Guards — the promotions).\*\* Every one honours `theme-allow <id> — <why>` on the
|
|
362
620
|
node and `theme-allow-file <id> — <why>` on the file, the same grammar as the rest.
|
|
363
621
|
|
|
364
622
|
| Rule / kind | Fires on | Ships |
|
|
365
623
|
| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
|
|
366
624
|
| `basalt/hand-rolled-filter` | A raw Mantine `Select`/`SegmentedControl`/… handed to a TIERED home slot (`actions`/`filters`/`tabs`/`sync`/`filtersEnd` on `PageBar`/`Section`/`WidgetHeader`/`ChartCard`/`StatCard`/`BasaltDataTable`/`SettingsSection`/`FilterSet`) | error |
|
|
367
625
|
| `basalt/page-bar-budget` | A second `PageBar` in the SAME returned tree, >4 `actions.secondary`, >3 `Section` actions, a second filled `Button`/`ActionIcon` in one slot (outside an overlay) | error |
|
|
368
|
-
| `basalt/control-outside-home` | The same raw control with no home at all — exempt under a settings row / overlay / composer, in an `@mantine/form` file,
|
|
369
|
-
| `basalt/control-size-literal` | `size`/`w`/`fullWidth`/`visibleFrom`/`hiddenFrom` on anything inside a home slot (the slot sets the tier) |
|
|
370
|
-
| `basalt/in-body-page-title` | `<Title order={1\|2}>` outside prose/overlay context and outside a `content/` path |
|
|
371
|
-
| `basalt/responsive-twin` | The same control mounted twice, one `visibleFrom="X"` and one `hiddenFrom="X"` |
|
|
372
|
-
| `basalt/search-literal-link` | A `search:` object literal in a `linkOptions()` inside `defineNav()`/`navGroup()` |
|
|
373
|
-
| `basalt/use-search-from-literal` | `useSearch({ from: '<route>' })` |
|
|
374
|
-
| `in-body-page-title` (guard kind) | The text lane of the same law — SAME id, so one annotation waives both lanes |
|
|
375
|
-
| `raw-selection-control` (guard kind) | The text lane of `control-outside-home`, approximated by a 12-line host-tag window
|
|
626
|
+
| `basalt/control-outside-home` | The same raw control with no home at all — exempt under a settings row / overlay / composer, in an `@mantine/form` file, in a file that DEFINES a basalt control, or in a file basenamed `*-{modal,drawer,popover,panel,form}.tsx` | warn → 1.28.0 |
|
|
627
|
+
| `basalt/control-size-literal` | `size`/`w`/`fullWidth`/`visibleFrom`/`hiddenFrom` on anything inside a home slot (the slot sets the tier) | **error** |
|
|
628
|
+
| `basalt/in-body-page-title` | `<Title order={1\|2}>` outside prose/overlay context and outside a `content/` path | **error** |
|
|
629
|
+
| `basalt/responsive-twin` | The same control mounted twice, one `visibleFrom="X"` and one `hiddenFrom="X"` | **error** |
|
|
630
|
+
| `basalt/search-literal-link` | A `search:` object literal in a `linkOptions()` inside `defineNav()`/`navGroup()` | **error** |
|
|
631
|
+
| `basalt/use-search-from-literal` | `useSearch({ from: '<route>' })` | **error** |
|
|
632
|
+
| `in-body-page-title` (guard kind) | The text lane of the same law — SAME id, so one annotation waives both lanes | **error** |
|
|
633
|
+
| `raw-selection-control` (guard kind) | The text lane of `control-outside-home`, approximated by a 12-line host-tag window, plus the same overlay-basename exemption | warn → 1.28.0 |
|
|
376
634
|
|
|
377
635
|
**A `SettingsRow`'s `control` is not a tiered slot, so nothing in this table fires inside one.**
|
|
378
636
|
It is law C1's third home — the form row — and a form keeps Mantine's `md` tier
|
|
@@ -741,7 +999,7 @@ instead of dumping help and letting the dump read like a choice.
|
|
|
741
999
|
- **`tokens:css --check` blanks the provenance line (line 2) before comparing**, so a version bump
|
|
742
1000
|
alone no longer forces a no-op commit in a tokens-only consumer, where the gate is byte-equality.
|
|
743
1001
|
A stale provenance line is now a note on an otherwise-passing check. The `@generated` header is
|
|
744
|
-
still emitted byte-identical — the line stays, it just stops gating. **Superseded
|
|
1002
|
+
still emitted byte-identical — the line stays, it just stops gating. **Superseded at 1.26.0:
|
|
745
1003
|
blanking the WHOLE line also stopped gating the regeneration command it carries. Only the version
|
|
746
1004
|
token is neutralized now.**
|
|
747
1005
|
|
|
@@ -912,7 +1170,7 @@ icons — it is not yet **sufficient**. If you went hybrid over the 404s, droppi
|
|
|
912
1170
|
half here costs you every icon unless your `public/` matches basalt's six filenames: at this
|
|
913
1171
|
release `icons` was still `false | { dir?: string }` over those six, so an app whose icon is
|
|
914
1172
|
`favicon.svg` could pick between a manifest naming two PNGs it never builds and a manifest with no
|
|
915
|
-
`icons` member at all. The array form that fixes it is in `##
|
|
1173
|
+
`icons` member at all. The array form that fixes it is in `## 1.26.0`. The option's JSDoc said
|
|
916
1174
|
"skips the head `<link>` icons"; it now says what the option does.
|
|
917
1175
|
|
|
918
1176
|
**Two `theme-allow` comment shapes were silently broken and now work.** Plainly: this is the fourth
|
|
@@ -920,7 +1178,7 @@ hole found in this one contract in three rounds. A thirteen-shape matrix — eve
|
|
|
920
1178
|
had been SEEN to write — is pinned in both halves, `src/guard/check-source.test.ts` and
|
|
921
1179
|
`configs/oxlint-plugin.test.ts`. **The claim that followed it here, that the two parsers could
|
|
922
1180
|
therefore no longer disagree, was false when it was written**: a list of collected anecdotes cannot
|
|
923
|
-
close a contract. Three more holes were open at this release. See `##
|
|
1181
|
+
close a contract. Three more holes were open at this release. See `## 1.26.0`.
|
|
924
1182
|
|
|
925
1183
|
| Shape | What it did | Now |
|
|
926
1184
|
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
|
|
@@ -1207,35 +1465,43 @@ live in `configs/oxlint.json`, and since 1.20.0 their grace is tracked by `PLUGI
|
|
|
1207
1465
|
named export beside the plugin — a test asserts it against the shipped preset in both directions, so
|
|
1208
1466
|
deleting an entry forces the level flip in the same commit.
|
|
1209
1467
|
|
|
1210
|
-
| Rule | Landed | Became `error`
|
|
1211
|
-
| -------------------------------------- | ---------------- |
|
|
1212
|
-
| `mantine-shade-index` (guard kind) | 1.7.0 as `warn` | **1.11.0**
|
|
1213
|
-
| `basalt/raw-scroll-container` | ≤1.2.0 as `off` | `warn` 1.10.0 → **`error` 1.13.0**
|
|
1214
|
-
| `basalt/ai-sdk-major` | 1.10.0 as `warn` | **1.13.0**
|
|
1215
|
-
| `basalt/agent-no-raw-usechat` | 1.10.0 as `warn` | **1.13.0**
|
|
1216
|
-
| `basalt/agent-resume-guard` | 1.10.0 as `warn` | **1.13.0**
|
|
1217
|
-
| `basalt/raw-size-literal` | 1.7.0 as `warn` | **1.20.0**
|
|
1218
|
-
| `basalt/hand-rolled-plot` | 1.21.0 as `warn` | **
|
|
1219
|
-
| `basalt/chart-legend-literal` | 1.20.0 as `warn` | **
|
|
1220
|
-
| `basalt/shadow-basalt-export` | 1.20.0 as `warn` | ADVISORY — permanent `warn`, see below, never subject to the C16 gate
|
|
1221
|
-
| `basalt/hand-rolled-shell` | 1.20.0 as `warn` | **
|
|
1222
|
-
| `theme-allow-unscoped` (guard kind) | 1.20.0 as `warn` | **
|
|
1223
|
-
| `surface-shadow-override` (guard kind) | 1.20.0 as `warn` | **
|
|
1224
|
-
| `css-raw-surface` (guard kind) | 1.20.0 as `warn` | **
|
|
1225
|
-
| `inline-font-size` (guard kind) | 1.20.0 as `warn` | **
|
|
1226
|
-
| `hidden-inline-style` (guard kind) | 1.20.0 as `warn` | **
|
|
1468
|
+
| Rule | Landed | Became `error` |
|
|
1469
|
+
| -------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------- |
|
|
1470
|
+
| `mantine-shade-index` (guard kind) | 1.7.0 as `warn` | **1.11.0** |
|
|
1471
|
+
| `basalt/raw-scroll-container` | ≤1.2.0 as `off` | `warn` 1.10.0 → **`error` 1.13.0** |
|
|
1472
|
+
| `basalt/ai-sdk-major` | 1.10.0 as `warn` | **1.13.0** |
|
|
1473
|
+
| `basalt/agent-no-raw-usechat` | 1.10.0 as `warn` | **1.13.0** |
|
|
1474
|
+
| `basalt/agent-resume-guard` | 1.10.0 as `warn` | **1.13.0** |
|
|
1475
|
+
| `basalt/raw-size-literal` | 1.7.0 as `warn` | **1.20.0** |
|
|
1476
|
+
| `basalt/hand-rolled-plot` | 1.21.0 as `warn` | **1.26.0** (grace had restarted at the 1.21.0 widening) |
|
|
1477
|
+
| `basalt/chart-legend-literal` | 1.20.0 as `warn` | **1.26.0** |
|
|
1478
|
+
| `basalt/shadow-basalt-export` | 1.20.0 as `warn` | ADVISORY — permanent `warn`, see below, never subject to the C16 gate |
|
|
1479
|
+
| `basalt/hand-rolled-shell` | 1.20.0 as `warn` | **1.26.0** |
|
|
1480
|
+
| `theme-allow-unscoped` (guard kind) | 1.20.0 as `warn` | **1.26.0** |
|
|
1481
|
+
| `surface-shadow-override` (guard kind) | 1.20.0 as `warn` | **1.26.0** |
|
|
1482
|
+
| `css-raw-surface` (guard kind) | 1.20.0 as `warn` | **1.26.0** |
|
|
1483
|
+
| `inline-font-size` (guard kind) | 1.20.0 as `warn` | **1.26.0** |
|
|
1484
|
+
| `hidden-inline-style` (guard kind) | 1.20.0 as `warn` | **1.26.0** |
|
|
1485
|
+
| `basalt/control-size-literal` | 1.26.0 as `warn` | **1.27.0** |
|
|
1486
|
+
| `basalt/in-body-page-title` | 1.26.0 as `warn` | **1.27.0** |
|
|
1487
|
+
| `in-body-page-title` (guard kind) | 1.26.0 as `warn` | **1.27.0** — same id as the rule above, so one waiver covers both |
|
|
1488
|
+
| `basalt/responsive-twin` | 1.26.0 as `warn` | **1.27.0** |
|
|
1489
|
+
| `basalt/search-literal-link` | 1.26.0 as `warn` | **1.27.0** |
|
|
1490
|
+
| `basalt/use-search-from-literal` | 1.26.0 as `warn` | **1.27.0** |
|
|
1491
|
+
| `basalt/control-outside-home` | 1.26.0 as `warn` | still `warn`, `promote: '1.28.0'` — re-dated on 9 measured cross-file overlay warns in argo |
|
|
1492
|
+
| `raw-selection-control` (guard kind) | 1.26.0 as `warn` | still `warn`, `promote: '1.28.0'` — the text lane of the row above |
|
|
1227
1493
|
|
|
1228
1494
|
`card-with-border`, `inline-display`, `raw-html-layout`, `raw-form-control`, `raw-font-family` and
|
|
1229
1495
|
the other original guard kinds have been `error` since before 1.2.0 — they never had a grace minor.
|
|
1230
1496
|
Guard findings only gained a severity field at all in 1.4.0; before that every finding was fatal.
|
|
1231
1497
|
|
|
1232
|
-
**Both ledgers changed shape
|
|
1498
|
+
**Both ledgers changed shape at 1.26.0 (C16, `docs/CONTROLS-SPEC.md` §1).** An entry
|
|
1233
1499
|
used to be a bare promotion-note string with no expiry a machine could check — which is exactly how
|
|
1234
1500
|
the nine rows above sat at `warn` for five minors although the doctrine below says "one minor". Both
|
|
1235
1501
|
`PLUGIN_RULE_GRACE` (`configs/oxlint-plugin.js`) and `GRACE_PERIOD_KINDS` (`src/guard/index.ts`) now
|
|
1236
1502
|
hold `{ since, promote, why }` (semver strings), and a version-gated test fails the build once
|
|
1237
1503
|
`package.json`'s version reaches an entry's `promote` while the entry is still there — see
|
|
1238
|
-
"
|
|
1504
|
+
"1.26.0" above for the full change. `shadow-basalt-export` moved to a sibling ledger,
|
|
1239
1505
|
`PLUGIN_RULE_ADVISORY`, which the gate never checks: it was already documented as a possible
|
|
1240
1506
|
permanent `warn`, and the version-gate doctrine only fits an entry that has an honest promotion
|
|
1241
1507
|
date.
|
|
@@ -64,6 +64,9 @@ C1's cross-file case, a hand-rolled section heading, C11 outside `BasaltDataTabl
|
|
|
64
64
|
- **A control's home is a decision about reach**: it belongs next to what it acts on. If it affects
|
|
65
65
|
more than one widget it is promoted to the page bar; if it formats one widget it stays in that
|
|
66
66
|
widget's header.
|
|
67
|
+
- **An overlay rendered by the PARENT is exempt by FILENAME**: `control-outside-home` and
|
|
68
|
+
`raw-selection-control` skip a file basenamed `*-{modal,drawer,popover,panel,form}.tsx`, since no
|
|
69
|
+
scan of it sees the `<Modal>`. Outside that: `theme-allow-file control-outside-home — overlay`.
|
|
67
70
|
|
|
68
71
|
## The `ctl` tier — the home sizes the control
|
|
69
72
|
|
|
@@ -141,15 +144,17 @@ its items. A `custom` block is desktop-only, by design.
|
|
|
141
144
|
|
|
142
145
|
## Anti-patterns
|
|
143
146
|
|
|
144
|
-
| Instead of
|
|
145
|
-
|
|
|
146
|
-
| `useState` + a `SegmentedControl` in the page body
|
|
147
|
-
| a raw `Select`/`MultiSelect`/`Chip.Group` as a filter
|
|
148
|
-
|
|
|
149
|
-
|
|
|
150
|
-
| a
|
|
151
|
-
| a hand-
|
|
152
|
-
| `
|
|
153
|
-
|
|
|
154
|
-
|
|
|
155
|
-
|
|
|
147
|
+
| Instead of | Write |
|
|
148
|
+
| ------------------------------------------------------------ | ----------------------------------------------------- |
|
|
149
|
+
| `useState` + a `SegmentedControl` in the page body | a store field + `ViewTabs` in the home's `tabs` slot |
|
|
150
|
+
| a raw `Select`/`MultiSelect`/`Chip.Group` as a filter | `SelectFilter` / `MultiSelectFilter` |
|
|
151
|
+
| a numeric threshold widened into a string enum | `field.number` + `NumberFilter` |
|
|
152
|
+
| `<X visibleFrom="sm"/>` beside `<X hiddenFrom="sm"/>` | one control — the swap is already inside it |
|
|
153
|
+
| `size="xs"` on a button in a home slot | nothing; the home sets the tier |
|
|
154
|
+
| a hand-written `presetToParams` | `field.toWindow(value)` |
|
|
155
|
+
| a hand-counted `Filters (3)` badge | nothing; `FilterSet` derives it |
|
|
156
|
+
| `overflowX: 'auto'` on a filter row | nothing; the fold is basalt's |
|
|
157
|
+
| a local `Section` / `PageHeader` / `RefreshButton` | `Section` / `PageBar` / `SyncButton` |
|
|
158
|
+
| an in-body `<Title order={1}>` | the route's breadcrumb title |
|
|
159
|
+
| the same on a shell-less surface (auth gate, error boundary) | `theme-allow in-body-page-title — shell-less surface` |
|
|
160
|
+
| a hand-built joined button row | `ControlGroup` (or `group: true` on the actions) |
|
|
@@ -83,6 +83,11 @@ export const analytics = createSearchStore({
|
|
|
83
83
|
so unrelated params survive.
|
|
84
84
|
- **`useActiveCount()` / `useReset()`** are what a `Filters (n)` pill and its `Reset all` derive
|
|
85
85
|
from. Never hand-count either.
|
|
86
|
+
- **A reset UNSETS.** `useReset()` and `field.clear()` DELETE the persisted key; they never write
|
|
87
|
+
`fallback`, which would pin a thunk fallback (`() => todayIso()`) as a value nobody chose. A
|
|
88
|
+
hand-rolled reset calls `field.clear()`, never `setValue(field.fallback)`.
|
|
89
|
+
- **`set(next, { patch })`** merges sibling params the store does NOT own into the same navigate. A
|
|
90
|
+
key another field of the same store owns throws in dev — write it through that field's setter.
|
|
86
91
|
|
|
87
92
|
## Nav links carry the store BY REFERENCE
|
|
88
93
|
|