@chronogrove/ui 0.81.0 → 0.82.1

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
@@ -12,7 +12,7 @@ pnpm add @chronogrove/ui
12
12
 
13
13
  Use **`pnpm publish`** for releases so `workspace:` dependencies in dependents are rewritten; see [pnpm workspaces — publishing](https://pnpm.io/workspaces#publishing-workspace-packages).
14
14
 
15
- **Shared dependencies with `gatsby-theme-chronogrove`:** both packages depend on Theme UI, Emotion, and related libraries, with versions driven by the root [pnpm catalog](../../pnpm-workspace.yaml). When you bump those catalog entries, update **`packages/ui`** and **`theme`** in the **same change** so the theme and `@chronogrove/ui` stay aligned and you avoid duplicate or mismatched installs.
15
+ **Shared dependencies with `gatsby-theme-chronogrove`:** both packages depend on Theme UI, Emotion, **`three`** (where WebGL backgrounds or artwork import it), and related libraries, with versions driven by the root [pnpm catalog](../../pnpm-workspace.yaml). When you bump those catalog entries, update **`packages/ui`**, **`theme`**, and any other workspace `package.json` files that reference `catalog:` for the same keys in the **same change** so installs stay aligned and you avoid duplicate or mismatched trees.
16
16
 
17
17
  ## Subpath exports
18
18
 
@@ -24,9 +24,8 @@ Prefer deep imports so bundles stay lean:
24
24
  | `@chronogrove/ui/theme` | Default Theme UI theme object + named exports |
25
25
  | `@chronogrove/ui/provider` | `ChronogroveThemeProvider` |
26
26
  | `@chronogrove/ui/color-mode` | Storage key, reconcile event, SSR inline builders, `chronogroveHeadTheme` (RSC-safe), `resolveChronogroveSurfaceColors`, `useDocumentColorModeSurface`, browser sync, `reconcileThemeUiColorModeOnNavigation` |
27
- | `@chronogrove/ui/animated-page-background` | **`ChronogroveAnimatedPageBackground`** — same stack as the Gatsby home: fixed `z-index: 0`, light = solid theme background, dark = **three.js** Color Bends + scroll-linked gradient overlay and parallax (`three` is a dependency of this package). **Not** the same as `page-backdrop`. |
27
+ | `@chronogrove/ui/animated-page-background` | **`ChronogroveAnimatedPageBackground`** — same stack as the Gatsby home: fixed `z-index: 0`, light = solid theme background, dark = **three.js** Color Bends + scroll-linked gradient overlay and parallax (`three` is a dependency of this package). Animation timing uses **`THREE.Timer`** (not deprecated `Clock`, three.js r183+). |
28
28
  | `@chronogrove/ui/color-bends` | **`ColorBends`** — lower-level three.js gradient background used inside `ChronogroveAnimatedPageBackground`. Prefer the full **`animated-page-background`** or **`@chronogrove/ui/next`** shell unless you need the raw component. |
29
- | `@chronogrove/ui/page-backdrop` | **`ChronogrovePageBackdrop`** — lightweight alternative: fixed `z-index: 0` fill without WebGL (CSS gradients in dark mode). Use when you cannot ship `three`. |
30
29
  | `@chronogrove/ui/next` | **Next.js App Router helpers:** `ChronogroveNextRootLayoutHead` (RSC `<head>` injections), `ChronogroveNextEmotionRegistry`, `ChronogroveNextAppShell` (theme + three.js background + surface sync + soft-nav reconcile), `ChronogroveNextThemeUiColorModeRouteSync` (standalone). Requires `next` (peer, optional for the rest of the package). |
31
30
  | `@chronogrove/ui/action-card-layout` | **`actionCardPinnedLayoutSx`** — layout `sx` for `Card variant="actionCard"` (matches GitHub pinned cards). |
32
31
  | `@chronogrove/ui/emotion-cache` | `createChronogroveEmotionCache`, `getChronogroveEmotionCache` |
@@ -41,8 +40,12 @@ Prefer deep imports so bundles stay lean:
41
40
  | `@chronogrove/ui/lazy-load` | Defer children until in viewport (`react-intersection-observer`) |
42
41
  | `@chronogrove/ui/header` | Masthead shell (`variant: styles.Header`) |
43
42
  | `@chronogrove/ui/page-header` | Blog-style `h1` heading (`p-name`) |
43
+ | `@chronogrove/ui/profile-metrics-badge` | Metrics row (`Badge variant="metrics"`) for dashboard widget headers |
44
+ | `@chronogrove/ui/widget-header` | Widget section title row (optional Font Awesome icon, aside slot, optional metrics) |
44
45
  | `@chronogrove/ui/gatsby` | Color-mode Gatsby SSR/browser helpers |
45
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`**. The authoritative list is **`package.json`** → **`exports`**.
48
+
46
49
  ## Next.js (App Router)
47
50
 
48
51
  **Reference app:** [`examples/chronogrove-next`](../../examples/chronogrove-next) (`chronogrove-next`). Run `pnpm --filter chronogrove-next dev` from the repo root.
@@ -54,7 +57,9 @@ Prefer deep imports so bundles stay lean:
54
57
  - **Server `layout`:** Keep the root layout a Server Component. **Do not** import `@chronogrove/ui/theme` here—it loads `theme-ui`’s `merge` and triggers React `createContext`, which Next.js disallows in RSC. Use **`ChronogroveNextRootLayoutHead`** from `@chronogrove/ui/next`, or manually pass **`chronogroveHeadTheme`** from `@chronogrove/ui/color-mode` to `resolveChronogroveSurfaceColors` and emit **in order**: `<meta name="emotion-insertion-point" content="" />`, then the Theme UI no-flash script, HTML background script, and fallback CSS (same composition as [`buildThemeUiColorModeHeadComponents`](./src/gatsby/build-theme-ui-color-mode-head-components.js) in `@chronogrove/ui/gatsby`).
55
58
  - **Client Emotion registry + theme:** Use **`ChronogroveNextEmotionRegistry`** from `@chronogrove/ui/next`, or wrap the app in Emotion’s `CacheProvider` with a **per-request** cache with `key: 'css'` and Next’s `useServerInsertedHTML` ([Next.js: CSS-in-JS](https://nextjs.org/docs/app/building-your-application/styling/css-in-js)). **Do not** rely on `getChronogroveEmotionCache()` for SSR—it is a browser-oriented singleton. Import the **full** theme from `@chronogrove/ui/theme` only inside client components. Anything using `useColorMode`, `useThemeUI`, or `ChronogroveThemeProvider` must be `'use client'`. **Order:** `CacheProvider` (registry) **outside** `ChronogroveThemeProvider` **inside** `<body>`.
56
59
  - **Document surface (match Gatsby `RootWrapper`):** **`ChronogroveNextAppShell`** calls **`useDocumentColorModeSurface`** from `@chronogrove/ui/color-mode` once. It syncs `document.documentElement`’s `theme-ui-*` class, `data-theme-ui-color-mode`, and inline page background from the **resolved** Theme UI theme (`rawColors` / `colors.background`). Without it, Emotion can win the cascade over head fallback CSS and panel tokens (`bg: 'panel-background'`) may not update in dark mode after hydration.
57
- - **Animated page background (match Gatsby home):** **`ChronogroveNextAppShell`** includes **`ChronogroveAnimatedPageBackground`** (three.js Color Bends). For a **CSS-only** backdrop without `three`, use **`ChronogrovePageBackdrop`** from `@chronogrove/ui/page-backdrop` instead and compose your own shell. Content should sit in a **`position: relative; z-index: 1`** wrapper.
60
+ - **Animated page background (match Gatsby home):** **`ChronogroveNextAppShell`** includes **`ChronogroveAnimatedPageBackground`** (three.js Color Bends). Content should sit in a **`position: relative; z-index: 1`** wrapper above the fixed background layer.
61
+ - **`WidgetHeader` + icons:** This package depends on **`@fortawesome/fontawesome-svg-core`** and **`@fortawesome/react-fontawesome`**. Icon **definitions** (e.g. `faGithub`) come from a Font Awesome kit you add to **your** app—typically **`@fortawesome/free-brands-svg-icons`** for brands—the same pattern as **`gatsby-theme-chronogrove`** widgets.
62
+ - **`MetricCard` loading state:** Pass **`loading`** or **`showPlaceholder`** (equivalent) for the built-in pulse placeholder (or **`loadingSlot`** to override).
58
63
  - **Soft navigations:** **`ChronogroveNextAppShell`** includes **`ChronogroveNextThemeUiColorModeRouteSync`**, which calls `reconcileThemeUiColorModeOnNavigation` after pathname changes (not on initial mount). Running reconcile on mount can fight `useColorMode` toggles. Gatsby’s equivalent is `onRouteUpdateThemeUiColorMode` (no initial `onRouteUpdate`).
59
64
  - **Hydration:** Set `suppressHydrationWarning` on `<html>` and `<body>`. The inline no-flash / background scripts run before React hydrates and update `<html>` (`theme-ui-*` classes, `data-theme-ui-color-mode`, background), so the DOM no longer matches the server-rendered markup—React would warn without this flag (similar to [next-themes](https://github.com/pacocoursey/next-themes) on Next.js App Router).
60
65
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chronogrove/ui",
3
- "version": "0.81.0",
3
+ "version": "0.82.1",
4
4
  "description": "Chronogrove Theme UI theme, color mode helpers, and shared UI primitives",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -102,6 +102,10 @@
102
102
  "import": "./src/widget-call-to-action.js",
103
103
  "default": "./src/widget-call-to-action.js"
104
104
  },
105
+ "./widget-header": {
106
+ "import": "./src/widget-header.js",
107
+ "default": "./src/widget-header.js"
108
+ },
105
109
  "./lazy-load": {
106
110
  "import": "./src/lazy-load.js",
107
111
  "default": "./src/lazy-load.js"
@@ -118,9 +122,9 @@
118
122
  "import": "./src/page-header.js",
119
123
  "default": "./src/page-header.js"
120
124
  },
121
- "./page-backdrop": {
122
- "import": "./src/page-backdrop.js",
123
- "default": "./src/page-backdrop.js"
125
+ "./profile-metrics-badge": {
126
+ "import": "./src/profile-metrics-badge.js",
127
+ "default": "./src/profile-metrics-badge.js"
124
128
  },
125
129
  "./animated-page-background": {
126
130
  "import": "./src/animated-page-background/index.js",
@@ -140,7 +144,7 @@
140
144
  }
141
145
  },
142
146
  "peerDependencies": {
143
- "next": "^14.0.0 || ^15.0.0",
147
+ "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
144
148
  "react": "^18.0.0 || ^19.0.0",
145
149
  "react-dom": "^18.0.0 || ^19.0.0"
146
150
  },
@@ -152,6 +156,8 @@
152
156
  "dependencies": {
153
157
  "@emotion/cache": "^11.14.0",
154
158
  "@emotion/react": "^11.14.0",
159
+ "@fortawesome/fontawesome-svg-core": "^7.2.0",
160
+ "@fortawesome/react-fontawesome": "^3.3.0",
155
161
  "@theme-toggles/react": "^4.1.0",
156
162
  "@theme-ui/components": "^0.17.4",
157
163
  "@theme-ui/presets": "^0.17.4",
@@ -168,7 +174,7 @@
168
174
  "babel-jest": "^30.3.0",
169
175
  "jest": "^30.3.0",
170
176
  "jest-environment-jsdom": "^30.3.0",
171
- "next": "^15.1.0",
177
+ "next": "^16.2.3",
172
178
  "react": "^19.2.5",
173
179
  "react-dom": "^19.2.5"
174
180
  },
@@ -0,0 +1,20 @@
1
+ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2
+
3
+ exports[`ProfileMetricsBadge matches the snapshot with metrics 1`] = `
4
+ <DocumentFragment>
5
+ <div
6
+ class="css-1182k7c"
7
+ >
8
+ <div
9
+ class="css-1njnxko"
10
+ >
11
+ 10 Followers
12
+ </div>
13
+ <div
14
+ class="css-137c6l"
15
+ >
16
+ 20 Following
17
+ </div>
18
+ </div>
19
+ </DocumentFragment>
20
+ `;
@@ -0,0 +1,33 @@
1
+ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2
+
3
+ exports[`WidgetHeader matches the snapshot 1`] = `
4
+ <DocumentFragment>
5
+ <header
6
+ class="css-7pzsne"
7
+ >
8
+ <div
9
+ class="css-s076pz"
10
+ >
11
+ <h2
12
+ class="css-16f4ihe"
13
+ >
14
+ <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>
25
+ <div
26
+ class="sidebar-content"
27
+ >
28
+ Sidebar
29
+ </div>
30
+ </div>
31
+ </header>
32
+ </DocumentFragment>
33
+ `;
@@ -178,7 +178,8 @@ export default function ColorBends({
178
178
 
179
179
  container.appendChild(renderer.domElement)
180
180
 
181
- const clock = new THREE.Clock()
181
+ const timer = new THREE.Timer()
182
+ timer.connect(document)
182
183
 
183
184
  const handleResize = () => {
184
185
  const w = container.clientWidth || 1
@@ -197,9 +198,10 @@ export default function ColorBends({
197
198
  window.addEventListener('resize', handleResize)
198
199
  }
199
200
 
200
- const loop = () => {
201
- const dt = clock.getDelta()
202
- const elapsed = clock.elapsedTime
201
+ const loop = time => {
202
+ timer.update(time)
203
+ const dt = timer.getDelta()
204
+ const elapsed = timer.getElapsed()
203
205
 
204
206
  material.uniforms.uTime.value = elapsed
205
207
 
@@ -222,6 +224,7 @@ export default function ColorBends({
222
224
  rafRef.current = requestAnimationFrame(loop)
223
225
 
224
226
  return () => {
227
+ timer.dispose()
225
228
  if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
226
229
  if (resizeObserverRef.current) resizeObserverRef.current.disconnect()
227
230
  else window.removeEventListener('resize', handleResize)
@@ -6,13 +6,14 @@ import isDarkMode from './helpers/isDarkMode.js'
6
6
 
7
7
  /**
8
8
  * Metric summary card (e.g. Goodreads profile metrics). Uses `metricCard` / `metricCardDark` card variants.
9
- * When `loading` is true, renders `loadingSlot` or a lightweight pulse placeholder (no react-placeholder dependency).
9
+ * When `loading` or `showPlaceholder` is true, renders `loadingSlot` or a lightweight pulse placeholder (no react-placeholder dependency).
10
10
  */
11
- const MetricCard = ({ title, value, loading = false, loadingSlot, sx, ...props }) => {
11
+ const MetricCard = ({ title, value, loading = false, showPlaceholder, loadingSlot, sx, ...props }) => {
12
12
  const { colorMode } = useThemeUI()
13
13
  const variant = isDarkMode(colorMode) ? 'metricCardDark' : 'metricCard'
14
+ const isLoading = Boolean(loading || showPlaceholder)
14
15
 
15
- const body = loading ? (
16
+ const body = isLoading ? (
16
17
  (loadingSlot ?? (
17
18
  <Box
18
19
  aria-busy='true'
@@ -39,6 +39,15 @@ describe('MetricCard', () => {
39
39
  expect(screen.getByRole('status')).toBeInTheDocument()
40
40
  })
41
41
 
42
+ it('treats showPlaceholder as loading', () => {
43
+ render(
44
+ <ThemeUIProvider theme={theme}>
45
+ <MetricCard title='F' value='1' showPlaceholder />
46
+ </ThemeUIProvider>
47
+ )
48
+ expect(screen.getByRole('status')).toBeInTheDocument()
49
+ })
50
+
42
51
  it('uses metricCardDark when dark', () => {
43
52
  useThemeUI.mockReturnValue({ colorMode: 'dark' })
44
53
  render(
@@ -0,0 +1,32 @@
1
+ import React from 'react'
2
+ import { Badge, Box } from '@theme-ui/components'
3
+
4
+ const ProfileMetricsBadge = ({ compact = false, isLoading, metrics = [] }) => {
5
+ let metricsToShow
6
+ if (isLoading) {
7
+ metricsToShow = [{}, {}]
8
+ } else if (Array.isArray(metrics)) {
9
+ metricsToShow = metrics
10
+ } else {
11
+ metricsToShow = []
12
+ }
13
+
14
+ return (
15
+ <Box
16
+ sx={{
17
+ fontFamily: 'heading',
18
+ ...(compact ? { mt: 0, pb: 0, pt: 0 } : { mt: 2, pb: 4, pt: 1 }),
19
+ display: 'flex',
20
+ justifyContent: ['center', 'unset']
21
+ }}
22
+ >
23
+ {metricsToShow.map(({ displayName, id, value }, idx) => (
24
+ <Badge key={id || idx} variant='metrics' ml={idx !== 0 && 2}>
25
+ {value} {displayName}
26
+ </Badge>
27
+ ))}
28
+ </Box>
29
+ )
30
+ }
31
+
32
+ export default ProfileMetricsBadge
@@ -0,0 +1,89 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { ThemeUIProvider } from 'theme-ui'
3
+
4
+ import chronogroveTheme from './theme.js'
5
+ import ProfileMetricsBadge from './profile-metrics-badge.js'
6
+
7
+ describe('ProfileMetricsBadge', () => {
8
+ it('uses default props when none are passed', () => {
9
+ const { container } = render(
10
+ <ThemeUIProvider theme={chronogroveTheme}>
11
+ <ProfileMetricsBadge />
12
+ </ThemeUIProvider>
13
+ )
14
+ expect(container.firstChild).toBeInTheDocument()
15
+ })
16
+
17
+ it('matches the snapshot with metrics', () => {
18
+ const metrics = [
19
+ { displayName: 'Followers', id: 'followers', value: 10 },
20
+ { displayName: 'Following', id: 'following', value: 20 }
21
+ ]
22
+ const { asFragment } = render(
23
+ <ThemeUIProvider theme={chronogroveTheme}>
24
+ <ProfileMetricsBadge metrics={metrics} />
25
+ </ThemeUIProvider>
26
+ )
27
+ expect(asFragment()).toMatchSnapshot()
28
+ })
29
+
30
+ it('renders metrics with value and displayName', () => {
31
+ const metrics = [{ displayName: 'Friends', id: 'friends', value: 5 }]
32
+ render(
33
+ <ThemeUIProvider theme={chronogroveTheme}>
34
+ <ProfileMetricsBadge metrics={metrics} />
35
+ </ThemeUIProvider>
36
+ )
37
+ expect(screen.getByText('5 Friends')).toBeInTheDocument()
38
+ })
39
+
40
+ it('uses compact spacing when compact is true', () => {
41
+ const metrics = [{ displayName: 'Count', id: 'count', value: 1 }]
42
+ const { container } = render(
43
+ <ThemeUIProvider theme={chronogroveTheme}>
44
+ <ProfileMetricsBadge compact metrics={metrics} />
45
+ </ThemeUIProvider>
46
+ )
47
+ expect(container.firstChild).toBeInTheDocument()
48
+ expect(screen.getByText('1 Count')).toBeInTheDocument()
49
+ })
50
+
51
+ it('shows placeholder badges when isLoading', () => {
52
+ const { container } = render(
53
+ <ThemeUIProvider theme={chronogroveTheme}>
54
+ <ProfileMetricsBadge isLoading metrics={[]} />
55
+ </ThemeUIProvider>
56
+ )
57
+ const wrapper = container.querySelector('[class*="css-"]')
58
+ expect(wrapper).toBeInTheDocument()
59
+ expect(wrapper.children).toHaveLength(2)
60
+ })
61
+
62
+ it('handles non-array metrics with default empty array', () => {
63
+ const { container } = render(
64
+ <ThemeUIProvider theme={chronogroveTheme}>
65
+ <ProfileMetricsBadge metrics={null} />
66
+ </ThemeUIProvider>
67
+ )
68
+ expect(container.firstChild).toBeInTheDocument()
69
+ })
70
+
71
+ it('treats non-array metrics as empty when not loading', () => {
72
+ const { container } = render(
73
+ <ThemeUIProvider theme={chronogroveTheme}>
74
+ <ProfileMetricsBadge metrics='nope' />
75
+ </ThemeUIProvider>
76
+ )
77
+ expect(container.firstChild).toBeInTheDocument()
78
+ expect(container.textContent).toBe('')
79
+ })
80
+
81
+ it('renders nothing when metrics is an empty array', () => {
82
+ const { container } = render(
83
+ <ThemeUIProvider theme={chronogroveTheme}>
84
+ <ProfileMetricsBadge metrics={[]} />
85
+ </ThemeUIProvider>
86
+ )
87
+ expect(container.textContent).toBe('')
88
+ })
89
+ })
@@ -0,0 +1,93 @@
1
+ import React from 'react'
2
+ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
3
+ import { Box, Heading } from '@theme-ui/components'
4
+
5
+ import ProfileMetricsBadge from './profile-metrics-badge.js'
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
+ }
31
+
32
+ /**
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.
37
+ */
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
+ const WidgetHeader = ({ aside, children, icon, metrics, metricsLoading }) => {
54
+ const hasMetrics = (Array.isArray(metrics) && metrics.length > 0) || metricsLoading
55
+ return (
56
+ <Box
57
+ as='header'
58
+ sx={{
59
+ ...baseHeaderStyles,
60
+ // Extra margin below header when metrics are present so spacing matches Latest Posts (no metrics)
61
+ mb: hasMetrics ? 4 : 2
62
+ }}
63
+ >
64
+ <Box sx={titleGroupStyles}>
65
+ <Heading as='h2' sx={headingStyles}>
66
+ {icon && (
67
+ <Box
68
+ as='span'
69
+ sx={{
70
+ display: 'inline-flex',
71
+ alignItems: 'baseline',
72
+ mr: 2,
73
+ fontSize: 4,
74
+ '& svg': { width: '1em', height: '1em' }
75
+ }}
76
+ >
77
+ <FontAwesomeIcon icon={icon} aria-hidden='true' />
78
+ </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} />
87
+ </Box>
88
+ )}
89
+ </Box>
90
+ )
91
+ }
92
+
93
+ export default WidgetHeader
@@ -0,0 +1,76 @@
1
+ import React from 'react'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { ThemeUIProvider } from 'theme-ui'
4
+
5
+ import chronogroveTheme from './theme.js'
6
+ import WidgetHeader from './widget-header.js'
7
+
8
+ jest.mock('@fortawesome/react-fontawesome', () => ({
9
+ FontAwesomeIcon: ({ icon }) => <span data-testid='fa-icon' data-icon={icon?.iconName ?? ''} aria-hidden />
10
+ }))
11
+
12
+ const aside = <div className='sidebar-content'>Sidebar</div>
13
+ const mockIcon = { iconName: 'spotify', prefix: 'fab' }
14
+
15
+ describe('WidgetHeader', () => {
16
+ it('matches the snapshot', () => {
17
+ const widgetTitle = 'Neat & Interesting Widget'
18
+ const { asFragment } = render(
19
+ <ThemeUIProvider theme={chronogroveTheme}>
20
+ <WidgetHeader aside={aside} icon={mockIcon}>
21
+ {widgetTitle}
22
+ </WidgetHeader>
23
+ </ThemeUIProvider>
24
+ )
25
+ expect(asFragment()).toMatchSnapshot()
26
+ })
27
+
28
+ it('renders metrics row when metrics are provided', () => {
29
+ const metrics = [{ displayName: 'Stars', id: 's', value: 12 }]
30
+ const { getByText } = render(
31
+ <ThemeUIProvider theme={chronogroveTheme}>
32
+ <WidgetHeader metrics={metrics}>Title</WidgetHeader>
33
+ </ThemeUIProvider>
34
+ )
35
+ expect(getByText('12 Stars')).toBeInTheDocument()
36
+ })
37
+
38
+ it('renders loading metrics placeholders when metricsLoading', () => {
39
+ const { container } = render(
40
+ <ThemeUIProvider theme={chronogroveTheme}>
41
+ <WidgetHeader metrics={[]} metricsLoading>
42
+ Title
43
+ </WidgetHeader>
44
+ </ThemeUIProvider>
45
+ )
46
+ const badges = container.querySelectorAll('[class*="css-"]')
47
+ expect(badges.length).toBeGreaterThan(0)
48
+ })
49
+
50
+ it('renders without icon', () => {
51
+ render(
52
+ <ThemeUIProvider theme={chronogroveTheme}>
53
+ <WidgetHeader aside={aside}>No icon</WidgetHeader>
54
+ </ThemeUIProvider>
55
+ )
56
+ expect(screen.getByText('No icon')).toBeInTheDocument()
57
+ expect(screen.queryByTestId('fa-icon')).not.toBeInTheDocument()
58
+ })
59
+
60
+ it('uses borderColor fallback when gray[4] is missing', () => {
61
+ const themeMissingGray4 = {
62
+ ...chronogroveTheme,
63
+ colors: {
64
+ ...chronogroveTheme.colors,
65
+ gray: { 0: '#111', 1: '#222', 2: '#333', 3: '#444' }
66
+ }
67
+ }
68
+ const { container } = render(
69
+ <ThemeUIProvider theme={themeMissingGray4}>
70
+ <WidgetHeader>Border test</WidgetHeader>
71
+ </ThemeUIProvider>
72
+ )
73
+ expect(container.querySelector('header')).toBeTruthy()
74
+ expect(screen.getByText('Border test')).toBeInTheDocument()
75
+ })
76
+ })
@@ -1,42 +0,0 @@
1
- 'use client'
2
-
3
- import { Box } from '@theme-ui/components'
4
- import { useColorMode } from 'theme-ui'
5
-
6
- import isDarkMode from './helpers/isDarkMode.js'
7
-
8
- /**
9
- * Fixed viewport layer behind page content (`z-index: 0`), matching the role of
10
- * `AnimatedPageBackground` on the Gatsby home: a real surface under `z-index: 1` UI so
11
- * frosted panels (`backdrop-filter` + `panel-background`) have something to blur and tint
12
- * against. The full site uses WebGL Color Bends there; this package ships a **lightweight**
13
- * CSS gradient treatment only (no three.js).
14
- */
15
- export function ChronogrovePageBackdrop() {
16
- const [colorMode] = useColorMode()
17
- const dark = isDarkMode(colorMode)
18
-
19
- return (
20
- <Box
21
- aria-hidden
22
- sx={{
23
- position: 'fixed',
24
- inset: 0,
25
- width: '100vw',
26
- minHeight: '100vh',
27
- zIndex: 0,
28
- pointerEvents: 'none',
29
- bg: 'background',
30
- // Dark: subtle primary/secondary glows — same intent as Color Bends, fraction of the cost.
31
- backgroundImage: dark
32
- ? `
33
- radial-gradient(ellipse 120% 85% at 50% -15%, rgba(74, 158, 255, 0.16) 0%, transparent 52%),
34
- radial-gradient(ellipse 90% 70% at 95% 85%, rgba(113, 30, 155, 0.12) 0%, transparent 48%),
35
- radial-gradient(ellipse 70% 50% at 10% 60%, rgba(128, 0, 128, 0.08) 0%, transparent 45%)
36
- `
37
- : 'none',
38
- transition: 'background-image 0.3s ease'
39
- }}
40
- />
41
- )
42
- }
@@ -1,41 +0,0 @@
1
- /**
2
- * @jest-environment jsdom
3
- */
4
- import React from 'react'
5
- import { render } from '@testing-library/react'
6
- import { ThemeUIProvider } from 'theme-ui'
7
-
8
- import chronogroveTheme from './theme.js'
9
- import { ChronogrovePageBackdrop } from './page-backdrop.js'
10
-
11
- describe('ChronogrovePageBackdrop', () => {
12
- beforeEach(() => {
13
- window.localStorage.removeItem('theme-ui-color-mode')
14
- })
15
-
16
- it('renders a fixed backdrop layer', () => {
17
- const { container } = render(
18
- <ThemeUIProvider theme={chronogroveTheme}>
19
- <ChronogrovePageBackdrop />
20
- </ThemeUIProvider>
21
- )
22
- const layer = container.querySelector('[aria-hidden="true"]')
23
- expect(layer).toBeTruthy()
24
- })
25
-
26
- it('adds gradient overlays in dark mode', () => {
27
- window.localStorage.setItem('theme-ui-color-mode', 'dark')
28
- const { container } = render(
29
- <ThemeUIProvider
30
- theme={{
31
- ...chronogroveTheme,
32
- config: { useLocalStorage: true, useColorSchemeMediaQuery: false }
33
- }}
34
- >
35
- <ChronogrovePageBackdrop />
36
- </ThemeUIProvider>
37
- )
38
- const layer = container.querySelector('[aria-hidden="true"]')
39
- expect(window.getComputedStyle(layer).backgroundImage).toMatch(/radial-gradient/)
40
- })
41
- })