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.
Files changed (107) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +8 -0
  3. package/MIGRATING.md +299 -33
  4. package/agent/rules/basalt-controls.md +17 -12
  5. package/agent/rules/basalt-state.md +5 -0
  6. package/configs/oxlint-plugin.js +251 -66
  7. package/configs/oxlint-plugin.test.ts +243 -0
  8. package/configs/oxlint.json +5 -5
  9. package/dist/controls/enum-filter.d.ts +11 -2
  10. package/dist/controls/enum-filter.d.ts.map +1 -1
  11. package/dist/controls/enum-filter.js +4 -2
  12. package/dist/controls/filter-context.d.ts +7 -2
  13. package/dist/controls/filter-context.d.ts.map +1 -1
  14. package/dist/controls/index.d.ts +1 -0
  15. package/dist/controls/index.d.ts.map +1 -1
  16. package/dist/controls/index.js +2 -0
  17. package/dist/controls/multi-select-filter.js +2 -2
  18. package/dist/controls/number-filter.d.ts +26 -0
  19. package/dist/controls/number-filter.d.ts.map +1 -0
  20. package/dist/controls/number-filter.js +143 -0
  21. package/dist/controls/range-filter.d.ts +19 -5
  22. package/dist/controls/range-filter.d.ts.map +1 -1
  23. package/dist/controls/range-filter.js +22 -7
  24. package/dist/controls/range-filter.type-guard.test.d.ts +2 -0
  25. package/dist/controls/range-filter.type-guard.test.d.ts.map +1 -0
  26. package/dist/controls/search-filter.js +1 -1
  27. package/dist/controls/toggle-filter.js +1 -1
  28. package/dist/dashboard/index.d.ts +1 -1
  29. package/dist/dashboard/index.d.ts.map +1 -1
  30. package/dist/dashboard/index.js +3 -1
  31. package/dist/dashboard/stat-card.d.ts +52 -1
  32. package/dist/dashboard/stat-card.d.ts.map +1 -1
  33. package/dist/dashboard/stat-card.js +42 -15
  34. package/dist/dashboard/stat-card.module.css +71 -0
  35. package/dist/dashboard/stat-card.module.css.d.ts +4 -0
  36. package/dist/data/data-table.d.ts.map +1 -1
  37. package/dist/data/data-table.js +8 -0
  38. package/dist/guard/grace.test.d.ts.map +1 -1
  39. package/dist/guard/index.d.ts +1 -1
  40. package/dist/guard/index.d.ts.map +1 -1
  41. package/dist/guard/index.js +6 -10
  42. package/dist/index.d.ts +2 -2
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/router-tanstack/field.d.ts +1 -1
  45. package/dist/router-tanstack/field.d.ts.map +1 -1
  46. package/dist/router-tanstack/index.d.ts +1 -1
  47. package/dist/router-tanstack/index.d.ts.map +1 -1
  48. package/dist/router-tanstack/search-store.d.ts +4 -1
  49. package/dist/router-tanstack/search-store.d.ts.map +1 -1
  50. package/dist/router-tanstack/search-store.js +91 -7
  51. package/dist/shell/page-bar.d.ts +1 -1
  52. package/dist/state/fields.d.ts +209 -36
  53. package/dist/state/fields.d.ts.map +1 -1
  54. package/dist/state/fields.js +68 -32
  55. package/dist/state/persisted.d.ts +12 -0
  56. package/dist/state/persisted.d.ts.map +1 -1
  57. package/dist/state/persisted.js +36 -18
  58. package/dist/state/persisted.test.d.ts +2 -0
  59. package/dist/state/persisted.test.d.ts.map +1 -0
  60. package/dist/state.d.ts +1 -1
  61. package/dist/state.d.ts.map +1 -1
  62. package/dist/surfaces.d.ts +1 -1
  63. package/dist/surfaces.d.ts.map +1 -1
  64. package/dist/surfaces.js +4 -3
  65. package/dist/widget-header/widget-header.d.ts +31 -1
  66. package/dist/widget-header/widget-header.d.ts.map +1 -1
  67. package/dist/widget-header/widget-header.js +13 -1
  68. package/dist/widget-header/widget-header.module.css +15 -0
  69. package/dist/widget-header/widget-header.module.css.d.ts +1 -0
  70. package/llms.txt +3 -3
  71. package/package.json +1 -1
  72. package/src/controls/controls.router.test.tsx +416 -0
  73. package/src/controls/enum-filter.tsx +14 -3
  74. package/src/controls/filter-context.tsx +7 -2
  75. package/src/controls/index.ts +1 -0
  76. package/src/controls/multi-select-filter.tsx +2 -2
  77. package/src/controls/number-filter.tsx +291 -0
  78. package/src/controls/props-contract.test.tsx +31 -0
  79. package/src/controls/range-filter.tsx +64 -11
  80. package/src/controls/range-filter.type-guard.test.tsx +122 -0
  81. package/src/controls/search-filter.tsx +1 -1
  82. package/src/controls/toggle-filter.tsx +1 -1
  83. package/src/dashboard/index.ts +6 -1
  84. package/src/dashboard/stat-card.module.css +71 -0
  85. package/src/dashboard/stat-card.module.css.d.ts +4 -0
  86. package/src/dashboard/stat-card.test.tsx +231 -0
  87. package/src/dashboard/stat-card.tsx +81 -0
  88. package/src/data/data-table.tsx +8 -0
  89. package/src/guard/check-source.test.ts +35 -2
  90. package/src/guard/grace.test.ts +14 -5
  91. package/src/guard/index.ts +30 -17
  92. package/src/index.ts +5 -0
  93. package/src/router-tanstack/field.ts +4 -0
  94. package/src/router-tanstack/index.ts +4 -0
  95. package/src/router-tanstack/search-store.router.test.tsx +232 -11
  96. package/src/router-tanstack/search-store.test.ts +160 -0
  97. package/src/router-tanstack/search-store.ts +177 -11
  98. package/src/shell/page-bar.tsx +1 -1
  99. package/src/state/fields.test.tsx +254 -0
  100. package/src/state/fields.ts +328 -70
  101. package/src/state/persisted.test.ts +91 -0
  102. package/src/state/persisted.ts +76 -26
  103. package/src/state.ts +6 -0
  104. package/src/surfaces.ts +4 -3
  105. package/src/widget-header/widget-header.module.css +15 -0
  106. package/src/widget-header/widget-header.module.css.d.ts +1 -0
  107. package/src/widget-header/widget-header.tsx +46 -1
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `createPersistedState` — the in-tab notification contract.
3
+ *
4
+ * The versioned envelope and the cross-tab path are exercised through every store that persists
5
+ * (`fields.test.tsx`, `search-store.test.ts`); what only shows up here is TWO instances over ONE
6
+ * key, which is ordinary rather than exotic: a page's store and a widget's own state legitimately
7
+ * name the same key. Per-instance listener sets made a write through one invisible to the other in
8
+ * the SAME tab — while the cross-tab path worked, which is what made it read as a caching bug.
9
+ */
10
+ import { beforeEach, describe, expect, test } from 'bun:test'
11
+ import { act, renderHook } from '@testing-library/react'
12
+ import { createPersistedState, readPersistedValue } from './persisted'
13
+
14
+ beforeEach(() => {
15
+ localStorage.clear()
16
+ })
17
+
18
+ describe('createPersistedState — one key, two instances', () => {
19
+ test('a write through A re-renders a hook on B with the new value', () => {
20
+ const useA = createPersistedState({ key: 'shared-draft', version: 1, initial: 'a' })
21
+ const useB = createPersistedState({ key: 'shared-draft', version: 1, initial: 'a' })
22
+
23
+ const a = renderHook(() => useA())
24
+ const b = renderHook(() => useB())
25
+ expect(b.result.current[0]).toBe('a')
26
+
27
+ act(() => {
28
+ a.result.current[1]('written-through-A')
29
+ })
30
+
31
+ expect(b.result.current[0]).toBe('written-through-A')
32
+ expect(a.result.current[0]).toBe('written-through-A')
33
+ expect(readPersistedValue('shared-draft', 1)).toBe('written-through-A')
34
+ })
35
+
36
+ test('the notification is per KEY — a second key is not woken', () => {
37
+ const useShared = createPersistedState({ key: 'k-one', version: 1, initial: 0 })
38
+ const useOther = createPersistedState({ key: 'k-two', version: 1, initial: 0 })
39
+
40
+ let otherRenders = 0
41
+ const shared = renderHook(() => useShared())
42
+ renderHook(() => {
43
+ otherRenders += 1
44
+ return useOther()
45
+ })
46
+ const before = otherRenders
47
+
48
+ act(() => {
49
+ shared.result.current[1](7)
50
+ })
51
+
52
+ expect(shared.result.current[0]).toBe(7)
53
+ expect(otherRenders).toBe(before)
54
+ })
55
+
56
+ test('unmounting one instance leaves the other subscribed', () => {
57
+ const useA = createPersistedState({ key: 'shared-unmount', version: 1, initial: 'a' })
58
+ const useB = createPersistedState({ key: 'shared-unmount', version: 1, initial: 'a' })
59
+
60
+ const a = renderHook(() => useA())
61
+ const b = renderHook(() => useB())
62
+ a.unmount()
63
+
64
+ const c = renderHook(() => useA())
65
+ act(() => {
66
+ c.result.current[1]('later')
67
+ })
68
+
69
+ expect(b.result.current[0]).toBe('later')
70
+ })
71
+
72
+ test('an object value stays referentially stable between writes (the snapshot cache)', () => {
73
+ const useDraft = createPersistedState<{ title: string }>({
74
+ key: 'shared-object',
75
+ version: 1,
76
+ initial: { title: '' },
77
+ })
78
+
79
+ const { result } = renderHook(() => useDraft())
80
+ const first = result.current[0]
81
+ act(() => {
82
+ result.current[1]({ title: 'one' })
83
+ })
84
+ const written = result.current[0]
85
+
86
+ expect(first).not.toBe(written)
87
+ expect(written).toEqual({ title: 'one' })
88
+ // Same raw string → same parsed reference, which is what keeps useSyncExternalStore quiet.
89
+ expect(renderHook(() => useDraft()).result.current[0]).toBe(written)
90
+ })
91
+ })
@@ -32,6 +32,42 @@ export type PersistedStateOptions<T> = {
32
32
  readonly schema?: StandardSchemaV1<unknown, T>
33
33
  }
34
34
 
35
+ /**
36
+ * Every subscriber for ONE storage key, shared by every `createPersistedState` instance over that
37
+ * key — plus the single `storage` event listener the key needs.
38
+ *
39
+ * Module-level rather than per-instance because two instances of the same key are ordinary, not a
40
+ * mistake: a page's `createSearchStore` and a widget's `createPersistedState` legitimately name the
41
+ * same key, and a consumer re-exporting a store from two modules ends up there by accident. With
42
+ * per-instance listener sets a write through one instance updated localStorage and notified only
43
+ * its own hooks, so the other instance's components kept rendering the stale value until something
44
+ * else re-rendered them — in the SAME tab, while the cross-tab path (the `storage` event) worked.
45
+ *
46
+ * The map is keyed by the NAMESPACED key and holds no window reference; a channel is created on
47
+ * first subscribe, which only happens in the browser (`subscribe` is never passed on the server).
48
+ */
49
+ type KeyChannel = {
50
+ readonly listeners: Set<() => void>
51
+ handler: ((e: StorageEvent) => void) | null
52
+ }
53
+
54
+ const channels = new Map<string, KeyChannel>()
55
+
56
+ /** Wake every hook on a key, whichever instance holds it. */
57
+ function notify(storageKey: string): void {
58
+ const channel = channels.get(storageKey)
59
+ if (channel === undefined) return
60
+ for (const listener of channel.listeners) listener()
61
+ }
62
+
63
+ function channelFor(storageKey: string): KeyChannel {
64
+ const existing = channels.get(storageKey)
65
+ if (existing !== undefined) return existing
66
+ const created: KeyChannel = { listeners: new Set(), handler: null }
67
+ channels.set(storageKey, created)
68
+ return created
69
+ }
70
+
35
71
  /** The envelope stored in localStorage: `{ v: number, value: unknown }`. */
36
72
  type Envelope = { v: number; value: unknown }
37
73
 
@@ -73,16 +109,19 @@ function parseStorage<T>(raw: string | null, opts: PersistedStateOptions<T>): T
73
109
  }
74
110
  }
75
111
 
76
- function writeStorage<T>(opts: PersistedStateOptions<T>, next: T): void {
77
- const storageKey = `basalt:${opts.key}`
112
+ function writeEnvelope(storageKey: string, version: number, value: unknown): void {
78
113
  try {
79
- const envelope: Envelope = { v: opts.version, value: next }
114
+ const envelope: Envelope = { v: version, value }
80
115
  window.localStorage.setItem(storageKey, JSON.stringify(envelope))
81
116
  } catch {
82
117
  // Silently fail (storage full, private browsing, etc.)
83
118
  }
84
119
  }
85
120
 
121
+ function writeStorage<T>(opts: PersistedStateOptions<T>, next: T): void {
122
+ writeEnvelope(`basalt:${opts.key}`, opts.version, next)
123
+ }
124
+
86
125
  /**
87
126
  * Versioned, Standard-Schema-validated localStorage state via useSyncExternalStore (no zustand).
88
127
  * SSR-safe (getServerSnapshot returns `initial`). Cross-tab via the storage event.
@@ -100,32 +139,25 @@ export function createPersistedState<T>(
100
139
  ): () => readonly [T, (next: T) => void] {
101
140
  const storageKey = `basalt:${opts.key}`
102
141
 
103
- // One Set of subscriber callbacks — mirrors notifications/store.ts single-listener-to-Set pattern.
104
- const listeners = new Set<() => void>()
105
-
106
- // ONE module-scoped 'storage' event listener per key, registered lazily on first subscriber.
107
- // Removed when the last subscriber unsubscribes (cleanup semantics preserved).
108
- let storageHandler: ((e: StorageEvent) => void) | null = null
109
-
110
142
  const subscribe = (cb: () => void): (() => void) => {
111
- listeners.add(cb)
112
-
113
- // Attach the shared window listener on the first subscriber.
114
- if (storageHandler === null) {
115
- storageHandler = (e: StorageEvent): void => {
116
- if (e.key === storageKey) {
117
- for (const listener of listeners) listener()
118
- }
143
+ // The subscriber set is the KEY's, not this instance's — see `KeyChannel`.
144
+ const channel = channelFor(storageKey)
145
+ channel.listeners.add(cb)
146
+
147
+ // ONE 'storage' event listener per key, registered lazily on the first subscriber and removed
148
+ // when the last one leaves (cleanup semantics preserved).
149
+ if (channel.handler === null) {
150
+ channel.handler = (e: StorageEvent): void => {
151
+ if (e.key === storageKey) notify(storageKey)
119
152
  }
120
- window.addEventListener('storage', storageHandler)
153
+ window.addEventListener('storage', channel.handler)
121
154
  }
122
155
 
123
156
  return () => {
124
- listeners.delete(cb)
125
- // Detach the shared window listener when the last subscriber unsubscribes.
126
- if (listeners.size === 0 && storageHandler !== null) {
127
- window.removeEventListener('storage', storageHandler)
128
- storageHandler = null
157
+ channel.listeners.delete(cb)
158
+ if (channel.listeners.size === 0 && channel.handler !== null) {
159
+ window.removeEventListener('storage', channel.handler)
160
+ channel.handler = null
129
161
  }
130
162
  }
131
163
  }
@@ -158,8 +190,9 @@ export function createPersistedState<T>(
158
190
 
159
191
  const setState = (next: T): void => {
160
192
  writeStorage(opts, next)
161
- // Notify in-tab listeners (the 'storage' event only fires in OTHER tabs)
162
- for (const cb of listeners) cb()
193
+ // Notify every in-tab listener on this KEY — the 'storage' event fires in OTHER tabs only, and
194
+ // a second instance of the same key has its own hooks to wake.
195
+ notify(storageKey)
163
196
  }
164
197
 
165
198
  // Detect SSR once at creation time — the environment doesn't change between renders.
@@ -209,3 +242,20 @@ export function readPersistedValue(key: string, version?: number): unknown | nul
209
242
  return null
210
243
  }
211
244
  }
245
+
246
+ /**
247
+ * Write a value under the namespaced key `basalt:<key>` in the same versioned envelope
248
+ * `createPersistedState` writes, and wake every hook on that key — the write half of
249
+ * `readPersistedValue`, and plain in the same way: no React required.
250
+ *
251
+ * The one call site is a store field's `clear()`, which has to DELETE its key from the store's
252
+ * record from an event handler, where the hook's setter is not in reach. Not exported from the
253
+ * `basalt-ui/state` barrel: the two store factories are the API, this is their seam.
254
+ *
255
+ * @internal
256
+ */
257
+ export function writePersistedValue(key: string, version: number, value: unknown): void {
258
+ const storageKey = `basalt:${key}`
259
+ writeEnvelope(storageKey, version, value)
260
+ notify(storageKey)
261
+ }
package/src/state.ts CHANGED
@@ -30,6 +30,10 @@ export {
30
30
  type RangeValue,
31
31
  type RangeParams,
32
32
  type RangePresets,
33
+ type RangeWindow,
34
+ type RangeWindows,
35
+ type FieldFallback,
36
+ type FieldSetOptions,
33
37
  type EnumField,
34
38
  type MultiField,
35
39
  type RangeField,
@@ -47,6 +51,8 @@ export {
47
51
  type StoreEntry,
48
52
  type StoreCoreOptions,
49
53
  type StoreCore,
54
+ type FieldUse,
55
+ type FieldWrite,
50
56
  resolveFieldCodec,
51
57
  createStoreCore,
52
58
  } from './state/fields'
package/src/surfaces.ts CHANGED
@@ -553,8 +553,9 @@ export const SURFACES = {
553
553
  // The text lane of law C1 — the plugin's `control-outside-home` seen through a 12-line host-tag
554
554
  // window, since a regex scan has no ancestry. Same law, same wave, same promotion.
555
555
  guardKinds: ['raw-selection-control'],
556
- // The control tier itself (laws C1/C3/C5/C9). `hand-rolled-filter` ships `error` — "inside a
557
- // slot" is structural; the other three are grace entries in `PLUGIN_RULE_GRACE` until 1.27.0.
556
+ // The control tier itself (laws C1/C3/C5/C9). `hand-rolled-filter`, `control-size-literal` and
557
+ // `responsive-twin` all ship `error`; `control-outside-home` is the one grace entry left in
558
+ // `PLUGIN_RULE_GRACE`, re-dated to 1.28.0 against the wave-7 measurement (see its `why`).
558
559
  // The budget rules live on `.` with `PageBar` itself.
559
560
  pluginRules: [
560
561
  'hand-rolled-filter',
@@ -571,7 +572,7 @@ export const SURFACES = {
571
572
  'C12 — one shape for refresh/sync (SyncButton); only the alias table sees a renamed copy',
572
573
  ],
573
574
  description:
574
- '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.',
575
+ '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.',
575
576
  optionalPeers: [],
576
577
  forbiddenImports: [],
577
578
  },
@@ -199,6 +199,21 @@
199
199
  color: var(--vx-ink);
200
200
  }
201
201
 
202
+ /*
203
+ * The unit beside the hero value. Mono like the number it qualifies (so `412 TSS` sits on one
204
+ * baseline grid), `--vx-text-sm` and `--vx-muted` so it never competes with the numeral — the whole
205
+ * point is that a unit is the SMALL half of the pair. `flex-shrink: 0`: the value is the row's
206
+ * shrinkable half (see `.value`), and a unit that ellipsized to `TS…` would state nothing.
207
+ */
208
+ .unit {
209
+ flex-shrink: 0;
210
+ align-self: baseline;
211
+ font-family: var(--basalt-font-mono);
212
+ font-size: var(--vx-text-sm);
213
+ font-weight: 500;
214
+ color: var(--vx-muted);
215
+ }
216
+
202
217
  /* The C1/C5 home for header actions — always pinned right, regardless of what else is present. */
203
218
  .actions {
204
219
  display: inline-flex;
@@ -11,6 +11,7 @@ declare const classes: {
11
11
  readonly count: string
12
12
  readonly metrics: string
13
13
  readonly value: string
14
+ readonly unit: string
14
15
  readonly actions: string
15
16
  readonly subtitle: string
16
17
  readonly sparkline: string
@@ -51,10 +51,40 @@ export type WidgetHeaderProps = {
51
51
  /** Pre-formatted metric value (mono, hero size). Renders on its own row with `delta`, directly
52
52
  * under the title row — never inline with the title. */
53
53
  value?: string
54
+ /**
55
+ * The value's unit, rendered immediately after it on the hero row: muted, mono, `--vx-text-sm`.
56
+ *
57
+ * It exists because `value` is a pre-formatted `string` and a KPI's unit is not part of the
58
+ * number: `412` and `TSS` are two facts at two weights, and jamming them into one string paints
59
+ * the unit at the hero's 24px — which reads as a second numeral. `subtitle` was the only place a
60
+ * unit could go before this, and a unit on its own line under the value is a different claim (the
61
+ * BASIS: `7-day rolling`), so cards using both had to pick one.
62
+ *
63
+ * Never a substitute for the number's own formatting: a thousands separator, a currency symbol or
64
+ * a `%` belong in `value`, where they are part of how the numeral reads.
65
+ */
66
+ unit?: string
54
67
  /** Signed delta rendered via `DeltaBadge`, on the same hero-metric row as `value`. */
55
68
  delta?: number
56
69
  /** Comparison timeframe forwarded to `DeltaBadge` (e.g. `MoM`). */
57
70
  deltaPeriod?: string
71
+ /**
72
+ * Formats `delta` into the chip's label — forwarded to `DeltaBadge`'s `format`. Defaults to
73
+ * `${Math.abs(delta).toFixed(1)}%`.
74
+ *
75
+ * It exists because not every delta is a percentage: a pace card's trend is `0:12 /km` and a
76
+ * throughput card's is `0.3 km/h`, and the default printed both as a percent — a wrong unit on a
77
+ * KPI, which is worse than no chip. A FUNCTION rather than a label string, so the number stays the
78
+ * number: `delta` still drives the tone and the glyph, and there is exactly one place the sign is
79
+ * decided.
80
+ *
81
+ * The value arrives SIGNED, so a formatter that wants to print the sign itself (`−0:12 /km`) can —
82
+ * pair it with `deltaGlyph={false}` so the ▼ does not say the same thing twice.
83
+ */
84
+ deltaFormat?: (delta: number) => string
85
+ /** Render `DeltaBadge`'s ▲/▼ glyph. Defaults to `true`; a zero delta never shows one. Pass
86
+ * `false` when `deltaFormat` prints the sign itself. */
87
+ deltaGlyph?: boolean
58
88
  /** Trend visual slot, rendered below the metric row. Carries `data-placement="right"` so a
59
89
  * wave-3 composer's `bleed` layout can select it. */
60
90
  sparkline?: ReactNode
@@ -127,8 +157,11 @@ export function WidgetHeader({
127
157
  subtitle,
128
158
  info,
129
159
  value,
160
+ unit,
130
161
  delta,
131
162
  deltaPeriod,
163
+ deltaFormat,
164
+ deltaGlyph,
132
165
  sparkline,
133
166
  count,
134
167
  actions,
@@ -154,7 +187,19 @@ export function WidgetHeader({
154
187
  {(value !== undefined || delta !== undefined) && (
155
188
  <div className={classes.metrics}>
156
189
  {value !== undefined && <span className={classes.value}>{value}</span>}
157
- {delta !== undefined && <DeltaBadge value={delta} period={deltaPeriod} />}
190
+ {/* Gated on `value`, not on itself: a unit with nothing to qualify is a stray word, and a
191
+ card that lost its number must not keep printing `TSS` beside the empty space. */}
192
+ {value !== undefined && unit !== undefined && (
193
+ <span className={classes.unit}>{unit}</span>
194
+ )}
195
+ {delta !== undefined && (
196
+ <DeltaBadge
197
+ value={delta}
198
+ period={deltaPeriod}
199
+ {...(deltaFormat !== undefined && { format: deltaFormat })}
200
+ {...(deltaGlyph !== undefined && { withGlyph: deltaGlyph })}
201
+ />
202
+ )}
158
203
  </div>
159
204
  )}
160
205
  {subtitle !== undefined && <span className={classes.subtitle}>{subtitle}</span>}