@chronogrove/ui 0.83.1 → 0.83.3

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/README.md CHANGED
@@ -44,7 +44,7 @@ Prefer deep imports so bundles stay lean:
44
44
  | `@chronogrove/ui/widget-header` | Widget section title row (optional Font Awesome icon, aside slot, optional metrics) |
45
45
  | `@chronogrove/ui/gatsby` | Color-mode Gatsby SSR/browser helpers |
46
46
 
47
- **Additional subpaths:** The table lists the most common entry points. Also published: **`pagination`** (full bar; composes **`pagination-button`**), **`category-label`**, **`metric-badge`**, **`metric-card`**, **`muted-card-footer`**, **`status-card`**, **`widget-section`**, **`widget-call-to-action`**, **`external-link-icon`**, **`thumbnail-strip`**, **`image-thumbnails`** (`optimizeSrc` for CDN resizing; Gatsby passes a Cloudinary helper). The authoritative list is **`package.json`** → **`exports`**.
47
+ **Additional subpaths:** The table lists the most common entry points. Also published: **`pagination`** (full bar; composes **`pagination-button`**), **`category-label`**, **`metric-badge`**, **`metric-card`**, **`muted-card-footer`**, **`status-card`**, **`widget-section`** (dashboard `<section>`; when **`id`** is passed, **`tabIndex={-1}`** defaults so hosts can **`focus`** the landmark after hash / in-page navigation), **`widget-call-to-action`**, **`external-link-icon`**, **`thumbnail-strip`**, **`image-thumbnails`** (`optimizeSrc` for CDN resizing; Gatsby passes a Cloudinary helper). The authoritative list is **`package.json`** → **`exports`**.
48
48
 
49
49
  ## Next.js (App Router)
50
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chronogrove/ui",
3
- "version": "0.83.1",
3
+ "version": "0.83.3",
4
4
  "description": "Chronogrove Theme UI theme, color mode helpers, and shared UI primitives",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -177,26 +177,26 @@
177
177
  "@emotion/cache": "^11.14.0",
178
178
  "@emotion/react": "^11.14.0",
179
179
  "@fortawesome/fontawesome-svg-core": "^7.2.0",
180
- "@fortawesome/react-fontawesome": "^3.3.0",
180
+ "@fortawesome/react-fontawesome": "^3.3.1",
181
181
  "@theme-toggles/react": "^4.1.0",
182
182
  "@theme-ui/components": "^0.17.4",
183
183
  "@theme-ui/presets": "^0.17.4",
184
184
  "react-intersection-observer": "^10.0.3",
185
185
  "theme-ui": "^0.17.4",
186
- "three": "^0.183.2"
186
+ "three": "^0.184.0"
187
187
  },
188
188
  "devDependencies": {
189
189
  "@babel/core": "^7.29.0",
190
- "@babel/preset-env": "^7.29.2",
190
+ "@babel/preset-env": "^7.29.5",
191
191
  "@babel/preset-react": "^7.28.5",
192
192
  "@testing-library/jest-dom": "^6.9.1",
193
193
  "@testing-library/react": "^16.3.2",
194
- "babel-jest": "^30.3.0",
195
- "jest": "^30.3.0",
196
- "jest-environment-jsdom": "^30.3.0",
197
- "next": "^16.2.3",
198
- "react": "^19.2.5",
199
- "react-dom": "^19.2.5"
194
+ "babel-jest": "^30.4.1",
195
+ "jest": "^30.4.2",
196
+ "jest-environment-jsdom": "^30.4.1",
197
+ "next": "^16.2.6",
198
+ "react": "^19.2.6",
199
+ "react-dom": "^19.2.6"
200
200
  },
201
201
  "scripts": {
202
202
  "test": "jest --config jest.config.cjs",
@@ -115,8 +115,8 @@ exports[`Theme Configuration a snapshot of the configuration matches the snapsho
115
115
  "paddingLeft": "8px",
116
116
  },
117
117
  "&:hover, &:focus": {
118
- "boxShadow": "lg",
119
- "transform": "scale(1.015)",
118
+ "boxShadow": "0 1px 0 rgba(0,0,0,0.06), 0 2px 0 rgba(0,0,0,0.04), 0 3px 0 rgba(0,0,0,0.03), 0 8px 20px rgba(66,46,163,0.14)",
119
+ "transform": "translateY(-3px) scale(1.008)",
120
120
  },
121
121
  ".card-media": {
122
122
  "mb": 2,
@@ -145,7 +145,7 @@ exports[`Theme Configuration a snapshot of the configuration matches the snapsho
145
145
  "height": "100%",
146
146
  "padding": 3,
147
147
  "textDecoration": "none",
148
- "transition": "all 200ms ease-in-out",
148
+ "transition": "transform 200ms ease-in-out, box-shadow 200ms ease-in-out",
149
149
  },
150
150
  "StatusCardDark": {
151
151
  "backgroundColor": "#1e2530",
@@ -175,8 +175,8 @@ exports[`Theme Configuration a snapshot of the configuration matches the snapsho
175
175
  },
176
176
  "actionCard": {
177
177
  "&:hover, &:focus": {
178
- "boxShadow": "lg",
179
- "transform": "scale(1.015)",
178
+ "boxShadow": "0 1px 0 rgba(0,0,0,0.06), 0 2px 0 rgba(0,0,0,0.04), 0 3px 0 rgba(0,0,0,0.03), 0 8px 20px rgba(66,46,163,0.14)",
179
+ "transform": "translateY(-3px) scale(1.008)",
180
180
  },
181
181
  "WebkitBackdropFilter": "blur(10px)",
182
182
  "a": {
@@ -196,7 +196,7 @@ exports[`Theme Configuration a snapshot of the configuration matches the snapsho
196
196
  ],
197
197
  "padding": 3,
198
198
  "textDecoration": "none",
199
- "transition": "all 200ms ease-in-out",
199
+ "transition": "transform 200ms ease-in-out, box-shadow 200ms ease-in-out",
200
200
  },
201
201
  "aiSummary": {
202
202
  "@keyframes blink": {
@@ -356,7 +356,7 @@ exports[`Theme Configuration a snapshot of the configuration matches the snapsho
356
356
  "backdropFilter": "blur(12px) saturate(150%)",
357
357
  "bg": "panel-background",
358
358
  "borderRadius": "card",
359
- "boxShadow": "default",
359
+ "boxShadow": "0 1px 0 rgba(0,0,0,0.06), 0 2px 0 rgba(0,0,0,0.04), 0 3px 0 rgba(0,0,0,0.03), 0 4px 12px rgba(66,46,163,0.08)",
360
360
  "color": "text",
361
361
  "flexGrow": 1,
362
362
  "fontSize": [
@@ -762,8 +762,8 @@ exports[`Theme Configuration a snapshot of the configuration matches the snapsho
762
762
  },
763
763
  "InstagramItem": {
764
764
  "&:hover, &:focus": {
765
- "boxShadow": "lg",
766
- "transform": "scale(1.015)",
765
+ "boxShadow": "0 1px 0 rgba(0,0,0,0.06), 0 2px 0 rgba(0,0,0,0.04), 0 3px 0 rgba(0,0,0,0.03), 0 8px 20px rgba(66,46,163,0.14)",
766
+ "transform": "translateY(-3px) scale(1.008)",
767
767
  },
768
768
  "background": "none",
769
769
  "border": "none",
@@ -772,7 +772,7 @@ exports[`Theme Configuration a snapshot of the configuration matches the snapsho
772
772
  "cursor": "pointer",
773
773
  "overflow": "hidden",
774
774
  "p": 0,
775
- "transition": "all 200ms ease-in-out",
775
+ "transition": "transform 200ms ease-in-out, box-shadow 200ms ease-in-out",
776
776
  },
777
777
  "IntroExperienceSlide": {
778
778
  "&.active-slide": {
@@ -3,29 +3,34 @@
3
3
  exports[`WidgetHeader matches the snapshot 1`] = `
4
4
  <DocumentFragment>
5
5
  <header
6
- class="css-7pzsne"
6
+ class="css-ubozo4"
7
7
  >
8
8
  <div
9
- class="css-s076pz"
9
+ class="css-3vwaby"
10
10
  >
11
- <h2
12
- class="css-16f4ihe"
11
+ <div
12
+ aria-hidden="true"
13
+ class="css-1muvmnm"
13
14
  >
14
15
  <span
15
- class="css-2s41mq"
16
- >
17
- <span
18
- aria-hidden="true"
19
- data-icon="spotify"
20
- data-testid="fa-icon"
21
- />
22
- </span>
23
- Neat & Interesting Widget
24
- </h2>
16
+ aria-hidden="true"
17
+ data-icon="spotify"
18
+ data-testid="fa-icon"
19
+ />
20
+ </div>
25
21
  <div
26
- class="sidebar-content"
22
+ class="css-m8374l"
27
23
  >
28
- Sidebar
24
+ <h2
25
+ class="css-1tfs9qy"
26
+ >
27
+ Neat & Interesting Widget
28
+ </h2>
29
+ <div
30
+ class="sidebar-content"
31
+ >
32
+ Sidebar
33
+ </div>
29
34
  </div>
30
35
  </div>
31
36
  </header>
@@ -6,13 +6,38 @@
6
6
  /** Fallback RGB string when hex is invalid (blue) */
7
7
  const HEX_TO_RGB_FALLBACK = '74, 158, 255'
8
8
 
9
+ /**
10
+ * Normalizes #rgb / #rgba shorthand, #rrggbb, or #rrggbbaa to #rrggbb for RGB extraction.
11
+ * Appending two hex digits to 3-digit theme colors (e.g. `#111` + `22`) is not valid CSS.
12
+ *
13
+ * @param {string} hex
14
+ * @returns {string|null} `#rrggbb` or null if not parseable as hex
15
+ */
16
+ export const normalizeHexToRrggbb = hex => {
17
+ if (typeof hex !== 'string') return null
18
+ const raw = hex.trim().replace(/^#/, '')
19
+ if (!raw || !/^[a-f\d]+$/i.test(raw)) return null
20
+ if (raw.length === 3) {
21
+ return `#${[...raw].map(c => c + c).join('')}`
22
+ }
23
+ if (raw.length === 4) {
24
+ const [r, g, b] = raw
25
+ return `#${r}${r}${g}${g}${b}${b}`
26
+ }
27
+ if (raw.length === 6) return `#${raw}`
28
+ if (raw.length === 8) return `#${raw.slice(0, 6)}`
29
+ return null
30
+ }
31
+
9
32
  /**
10
33
  * Converts hex color to RGB string for use in rgba()
11
- * @param {string} hex - Hex color code (with or without #)
34
+ * @param {string} hex - Hex color code (with or without #); 3- and 4-digit shorthand supported
12
35
  * @returns {string} RGB values as comma-separated string (e.g. "66, 46, 163")
13
36
  */
14
37
  export const hexToRgb = hex => {
15
- const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
38
+ const rrggbb = normalizeHexToRrggbb(hex)
39
+ if (!rrggbb) return HEX_TO_RGB_FALLBACK
40
+ const result = /^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(rrggbb)
16
41
  if (!result) return HEX_TO_RGB_FALLBACK
17
42
  return `${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}`
18
43
  }
@@ -1,10 +1,39 @@
1
- import { hexToRgb, hexToRgba, BUTTON_PRIMARY_COLORS } from './color-utils'
1
+ import { hexToRgb, hexToRgba, normalizeHexToRrggbb, BUTTON_PRIMARY_COLORS } from './color-utils'
2
+
3
+ describe('normalizeHexToRrggbb', () => {
4
+ it('expands 3-digit shorthand', () => {
5
+ expect(normalizeHexToRrggbb('#111')).toBe('#111111')
6
+ expect(normalizeHexToRrggbb('#fff')).toBe('#ffffff')
7
+ expect(normalizeHexToRrggbb('abc')).toBe('#aabbcc')
8
+ })
9
+
10
+ it('expands 4-digit shorthand to RGB only', () => {
11
+ expect(normalizeHexToRrggbb('#f0f8')).toBe('#ff00ff')
12
+ })
13
+
14
+ it('passes through 6-digit and strips alpha from 8-digit', () => {
15
+ expect(normalizeHexToRrggbb('#112233')).toBe('#112233')
16
+ expect(normalizeHexToRrggbb('#11223344')).toBe('#112233')
17
+ })
18
+
19
+ it('returns null for invalid input', () => {
20
+ expect(normalizeHexToRrggbb('')).toBeNull()
21
+ expect(normalizeHexToRrggbb('not-hex')).toBeNull()
22
+ expect(normalizeHexToRrggbb('#12')).toBeNull()
23
+ expect(normalizeHexToRrggbb(null)).toBeNull()
24
+ })
25
+ })
2
26
 
3
27
  describe('hexToRgb', () => {
4
28
  it('converts hex with hash to RGB', () => {
5
29
  expect(hexToRgb('#422EA3')).toBe('66, 46, 163')
6
30
  })
7
31
 
32
+ it('converts 3-digit shorthand (theme text colors)', () => {
33
+ expect(hexToRgb('#111')).toBe('17, 17, 17')
34
+ expect(hexToRgb('#fff')).toBe('255, 255, 255')
35
+ })
36
+
8
37
  it('converts hex without hash to RGB', () => {
9
38
  expect(hexToRgb('4a9eff')).toBe('74, 158, 255')
10
39
  })
@@ -23,6 +52,10 @@ describe('hexToRgba', () => {
23
52
  expect(hexToRgba('#422EA3', 0.5)).toBe('rgba(66, 46, 163, 0.5)')
24
53
  })
25
54
 
55
+ it('supports 3-digit shorthand with fractional alpha', () => {
56
+ expect(hexToRgba('#111', 34 / 255)).toBe('rgba(17, 17, 17, 0.13333333333333333)')
57
+ })
58
+
26
59
  it('accepts hex without hash', () => {
27
60
  expect(hexToRgba('4a9eff', 1)).toBe('rgba(74, 158, 255, 1)')
28
61
  })
package/src/theme.js CHANGED
@@ -14,11 +14,12 @@ const fonts = {
14
14
  }
15
15
 
16
16
  export const floatOnHover = {
17
- transition: 'all 200ms ease-in-out',
17
+ transition: 'transform 200ms ease-in-out, box-shadow 200ms ease-in-out',
18
18
 
19
19
  '&:hover, &:focus': {
20
- transform: 'scale(1.015)',
21
- boxShadow: 'lg'
20
+ transform: 'translateY(-3px) scale(1.008)',
21
+ boxShadow:
22
+ '0 1px 0 rgba(0,0,0,0.06), 0 2px 0 rgba(0,0,0,0.04), 0 3px 0 rgba(0,0,0,0.03), 0 8px 20px rgba(66,46,163,0.14)'
22
23
  }
23
24
  }
24
25
 
@@ -81,7 +82,9 @@ export const card = {
81
82
  borderRadius: 'card',
82
83
  bg: 'panel-background',
83
84
  color: 'text',
84
- boxShadow: 'default',
85
+ // Layered paper-stack shadow: each layer is a 1px offset slice, the final spread is the ambient
86
+ boxShadow:
87
+ '0 1px 0 rgba(0,0,0,0.06), 0 2px 0 rgba(0,0,0,0.04), 0 3px 0 rgba(0,0,0,0.03), 0 4px 12px rgba(66,46,163,0.08)',
85
88
  flexGrow: 1,
86
89
  padding: 3,
87
90
  fontSize: [1, 2],
@@ -2,88 +2,177 @@ import React from 'react'
2
2
  import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
3
3
  import { Box, Heading } from '@theme-ui/components'
4
4
 
5
- import ProfileMetricsBadge from './profile-metrics-badge.js'
5
+ import { hexToRgba, normalizeHexToRrggbb } from './color-utils.js'
6
6
 
7
- const baseHeaderStyles = {
8
- display: 'flex',
9
- flexDirection: ['column', 'row'],
10
- alignItems: ['center', 'baseline'],
11
- justifyContent: ['center', 'space-between'],
12
- gap: [2, 3],
13
- flexWrap: 'wrap',
14
- pb: 2,
15
- // Soft separator: visible but not harsh (gray[4] in theme scale; fallback ~12% black)
16
- borderBottom: '1px solid',
17
- borderColor: theme => theme?.colors?.gray?.[4] ?? 'rgba(0,0,0,0.12)'
18
- }
19
-
20
- /**
21
- * Groups headline + CTA so they sit together on the left; metrics stay on the right.
22
- * Always row so headline + CTA stay on one line at every breakpoint (including mobile).
23
- */
24
- const titleGroupStyles = {
25
- display: 'flex',
26
- flexDirection: 'row',
27
- alignItems: 'baseline',
28
- gap: [1, 2],
29
- order: 1
30
- }
7
+ /** Alpha matching prior intent of appending `22` to 8-digit #RRGGBBAA (34/255) */
8
+ const METRIC_CHIP_BORDER_ALPHA = 34 / 255
31
9
 
32
10
  /**
33
- * Override default heading margin, padding, and line-height so the headline and CTA
34
- * align on the same baseline at every breakpoint. Theme/global styles often add
35
- * vertical space to h2; zeroing it and using a tight line-height prevents offset.
36
- * Baseline alignment for icon + text keeps the headline baseline consistent.
11
+ * WidgetHeader
12
+ *
13
+ * Social-dashboard section headline: a large clean heading paired with square
14
+ * data-chip metrics. No horizontal rules section identity comes from the
15
+ * colored icon chip and typographic weight alone.
16
+ *
17
+ * Layout:
18
+ * [icon chip] [Heading] [CTA aside] [metrics chips →]
19
+ *
20
+ * The icon chip is a small square badge (accent bg, white icon) that reads like
21
+ * a label on a physical card index tab. Metrics are monospaced square chips:
22
+ * number + unit in a bordered box, no rounded pills.
37
23
  */
38
- const headingStyles = {
39
- fontSize: [4, 5],
40
- display: 'flex',
41
- alignItems: 'baseline',
42
- m: 0,
43
- py: 0,
44
- pt: 0,
45
- pb: 0,
46
- lineHeight: 1
47
- }
48
-
49
- const metricsStyles = {
50
- order: 2
51
- }
52
-
53
24
  const WidgetHeader = ({ aside, children, icon, metrics, metricsLoading }) => {
54
25
  const hasMetrics = (Array.isArray(metrics) && metrics.length > 0) || metricsLoading
26
+
27
+ // Placeholder chips while loading
28
+ const metricsToShow = metricsLoading ? [{}, {}] : Array.isArray(metrics) ? metrics : []
29
+
55
30
  return (
56
31
  <Box
57
32
  as='header'
58
33
  sx={{
59
- ...baseHeaderStyles,
60
- // Extra margin below header when metrics are present so spacing matches Latest Posts (no metrics)
34
+ display: 'flex',
35
+ flexDirection: ['column', 'row'],
36
+ alignItems: ['flex-start', 'center'],
37
+ justifyContent: ['flex-start', 'space-between'],
38
+ gap: [2, 3],
39
+ flexWrap: 'wrap',
40
+ pb: 3,
61
41
  mb: hasMetrics ? 4 : 2
62
42
  }}
63
43
  >
64
- <Box sx={titleGroupStyles}>
65
- <Heading as='h2' sx={headingStyles}>
66
- {icon && (
44
+ {/* Left cluster: icon chip (center-aligned) + heading/CTA sub-row (baseline-aligned) */}
45
+ <Box
46
+ sx={{
47
+ display: 'flex',
48
+ flexDirection: 'row',
49
+ alignItems: 'center',
50
+ gap: 2,
51
+ order: 1,
52
+ minWidth: 0
53
+ }}
54
+ >
55
+ {/* Colored square icon chip — center-aligned with the heading block */}
56
+ {icon && (
57
+ <Box
58
+ aria-hidden='true'
59
+ sx={{
60
+ flexShrink: 0,
61
+ width: '32px',
62
+ height: '32px',
63
+ borderRadius: '6px',
64
+ backgroundColor: 'primary',
65
+ display: 'flex',
66
+ alignItems: 'center',
67
+ justifyContent: 'center',
68
+ boxShadow: theme =>
69
+ `inset 0 -2px 0 rgba(0,0,0,0.2), 0 1px 3px rgba(${theme.colors?.primaryRgb ?? '66,46,163'},0.3)`
70
+ }}
71
+ >
72
+ <FontAwesomeIcon icon={icon} style={{ width: 14, height: 14, color: '#fff' }} />
73
+ </Box>
74
+ )}
75
+
76
+ {/* Heading + CTA aside: baseline-aligned with each other, same as original */}
77
+ <Box
78
+ sx={{
79
+ display: 'flex',
80
+ flexDirection: 'row',
81
+ alignItems: 'baseline',
82
+ gap: [1, 2],
83
+ minWidth: 0
84
+ }}
85
+ >
86
+ <Heading
87
+ as='h2'
88
+ sx={{
89
+ fontSize: [4, 5],
90
+ fontFamily: 'heading',
91
+ fontWeight: 'bold',
92
+ lineHeight: 1,
93
+ m: 0,
94
+ p: 0,
95
+ textShadow: '0 1px 0 rgba(255,255,255,0.5), 0 -1px 0 rgba(0,0,0,0.07)',
96
+ letterSpacing: '-0.01em'
97
+ }}
98
+ >
99
+ {children}
100
+ </Heading>
101
+
102
+ {aside}
103
+ </Box>
104
+ </Box>
105
+
106
+ {/* Right cluster: square metric chips */}
107
+ {hasMetrics && (
108
+ <Box
109
+ sx={{
110
+ display: 'flex',
111
+ flexDirection: 'row',
112
+ gap: '6px',
113
+ alignItems: 'center',
114
+ order: 2,
115
+ flexShrink: 0
116
+ }}
117
+ >
118
+ {metricsToShow.map(({ displayName, id, value } = {}, idx) => (
67
119
  <Box
68
- as='span'
120
+ key={id || idx}
69
121
  sx={{
70
122
  display: 'inline-flex',
71
- alignItems: 'baseline',
72
- mr: 2,
73
- fontSize: 4,
74
- '& svg': { width: '1em', height: '1em' }
123
+ flexDirection: 'column',
124
+ alignItems: 'center',
125
+ justifyContent: 'center',
126
+ minWidth: '52px',
127
+ px: '6px',
128
+ py: '4px',
129
+ borderRadius: '4px',
130
+ border: '1px solid',
131
+ // Text-based border: theme `text` is often 3-digit hex (#111 / #fff); appending
132
+ // `22` for alpha is invalid CSS — use rgba() from expanded hex instead.
133
+ borderColor: theme => {
134
+ const text = theme?.colors?.text ?? '#111'
135
+ const base = normalizeHexToRrggbb(text)
136
+ return base ? hexToRgba(base, METRIC_CHIP_BORDER_ALPHA) : 'rgba(17, 17, 17, 0.133)'
137
+ },
138
+ // panel-background is defined for both light + dark in chronogrove-theme-surface-colors
139
+ backgroundColor: 'panel-background',
140
+ ...(metricsLoading
141
+ ? {
142
+ animation: 'cgPulse 1.4s ease-in-out infinite',
143
+ opacity: 0.5
144
+ }
145
+ : {})
75
146
  }}
76
147
  >
77
- <FontAwesomeIcon icon={icon} aria-hidden='true' />
148
+ <Box
149
+ sx={{
150
+ fontFamily: 'monospace',
151
+ fontSize: '0.8rem',
152
+ fontWeight: 'bold',
153
+ lineHeight: 1.1,
154
+ color: 'primary',
155
+ letterSpacing: '-0.02em'
156
+ }}
157
+ >
158
+ {value ?? '—'}
159
+ </Box>
160
+ <Box
161
+ sx={{
162
+ fontFamily: 'heading',
163
+ fontSize: '0.6rem',
164
+ fontWeight: 'normal',
165
+ lineHeight: 1.2,
166
+ color: 'textMuted',
167
+ textTransform: 'uppercase',
168
+ letterSpacing: '0.06em',
169
+ whiteSpace: 'nowrap'
170
+ }}
171
+ >
172
+ {displayName ?? ''}
173
+ </Box>
78
174
  </Box>
79
- )}
80
- {children}
81
- </Heading>
82
- {aside}
83
- </Box>
84
- {((Array.isArray(metrics) && metrics.length > 0) || metricsLoading) && (
85
- <Box sx={metricsStyles}>
86
- <ProfileMetricsBadge compact isLoading={metricsLoading} metrics={metrics} />
175
+ ))}
87
176
  </Box>
88
177
  )}
89
178
  </Box>
@@ -29,22 +29,25 @@ describe('WidgetHeader', () => {
29
29
  const metrics = [{ displayName: 'Stars', id: 's', value: 12 }]
30
30
  const { getByText } = render(
31
31
  <ThemeUIProvider theme={chronogroveTheme}>
32
- <WidgetHeader metrics={metrics}>Title</WidgetHeader>
32
+ <WidgetHeader icon={mockIcon} metrics={metrics}>
33
+ Title
34
+ </WidgetHeader>
33
35
  </ThemeUIProvider>
34
36
  )
35
- expect(getByText('12 Stars')).toBeInTheDocument()
37
+ expect(getByText('12')).toBeInTheDocument()
38
+ expect(getByText('Stars')).toBeInTheDocument()
36
39
  })
37
40
 
38
41
  it('renders loading metrics placeholders when metricsLoading', () => {
39
- const { container } = render(
42
+ render(
40
43
  <ThemeUIProvider theme={chronogroveTheme}>
41
- <WidgetHeader metrics={[]} metricsLoading>
44
+ <WidgetHeader icon={mockIcon} metrics={[]} metricsLoading>
42
45
  Title
43
46
  </WidgetHeader>
44
47
  </ThemeUIProvider>
45
48
  )
46
- const badges = container.querySelectorAll('[class*="css-"]')
47
- expect(badges.length).toBeGreaterThan(0)
49
+ const placeholders = screen.getAllByText('')
50
+ expect(placeholders).toHaveLength(2)
48
51
  })
49
52
 
50
53
  it('renders without icon', () => {
@@ -57,20 +60,73 @@ describe('WidgetHeader', () => {
57
60
  expect(screen.queryByTestId('fa-icon')).not.toBeInTheDocument()
58
61
  })
59
62
 
60
- it('uses borderColor fallback when gray[4] is missing', () => {
61
- const themeMissingGray4 = {
63
+ it('renders metric chips when theme omits gray scale entries', () => {
64
+ const themeMissingGray = {
62
65
  ...chronogroveTheme,
63
66
  colors: {
64
67
  ...chronogroveTheme.colors,
65
- gray: { 0: '#111', 1: '#222', 2: '#333', 3: '#444' }
68
+ gray: undefined
66
69
  }
67
70
  }
68
- const { container } = render(
69
- <ThemeUIProvider theme={themeMissingGray4}>
70
- <WidgetHeader>Border test</WidgetHeader>
71
+ render(
72
+ <ThemeUIProvider theme={themeMissingGray}>
73
+ <WidgetHeader icon={mockIcon} metrics={[{ displayName: 'X', id: 'x', value: 1 }]}>
74
+ Border test
75
+ </WidgetHeader>
71
76
  </ThemeUIProvider>
72
77
  )
73
- expect(container.querySelector('header')).toBeTruthy()
74
78
  expect(screen.getByText('Border test')).toBeInTheDocument()
79
+ expect(screen.getByText('1')).toBeInTheDocument()
80
+ expect(screen.getByText('X')).toBeInTheDocument()
81
+ })
82
+
83
+ it('renders icon chip when theme omits primaryRgb (shadow fallback)', () => {
84
+ const colorsRest = { ...chronogroveTheme.colors }
85
+ delete colorsRest.primaryRgb
86
+ const themeNoPrimaryRgb = { ...chronogroveTheme, colors: colorsRest }
87
+ render(
88
+ <ThemeUIProvider theme={themeNoPrimaryRgb}>
89
+ <WidgetHeader icon={mockIcon}>Chip</WidgetHeader>
90
+ </ThemeUIProvider>
91
+ )
92
+ expect(screen.getByText('Chip')).toBeInTheDocument()
93
+ expect(screen.getByTestId('fa-icon')).toBeInTheDocument()
94
+ })
95
+
96
+ it('renders metric chip border when theme omits colors.text (fallback)', () => {
97
+ const colorsRest = { ...chronogroveTheme.colors }
98
+ delete colorsRest.text
99
+ const themeNoText = { ...chronogroveTheme, colors: colorsRest }
100
+ render(
101
+ <ThemeUIProvider theme={themeNoText}>
102
+ <WidgetHeader icon={mockIcon} metrics={[{ displayName: 'Y', id: 'y', value: 2 }]}>
103
+ T
104
+ </WidgetHeader>
105
+ </ThemeUIProvider>
106
+ )
107
+ expect(screen.getByText('2')).toBeInTheDocument()
108
+ expect(screen.getByText('Y')).toBeInTheDocument()
109
+ })
110
+
111
+ it('handles metrics array with undefined entry (default destructuring)', () => {
112
+ render(
113
+ <ThemeUIProvider theme={chronogroveTheme}>
114
+ <WidgetHeader icon={mockIcon} metrics={[undefined]}>
115
+ T
116
+ </WidgetHeader>
117
+ </ThemeUIProvider>
118
+ )
119
+ expect(screen.getByText('—')).toBeInTheDocument()
120
+ })
121
+
122
+ it('uses metricsToShow empty array when metrics is not an array', () => {
123
+ render(
124
+ <ThemeUIProvider theme={chronogroveTheme}>
125
+ <WidgetHeader icon={mockIcon} metrics={null}>
126
+ No metrics prop array
127
+ </WidgetHeader>
128
+ </ThemeUIProvider>
129
+ )
130
+ expect(screen.getByText('No metrics prop array')).toBeInTheDocument()
75
131
  })
76
132
  })
@@ -13,7 +13,7 @@ const sectionSx = {
13
13
  /**
14
14
  * Wraps a dashboard widget: vertical spacing and optional fatal-error overlay.
15
15
  */
16
- const WidgetSection = ({ children, hasFatalError, id, styleOverrides = {}, ...props }) => {
16
+ const WidgetSection = ({ children, hasFatalError, id, styleOverrides = {}, tabIndex, ...props }) => {
17
17
  const { colorMode } = useThemeUI()
18
18
  const darkMode = isDarkMode(colorMode)
19
19
 
@@ -30,6 +30,7 @@ const WidgetSection = ({ children, hasFatalError, id, styleOverrides = {}, ...pr
30
30
  : {})
31
31
  }}
32
32
  {...(id ? { id } : {})}
33
+ tabIndex={id ? (tabIndex ?? -1) : tabIndex}
33
34
  {...props}
34
35
  >
35
36
  {hasFatalError && (
@@ -36,6 +36,38 @@ describe('WidgetSection', () => {
36
36
  expect(document.getElementById('w1')).toBeTruthy()
37
37
  })
38
38
 
39
+ it('sets tabIndex -1 when id is provided so the section can receive programmatic focus', () => {
40
+ useThemeUI.mockReturnValue({ colorMode: 'default' })
41
+ render(
42
+ <ThemeUIProvider theme={{}}>
43
+ <WidgetSection id='w1'>x</WidgetSection>
44
+ </ThemeUIProvider>
45
+ )
46
+ expect(document.getElementById('w1')).toHaveAttribute('tabindex', '-1')
47
+ })
48
+
49
+ it('respects an explicit tabIndex when id is provided', () => {
50
+ useThemeUI.mockReturnValue({ colorMode: 'default' })
51
+ render(
52
+ <ThemeUIProvider theme={{}}>
53
+ <WidgetSection id='w1' tabIndex={0}>
54
+ x
55
+ </WidgetSection>
56
+ </ThemeUIProvider>
57
+ )
58
+ expect(document.getElementById('w1')).toHaveAttribute('tabindex', '0')
59
+ })
60
+
61
+ it('does not set tabIndex when id is omitted', () => {
62
+ useThemeUI.mockReturnValue({ colorMode: 'default' })
63
+ render(
64
+ <ThemeUIProvider theme={{}}>
65
+ <WidgetSection>plain</WidgetSection>
66
+ </ThemeUIProvider>
67
+ )
68
+ expect(screen.getByText('plain').closest('section')).not.toHaveAttribute('tabindex')
69
+ })
70
+
39
71
  it('shows fatal error overlay', () => {
40
72
  useThemeUI.mockReturnValue({ colorMode: 'dark' })
41
73
  render(