@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,189 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import React from 'react'
6
+ import { render, waitFor, fireEvent } from '@testing-library/react'
7
+ import { ThemeUIProvider, useColorMode, useThemeUI } from 'theme-ui'
8
+
9
+ import * as colorUtils from '../color-utils.js'
10
+
11
+ jest.mock('theme-ui', () => ({
12
+ ...jest.requireActual('theme-ui'),
13
+ useColorMode: jest.fn(),
14
+ useThemeUI: jest.fn()
15
+ }))
16
+
17
+ import chronogroveTheme from '../theme.js'
18
+ import ChronogroveAnimatedPageBackground from './ChronogroveAnimatedPageBackground.js'
19
+
20
+ jest.mock('./ColorBends.js', () => {
21
+ return function MockColorBends() {
22
+ return <div data-testid='mock-color-bends' />
23
+ }
24
+ })
25
+
26
+ const realHexToRgba = jest.requireActual('../color-utils.js').hexToRgba
27
+
28
+ function setScrollY(y) {
29
+ Object.defineProperty(window, 'scrollY', { configurable: true, value: y, writable: true })
30
+ }
31
+
32
+ describe('ChronogroveAnimatedPageBackground', () => {
33
+ let hexToRgbaSpy
34
+
35
+ beforeEach(() => {
36
+ window.localStorage.removeItem('theme-ui-color-mode')
37
+ Object.defineProperty(document.documentElement, 'scrollHeight', {
38
+ configurable: true,
39
+ value: 2000
40
+ })
41
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 })
42
+ setScrollY(0)
43
+ hexToRgbaSpy = jest.spyOn(colorUtils, 'hexToRgba').mockImplementation((hex, alpha) => realHexToRgba(hex, alpha))
44
+ useColorMode.mockReturnValue(['default'])
45
+ useThemeUI.mockReturnValue({ theme: chronogroveTheme })
46
+ })
47
+
48
+ afterEach(() => {
49
+ hexToRgbaSpy.mockRestore()
50
+ })
51
+
52
+ function renderBackground(ui, theme = chronogroveTheme) {
53
+ useThemeUI.mockReturnValue({ theme })
54
+ return render(<ThemeUIProvider theme={theme}>{ui}</ThemeUIProvider>)
55
+ }
56
+
57
+ it('renders fixed backdrop and overlay layers', async () => {
58
+ const { container, rerender } = renderBackground(<ChronogroveAnimatedPageBackground />)
59
+
60
+ await waitFor(() => {
61
+ expect(container.querySelectorAll('[aria-hidden="true"]')).toHaveLength(2)
62
+ })
63
+
64
+ rerender(
65
+ <ThemeUIProvider theme={chronogroveTheme}>
66
+ <ChronogroveAnimatedPageBackground
67
+ overlayHeight='200px'
68
+ darkOpacity={0.2}
69
+ fadeDistance={500}
70
+ maxParallaxOffset={80}
71
+ />
72
+ </ThemeUIProvider>
73
+ )
74
+
75
+ await waitFor(() => {
76
+ expect(container.querySelectorAll('[aria-hidden="true"]')).toHaveLength(2)
77
+ })
78
+ })
79
+
80
+ it('shows ColorBends in dark mode', async () => {
81
+ useColorMode.mockReturnValue(['dark'])
82
+ const { getByTestId } = renderBackground(<ChronogroveAnimatedPageBackground />)
83
+
84
+ await waitFor(() => {
85
+ expect(getByTestId('mock-color-bends')).toBeInTheDocument()
86
+ })
87
+ })
88
+
89
+ it('uses dark fallback hex when gradient resolves a CSS var in dark mode', async () => {
90
+ useColorMode.mockReturnValue(['dark'])
91
+ const varTheme = {
92
+ rawColors: { background: 'var(--page-bg)' },
93
+ colors: { background: '#ffffff' }
94
+ }
95
+ renderBackground(<ChronogroveAnimatedPageBackground />, varTheme)
96
+
97
+ await waitFor(() => {
98
+ expect(hexToRgbaSpy).toHaveBeenCalledWith('#14141F', 0.6)
99
+ expect(hexToRgbaSpy).toHaveBeenCalledWith('#14141F', 0.2)
100
+ })
101
+ })
102
+
103
+ it('uses light fallback hex when gradient resolves a CSS var in light mode', async () => {
104
+ useColorMode.mockReturnValue(['default'])
105
+ const varTheme = {
106
+ rawColors: { background: 'var(--page-bg)' },
107
+ colors: { background: '#ffffff' }
108
+ }
109
+ renderBackground(<ChronogroveAnimatedPageBackground />, varTheme)
110
+
111
+ await waitFor(() => {
112
+ expect(hexToRgbaSpy).toHaveBeenCalledWith('#fdf8f5', 0.6)
113
+ expect(hexToRgbaSpy).toHaveBeenCalledWith('#fdf8f5', 0.2)
114
+ })
115
+ })
116
+
117
+ it('falls back to surface hex when theme omits background tokens', async () => {
118
+ useColorMode.mockReturnValue(['dark'])
119
+ renderBackground(<ChronogroveAnimatedPageBackground />, {})
120
+
121
+ await waitFor(() => {
122
+ expect(hexToRgbaSpy).toHaveBeenCalled()
123
+ })
124
+ })
125
+
126
+ it('prefers colors.background when rawColors are absent', async () => {
127
+ useColorMode.mockReturnValue(['default'])
128
+ renderBackground(<ChronogroveAnimatedPageBackground />, {
129
+ colors: { background: '#aabbcc' }
130
+ })
131
+
132
+ await waitFor(() => {
133
+ expect(hexToRgbaSpy).toHaveBeenCalledWith('#aabbcc', 0.6)
134
+ expect(hexToRgbaSpy).toHaveBeenCalledWith('#aabbcc', 0.2)
135
+ })
136
+ })
137
+
138
+ it('prefers rawColors.background over colors.background', async () => {
139
+ useColorMode.mockReturnValue(['default'])
140
+ renderBackground(<ChronogroveAnimatedPageBackground />, {
141
+ rawColors: { background: '#112233' },
142
+ colors: { background: '#eeeeee' }
143
+ })
144
+
145
+ await waitFor(() => {
146
+ expect(hexToRgbaSpy).toHaveBeenCalledWith('#112233', 0.6)
147
+ expect(hexToRgbaSpy).toHaveBeenCalledWith('#112233', 0.2)
148
+ })
149
+ })
150
+
151
+ it('falls through to colors.background when raw background is null', async () => {
152
+ useColorMode.mockReturnValue(['default'])
153
+ renderBackground(<ChronogroveAnimatedPageBackground />, {
154
+ rawColors: { background: null },
155
+ colors: { background: '#ccddee' }
156
+ })
157
+
158
+ await waitFor(() => {
159
+ expect(hexToRgbaSpy).toHaveBeenCalledWith('#ccddee', 0.6)
160
+ expect(hexToRgbaSpy).toHaveBeenCalledWith('#ccddee', 0.2)
161
+ })
162
+ })
163
+
164
+ it('uses built-in fallbacks when theme is undefined', async () => {
165
+ useColorMode.mockReturnValue(['default'])
166
+ useThemeUI.mockReturnValue({ theme: undefined })
167
+ const { container } = render(
168
+ <ThemeUIProvider theme={chronogroveTheme}>
169
+ <ChronogroveAnimatedPageBackground />
170
+ </ThemeUIProvider>
171
+ )
172
+
173
+ await waitFor(() => {
174
+ expect(container.querySelectorAll('[aria-hidden="true"]')).toHaveLength(2)
175
+ expect(hexToRgbaSpy).toHaveBeenCalled()
176
+ })
177
+ })
178
+
179
+ it('updates overlay on scroll', async () => {
180
+ const { container } = renderBackground(<ChronogroveAnimatedPageBackground fadeDistance={100} />)
181
+
182
+ await waitFor(() => {
183
+ expect(container.querySelectorAll('[aria-hidden="true"]')).toHaveLength(2)
184
+ })
185
+
186
+ setScrollY(50)
187
+ fireEvent.scroll(window)
188
+ })
189
+ })
@@ -0,0 +1,309 @@
1
+ import React, { useEffect, useRef } from 'react'
2
+
3
+ import * as THREE from 'three'
4
+ import './color-bends.css'
5
+
6
+ const MAX_COLORS = 8
7
+
8
+ const frag = `
9
+ #define MAX_COLORS ${MAX_COLORS}
10
+ uniform vec2 uCanvas;
11
+ uniform float uTime;
12
+ uniform float uSpeed;
13
+ uniform vec2 uRot;
14
+ uniform int uColorCount;
15
+ uniform vec3 uColors[MAX_COLORS];
16
+ uniform int uTransparent;
17
+ uniform float uScale;
18
+ uniform float uFrequency;
19
+ uniform float uWarpStrength;
20
+ uniform vec2 uPointer; // in NDC [-1,1]
21
+ uniform float uMouseInfluence;
22
+ uniform float uParallax;
23
+ uniform float uNoise;
24
+ varying vec2 vUv;
25
+
26
+ void main() {
27
+ float t = uTime * uSpeed;
28
+ vec2 p = vUv * 2.0 - 1.0;
29
+ p += uPointer * uParallax * 0.1;
30
+ vec2 rp = vec2(p.x * uRot.x - p.y * uRot.y, p.x * uRot.y + p.y * uRot.x);
31
+ vec2 q = vec2(rp.x * (uCanvas.x / uCanvas.y), rp.y);
32
+ q /= max(uScale, 0.0001);
33
+ q /= 0.5 + 0.2 * dot(q, q);
34
+ q += 0.2 * cos(t) - 7.56;
35
+ vec2 toward = (uPointer - rp);
36
+ q += toward * uMouseInfluence * 0.2;
37
+
38
+ vec3 col = vec3(0.0);
39
+ float a = 1.0;
40
+
41
+ if (uColorCount > 0) {
42
+ vec2 s = q;
43
+ vec3 sumCol = vec3(0.0);
44
+ float cover = 0.0;
45
+
46
+ for (int i = 0; i < MAX_COLORS; ++i) {
47
+ if (i >= uColorCount) break;
48
+ s -= 0.01;
49
+ vec2 r = sin(1.5 * (s.yx * uFrequency) + 2.0 * cos(s * uFrequency));
50
+ float m0 = length(r + sin(5.0 * r.y * uFrequency - 3.0 * t + float(i)) / 4.0);
51
+ float kBelow = clamp(uWarpStrength, 0.0, 1.0);
52
+ float kMix = pow(kBelow, 0.3); // strong response across 0..1
53
+ float gain = 1.0 + max(uWarpStrength - 1.0, 0.0); // allow >1 to amplify displacement
54
+ vec2 disp = (r - s) * kBelow;
55
+ vec2 warped = s + disp * gain;
56
+ float m1 = length(warped + sin(5.0 * warped.y * uFrequency - 3.0 * t + float(i)) / 4.0);
57
+ float m = mix(m0, m1, kMix);
58
+ float w = 1.0 - exp(-6.0 / exp(6.0 * m));
59
+ sumCol += uColors[i] * w;
60
+ cover = max(cover, w);
61
+ }
62
+
63
+ col = clamp(sumCol, 0.0, 1.0);
64
+ a = uTransparent > 0 ? cover : 1.0;
65
+ } else {
66
+ vec2 s = q;
67
+ for (int k = 0; k < 3; ++k) {
68
+ s -= 0.01;
69
+ vec2 r = sin(1.5 * (s.yx * uFrequency) + 2.0 * cos(s * uFrequency));
70
+ float m0 = length(r + sin(5.0 * r.y * uFrequency - 3.0 * t + float(k)) / 4.0);
71
+ float kBelow = clamp(uWarpStrength, 0.0, 1.0);
72
+ float kMix = pow(kBelow, 0.3);
73
+ float gain = 1.0 + max(uWarpStrength - 1.0, 0.0);
74
+ vec2 disp = (r - s) * kBelow;
75
+ vec2 warped = s + disp * gain;
76
+ float m1 = length(warped + sin(5.0 * warped.y * uFrequency - 3.0 * t + float(k)) / 4.0);
77
+ float m = mix(m0, m1, kMix);
78
+ col[k] = 1.0 - exp(-6.0 / exp(6.0 * m));
79
+ }
80
+ a = uTransparent > 0 ? max(max(col.r, col.g), col.b) : 1.0;
81
+ }
82
+
83
+ if (uNoise > 0.0001) {
84
+ float n = fract(sin(dot(gl_FragCoord.xy + vec2(uTime), vec2(12.9898, 78.233))) * 43758.5453123);
85
+ col += (n - 0.5) * uNoise;
86
+ col = clamp(col, 0.0, 1.0);
87
+ }
88
+
89
+ vec3 rgb = (uTransparent > 0) ? col * a : col;
90
+ gl_FragColor = vec4(rgb, a);
91
+ }
92
+ `
93
+
94
+ const vert = `
95
+ varying vec2 vUv;
96
+ void main() {
97
+ vUv = uv;
98
+ gl_Position = vec4(position, 1.0);
99
+ }
100
+ `
101
+
102
+ export default function ColorBends({
103
+ className,
104
+ style,
105
+ rotation = 45,
106
+ speed = 0.2,
107
+ colors = [],
108
+ transparent = true,
109
+ autoRotate = 0,
110
+ scale = 1,
111
+ frequency = 1,
112
+ warpStrength = 1,
113
+ mouseInfluence = 1,
114
+ parallax = 0.5,
115
+ noise = 0.1
116
+ }) {
117
+ const containerRef = useRef(null)
118
+ const rendererRef = useRef(null)
119
+ const rafRef = useRef(null)
120
+ const materialRef = useRef(null)
121
+ const resizeObserverRef = useRef(null)
122
+ const rotationRef = useRef(rotation)
123
+ const autoRotateRef = useRef(autoRotate)
124
+ const pointerTargetRef = useRef(new THREE.Vector2(0, 0))
125
+ const pointerCurrentRef = useRef(new THREE.Vector2(0, 0))
126
+ const pointerSmoothRef = useRef(8)
127
+
128
+ useEffect(() => {
129
+ const container = containerRef.current
130
+ if (!container) return
131
+
132
+ const scene = new THREE.Scene()
133
+ const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1)
134
+ const geometry = new THREE.PlaneGeometry(2, 2)
135
+ const uColorsArray = Array.from({ length: MAX_COLORS }, () => new THREE.Vector3(0, 0, 0))
136
+
137
+ const material = new THREE.ShaderMaterial({
138
+ vertexShader: vert,
139
+ fragmentShader: frag,
140
+ uniforms: {
141
+ uCanvas: { value: new THREE.Vector2(1, 1) },
142
+ uTime: { value: 0 },
143
+ uSpeed: { value: speed },
144
+ uRot: { value: new THREE.Vector2(1, 0) },
145
+ uColorCount: { value: 0 },
146
+ uColors: { value: uColorsArray },
147
+ uTransparent: { value: transparent ? 1 : 0 },
148
+ uScale: { value: scale },
149
+ uFrequency: { value: frequency },
150
+ uWarpStrength: { value: warpStrength },
151
+ uPointer: { value: new THREE.Vector2(0, 0) },
152
+ uMouseInfluence: { value: mouseInfluence },
153
+ uParallax: { value: parallax },
154
+ uNoise: { value: noise }
155
+ },
156
+ premultipliedAlpha: true,
157
+ transparent: true
158
+ })
159
+
160
+ materialRef.current = material
161
+
162
+ const mesh = new THREE.Mesh(geometry, material)
163
+ scene.add(mesh)
164
+
165
+ const renderer = new THREE.WebGLRenderer({
166
+ antialias: false,
167
+ powerPreference: 'high-performance',
168
+ alpha: true
169
+ })
170
+
171
+ rendererRef.current = renderer
172
+ renderer.outputColorSpace = THREE.SRGBColorSpace
173
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
174
+ renderer.setClearColor(0x000000, transparent ? 0 : 1)
175
+ renderer.domElement.style.width = '100%'
176
+ renderer.domElement.style.height = '100%'
177
+ renderer.domElement.style.display = 'block'
178
+
179
+ container.appendChild(renderer.domElement)
180
+
181
+ const clock = new THREE.Clock()
182
+
183
+ const handleResize = () => {
184
+ const w = container.clientWidth || 1
185
+ const h = container.clientHeight || 1
186
+ renderer.setSize(w, h, false)
187
+ material.uniforms.uCanvas.value.set(w, h)
188
+ }
189
+
190
+ handleResize()
191
+
192
+ if ('ResizeObserver' in window) {
193
+ const ro = new ResizeObserver(handleResize)
194
+ ro.observe(container)
195
+ resizeObserverRef.current = ro
196
+ } else {
197
+ window.addEventListener('resize', handleResize)
198
+ }
199
+
200
+ const loop = () => {
201
+ const dt = clock.getDelta()
202
+ const elapsed = clock.elapsedTime
203
+
204
+ material.uniforms.uTime.value = elapsed
205
+
206
+ const deg = (rotationRef.current % 360) + autoRotateRef.current * elapsed
207
+ const rad = (deg * Math.PI) / 180
208
+ const c = Math.cos(rad)
209
+ const s = Math.sin(rad)
210
+ material.uniforms.uRot.value.set(c, s)
211
+
212
+ const cur = pointerCurrentRef.current
213
+ const tgt = pointerTargetRef.current
214
+ const amt = Math.min(1, dt * pointerSmoothRef.current)
215
+ cur.lerp(tgt, amt)
216
+ material.uniforms.uPointer.value.copy(cur)
217
+
218
+ renderer.render(scene, camera)
219
+ rafRef.current = requestAnimationFrame(loop)
220
+ }
221
+
222
+ rafRef.current = requestAnimationFrame(loop)
223
+
224
+ return () => {
225
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
226
+ if (resizeObserverRef.current) resizeObserverRef.current.disconnect()
227
+ else window.removeEventListener('resize', handleResize)
228
+ geometry.dispose()
229
+ material.dispose()
230
+ renderer.dispose()
231
+ if (renderer.domElement && renderer.domElement.parentElement === container) {
232
+ container.removeChild(renderer.domElement)
233
+ }
234
+ }
235
+ }, [frequency, mouseInfluence, noise, parallax, scale, speed, transparent, warpStrength])
236
+
237
+ useEffect(() => {
238
+ const material = materialRef.current
239
+ const renderer = rendererRef.current
240
+ if (!material) return
241
+
242
+ rotationRef.current = rotation
243
+ autoRotateRef.current = autoRotate
244
+ material.uniforms.uSpeed.value = speed
245
+ material.uniforms.uScale.value = scale
246
+ material.uniforms.uFrequency.value = frequency
247
+ material.uniforms.uWarpStrength.value = warpStrength
248
+ material.uniforms.uMouseInfluence.value = mouseInfluence
249
+ material.uniforms.uParallax.value = parallax
250
+ material.uniforms.uNoise.value = noise
251
+
252
+ const toVec3 = hex => {
253
+ const h = hex.replace('#', '').trim()
254
+ const v =
255
+ h.length === 3
256
+ ? [parseInt(h[0] + h[0], 16), parseInt(h[1] + h[1], 16), parseInt(h[2] + h[2], 16)]
257
+ : [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]
258
+ return new THREE.Vector3(v[0] / 255, v[1] / 255, v[2] / 255)
259
+ }
260
+
261
+ const arr = (colors || [])
262
+ .filter(color => typeof color === 'string')
263
+ .slice(0, MAX_COLORS)
264
+ .map(toVec3)
265
+
266
+ for (let i = 0; i < MAX_COLORS; i++) {
267
+ const vec = material.uniforms.uColors.value[i]
268
+ if (i < arr.length) vec.copy(arr[i])
269
+ else vec.set(0, 0, 0)
270
+ }
271
+
272
+ material.uniforms.uColorCount.value = arr.length
273
+ material.uniforms.uTransparent.value = transparent ? 1 : 0
274
+ if (renderer) renderer.setClearColor(0x000000, transparent ? 0 : 1)
275
+ }, [
276
+ rotation,
277
+ autoRotate,
278
+ speed,
279
+ scale,
280
+ frequency,
281
+ warpStrength,
282
+ mouseInfluence,
283
+ parallax,
284
+ noise,
285
+ colors,
286
+ transparent
287
+ ])
288
+
289
+ useEffect(() => {
290
+ const material = materialRef.current
291
+ const container = containerRef.current
292
+ if (!material || !container) return
293
+
294
+ const handlePointerMove = e => {
295
+ const rect = container.getBoundingClientRect()
296
+ const x = ((e.clientX - rect.left) / (rect.width || 1)) * 2 - 1
297
+ const y = -(((e.clientY - rect.top) / (rect.height || 1)) * 2 - 1)
298
+ pointerTargetRef.current.set(x, y)
299
+ }
300
+
301
+ container.addEventListener('pointermove', handlePointerMove)
302
+
303
+ return () => {
304
+ container.removeEventListener('pointermove', handlePointerMove)
305
+ }
306
+ }, [])
307
+
308
+ return <div ref={containerRef} className={`color-bends-container ${className || ''}`} style={style} />
309
+ }
@@ -0,0 +1,13 @@
1
+ .color-bends-container {
2
+ position: relative;
3
+ width: 100%;
4
+ height: 100%;
5
+ overflow: hidden;
6
+ background-color: transparent;
7
+ }
8
+
9
+ .color-bends-container canvas {
10
+ width: 100% !important;
11
+ height: 100% !important;
12
+ display: block;
13
+ }
@@ -0,0 +1,2 @@
1
+ export { default } from './ChronogroveAnimatedPageBackground.js'
2
+ export { default as ChronogroveAnimatedPageBackground } from './ChronogroveAnimatedPageBackground.js'
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ jest.mock('./ColorBends.js', () => {
6
+ return function MockColorBends() {
7
+ return null
8
+ }
9
+ })
10
+
11
+ import DefaultBackground, { ChronogroveAnimatedPageBackground } from './index.js'
12
+
13
+ describe('animated-page-background barrel', () => {
14
+ it('re-exports the animated background as default and named export', () => {
15
+ expect(ChronogroveAnimatedPageBackground).toEqual(expect.any(Function))
16
+ expect(DefaultBackground).toBe(ChronogroveAnimatedPageBackground)
17
+ })
18
+ })
package/src/button.js CHANGED
@@ -1,8 +1,9 @@
1
- /** @jsx jsx */
2
- import { jsx } from 'theme-ui'
1
+ import React from 'react'
2
+ import { Box } from '@theme-ui/components'
3
3
 
4
4
  const Button = ({ variant = 'primary', ...props }) => (
5
- <button
5
+ <Box
6
+ as='button'
6
7
  {...props}
7
8
  sx={{
8
9
  appearance: 'none',
@@ -0,0 +1,23 @@
1
+ import React from 'react'
2
+ import { Box } from '@theme-ui/components'
3
+
4
+ /**
5
+ * Styled label for post categories. Pass display text as `children` (site-specific mapping stays in the app).
6
+ */
7
+ const CategoryLabel = ({ children, sx: sxProp = {}, ...props }) => (
8
+ <Box
9
+ sx={{
10
+ display: 'inline-block',
11
+ fontSize: [0],
12
+ fontFamily: 'heading',
13
+ color: 'primary',
14
+ letterSpacing: '0.05em',
15
+ ...sxProp
16
+ }}
17
+ {...props}
18
+ >
19
+ {children}
20
+ </Box>
21
+ )
22
+
23
+ export default CategoryLabel
@@ -0,0 +1,24 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { ThemeUIProvider } from 'theme-ui'
3
+
4
+ import CategoryLabel from './category-label.js'
5
+
6
+ const theme = { colors: { primary: '#422EA3' } }
7
+
8
+ const renderWithTheme = ui => render(<ThemeUIProvider theme={theme}>{ui}</ThemeUIProvider>)
9
+
10
+ describe('CategoryLabel', () => {
11
+ it('renders children', () => {
12
+ renderWithTheme(<CategoryLabel>Travel</CategoryLabel>)
13
+ expect(screen.getByText('Travel')).toBeInTheDocument()
14
+ })
15
+
16
+ it('merges sx', () => {
17
+ renderWithTheme(
18
+ <CategoryLabel sx={{ color: 'red' }} data-testid='cat'>
19
+ X
20
+ </CategoryLabel>
21
+ )
22
+ expect(screen.getByTestId('cat')).toBeInTheDocument()
23
+ })
24
+ })
@@ -0,0 +1,37 @@
1
+ import React from 'react'
2
+
3
+ /** Chevron left (inline SVG, no icon font). */
4
+ export const ChevronLeftIcon = props => (
5
+ <svg
6
+ xmlns='http://www.w3.org/2000/svg'
7
+ viewBox='0 0 320 512'
8
+ width='1em'
9
+ height='1em'
10
+ aria-hidden='true'
11
+ focusable='false'
12
+ {...props}
13
+ >
14
+ <path
15
+ fill='currentColor'
16
+ d='M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l192 192c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L77.3 256 246.6 86.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192z'
17
+ />
18
+ </svg>
19
+ )
20
+
21
+ /** Chevron right (inline SVG, no icon font). */
22
+ export const ChevronRightIcon = props => (
23
+ <svg
24
+ xmlns='http://www.w3.org/2000/svg'
25
+ viewBox='0 0 320 512'
26
+ width='1em'
27
+ height='1em'
28
+ aria-hidden='true'
29
+ focusable='false'
30
+ {...props}
31
+ >
32
+ <path
33
+ fill='currentColor'
34
+ d='M310.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L242.7 256 73.4 86.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l192 192z'
35
+ />
36
+ </svg>
37
+ )
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Canonical string colors for page surface + frosted panels (default + dark mode).
3
+ *
4
+ * Imported by `theme.js` (full Theme UI merge) and `color-mode/chronogrove-head-theme.js`
5
+ * (RSC-safe head theme) so SSR inline CSS and the client Theme UI theme stay aligned.
6
+ *
7
+ * This module must stay free of `theme-ui` / `@theme-ui/presets` / React so it can load in
8
+ * Next.js Server Components.
9
+ */
10
+ export const chronogroveThemeSurfaceColorsLight = {
11
+ background: '#fdf8f5',
12
+ text: '#111',
13
+ textMuted: '#333',
14
+ 'panel-background': 'rgba(255, 255, 255, 0.45)'
15
+ }
16
+
17
+ export const chronogroveThemeSurfaceColorsDark = {
18
+ background: '#14141F',
19
+ text: '#fff',
20
+ textMuted: '#d8d8d8',
21
+ 'panel-background': 'rgba(20, 20, 31, 0.45)'
22
+ }
@@ -1,5 +1,9 @@
1
+ import { chronogroveHeadTheme } from './chronogrove-head-theme.js'
1
2
  import { THEME_UI_COLOR_MODE_STORAGE_KEY } from './constants.js'
2
3
  import { normalizeThemeUiColorMode } from './normalize.js'
4
+ import { resolveChronogroveSurfaceColors } from './resolve-theme-colors.js'
5
+
6
+ const SURFACE = resolveChronogroveSurfaceColors(chronogroveHeadTheme)
3
7
 
4
8
  export function resolveThemeUiColorMode(storageKey = THEME_UI_COLOR_MODE_STORAGE_KEY) {
5
9
  let mode
@@ -49,6 +53,9 @@ export function syncThemeUiColorMode(storageKey = THEME_UI_COLOR_MODE_STORAGE_KE
49
53
  .forEach(className => htmlElement.classList.remove(className))
50
54
  htmlElement.classList.add(`theme-ui-${mode}`)
51
55
  htmlElement.setAttribute('data-theme-ui-color-mode', mode)
56
+ // Match `buildHtmlBackgroundInlineScript`: inline background must follow toggles; otherwise the
57
+ // initial head script’s color sticks and wins over Theme UI’s `--theme-ui-colors-background`.
58
+ htmlElement.style.backgroundColor = mode === 'dark' ? SURFACE.darkBackgroundHex : SURFACE.defaultBackgroundHex
52
59
  }
53
60
 
54
61
  export function scheduleThemeUiColorModeSync(storageKey = THEME_UI_COLOR_MODE_STORAGE_KEY) {
@@ -86,6 +86,13 @@ describe('syncThemeUiColorMode', () => {
86
86
  syncThemeUiColorMode()
87
87
  expect(document.documentElement.classList.contains('theme-ui-dark')).toBe(true)
88
88
  expect(document.documentElement.getAttribute('data-theme-ui-color-mode')).toBe('dark')
89
+ expect(document.documentElement.style.backgroundColor).toBe('rgb(20, 20, 31)')
90
+ })
91
+
92
+ it('sets html inline background for default mode', () => {
93
+ window.localStorage.setItem('theme-ui-color-mode', 'default')
94
+ syncThemeUiColorMode()
95
+ expect(document.documentElement.style.backgroundColor).toBe('rgb(253, 248, 245)')
89
96
  })
90
97
 
91
98
  it('strips prior theme-ui-* classes before applying', () => {