@chronogrove/ui 0.79.0 → 0.81.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 (72) hide show
  1. package/README.md +40 -19
  2. package/package.json +73 -6
  3. package/src/__snapshots__/header.spec.js.snap +8 -8
  4. package/src/__snapshots__/theme.spec.js.snap +39 -20
  5. package/src/action-button.js +6 -6
  6. package/src/action-button.spec.js +14 -2
  7. package/src/action-card-layout.js +13 -0
  8. package/src/action-card-layout.spec.js +13 -0
  9. package/src/animated-page-background/ChronogroveAnimatedPageBackground.js +153 -0
  10. package/src/animated-page-background/ChronogroveAnimatedPageBackground.spec.js +189 -0
  11. package/src/animated-page-background/ColorBends.js +309 -0
  12. package/src/animated-page-background/color-bends.css +13 -0
  13. package/src/animated-page-background/index.js +2 -0
  14. package/src/animated-page-background/index.spec.js +18 -0
  15. package/src/button.js +4 -3
  16. package/src/category-label.js +23 -0
  17. package/src/category-label.spec.js +24 -0
  18. package/src/chevron-icons.js +37 -0
  19. package/src/chronogrove-theme-surface-colors.js +22 -0
  20. package/src/color-mode/browser-sync.js +7 -0
  21. package/src/color-mode/browser-sync.spec.js +7 -0
  22. package/src/color-mode/chronogrove-head-theme.js +22 -0
  23. package/src/color-mode/head-inline.js +40 -5
  24. package/src/color-mode/head-inline.spec.js +29 -0
  25. package/src/color-mode/index.js +3 -0
  26. package/src/color-mode/resolve-theme-colors.js +18 -6
  27. package/src/color-mode/resolve-theme-colors.spec.js +13 -3
  28. package/src/color-mode/spa-navigation.js +14 -0
  29. package/src/color-mode/spa-navigation.spec.js +25 -0
  30. package/src/color-mode/use-document-color-mode-surface.js +52 -0
  31. package/src/color-mode/use-document-color-mode-surface.node.spec.js +12 -0
  32. package/src/color-mode/use-document-color-mode-surface.spec.js +154 -0
  33. package/src/color-toggle-styles.css +10 -0
  34. package/src/color-toggle.js +11 -2
  35. package/src/emotion-cache.node.spec.js +13 -0
  36. package/src/emotion-cache.spec.js +12 -0
  37. package/src/external-link-icon.js +30 -0
  38. package/src/external-link-icon.spec.js +16 -0
  39. package/src/gatsby/build-theme-ui-color-mode-head-components.js +7 -1
  40. package/src/gatsby/index.spec.js +42 -0
  41. package/src/gatsby/on-route-update-color-mode.js +1 -14
  42. package/src/header.js +4 -16
  43. package/src/lazy-load.js +30 -11
  44. package/src/lazy-load.spec.js +9 -5
  45. package/src/metric-badge.js +10 -0
  46. package/src/metric-badge.spec.js +15 -0
  47. package/src/metric-card.js +95 -0
  48. package/src/metric-card.spec.js +60 -0
  49. package/src/muted-card-footer.js +22 -0
  50. package/src/muted-card-footer.spec.js +25 -0
  51. package/src/next/app-shell.js +34 -0
  52. package/src/next/emotion-registry.js +68 -0
  53. package/src/next/emotion-registry.spec.js +99 -0
  54. package/src/next/index.js +4 -0
  55. package/src/next/root-layout-head.js +42 -0
  56. package/src/next/root-layout-head.spec.js +17 -0
  57. package/src/next/theme-ui-color-mode-route-sync.js +32 -0
  58. package/src/page-backdrop.js +42 -0
  59. package/src/page-backdrop.spec.js +41 -0
  60. package/src/pagination-button.js +4 -4
  61. package/src/pagination-button.spec.js +26 -2
  62. package/src/pagination.js +198 -0
  63. package/src/pagination.spec.js +281 -0
  64. package/src/skip-nav/SkipNavLink.js +6 -5
  65. package/src/skip-nav/SkipNavLink.spec.js +11 -0
  66. package/src/status-card.js +18 -0
  67. package/src/status-card.spec.js +38 -0
  68. package/src/theme.js +27 -20
  69. package/src/widget-call-to-action.js +106 -0
  70. package/src/widget-call-to-action.spec.js +115 -0
  71. package/src/widget-section.js +83 -0
  72. package/src/widget-section.spec.js +59 -0
@@ -0,0 +1,42 @@
1
+ import {
2
+ chronogroveHeadTheme,
3
+ resolveChronogroveSurfaceColors,
4
+ buildThemeUiNoFlashInlineScript,
5
+ buildHtmlBackgroundInlineScript,
6
+ buildThemeUiColorModeFallbackCss
7
+ } from '../color-mode/index.js'
8
+
9
+ /**
10
+ * Server Component fragment for the root `<head>`: Emotion insertion point, Theme UI no-flash
11
+ * script, HTML background script, and fallback CSS (same composition as
12
+ * `buildThemeUiColorModeHeadComponents` for Gatsby).
13
+ */
14
+ export function ChronogroveNextRootLayoutHead() {
15
+ const surface = resolveChronogroveSurfaceColors(chronogroveHeadTheme)
16
+ const colorModeScript = buildThemeUiNoFlashInlineScript()
17
+ const htmlBackgroundScript = buildHtmlBackgroundInlineScript({
18
+ defaultBackgroundHex: surface.defaultBackgroundHex,
19
+ darkBackgroundHex: surface.darkBackgroundHex
20
+ })
21
+ const colorModeFallbackCSS = buildThemeUiColorModeFallbackCss({
22
+ defaultBackgroundHex: surface.defaultBackgroundHex,
23
+ darkBackgroundHex: surface.darkBackgroundHex,
24
+ defaultTextHex: surface.defaultTextHex,
25
+ defaultTextMutedHex: surface.defaultTextMutedHex,
26
+ darkTextHex: surface.darkTextHex,
27
+ darkTextMutedHex: surface.darkTextMutedHex,
28
+ defaultPanelBackground: surface.defaultPanelBackground,
29
+ darkPanelBackground: surface.darkPanelBackground,
30
+ defaultPanelText: surface.defaultPanelText,
31
+ darkPanelText: surface.darkPanelText
32
+ })
33
+
34
+ return (
35
+ <>
36
+ <meta name='emotion-insertion-point' content='' />
37
+ <script dangerouslySetInnerHTML={{ __html: colorModeScript }} />
38
+ <script dangerouslySetInnerHTML={{ __html: htmlBackgroundScript }} />
39
+ <style dangerouslySetInnerHTML={{ __html: colorModeFallbackCSS }} />
40
+ </>
41
+ )
42
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import React from 'react'
5
+ import { render } from '@testing-library/react'
6
+
7
+ import { ChronogroveNextRootLayoutHead } from './root-layout-head.js'
8
+
9
+ describe('ChronogroveNextRootLayoutHead', () => {
10
+ it('emits scripts and fallback CSS (meta belongs in document <head> in real App Router layouts)', () => {
11
+ const { container } = render(<ChronogroveNextRootLayoutHead />)
12
+ // RTL renders into a div; React omits invalid <meta> there — in Next, `<head><ChronogroveNextRootLayoutHead /></head>` keeps the meta.
13
+ expect(container.querySelectorAll('script')).toHaveLength(2)
14
+ expect(container.querySelector('style')).toBeTruthy()
15
+ expect(container.textContent).toMatch(/theme-ui-color-mode/)
16
+ })
17
+ })
@@ -0,0 +1,32 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef } from 'react'
4
+ import { usePathname } from 'next/navigation'
5
+
6
+ import { reconcileThemeUiColorModeOnNavigation } from '../color-mode/index.js'
7
+
8
+ /**
9
+ * Keeps Theme UI color mode aligned after Next.js **client-side navigations** (same role as Gatsby
10
+ * `onRouteUpdate`). Do **not** run on initial mount: `reconcileThemeUiColorModeOnNavigation` syncs the
11
+ * DOM from `localStorage`, while Theme UI applies a toggle by updating React state first and writing
12
+ * `localStorage` in a `useEffect` — an eager reconcile can re-read stale `localStorage` and force
13
+ * the page back to the previous mode (e.g. stuck in dark).
14
+ */
15
+ export function ChronogroveNextThemeUiColorModeRouteSync() {
16
+ const pathname = usePathname()
17
+ const previousPathname = useRef(null)
18
+
19
+ useEffect(() => {
20
+ if (previousPathname.current === null) {
21
+ previousPathname.current = pathname
22
+ return
23
+ }
24
+ if (previousPathname.current === pathname) {
25
+ return
26
+ }
27
+ previousPathname.current = pathname
28
+ reconcileThemeUiColorModeOnNavigation()
29
+ }, [pathname])
30
+
31
+ return null
32
+ }
@@ -0,0 +1,42 @@
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
+ }
@@ -0,0 +1,41 @@
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
+ })
@@ -1,6 +1,6 @@
1
- /** @jsx jsx */
2
1
  import React from 'react'
3
- import { jsx, useThemeUI } from 'theme-ui'
2
+ import { Box } from '@theme-ui/components'
3
+ import { useThemeUI } from 'theme-ui'
4
4
  import isDarkMode from './helpers/isDarkMode.js'
5
5
  import { hexToRgb } from './color-utils.js'
6
6
 
@@ -97,9 +97,9 @@ const PaginationButton = ({
97
97
  )
98
98
 
99
99
  return (
100
- <button type='button' onClick={onClick} disabled={disabled} sx={baseStyles} {...props}>
100
+ <Box as='button' type='button' onClick={onClick} disabled={disabled} sx={baseStyles} {...props}>
101
101
  {content}
102
- </button>
102
+ </Box>
103
103
  )
104
104
  }
105
105
 
@@ -1,5 +1,3 @@
1
- /** @jsx jsx */
2
- import { jsx } from 'theme-ui'
3
1
  import { render, screen, fireEvent } from '@testing-library/react'
4
2
  import { ThemeUIProvider } from 'theme-ui'
5
3
 
@@ -96,6 +94,20 @@ describe('PaginationButton', () => {
96
94
  })
97
95
  })
98
96
 
97
+ it('uses dark secondary palette when color mode is dark', () => {
98
+ mockUseThemeUI.mockReturnValueOnce({
99
+ colorMode: 'dark',
100
+ theme: {
101
+ colors: {
102
+ primary: BUTTON_PRIMARY_COLORS.light,
103
+ primaryRgb: '66, 46, 163'
104
+ }
105
+ }
106
+ })
107
+ renderWithProviders(<PaginationButton variant='secondary'>2</PaginationButton>)
108
+ expect(screen.getByRole('button', { name: /2/i })).toHaveStyle({ color: '#888' })
109
+ })
110
+
99
111
  it('applies small size styles', () => {
100
112
  renderWithProviders(<PaginationButton size='small'>1</PaginationButton>)
101
113
 
@@ -118,6 +130,18 @@ describe('PaginationButton', () => {
118
130
  })
119
131
  })
120
132
 
133
+ it('falls back to medium size when size is invalid', () => {
134
+ renderWithProviders(<PaginationButton size='invalid'>1</PaginationButton>)
135
+
136
+ const button = screen.getByRole('button', { name: /1/i })
137
+ expect(button).toHaveStyle({
138
+ fontSize: '11px',
139
+ minWidth: '28px',
140
+ height: '28px',
141
+ padding: '6px 10px'
142
+ })
143
+ })
144
+
121
145
  it('renders with icon', () => {
122
146
  const TestIcon = () => <span data-testid='test-icon'>←</span>
123
147
  renderWithProviders(<PaginationButton icon={<TestIcon />}>Prev</PaginationButton>)
@@ -0,0 +1,198 @@
1
+ import React from 'react'
2
+ import { Box } from '@theme-ui/components'
3
+
4
+ import PaginationButton from './pagination-button.js'
5
+ import { ChevronLeftIcon, ChevronRightIcon } from './chevron-icons.js'
6
+
7
+ /**
8
+ * Full pagination control (prev/next + page numbers, or simple mode).
9
+ * Default prev/next icons are inline SVGs; pass `prevIcon` / `nextIcon` to override.
10
+ */
11
+ const Pagination = ({
12
+ currentPage,
13
+ totalPages,
14
+ onPageChange,
15
+ variant = 'primary',
16
+ size = 'medium',
17
+ showPageInfo = true,
18
+ maxVisiblePages = 5,
19
+ simple = false,
20
+ prevIcon = <ChevronLeftIcon />,
21
+ nextIcon = <ChevronRightIcon />
22
+ }) => {
23
+ const goToPage = page => {
24
+ if (page >= 1 && page <= totalPages && page !== currentPage) {
25
+ onPageChange(page)
26
+ }
27
+ }
28
+
29
+ const goToPrevious = () => goToPage(currentPage - 1)
30
+ const goToNext = () => goToPage(currentPage + 1)
31
+
32
+ const getVisiblePages = () => {
33
+ const pages = []
34
+ const halfVisible = Math.floor(maxVisiblePages / 2)
35
+
36
+ let startPage = Math.max(1, currentPage - halfVisible)
37
+ let endPage = Math.min(totalPages, currentPage + halfVisible)
38
+
39
+ if (endPage - startPage + 1 < maxVisiblePages) {
40
+ if (startPage === 1) {
41
+ endPage = Math.min(totalPages, startPage + maxVisiblePages - 1)
42
+ } else {
43
+ startPage = Math.max(1, endPage - maxVisiblePages + 1)
44
+ }
45
+ }
46
+
47
+ for (let i = startPage; i <= endPage; i++) {
48
+ pages.push(i)
49
+ }
50
+
51
+ return pages
52
+ }
53
+
54
+ if (totalPages <= 1) {
55
+ return null
56
+ }
57
+
58
+ const visiblePages = getVisiblePages()
59
+
60
+ if (simple) {
61
+ return (
62
+ <Box sx={{ width: '100%' }}>
63
+ <Box
64
+ sx={{
65
+ display: 'flex',
66
+ justifyContent: 'center',
67
+ alignItems: 'center',
68
+ gap: 1.5
69
+ }}
70
+ >
71
+ <PaginationButton
72
+ onClick={goToPrevious}
73
+ disabled={currentPage === 1}
74
+ variant={variant}
75
+ size={size}
76
+ icon={prevIcon}
77
+ aria-label='Previous page'
78
+ />
79
+ {showPageInfo && (
80
+ <Box as='span' sx={{ fontSize: 0, color: 'textMuted' }}>
81
+ {currentPage}/{totalPages}
82
+ </Box>
83
+ )}
84
+ <PaginationButton
85
+ onClick={goToNext}
86
+ disabled={currentPage === totalPages}
87
+ variant={variant}
88
+ size={size}
89
+ icon={nextIcon}
90
+ aria-label='Next page'
91
+ />
92
+ </Box>
93
+ </Box>
94
+ )
95
+ }
96
+
97
+ return (
98
+ <Box sx={{ width: '100%' }}>
99
+ <Box
100
+ sx={{
101
+ display: 'flex',
102
+ justifyContent: 'center',
103
+ alignItems: 'center',
104
+ mt: 4,
105
+ gap: [1, 2]
106
+ }}
107
+ >
108
+ <PaginationButton
109
+ onClick={goToPrevious}
110
+ disabled={currentPage === 1}
111
+ variant={variant}
112
+ size={size}
113
+ icon={prevIcon}
114
+ aria-label='Previous page'
115
+ />
116
+
117
+ <Box
118
+ sx={{
119
+ display: 'flex',
120
+ alignItems: 'center',
121
+ gap: 1,
122
+ mx: 2
123
+ }}
124
+ >
125
+ {visiblePages[0] > 1 && (
126
+ <>
127
+ <PaginationButton onClick={() => goToPage(1)} variant={variant} size={size} aria-label='Go to page 1'>
128
+ 1
129
+ </PaginationButton>
130
+ {visiblePages[0] > 2 && (
131
+ <Box as='span' sx={{ color: 'textMuted', fontSize: '12px', px: 1 }}>
132
+ ...
133
+ </Box>
134
+ )}
135
+ </>
136
+ )}
137
+
138
+ {visiblePages.map(page => (
139
+ <PaginationButton
140
+ key={page}
141
+ onClick={() => goToPage(page)}
142
+ active={page === currentPage}
143
+ variant={variant}
144
+ size={size}
145
+ aria-label={`Go to page ${page}`}
146
+ aria-current={page === currentPage ? 'page' : undefined}
147
+ >
148
+ {page}
149
+ </PaginationButton>
150
+ ))}
151
+
152
+ {visiblePages[visiblePages.length - 1] < totalPages && (
153
+ <>
154
+ {visiblePages[visiblePages.length - 1] < totalPages - 1 && (
155
+ <Box as='span' sx={{ color: 'textMuted', fontSize: '12px', px: 1 }}>
156
+ ...
157
+ </Box>
158
+ )}
159
+ <PaginationButton
160
+ onClick={() => goToPage(totalPages)}
161
+ variant={variant}
162
+ size={size}
163
+ aria-label={`Go to page ${totalPages}`}
164
+ >
165
+ {totalPages}
166
+ </PaginationButton>
167
+ </>
168
+ )}
169
+ </Box>
170
+
171
+ <PaginationButton
172
+ onClick={goToNext}
173
+ disabled={currentPage === totalPages}
174
+ variant={variant}
175
+ size={size}
176
+ icon={nextIcon}
177
+ aria-label='Next page'
178
+ />
179
+ </Box>
180
+
181
+ {showPageInfo && (
182
+ <Box
183
+ sx={{
184
+ textAlign: 'center',
185
+ mt: 2,
186
+ fontSize: 0,
187
+ color: 'textMuted',
188
+ display: 'block'
189
+ }}
190
+ >
191
+ Page {currentPage} of {totalPages}
192
+ </Box>
193
+ )}
194
+ </Box>
195
+ )
196
+ }
197
+
198
+ export default Pagination