@titan-design/react-ui 0.10.0 → 0.11.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 (84) hide show
  1. package/dist/index.d.mts +399 -4
  2. package/dist/index.d.ts +399 -4
  3. package/dist/index.js +2295 -895
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +2279 -905
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/{pages-DyEx-lBf.d.ts → pages-B1hqnGIC.d.ts} +149 -15
  8. package/dist/{pages-D7hOD4Vj.d.mts → pages-CvsFqNtx.d.mts} +149 -15
  9. package/dist/pages.d.mts +1 -1
  10. package/dist/pages.d.ts +1 -1
  11. package/dist/pages.js +1974 -1740
  12. package/dist/pages.js.map +1 -1
  13. package/dist/pages.mjs +1975 -1741
  14. package/dist/pages.mjs.map +1 -1
  15. package/package.json +8 -3
  16. package/src/components/custom/Fatigue/DualGhostSpark.stories.tsx +258 -0
  17. package/src/components/custom/Fatigue/DualGhostSpark.test.tsx +138 -0
  18. package/src/components/custom/Fatigue/DualGhostSpark.tsx +208 -0
  19. package/src/components/custom/Fatigue/FatigueLights.stories.tsx +74 -0
  20. package/src/components/custom/Fatigue/FatigueLights.test.tsx +33 -0
  21. package/src/components/custom/Fatigue/FatigueLights.tsx +83 -0
  22. package/src/components/custom/Fatigue/GhostBand.test.tsx +235 -0
  23. package/src/components/custom/Fatigue/GhostBand.tsx +246 -0
  24. package/src/components/custom/Fatigue/GhostBloom.tsx +132 -0
  25. package/src/components/custom/Fatigue/GhostSpark.stories.tsx +175 -0
  26. package/src/components/custom/Fatigue/GhostSpark.test.tsx +45 -0
  27. package/src/components/custom/Fatigue/GhostSpark.tsx +126 -0
  28. package/src/components/custom/Fatigue/LiveFatigueCard.stories.tsx +126 -0
  29. package/src/components/custom/Fatigue/LiveFatigueCard.test.tsx +28 -0
  30. package/src/components/custom/Fatigue/LiveFatigueCard.tsx +86 -0
  31. package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +95 -0
  32. package/src/components/custom/Fatigue/LiveFatiguePanel.tsx +151 -0
  33. package/src/components/custom/Fatigue/README.md +93 -0
  34. package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +94 -0
  35. package/src/components/custom/Fatigue/RomProgressionChart.test.tsx +31 -0
  36. package/src/components/custom/Fatigue/RomProgressionChart.tsx +132 -0
  37. package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +226 -0
  38. package/src/components/custom/Fatigue/VelocityHero.stories.tsx +70 -0
  39. package/src/components/custom/Fatigue/VelocityHero.test.tsx +26 -0
  40. package/src/components/custom/Fatigue/VelocityHero.tsx +46 -0
  41. package/src/components/custom/Fatigue/VerdictHero.stories.tsx +59 -0
  42. package/src/components/custom/Fatigue/VerdictHero.test.tsx +49 -0
  43. package/src/components/custom/Fatigue/VerdictHero.tsx +79 -0
  44. package/src/components/custom/Fatigue/fatigue-mock.test.ts +59 -0
  45. package/src/components/custom/Fatigue/fatigue-mock.ts +268 -0
  46. package/src/components/custom/Fatigue/fatigue-model.ts +129 -0
  47. package/src/components/custom/Fatigue/fatigue-tokens.test.ts +70 -0
  48. package/src/components/custom/Fatigue/fatigue-tokens.ts +180 -0
  49. package/src/components/custom/Fatigue/index.ts +41 -0
  50. package/src/components/custom/Fatigue/tempo-pacing.test.ts +176 -0
  51. package/src/components/custom/Fatigue/tempo-pacing.ts +112 -0
  52. package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
  53. package/src/components/custom/Workout/ExerciseCard.test.tsx +4 -4
  54. package/src/components/custom/Workout/ExerciseCard.tsx +3 -1
  55. package/src/components/custom/Workout/ExerciseDetailPage.tsx +7 -1
  56. package/src/components/custom/Workout/README.md +62 -16
  57. package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
  58. package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
  59. package/src/components/custom/Workout/SessionRail.tsx +50 -13
  60. package/src/components/custom/Workout/SetRow.test.tsx +6 -6
  61. package/src/components/custom/Workout/SetRow.tsx +9 -2
  62. package/src/components/custom/Workout/VelocityStrip.compact.stories.tsx +191 -0
  63. package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +186 -0
  64. package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +296 -0
  65. package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +259 -0
  66. package/src/components/custom/Workout/VelocityStrip.stories.tsx +217 -250
  67. package/src/components/custom/Workout/VelocityStrip.test.tsx +196 -79
  68. package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
  69. package/src/components/custom/Workout/index.ts +3 -0
  70. package/src/components/custom/Workout/velocity-story-kit.tsx +438 -0
  71. package/src/components/custom/charts/SetBarChart.test.tsx +275 -0
  72. package/src/components/custom/charts/SetBarChart.tsx +651 -0
  73. package/src/components/custom/charts/live-rep-growth.ts +91 -0
  74. package/src/components/custom/index.ts +1 -0
  75. package/src/test/raw-color-patterns.test.ts +71 -0
  76. package/src/theme/ColorPalettes.stories.tsx +871 -0
  77. package/src/theme/ColorPrimitives.stories.tsx +300 -0
  78. package/src/theme/bar-paper.ts +59 -0
  79. package/src/theme/color-stories.coverage.test.ts +130 -0
  80. package/src/theme/color-story-kit.tsx +170 -0
  81. package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +0 -254
  82. package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +0 -145
  83. package/src/theme/ColorSystem.stories.tsx +0 -508
  84. package/src/theme/Colors.stories.tsx +0 -323
@@ -0,0 +1,651 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ /**
3
+ * SetBarChart — the generic per-rep value-height bar chart both the velocity hero and the
4
+ * ROM progression compose. It OWNS the chart geometry (value→height scaling, the shared
5
+ * `scaleMax` override, the plot-width-driven adaptive bar width / gap stepping / value-label
6
+ * thinning) and the shared bar language (paper treatment, grow-from-bottom live rep, up/down
7
+ * orientation, the set-type slot vocabulary). Everything domain-specific — bar COLOR, the
8
+ * reference overlay (velocity VL bands / ROM working+short lines) — is passed in, so an
9
+ * improvement to the chart (spacing, paper, live-grow) lands once for every consumer.
10
+ *
11
+ * VALUE-HEIGHT ONLY: the flat 3px `mini` strip stays its own thin strip in VelocityStrip.
12
+ */
13
+ import { useState, type ReactNode } from 'react'
14
+ import {
15
+ View,
16
+ Text,
17
+ Animated,
18
+ type ViewProps,
19
+ type ViewStyle,
20
+ type LayoutChangeEvent,
21
+ } from 'react-native'
22
+ import { primitiveRamps } from '../../../theme/tokens/primitives'
23
+ import { barPaper } from '../../../theme/bar-paper'
24
+ import { useOnSurfaceColor, useSurface, surfaceBackground } from '../../ui/surface/SurfaceContext'
25
+ import { useLiveRepGrowth } from './live-rep-growth'
26
+
27
+ /** Linear-blend two #RRGGBB hexes (`t`=0 → a, 1 → b) — the surface-relative solid to-do tone. */
28
+ function mixHex(a: string, b: string, t: number): string {
29
+ const parse = (h: string): [number, number, number] => {
30
+ const s = h.replace('#', '')
31
+ return [parseInt(s.slice(0, 2), 16), parseInt(s.slice(2, 4), 16), parseInt(s.slice(4, 6), 16)]
32
+ }
33
+ const [ar, ag, ab] = parse(a)
34
+ const [br, bg, bb] = parse(b)
35
+ const ch = (x: number, y: number): string =>
36
+ Math.round(x + (y - x) * t)
37
+ .toString(16)
38
+ .padStart(2, '0')
39
+ return `#${ch(ar, br)}${ch(ag, bg)}${ch(ab, bb)}`
40
+ }
41
+
42
+ /** The cyan variable-window fill — the range set's `floor..max` window (shared with SetStrip). */
43
+ export const VARIABLE_FILL = primitiveRamps.cyan[900]
44
+ /** The thin cyan-800 outline on the open-ended "continue" slot — reads as "keep going". */
45
+ export const CONTINUE_OUTLINE = primitiveRamps.cyan[800]
46
+
47
+ /**
48
+ * One drawn cell of the chart. `rep` carries a `value` and draws a value-height bar;
49
+ * `todo` / `variable` / `continue` are the set-type window stubs (planned remainder, the
50
+ * range floor..max window, the AMRAP/myo open tail); `empty` is a rep COLUMN a side didn't
51
+ * log — a faint constant-contrast placeholder that keeps the two diverging wings index-locked
52
+ * (a lagging side's missing rep sits at the SAME column index as the other side's rep).
53
+ * `leadingGap` adds spacing BEFORE the cell beyond the uniform inter-bar gap — the WIDE
54
+ * chunk-notch that splits drop sub-loads / myo clusters / cluster intra-rests. Absent
55
+ * `leadingGap` = the ordinary rep gap.
56
+ */
57
+ export interface SetSlot {
58
+ kind: 'rep' | 'todo' | 'variable' | 'continue' | 'empty'
59
+ value?: number
60
+ leadingGap?: number
61
+ }
62
+
63
+ /** The geometry SetBarChart hands a {@link SetBarChartProps.renderReference} overlay painter. */
64
+ export interface SetBarGeometry {
65
+ /** The height-scaling denominator (a value `v` sits `yOf(v)` px up from the baseline). */
66
+ scaleDenom: number
67
+ /** The drawable plot height (px) — `height` minus the value-label headroom when labels show. */
68
+ plotHeight: number
69
+ /** px UP from the baseline for a value. */
70
+ yOf: (value: number) => number
71
+ /** The tallest performed value — the running-best reps are measured against. */
72
+ best: number
73
+ /** The plot is vertically mirrored (a `down` wing) — counter-flip any text in the overlay. */
74
+ flip: boolean
75
+ }
76
+
77
+ export interface SetBarChartProps {
78
+ /** Ordered cells — performed reps + any set-type window stubs. */
79
+ slots: SetSlot[]
80
+ /** value → bar fill color. Reps only; window stubs use the fixed set-type tones. */
81
+ colorFor: (value: number) => string
82
+ /** Plot height (px). Bars scale into this (minus the value-label headroom when labels show). */
83
+ height: number
84
+ /**
85
+ * Height scaling. `peak` (default) scales to the performed max (+headroom); `fixed` scales
86
+ * to a fixed velocity ceiling so heights read the same absolute value across sets. Overridden
87
+ * by {@link scaleMax}.
88
+ */
89
+ scale?: 'peak' | 'fixed'
90
+ /**
91
+ * OVERRIDE the height denominator with `scaleMax * headroom` (BAR HEIGHT ONLY; color + the
92
+ * reference `best` still read this chart's own values). The diverging dual passes the pair's
93
+ * shared max so a stronger side reads TALLER against one common scale.
94
+ */
95
+ scaleMax?: number
96
+ /** `up` (default) grows bars UP from a bottom baseline; `down` mirrors the plot (grows DOWN, text upright). */
97
+ orientation?: 'up' | 'down'
98
+ /** Index (into the rep slots) of the newest rep — that bar grows up from the baseline as it lands. */
99
+ liveRepIndex?: number
100
+ /** The live rep set a new peak — its entrance overshoots then settles (a small bounce). */
101
+ isNewPeak?: boolean
102
+ /**
103
+ * Append dashed todo placeholders so the chart reads "3 of N done". Ignored when the `slots`
104
+ * already run to (or past) this count — a caller that supplies its own todo slots omits this.
105
+ */
106
+ targetReps?: number
107
+ /**
108
+ * Inter-bar gap as a fraction of bar width (gap ≈ gapRatio·barWidth, floored at 2px). Default
109
+ * {@link GAP_RATIO}. Lower = denser (toward the near-touching expanded density); narrow screens
110
+ * floor to 2px regardless. Exposed mainly for a spacing-density comparison.
111
+ */
112
+ gapRatio?: number
113
+ /** Corner radius on the bars (px). Default 5. */
114
+ barRadius?: number
115
+ /**
116
+ * Which corners round. `top` (default) — the bolder top-rounded WALL HERO look. `all` — the thin
117
+ * radius-2 flat-bar language (the SegmentedBar pill), for rep-level compact + expanded in the
118
+ * rail / card / table so a compact↔expanded morph keeps a constant all-corners radius.
119
+ */
120
+ cornerStyle?: 'top' | 'all'
121
+ /**
122
+ * FLAT height mode (the `compact` resting form). Every rep bar renders at ONE uniform short height
123
+ * ({@link FLAT_BAR_FRACTION} of the plot) instead of value-height — EVERYTHING else identical
124
+ * (colors, paper, the proportional spacing + chunk-notch, todo/variable/continue slots, gutter).
125
+ * Value labels are suppressed. Same x-positions/widths/gaps as value mode, so a compact↔expanded
126
+ * toggle only changes bar HEIGHTS in place (no horizontal reflow).
127
+ */
128
+ flat?: boolean
129
+ /**
130
+ * Draw a per-rep value label. Default false. When on, every label pins to one aligned row at the
131
+ * plot TOP (not offset per bar height) in the muted on-surface-secondary tone. Ignored in {@link flat} mode.
132
+ */
133
+ showValueLabels?: boolean
134
+ /** Format a rep value for its label. Default `String`. */
135
+ formatValue?: (value: number) => string
136
+ /**
137
+ * To-do placeholder treatment. `solid` (default) — a solid surface-relative section (the plane
138
+ * blended toward the on-surface neutral). `dashed` — a dashed outline stub. Exists so ROM can keep
139
+ * its dashed to-do while the hero (and the dual that composes it) take the solid section.
140
+ */
141
+ todoVariant?: 'solid' | 'dashed'
142
+ /**
143
+ * Pad the rendered columns to at least this many with placeholder to-do cells. The diverging dual
144
+ * passes the union column count of both wings so bars line up top↔bottom across the centre axis.
145
+ */
146
+ minColumns?: number
147
+ /** Paint a reference overlay (VL bands / working-standard lines) given the chart geometry. */
148
+ renderReference?: (geometry: SetBarGeometry) => ReactNode
149
+ /**
150
+ * Per-rep OVERLAY, rendered inside each rep column above the bar (for the framed `expanded`
151
+ * chrome to inject its per-bar m/s label + `onRepPress` pressable without duplicating bar
152
+ * rendering). When provided, SetBarChart suppresses its OWN value label (the overlay owns it) and
153
+ * the rep columns become interactive (not a11y-hidden).
154
+ */
155
+ renderBarOverlay?: (repIndex: number, value: number) => ReactNode
156
+ /**
157
+ * IN-PLACE expand animation: an `Animated.Value` 0→1 that morphs every rep bar between its FLAT
158
+ * (compact) height and its VALUE height, and fades the value labels in. The x-geometry stays fixed
159
+ * (widths / gaps / positions), so a compact↔expanded toggle animates only heights — no reflow.
160
+ * Overrides `flat` + the live-rep grow while animating.
161
+ */
162
+ expandProgress?: Animated.Value
163
+ /** Suppress the plot's own 2px baseline border (the dual draws ONE shared centre axis instead). */
164
+ hideBaseline?: boolean
165
+ /** Container testID. */
166
+ testID?: string
167
+ /** Prefix for the per-cell testIDs: `${prefix}-bar-N`, `${prefix}-label-N`, `${prefix}-slot-KIND`. Default `setbar`. */
168
+ testIDPrefix?: string
169
+ /** When set, the container reads as an `image` with this label; omit for a plain (unlabeled) plot. */
170
+ accessibilityLabel?: string
171
+ /**
172
+ * Optional stream / slot NAME (e.g. "Left Arm") rendered as a rotated vertical edge label down a
173
+ * left gutter — the same treatment the diverging dual gives each wing. Font size scales to `height`
174
+ * so it fits without truncation. Omitted → no gutter.
175
+ */
176
+ label?: string
177
+ className?: string
178
+ /** Rest View props (style is merged onto the container). */
179
+ viewProps?: ViewProps
180
+ }
181
+
182
+ /** The `scale="fixed"` velocity ceiling (m/s) — a bar height reads the same absolute value across sets. */
183
+ const FIXED_MAX_VALUE = 1.15
184
+ /** Headroom above the peak bar: just enough to seat its value label without a big empty band on top. */
185
+ export const PEAK_HEADROOM = 1.03
186
+
187
+ /** The height-scaling denominator for `scale` at the given performed max (guarded ≥ 0 by callers). */
188
+ export function scaleDenominator(scale: 'peak' | 'fixed', maxValue: number): number {
189
+ return scale === 'fixed' ? FIXED_MAX_VALUE : maxValue * PEAK_HEADROOM
190
+ }
191
+
192
+ /**
193
+ * The per-bar value-label font size (px), scaled to chart `height` (floored at 8) so the numbers stay
194
+ * proportionate to the bars at small heights instead of reading oversized. Hits the full 12px at the
195
+ * standard single-hero (220px) and dual-wing (~110px) scales; shrinks toward the floor on tiny charts.
196
+ */
197
+ export function valueLabelFontSize(height: number): number {
198
+ return Math.round(Math.max(8, Math.min(12, height * 0.11)))
199
+ }
200
+
201
+ // --- Hero geometry -----------------------------------------------------------
202
+ /** Default plot height (px) — tall enough to read a set's shape across a room. */
203
+ export const SET_BAR_DEFAULT_HEIGHT = 220
204
+ /** Value-label row height (px) — the only headroom reserved above the bars when labels show. */
205
+ const LABEL_ROW_HEIGHT = 16
206
+ /** Gap between the value-label row and the peak bar top (px). */
207
+ const LABEL_GAP = 3
208
+ /** Inter-bar gap as a fraction of bar width — the locked dense default (near expanded density). */
209
+ export const GAP_RATIO = 0.08
210
+ /** Floor on the proportional inter-bar gap (px) — narrow plots tighten to ~expanded density. */
211
+ const MIN_BAR_GAP = 2
212
+ /**
213
+ * The EXTRA margin at a chunk boundary (drop sub-load / myo cluster / cluster intra-rest), as a
214
+ * fraction of bar width — proportional so it scales with the dense spacing and reads as a clear
215
+ * cluster break (total boundary gap ≈ this + the normal gapRatio ≈ ⅓ of a bar). Floored so small
216
+ * charts still separate clusters.
217
+ */
218
+ const CHUNK_NOTCH_RATIO = 0.25
219
+ /** Floor on the chunk-boundary extra margin (px). */
220
+ const CHUNK_NOTCH_MIN_PX = 10
221
+ /** Cap on a single bar's width so a 2–3 rep set doesn't render slab-wide bars (px). */
222
+ export const BAR_MAX_WIDTH = 120
223
+ /** Below this per-bar width the value labels collide, so all but the peak + live rep are dropped. */
224
+ const LABEL_MIN_BAR_WIDTH = 30
225
+ /** Default top-corner radius on bars (px). */
226
+ const DEFAULT_BAR_RADIUS = 5
227
+ /** Minimum drawn height of a performed bar (px) — a near-zero rep still reads as a rep. */
228
+ const MIN_BAR_HEIGHT = 4
229
+ /** In `flat` (compact) mode every rep bar is this fraction of the plot height — a uniform short bar. */
230
+ const FLAT_BAR_FRACTION = 0.5
231
+ /** The stub height for a window cell (todo / variable / continue / empty) — a short, clear placeholder. */
232
+ const STUB_HEIGHT = 9
233
+ /** The ordinary rep gap the per-slot `leadingGap` is measured against (extra margin = leadingGap − this). */
234
+ const REP_GAP = 2
235
+
236
+ /**
237
+ * The shared adaptive bar layout — solve `N·bw + (N−1)·(gapRatio·bw) = plotWidth` for the per-bar
238
+ * width (capped at {@link BAR_MAX_WIDTH}) and the proportional inter-bar gap (floored at MIN_BAR_GAP).
239
+ * SetBarChart uses this for its own bars; the diverging compact/rail renderers call it too, so their
240
+ * columns land at the SAME x-positions and widths as the single chart — one shared geometry.
241
+ */
242
+ export function computeBarLayout(
243
+ plotWidth: number,
244
+ count: number,
245
+ gapRatio: number = GAP_RATIO
246
+ ): { barWidth: number; gap: number } {
247
+ const rawBarWidth =
248
+ plotWidth > 0 && count > 0 ? plotWidth / (count + (count - 1) * gapRatio) : BAR_MAX_WIDTH
249
+ const barWidth = Math.min(BAR_MAX_WIDTH, rawBarWidth)
250
+ const gap = plotWidth === 0 ? MIN_BAR_GAP : Math.max(MIN_BAR_GAP, gapRatio * barWidth)
251
+ return { barWidth, gap }
252
+ }
253
+
254
+ /** Is this cell a performed rep (carries a value)? */
255
+ function isRep(slot: SetSlot): boolean {
256
+ return slot.kind === 'rep'
257
+ }
258
+
259
+ /**
260
+ * The performed-rep index for each cell (`-1` for a window stub) — reps are contiguous from 0 in
261
+ * every set type, so this maps `liveRepIndex` / peak / label-thinning (rep-indexed) onto cells.
262
+ */
263
+ function repIndexByCell(cells: SetSlot[]): number[] {
264
+ let count = 0
265
+ return cells.map((slot) => (isRep(slot) ? count++ : -1))
266
+ }
267
+
268
+ export function SetBarChart({
269
+ slots,
270
+ colorFor,
271
+ height,
272
+ scale = 'peak',
273
+ scaleMax,
274
+ orientation = 'up',
275
+ liveRepIndex,
276
+ isNewPeak = false,
277
+ targetReps,
278
+ gapRatio = GAP_RATIO,
279
+ barRadius = DEFAULT_BAR_RADIUS,
280
+ cornerStyle = 'top',
281
+ flat = false,
282
+ showValueLabels: showValueLabelsProp = false,
283
+ formatValue = String,
284
+ todoVariant = 'solid',
285
+ minColumns,
286
+ renderReference,
287
+ renderBarOverlay,
288
+ expandProgress,
289
+ hideBaseline = false,
290
+ testID,
291
+ testIDPrefix = 'setbar',
292
+ accessibilityLabel,
293
+ label,
294
+ className,
295
+ viewProps = {},
296
+ }: SetBarChartProps) {
297
+ // Append todo placeholders up to `targetReps` (a caller that builds its own todo slots omits it),
298
+ // then pad to `minColumns` so a diverging wing aligns column-for-column with its sibling.
299
+ const targetPadded = Math.max(slots.length, targetReps ?? 0, minColumns ?? 0)
300
+ const cells: SetSlot[] =
301
+ targetPadded > slots.length
302
+ ? [
303
+ ...slots,
304
+ ...Array.from({ length: targetPadded - slots.length }, () => ({ kind: 'todo' as const })),
305
+ ]
306
+ : slots
307
+
308
+ const repValues = cells.filter(isRep).map((s) => s.value ?? 0)
309
+ const best = repValues.length > 0 ? Math.max(...repValues) : 0
310
+
311
+ // The whole plot mirrors for `down`; text nodes counter-flip so they read upright.
312
+ const flip = orientation === 'down'
313
+ const flipStyle = flip ? ({ transform: [{ scaleY: -1 as number }] } as const) : null
314
+
315
+ // Planned/to-do reps + the baseline draw in a SURFACE-relative neutral (on-surface tertiary)
316
+ // so they stay legible on every plane instead of a fixed charcoal.
317
+ const placeholderColor = useOnSurfaceColor('tertiary')
318
+ // The expanded strip's solid to-do tone: the surface plane blended toward the neutral (the same
319
+ // relative model), so a `todoVariant="solid"` section holds ~constant contrast on every plane.
320
+ const surface = useSurface()
321
+ const surfaceBg = surfaceBackground(surface.level, surface.mode)
322
+ const solidTodoColor = mixHex(surfaceBg, placeholderColor, 0.55)
323
+ // An `empty` cell (a rep the diverging side didn't log) is fainter than a planned to-do.
324
+ const emptyColor = mixHex(surfaceBg, placeholderColor, 0.28)
325
+ // `all` rounds every corner (the thin flat-bar pill); `top` keeps the bolder hero top-round.
326
+ const barCorners: ViewStyle =
327
+ cornerStyle === 'all'
328
+ ? { borderRadius: barRadius }
329
+ : { borderTopLeftRadius: barRadius, borderTopRightRadius: barRadius }
330
+
331
+ // `scaleMax` (the diverging dual's shared pair-max) overrides the height denominator so both
332
+ // sides share ONE height scale; color + the reference still read this chart's own values.
333
+ const scaleDenom =
334
+ scaleMax != null && scaleMax > 0 ? scaleMax * PEAK_HEADROOM : scaleDenominator(scale, best)
335
+
336
+ // `flat` (compact) mode suppresses value labels and renders every rep at one uniform short height.
337
+ const showValueLabels = showValueLabelsProp && !flat
338
+ // Reserve ONLY the value-label row (+ its gap to the peak bar) as headroom — not a fat fixed
339
+ // band — so the bars fill the plot and, at small heights, aren't over-stubbed.
340
+ const plotHeight = Math.max(0, height - (showValueLabels ? LABEL_ROW_HEIGHT + LABEL_GAP : 0))
341
+ const yOf = (value: number): number => (scaleDenom > 0 ? (value / scaleDenom) * plotHeight : 0)
342
+ // The `expandProgress` morph starts from a PARTIAL flat height (bars visibly grow to their value);
343
+ // a STATIC compact strip instead FILLS its plot — an 8px plot → an 8px bar, the SegmentedBar model —
344
+ // so a resting strip is sized by its container, not left half-empty.
345
+ const flatBarHeight = Math.max(MIN_BAR_HEIGHT, FLAT_BAR_FRACTION * plotHeight)
346
+ const compactBarHeight = Math.max(MIN_BAR_HEIGHT, plotHeight)
347
+ const valueBarHeight = (value: number): number =>
348
+ scaleDenom > 0
349
+ ? Math.max(MIN_BAR_HEIGHT, Math.min(1, value / scaleDenom) * plotHeight)
350
+ : MIN_BAR_HEIGHT
351
+ const barHeight = (value: number): number => (flat ? compactBarHeight : valueBarHeight(value))
352
+
353
+ // Live-rep grow-from-bottom (rep index → cell index; reps are contiguous from 0 in every set type).
354
+ const liveValue =
355
+ liveRepIndex != null && liveRepIndex < repValues.length ? repValues[liveRepIndex] : undefined
356
+ const liveGrowth = useLiveRepGrowth(true, liveRepIndex, liveValue, isNewPeak)
357
+
358
+ // Measure the plot so per-bar value labels can thin on a narrow chart (they collide once bars
359
+ // get thin). Width 0 (unmeasured) → show all, so test/server renders are unchanged.
360
+ const [plotW, setPlotW] = useState(0)
361
+ const onPlotLayout = (e: LayoutChangeEvent) => setPlotW(e.nativeEvent.layout.width)
362
+
363
+ // Inter-bar gap is PROPORTIONAL to bar width (gap ≈ gapRatio·barWidth, floored at MIN_BAR_GAP).
364
+ // Solve N·bw + (N−1)·(gapRatio·bw) = plotW → bw = plotW / (N + (N−1)·gapRatio); the bars tighten to
365
+ // ~expanded density on narrow plots (the floor) and the ratio sets how dense they read at wall scale.
366
+ const totalCells = cells.length
367
+ const { barWidth, gap } = computeBarLayout(plotW, totalCells, gapRatio)
368
+ const labelsCrowded = plotW > 0 && barWidth < LABEL_MIN_BAR_WIDTH
369
+
370
+ // Peak rep index (over rep values) — its label always survives label-thinning.
371
+ const peakRepIndex = repValues.reduce((b, v, i) => (v > repValues[b] ? i : b), 0)
372
+ const showBarLabel = (repIndex: number): boolean =>
373
+ !labelsCrowded || repIndex === peakRepIndex || repIndex === liveRepIndex
374
+
375
+ const geometry: SetBarGeometry = { scaleDenom, plotHeight, yOf, best, flip }
376
+
377
+ const { style: externalStyle, ...restProps } = viewProps
378
+ const a11y =
379
+ accessibilityLabel != null ? { accessibilityRole: 'image' as const, accessibilityLabel } : {}
380
+
381
+ // Which performed rep each cell is (window stubs → -1) — for live-grow / peak / label thinning.
382
+ const repIndices = repIndexByCell(cells)
383
+
384
+ return (
385
+ <View
386
+ className={className}
387
+ style={[{ height, flexDirection: 'row' }, externalStyle]}
388
+ testID={testID}
389
+ {...a11y}
390
+ {...restProps}
391
+ >
392
+ {label ? (
393
+ <ChartSideRail
394
+ sections={[{ label, testID: `${testIDPrefix}-side-label` }]}
395
+ sectionExtent={height}
396
+ />
397
+ ) : null}
398
+ <View
399
+ onLayout={onPlotLayout}
400
+ style={{
401
+ flex: 1,
402
+ flexDirection: 'row',
403
+ alignItems: 'flex-end',
404
+ gap,
405
+ position: 'relative',
406
+ borderBottomWidth: hideBaseline ? 0 : 2,
407
+ borderBottomColor: placeholderColor,
408
+ ...flipStyle,
409
+ }}
410
+ >
411
+ {renderReference?.(geometry)}
412
+
413
+ {cells.map((slot, i) => {
414
+ // A chunk boundary (leadingGap > the ordinary rep gap) gets extra margin PROPORTIONAL to
415
+ // bar width (floored) so the cluster break scales with the dense spacing and stays clear;
416
+ // ordinary cells add nothing beyond the container's flex gap.
417
+ const isChunkBoundary = (slot.leadingGap ?? REP_GAP) > REP_GAP
418
+ const extraGap = isChunkBoundary
419
+ ? Math.max(CHUNK_NOTCH_MIN_PX, CHUNK_NOTCH_RATIO * barWidth)
420
+ : 0
421
+ const column: ViewStyle = {
422
+ flex: 1,
423
+ maxWidth: BAR_MAX_WIDTH,
424
+ height: '100%',
425
+ alignItems: 'center',
426
+ justifyContent: 'flex-end',
427
+ marginLeft: extraGap,
428
+ }
429
+
430
+ if (!isRep(slot)) {
431
+ return (
432
+ <View key={i} accessibilityElementsHidden style={column}>
433
+ {renderStub(
434
+ slot.kind,
435
+ barCorners,
436
+ // Flat (compact) stubs fill the plot like the compact bars; else the fixed short stub.
437
+ flat ? compactBarHeight : STUB_HEIGHT,
438
+ placeholderColor,
439
+ testIDPrefix,
440
+ todoVariant,
441
+ solidTodoColor,
442
+ emptyColor
443
+ )}
444
+ </View>
445
+ )
446
+ }
447
+
448
+ const repIndex = repIndices[i]
449
+ const value = slot.value ?? 0
450
+ const isLive = liveRepIndex === repIndex
451
+ const color = colorFor(value)
452
+ // Bar height: `expandProgress` morphs flat↔value in place; else the live-rep grow; else static.
453
+ const barHeightStyle = expandProgress
454
+ ? expandProgress.interpolate({
455
+ inputRange: [0, 1],
456
+ outputRange: [flatBarHeight, valueBarHeight(value)],
457
+ })
458
+ : isLive
459
+ ? liveGrowth.interpolate({ inputRange: [0, 1], outputRange: [0, barHeight(value)] })
460
+ : barHeight(value)
461
+ // The wrapper's `renderBarOverlay` owns labels + press when present (so SetBarChart doesn't
462
+ // duplicate them); its own label shows otherwise, fading in with `expandProgress`.
463
+ const ownLabel = showValueLabels && !renderBarOverlay && showBarLabel(repIndex)
464
+ const labelBottom = (expandProgress ? valueBarHeight(best) : barHeight(best)) + LABEL_GAP
465
+ return (
466
+ <View key={i} accessibilityElementsHidden={renderBarOverlay == null} style={column}>
467
+ {ownLabel && (
468
+ // All labels pin to one aligned row JUST ABOVE THE PEAK BAR (not the container top),
469
+ // in the muted on-surface-secondary tone — so the row hugs the bars, no floating void.
470
+ <Animated.View
471
+ style={{
472
+ position: 'absolute',
473
+ bottom: labelBottom,
474
+ left: 0,
475
+ right: 0,
476
+ alignItems: 'center',
477
+ ...(expandProgress ? { opacity: expandProgress } : null),
478
+ }}
479
+ pointerEvents="none"
480
+ >
481
+ <Text
482
+ className="text-text-secondary"
483
+ style={[{ fontSize: valueLabelFontSize(height), fontWeight: '700' }, flipStyle]}
484
+ testID={`${testIDPrefix}-label-${repIndex}`}
485
+ >
486
+ {formatValue(value)}
487
+ </Text>
488
+ </Animated.View>
489
+ )}
490
+ <Animated.View
491
+ style={[
492
+ { width: '100%', height: barHeightStyle, backgroundColor: color },
493
+ barCorners,
494
+ barPaper(color, flip),
495
+ ]}
496
+ testID={`${testIDPrefix}-bar-${repIndex}`}
497
+ />
498
+ {renderBarOverlay?.(repIndex, value)}
499
+ </View>
500
+ )
501
+ })}
502
+ </View>
503
+ </View>
504
+ )
505
+ }
506
+
507
+ /** A window cell (planned / variable / continue / empty) — a fixed-height stub in its set-type tone. */
508
+ function renderStub(
509
+ kind: SetSlot['kind'],
510
+ barCorners: ViewStyle,
511
+ stubHeight: number,
512
+ placeholderColor: string,
513
+ testIDPrefix: string,
514
+ todoVariant: 'dashed' | 'solid',
515
+ solidTodoColor: string,
516
+ emptyColor: string
517
+ ): ReactNode {
518
+ const base: ViewStyle = {
519
+ width: '100%',
520
+ height: stubHeight,
521
+ ...barCorners,
522
+ }
523
+ if (kind === 'variable') {
524
+ return (
525
+ <View
526
+ style={{ ...base, backgroundColor: VARIABLE_FILL }}
527
+ testID={`${testIDPrefix}-slot-variable`}
528
+ />
529
+ )
530
+ }
531
+ if (kind === 'continue') {
532
+ return (
533
+ <View
534
+ style={{ ...base, borderWidth: 1, borderColor: CONTINUE_OUTLINE }}
535
+ testID={`${testIDPrefix}-slot-continue`}
536
+ />
537
+ )
538
+ }
539
+ if (kind === 'empty') {
540
+ // A rep column the diverging side did NOT log — a faint constant-contrast section, quieter than
541
+ // a planned to-do (it's a hole in this side's data, index-locked to the other side's rep).
542
+ return (
543
+ <View
544
+ style={{ ...base, backgroundColor: emptyColor }}
545
+ testID={`${testIDPrefix}-slot-empty`}
546
+ />
547
+ )
548
+ }
549
+ // todo — a solid surface-relative section (expanded language) or a dashed outline (hero language).
550
+ const todoStyle: ViewStyle =
551
+ todoVariant === 'solid'
552
+ ? { ...base, backgroundColor: solidTodoColor }
553
+ : { ...base, borderWidth: 1, borderStyle: 'dashed', borderColor: placeholderColor }
554
+ return <View style={todoStyle} testID={`${testIDPrefix}-slot-todo`} />
555
+ }
556
+
557
+ /** Side/stream-name label font size (px), scaled to chart height so a long name fits un-truncated. */
558
+ export function sideLabelFontSize(height: number): number {
559
+ return Math.round(Math.max(7, Math.min(13, height * 0.06)))
560
+ }
561
+
562
+ /**
563
+ * Below this vertical extent (px) the side label collapses to initials rather than shrink further.
564
+ * A 120px dual (60px per wing) sits below this, so its wings degrade to "L" / "R"; the compact `rail`
565
+ * is exempted at its call site (it keeps its own tuned full-name treatment).
566
+ */
567
+ export const SIDE_LABEL_INITIALS_BELOW = 70
568
+
569
+ /**
570
+ * The side-label text for the available vertical `extent`: the full name when there's room, else it
571
+ * degrades to INITIALS — the first letter of each word ("Left Arm" → "L A", "Left" → "L", "Right" →
572
+ * "R") — so a slot name stays legible at tiny chart heights instead of shrinking into a smear.
573
+ */
574
+ export function sideLabelText(label: string, extent: number): string {
575
+ if (extent >= SIDE_LABEL_INITIALS_BELOW) return label
576
+ return label
577
+ .trim()
578
+ .split(/\s+/)
579
+ .map((word) => word.charAt(0))
580
+ .join(' ')
581
+ .toUpperCase()
582
+ }
583
+
584
+ /** One section of the shared {@link ChartSideRail} — a wing's rotated stream/slot-name label. */
585
+ export interface SideRailSection {
586
+ label?: string
587
+ testID: string
588
+ }
589
+
590
+ /**
591
+ * The ONE shared gutter/side-rail lockup used by BOTH the single hero and the diverging dual, so a
592
+ * single chart is pixel-identical to the dual's upper half — going dual only adds a lower section.
593
+ * A fixed-width gutter (34px hero / 18px rail) with the `border-border` right hairline (the vertical
594
+ * axis) and one rotated, height-responsive, initials-collapsing label per section (each `sectionExtent`
595
+ * tall). The single passes ONE section; the dual passes two (up + down wings). Rendered null when no
596
+ * section carries a label. The rotate lives on a wrapper sized to `sectionExtent` so `numberOfLines`
597
+ * resolves against the tall vertical extent — a long name only ellipsizes when it exceeds the section.
598
+ */
599
+ export function ChartSideRail({
600
+ sections,
601
+ sectionExtent,
602
+ variant = 'hero',
603
+ }: {
604
+ sections: SideRailSection[]
605
+ sectionExtent: number
606
+ variant?: 'hero' | 'rail'
607
+ }) {
608
+ if (!sections.some((s) => s.label)) return null
609
+ const isRail = variant === 'rail'
610
+ const labelLength = Math.max(0, sectionExtent - (isRail ? 0 : 6))
611
+ // Font scales to the SECTION (wing) height so a slot name fits without truncating; the compact rail
612
+ // keeps its own tuned 7px full-name treatment (hero + single collapse to initials at small heights).
613
+ const fontSize = isRail ? 7 : sideLabelFontSize(sectionExtent)
614
+ const textStyle = {
615
+ maxWidth: '100%' as const,
616
+ textAlign: 'center' as const,
617
+ fontSize,
618
+ fontWeight: '700' as const,
619
+ letterSpacing: isRail || fontSize < 10 ? 0 : 2,
620
+ textTransform: 'uppercase' as const,
621
+ }
622
+ return (
623
+ <View style={{ width: isRail ? 18 : 34 }} accessibilityElementsHidden>
624
+ {sections.map((s) => (
625
+ <View
626
+ key={s.testID}
627
+ style={{ height: sectionExtent, alignItems: 'center', justifyContent: 'center' }}
628
+ >
629
+ {s.label ? (
630
+ <View
631
+ style={{
632
+ width: labelLength,
633
+ alignItems: 'center',
634
+ transform: [{ rotate: '-90deg' }],
635
+ }}
636
+ >
637
+ <Text
638
+ className="text-text-tertiary"
639
+ style={textStyle}
640
+ numberOfLines={1}
641
+ testID={s.testID}
642
+ >
643
+ {isRail ? s.label : sideLabelText(s.label, sectionExtent)}
644
+ </Text>
645
+ </View>
646
+ ) : null}
647
+ </View>
648
+ ))}
649
+ </View>
650
+ )
651
+ }