Skip to content

Commit f218e9e

Browse files
feat(calm-hub-ui): refine diagram minimap, node legibility and version timeline (finos#2763)
* feat(calm-hub-ui): refine diagram minimap, node legibility and version timeline Item-detail diagram refinements (redesign problems #4/#5/finos#6). - MiniMap: fixed 132x84 panel with a light mask, faint node chips and a #2563EB viewport rect, padded clear of the timeline and toggleable for dense graphs (fixes the oversized solid-blue overflow) - Node legibility: fitView gains a minZoom floor so fit-to-view no longer shrinks node labels below readability on dense graphs - Version timeline: relabelled "Browse versions" with explanatory text and a mono version pill; active moment re-tokenised to the brand blue; single- version items collapse the strip. Cross-view re-render + compare preserved * fix(calm-hub-ui): address Copilot feedback on the diagram phase - Restore window.matchMedia after the mobile ArchitectureGraph test so the mobile viewport can't leak into later tests (matches the SegmentedTypeTabs convention). - Correct the minimap mask comment (the alpha suffix is 40, not AA). - Add ml-auto to the timeline version pill so it is right-aligned as its docstring describes. * fix(calm-hub-ui): align timeline dots and drop no-op pill margin - Move the sparkline track line + progress fill to the dots' vertical centre (top 18->15) so the dots sit on the track for both active and inactive sizes, restoring the alignment the removed per-dot compensation used to provide. - Drop the no-op ml-auto on the version pill (TimelineHeader is content-sized in both callers, so it had no slack) and correct the docstring to describe the actual inline layout.
1 parent 601f11a commit f218e9e

7 files changed

Lines changed: 534 additions & 148 deletions

File tree

calm-hub-ui/src/hub/components/diagram-section/timeline/MomentCards.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,13 +137,15 @@ export function MomentCards({
137137
? moment.version === compareFrom || moment.version === compareTo
138138
: moment.version === currentVersion;
139139

140-
// The pill highlight (blue border + blueSoft bg + shadow) follows
141-
// the viewed card. Badges (NOW / FROM / TO) are independent.
142-
const borderColor = isViewed ? colors.calm.blue : colors.ink[200];
143-
const background = isViewed ? colors.calm.blueSoft : '#ffffff';
140+
// The pill highlight (blue border + tint bg + shadow) follows the
141+
// viewed card. Re-tokenised to redesign.primary so the timeline's
142+
// active state matches the one blue active system used elsewhere
143+
// (redesign problem #8). Badges (NOW / FROM / TO) are independent.
144+
const borderColor = isViewed ? colors.redesign.primary : colors.ink[200];
145+
const background = isViewed ? colors.redesign.tintBg : '#ffffff';
144146
const titleColor =
145147
badge === 'FROM' || badge === 'TO'
146-
? colors.calm.blueDeep
148+
? colors.redesign.primary
147149
: isViewed
148150
? colors.ink[900]
149151
: colors.ink[700];
@@ -193,7 +195,7 @@ export function MomentCards({
193195
padding: '10px 14px',
194196
minWidth: 124,
195197
boxShadow: isViewed
196-
? '0 6px 14px rgba(31,109,255,0.18)'
198+
? `0 6px 14px ${colors.redesign.primary}2e`
197199
: 'none',
198200
}}
199201
>

calm-hub-ui/src/hub/components/diagram-section/timeline/Sparkline.tsx

Lines changed: 132 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useEffect, useRef, useState } from 'react';
2-
import { IoChevronUpOutline, IoTimeOutline } from 'react-icons/io5';
2+
import { IoChevronUpOutline } from 'react-icons/io5';
33
import { colors } from '../../../../theme/colors.js';
4+
import { TimelineHeader } from './TimelineHeader.js';
45
import type { TimelineMoment } from './TimelineBar.js';
56

67
interface SparklineProps {
@@ -24,10 +25,13 @@ interface ContextMenuState {
2425
}
2526

2627
/**
27-
* Collapsed timeline strip — a sparkline of version dots with title + date below
28-
* each. Single mode highlights the viewed version; compare mode shows progress
29-
* between FROM and TO. Left-click navigates; right-click opens a compare-from /
30-
* compare-to menu.
28+
* Collapsed timeline strip — a "Browse versions" header (with the current-version
29+
* pill) above a sparkline of version dots with title + date below each. Single
30+
* mode highlights the viewed version; compare mode shows progress between FROM and
31+
* TO. Left-click navigates; right-click opens a compare-from / compare-to menu.
32+
*
33+
* A single-version resource collapses to just the header + version pill — there is
34+
* no meaningful track to scrub, so an inert one-dot slider is suppressed.
3135
*/
3236
export function Sparkline({
3337
moments,
@@ -45,6 +49,7 @@ export function Sparkline({
4549

4650
const comparing = compareFrom !== null && compareTo !== null;
4751
const total = moments.length;
52+
const singleVersion = total <= 1;
4853
// Evenly distribute dots from left to right. With a single moment, centre it.
4954
const pct = (i: number) => (total <= 1 ? 50 : (i / (total - 1)) * 100);
5055

@@ -109,147 +114,145 @@ export function Sparkline({
109114
setMenu(null);
110115
};
111116

117+
const newPill = showNewPill ? (
118+
<span
119+
data-testid="timeline-new-pill"
120+
className="font-inter rounded-full text-white"
121+
style={{ backgroundColor: colors.new, fontSize: 9.5, fontWeight: 700, padding: '2px 6px' }}
122+
>
123+
NEW
124+
</span>
125+
) : null;
126+
127+
const expandButton = (
128+
<button
129+
type="button"
130+
onClick={handleExpand}
131+
aria-label="Expand timeline"
132+
title="Expand timeline"
133+
className="shrink-0 flex items-center justify-center bg-transparent border-0 p-0 cursor-pointer"
134+
style={{ width: 28, height: 28 }}
135+
>
136+
<IoChevronUpOutline size={14} style={{ color: colors.ink[500], strokeWidth: 2 }} />
137+
</button>
138+
);
139+
112140
return (
113141
<div
114142
data-testid="timeline-bar-collapsed"
115-
className="flex items-start gap-4 bg-white border-t font-inter"
116-
style={{ borderTopColor: colors.ink[200], padding: '12px 20px 14px' }}
143+
className="flex flex-col bg-white border-t font-inter"
144+
style={{ borderTopColor: colors.ink[200], padding: '12px 20px 14px', gap: 8 }}
117145
>
118-
{/* Left cluster: clock + Timeline + (optional) NEW pill */}
119-
<div className="flex items-center gap-2 shrink-0" style={{ paddingTop: 4 }}>
120-
<IoTimeOutline size={14} style={{ color: colors.ink[500], strokeWidth: 2 }} />
121-
<span
122-
className="font-inter"
123-
style={{ fontSize: 12, fontWeight: 500, color: colors.ink[500] }}
124-
>
125-
Timeline
126-
</span>
127-
{showNewPill && (
128-
<span
129-
data-testid="timeline-new-pill"
130-
className="font-inter rounded-full text-white"
131-
style={{
132-
backgroundColor: colors.new,
133-
fontSize: 9.5,
134-
fontWeight: 700,
135-
padding: '2px 6px',
136-
}}
137-
>
138-
NEW
139-
</span>
140-
)}
146+
{/* Header row: purpose + version pill on the left, NEW pill + expand on the right. */}
147+
<div className="flex items-center gap-2">
148+
<TimelineHeader currentVersion={currentVersion}>{newPill}</TimelineHeader>
149+
<div className="ml-auto">{expandButton}</div>
141150
</div>
142151

143-
{/* Centre: sparkline track with dots + labels. overflow-hidden clips
144-
long labels at the track edge so they can never paint over the
145-
statically-positioned expand button (#2728). The height must fully
146-
contain the dot + label + date stack, otherwise the same clip would
147-
cut off the date row beneath each dot. */}
148-
<div
149-
data-testid="timeline-sparkline-track"
150-
className="relative flex-1"
151-
style={{ height: 72, overflow: 'hidden' }}
152-
>
153-
{/* Inner track wrapper inset 10px each side so dot percentages map directly */}
154-
<div className="absolute" style={{ left: 10, right: 10, top: 0, bottom: 0 }}>
155-
<div
156-
className="absolute"
157-
style={{ left: 0, right: 0, top: 18, height: 2, backgroundColor: colors.ink[200] }}
158-
/>
159-
{progressRight > progressLeft && (
152+
{/* Track row. A single-version resource has nothing to scrub, so the
153+
track is suppressed (the version pill already states what's shown).
154+
overflow-hidden clips long labels at the track edge (#2728). */}
155+
{!singleVersion && (
156+
<div
157+
data-testid="timeline-sparkline-track"
158+
className="relative"
159+
style={{ height: 72, overflow: 'hidden' }}
160+
>
161+
{/* Inner track wrapper inset 10px each side so dot percentages map directly */}
162+
<div className="absolute" style={{ left: 10, right: 10, top: 0, bottom: 0 }}>
160163
<div
161-
data-testid="timeline-progress"
162164
className="absolute"
163-
style={{
164-
left: `${progressLeft}%`,
165-
width: `${progressRight - progressLeft}%`,
166-
top: 18,
167-
height: 2,
168-
backgroundColor: colors.calm.blue,
169-
}}
165+
style={{ left: 0, right: 0, top: 15, height: 2, backgroundColor: colors.ink[200] }}
170166
/>
171-
)}
172-
{moments.map((moment, i) => {
173-
// Highlight the dot for whatever the diagram is currently showing
174-
// (the viewed version). In compare mode the two endpoints are
175-
// highlighted via isFrom / isTo below instead.
176-
const isCurrent = !comparing && i === viewedIdx;
177-
const isFrom = comparing && i === fromIdx;
178-
const isTo = comparing && i === toIdx;
179-
const isActive = isCurrent || isFrom || isTo;
180-
const dotSize = isActive ? 22 : 14;
181-
const dotMt = isActive ? 8 : 12;
182-
return (
167+
{progressRight > progressLeft && (
183168
<div
184-
key={moment.key}
185-
className="absolute top-0 flex flex-col items-center"
186-
style={{ left: `${pct(i)}%`, transform: 'translateX(-50%)' }}
187-
>
188-
<button
189-
type="button"
190-
aria-label={`Moment ${moment.label}`}
191-
aria-current={isCurrent ? 'true' : undefined}
192-
aria-pressed={isFrom || isTo ? true : undefined}
193-
onClick={() => handleDotClick(moment)}
194-
onContextMenu={(e) => handleDotContextMenu(e, moment)}
195-
className="bg-transparent p-0 border-0 cursor-pointer flex justify-center"
196-
style={{ width: 32, height: 32 }}
169+
data-testid="timeline-progress"
170+
className="absolute"
171+
style={{
172+
left: `${progressLeft}%`,
173+
width: `${progressRight - progressLeft}%`,
174+
top: 15,
175+
height: 2,
176+
backgroundColor: colors.redesign.primary,
177+
}}
178+
/>
179+
)}
180+
{moments.map((moment, i) => {
181+
// Highlight the dot for whatever the diagram is currently showing
182+
// (the viewed version). In compare mode the two endpoints are
183+
// highlighted via isFrom / isTo below instead.
184+
const isCurrent = !comparing && i === viewedIdx;
185+
const isFrom = comparing && i === fromIdx;
186+
const isTo = comparing && i === toIdx;
187+
const isActive = isCurrent || isFrom || isTo;
188+
// Active = filled 12px blue dot with a halo; others = 9px white
189+
// dots with a slate ring (redesign #4 anatomy).
190+
const dotSize = isActive ? 12 : 9;
191+
return (
192+
<div
193+
key={moment.key}
194+
className="absolute top-0 flex flex-col items-center"
195+
style={{ left: `${pct(i)}%`, transform: 'translateX(-50%)' }}
197196
>
197+
<button
198+
type="button"
199+
aria-label={`Moment ${moment.label}`}
200+
aria-current={isCurrent ? 'true' : undefined}
201+
aria-pressed={isFrom || isTo ? true : undefined}
202+
onClick={() => handleDotClick(moment)}
203+
onContextMenu={(e) => handleDotContextMenu(e, moment)}
204+
className="bg-transparent p-0 border-0 cursor-pointer flex items-center justify-center"
205+
style={{ width: 32, height: 32 }}
206+
>
207+
<div
208+
data-active={isActive ? 'true' : undefined}
209+
style={{
210+
width: dotSize,
211+
height: dotSize,
212+
borderRadius: 999,
213+
backgroundColor: isActive ? colors.redesign.primary : '#ffffff',
214+
border: isActive
215+
? `2px solid ${colors.redesign.primary}`
216+
: `1.5px solid ${colors.ink[400]}`,
217+
boxShadow: isActive
218+
? `0 0 0 4px ${colors.redesign.primary}33`
219+
: 'none',
220+
}}
221+
/>
222+
</button>
198223
<div
224+
className="font-inter"
199225
style={{
200-
width: dotSize,
201-
height: dotSize,
202-
borderRadius: 999,
203-
backgroundColor: isActive ? colors.calm.blue : '#ffffff',
204-
border: `2px solid ${isActive ? colors.calm.blue : colors.ink[300]}`,
205-
marginTop: dotMt,
226+
marginTop: 4,
227+
fontSize: 12,
228+
fontWeight: isActive ? 600 : 500,
229+
color: isActive ? colors.ink[900] : colors.ink[700],
230+
textAlign: 'center',
231+
// Bound + truncate long names so the strip stays
232+
// readable; the title tooltip shows the full name (#2728).
233+
maxWidth: 120,
234+
overflow: 'hidden',
235+
textOverflow: 'ellipsis',
236+
whiteSpace: 'nowrap',
206237
}}
207-
/>
208-
</button>
209-
<div
210-
className="font-inter"
211-
style={{
212-
marginTop: 4,
213-
fontSize: 12,
214-
fontWeight: isActive ? 600 : 500,
215-
color: isActive ? colors.ink[900] : colors.ink[700],
216-
textAlign: 'center',
217-
// Bound + truncate long names so the strip stays
218-
// readable; the title tooltip shows the full name (#2728).
219-
maxWidth: 120,
220-
overflow: 'hidden',
221-
textOverflow: 'ellipsis',
222-
whiteSpace: 'nowrap',
223-
}}
224-
title={moment.label}
225-
>
226-
{moment.label}
227-
</div>
228-
{moment.validFrom && (
229-
<div
230-
className="font-mono-jb"
231-
style={{ fontSize: 10, color: colors.ink[400] }}
238+
title={moment.label}
232239
>
233-
{moment.validFrom}
240+
{moment.label}
234241
</div>
235-
)}
236-
</div>
237-
);
238-
})}
242+
{moment.validFrom && (
243+
<div
244+
className="font-mono-jb"
245+
style={{ fontSize: 10, color: colors.ink[400] }}
246+
>
247+
{moment.validFrom}
248+
</div>
249+
)}
250+
</div>
251+
);
252+
})}
253+
</div>
239254
</div>
240-
</div>
241-
242-
{/* Right cluster: chevron-up to expand */}
243-
<button
244-
type="button"
245-
onClick={handleExpand}
246-
aria-label="Expand timeline"
247-
title="Expand timeline"
248-
className="shrink-0 flex items-center justify-center bg-transparent border-0 p-0 cursor-pointer"
249-
style={{ width: 28, height: 28 }}
250-
>
251-
<IoChevronUpOutline size={14} style={{ color: colors.ink[500], strokeWidth: 2 }} />
252-
</button>
255+
)}
253256

254257
{menu && (
255258
<div

0 commit comments

Comments
 (0)