@chronogrove/ui 0.78.0 → 0.80.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 (58) hide show
  1. package/README.md +42 -19
  2. package/package.json +42 -6
  3. package/src/__snapshots__/header.spec.js.snap +8 -8
  4. package/src/__snapshots__/theme.spec.js.snap +14 -15
  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/chronogrove-theme-surface-colors.js +22 -0
  17. package/src/color-mode/browser-sync.js +7 -0
  18. package/src/color-mode/browser-sync.spec.js +7 -0
  19. package/src/color-mode/chronogrove-head-theme.js +22 -0
  20. package/src/color-mode/head-inline.js +40 -5
  21. package/src/color-mode/head-inline.spec.js +29 -0
  22. package/src/color-mode/index.js +3 -0
  23. package/src/color-mode/resolve-theme-colors.js +18 -6
  24. package/src/color-mode/resolve-theme-colors.spec.js +13 -3
  25. package/src/color-mode/spa-navigation.js +14 -0
  26. package/src/color-mode/spa-navigation.spec.js +25 -0
  27. package/src/color-mode/use-document-color-mode-surface.js +52 -0
  28. package/src/color-mode/use-document-color-mode-surface.node.spec.js +12 -0
  29. package/src/color-mode/use-document-color-mode-surface.spec.js +154 -0
  30. package/src/color-toggle-styles.css +10 -0
  31. package/src/color-toggle.js +12 -3
  32. package/src/emotion-cache.node.spec.js +13 -0
  33. package/src/emotion-cache.spec.js +12 -0
  34. package/src/gatsby/build-theme-ui-color-mode-head-components.js +7 -1
  35. package/src/gatsby/index.spec.js +42 -0
  36. package/src/gatsby/on-route-update-color-mode.js +1 -14
  37. package/src/header.js +4 -16
  38. package/src/lazy-load.js +30 -11
  39. package/src/lazy-load.spec.js +9 -5
  40. package/src/next/app-shell.js +34 -0
  41. package/src/next/emotion-registry.js +68 -0
  42. package/src/next/emotion-registry.spec.js +99 -0
  43. package/src/next/index.js +4 -0
  44. package/src/next/root-layout-head.js +42 -0
  45. package/src/next/root-layout-head.spec.js +17 -0
  46. package/src/next/theme-ui-color-mode-route-sync.js +32 -0
  47. package/src/page-backdrop.js +42 -0
  48. package/src/page-backdrop.spec.js +41 -0
  49. package/src/pagination-button.js +4 -4
  50. package/src/pagination-button.spec.js +26 -2
  51. package/src/skip-nav/SkipNavLink.js +7 -6
  52. package/src/skip-nav/SkipNavLink.spec.js +11 -0
  53. package/src/theme.js +12 -15
  54. package/babel.config.cjs +0 -9
  55. package/jest.config.cjs +0 -33
  56. package/jest.setup.cjs +0 -1
  57. package/test-utils/mock-theme-toggles-react.js +0 -10
  58. package/turbo.json +0 -12
@@ -22,12 +22,41 @@ describe('head-inline scripts', () => {
22
22
 
23
23
  it('buildThemeUiColorModeFallbackCss sets CSS vars', () => {
24
24
  const css = buildThemeUiColorModeFallbackCss({
25
+ defaultBackgroundHex: '#fdf8f5',
26
+ darkBackgroundHex: '#14141F',
25
27
  defaultTextHex: '#111',
26
28
  defaultTextMutedHex: '#333',
27
29
  darkTextHex: '#fff',
28
30
  darkTextMutedHex: '#ccc'
29
31
  })
32
+ expect(css).toContain(':root {')
33
+ expect(css).toContain('--theme-ui-colors-background: #fdf8f5')
30
34
  expect(css).toContain('--theme-ui-colors-text: #111')
31
35
  expect(css).toContain('--theme-ui-colors-text: #fff')
36
+ expect(css).toContain('--theme-ui-colors-panel-background:')
37
+ expect(css).toContain('--theme-ui-colors-panel-text:')
38
+ })
39
+
40
+ it('buildThemeUiColorModeFallbackCss uses default background hexes when omitted', () => {
41
+ const css = buildThemeUiColorModeFallbackCss({
42
+ defaultTextHex: '#111',
43
+ defaultTextMutedHex: '#333',
44
+ darkTextHex: '#fff',
45
+ darkTextMutedHex: '#ccc'
46
+ })
47
+ expect(css).toContain('--theme-ui-colors-background: #fdf8f5')
48
+ expect(css).toContain('--theme-ui-colors-background: #14141F')
49
+ })
50
+
51
+ it('buildThemeUiColorModeFallbackCss uses chronogrove-theme-surface-colors when called with no options', () => {
52
+ const css = buildThemeUiColorModeFallbackCss()
53
+ expect(css).toContain('--theme-ui-colors-background: #fdf8f5')
54
+ expect(css).toContain('--theme-ui-colors-background: #14141F')
55
+ expect(css).toContain('rgba(255, 255, 255, 0.45)')
56
+ expect(css).toContain('rgba(20, 20, 31, 0.45)')
57
+ expect(css).toMatch(/--theme-ui-colors-text: #111/)
58
+ expect(css).toMatch(/--theme-ui-colors-text-muted: #333/)
59
+ expect(css).toMatch(/--theme-ui-colors-text: #fff/)
60
+ expect(css).toMatch(/--theme-ui-colors-text-muted: #d8d8d8/)
32
61
  })
33
62
  })
@@ -5,9 +5,12 @@ export {
5
5
  } from './constants.js'
6
6
  export { normalizeThemeUiColorMode } from './normalize.js'
7
7
  export { resolveChronogroveSurfaceColors } from './resolve-theme-colors.js'
8
+ export { chronogroveHeadTheme } from './chronogrove-head-theme.js'
8
9
  export {
9
10
  buildThemeUiNoFlashInlineScript,
10
11
  buildHtmlBackgroundInlineScript,
11
12
  buildThemeUiColorModeFallbackCss
12
13
  } from './head-inline.js'
13
14
  export { resolveThemeUiColorMode, syncThemeUiColorMode, scheduleThemeUiColorModeSync } from './browser-sync.js'
15
+ export { reconcileThemeUiColorModeOnNavigation } from './spa-navigation.js'
16
+ export { useDocumentColorModeSurface } from './use-document-color-mode-surface.js'
@@ -1,3 +1,8 @@
1
+ import {
2
+ chronogroveThemeSurfaceColorsDark,
3
+ chronogroveThemeSurfaceColorsLight
4
+ } from '../chronogrove-theme-surface-colors.js'
5
+
1
6
  function pickColor(value) {
2
7
  if (typeof value === 'string') {
3
8
  return value
@@ -12,12 +17,19 @@ function pickColor(value) {
12
17
  export function resolveChronogroveSurfaceColors(theme) {
13
18
  const colors = theme?.colors || {}
14
19
  const dark = colors.modes?.dark || {}
20
+ const light = chronogroveThemeSurfaceColorsLight
21
+ const darkSurface = chronogroveThemeSurfaceColorsDark
15
22
  return {
16
- defaultBackgroundHex: pickColor(colors.background) || '#fdf8f5',
17
- darkBackgroundHex: pickColor(dark.background) || '#14141F',
18
- defaultTextHex: pickColor(colors.text) || '#111',
19
- defaultTextMutedHex: pickColor(colors.textMuted) || '#333',
20
- darkTextHex: pickColor(dark.text) || '#fff',
21
- darkTextMutedHex: pickColor(dark.textMuted) || '#d8d8d8'
23
+ defaultBackgroundHex: pickColor(colors.background) || light.background,
24
+ darkBackgroundHex: pickColor(dark.background) || darkSurface.background,
25
+ defaultTextHex: pickColor(colors.text) || light.text,
26
+ defaultTextMutedHex: pickColor(colors.textMuted) || light.textMuted,
27
+ darkTextHex: pickColor(dark.text) || darkSurface.text,
28
+ darkTextMutedHex: pickColor(dark.textMuted) || darkSurface.textMuted,
29
+ defaultPanelBackground: pickColor(colors['panel-background']) || light['panel-background'],
30
+ darkPanelBackground: pickColor(dark['panel-background']) || darkSurface['panel-background'],
31
+ /** Head fallback only; panel copy usually uses `color: 'text'` — no separate `panel-text` token required. */
32
+ defaultPanelText: pickColor(colors['panel-text']) || pickColor(colors.text) || light.text,
33
+ darkPanelText: pickColor(dark['panel-text']) || pickColor(dark.text) || darkSurface.text
22
34
  }
23
35
  }
@@ -7,11 +7,13 @@ describe('resolveChronogroveSurfaceColors', () => {
7
7
  background: '#fdf8f5',
8
8
  text: '#111',
9
9
  textMuted: '#333',
10
+ 'panel-background': 'rgba(255, 255, 255, 0.45)',
10
11
  modes: {
11
12
  dark: {
12
13
  background: '#14141F',
13
14
  text: '#fff',
14
- textMuted: '#d8d8d8'
15
+ textMuted: '#d8d8d8',
16
+ 'panel-background': 'rgba(20, 20, 31, 0.45)'
15
17
  }
16
18
  }
17
19
  }
@@ -22,7 +24,11 @@ describe('resolveChronogroveSurfaceColors', () => {
22
24
  defaultTextHex: '#111',
23
25
  defaultTextMutedHex: '#333',
24
26
  darkTextHex: '#fff',
25
- darkTextMutedHex: '#d8d8d8'
27
+ darkTextMutedHex: '#d8d8d8',
28
+ defaultPanelBackground: 'rgba(255, 255, 255, 0.45)',
29
+ darkPanelBackground: 'rgba(20, 20, 31, 0.45)',
30
+ defaultPanelText: '#111',
31
+ darkPanelText: '#fff'
26
32
  })
27
33
  })
28
34
 
@@ -33,7 +39,11 @@ describe('resolveChronogroveSurfaceColors', () => {
33
39
  defaultTextHex: '#111',
34
40
  defaultTextMutedHex: '#333',
35
41
  darkTextHex: '#fff',
36
- darkTextMutedHex: '#d8d8d8'
42
+ darkTextMutedHex: '#d8d8d8',
43
+ defaultPanelBackground: 'rgba(255, 255, 255, 0.45)',
44
+ darkPanelBackground: 'rgba(20, 20, 31, 0.45)',
45
+ defaultPanelText: '#111',
46
+ darkPanelText: '#fff'
37
47
  })
38
48
  })
39
49
  })
@@ -0,0 +1,14 @@
1
+ import { RECONCILE_COLOR_MODE_EVENT } from './constants.js'
2
+ import { scheduleThemeUiColorModeSync } from './browser-sync.js'
3
+
4
+ /**
5
+ * Run after client-side navigations (e.g. Next.js App Router) so Theme UI color mode stays aligned
6
+ * with `localStorage` and the document. Dispatches {@link RECONCILE_COLOR_MODE_EVENT} for listeners.
7
+ * Same behavior as {@link onRouteUpdateThemeUiColorMode} in `@chronogrove/ui/gatsby`.
8
+ */
9
+ export function reconcileThemeUiColorModeOnNavigation() {
10
+ scheduleThemeUiColorModeSync()
11
+ if (typeof window !== 'undefined' && typeof window.CustomEvent === 'function') {
12
+ window.dispatchEvent(new window.CustomEvent(RECONCILE_COLOR_MODE_EVENT))
13
+ }
14
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { reconcileThemeUiColorModeOnNavigation } from './spa-navigation.js'
6
+ import { RECONCILE_COLOR_MODE_EVENT } from './constants.js'
7
+
8
+ describe('reconcileThemeUiColorModeOnNavigation', () => {
9
+ beforeEach(() => {
10
+ window.localStorage.removeItem('theme-ui-color-mode')
11
+ document.documentElement.removeAttribute('data-theme-ui-color-mode')
12
+ document.documentElement.className = ''
13
+ })
14
+
15
+ it('syncs DOM from localStorage and dispatches reconcile event', () => {
16
+ window.localStorage.setItem('theme-ui-color-mode', 'dark')
17
+ const listener = jest.fn()
18
+ window.addEventListener(RECONCILE_COLOR_MODE_EVENT, listener)
19
+
20
+ reconcileThemeUiColorModeOnNavigation()
21
+
22
+ expect(document.documentElement.getAttribute('data-theme-ui-color-mode')).toBe('dark')
23
+ expect(listener).toHaveBeenCalled()
24
+ })
25
+ })
@@ -0,0 +1,52 @@
1
+ 'use client'
2
+
3
+ import { useLayoutEffect } from 'react'
4
+ import { useColorMode, useThemeUI } from 'theme-ui'
5
+
6
+ import { resolveChronogroveSurfaceColors } from './resolve-theme-colors.js'
7
+
8
+ /**
9
+ * Applies Theme UI color mode + page background to `document.documentElement`. Exposed for tests;
10
+ * prefer {@link useDocumentColorModeSurface} in app code.
11
+ */
12
+ export function applyDocumentColorModeSurface(colorMode, theme, surface) {
13
+ if (typeof document === 'undefined') {
14
+ return
15
+ }
16
+ const normalizedColorMode = colorMode === 'light' ? 'default' : colorMode || 'default'
17
+ const isDark = normalizedColorMode === 'dark'
18
+ const bgColorRaw =
19
+ theme?.rawColors?.background ||
20
+ theme?.colors?.background ||
21
+ (isDark ? surface.darkBackgroundHex : surface.defaultBackgroundHex)
22
+ const htmlElement = document.documentElement
23
+ Array.from(htmlElement.classList)
24
+ .filter(className => className.startsWith('theme-ui-'))
25
+ .forEach(className => htmlElement.classList.remove(className))
26
+ htmlElement.classList.add(`theme-ui-${normalizedColorMode}`)
27
+ htmlElement.setAttribute('data-theme-ui-color-mode', normalizedColorMode)
28
+ htmlElement.style.backgroundColor = bgColorRaw
29
+ }
30
+
31
+ /**
32
+ * Keeps `document.documentElement` aligned with Theme UI color mode — same responsibility as
33
+ * `RootWrapper`’s layout effect in the Gatsby theme (`theme/src/components/root-wrapper.js`):
34
+ * `theme-ui-*` class, `data-theme-ui-color-mode`, and inline page background from the **resolved**
35
+ * theme (including `rawColors` / `colors.background` for the active mode).
36
+ *
37
+ * Without this on App Router, head fallback CSS and inline scripts can disagree with React
38
+ * hydration, and Emotion-injected `--theme-ui-colors-*` can win the cascade so panel surfaces
39
+ * (`bg: 'panel-background'`) never pick up dark tokens.
40
+ *
41
+ * Call once inside `ChronogroveThemeProvider` (client boundary). Optional on Gatsby if you
42
+ * already use the same hook from `RootWrapper`.
43
+ */
44
+ export function useDocumentColorModeSurface() {
45
+ const [colorMode] = useColorMode()
46
+ const { theme } = useThemeUI()
47
+ const surface = resolveChronogroveSurfaceColors(theme)
48
+
49
+ useLayoutEffect(() => {
50
+ applyDocumentColorModeSurface(colorMode, theme, surface)
51
+ }, [colorMode, theme, surface.darkBackgroundHex, surface.defaultBackgroundHex])
52
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ import { resolveChronogroveSurfaceColors } from './resolve-theme-colors.js'
6
+ import { applyDocumentColorModeSurface } from './use-document-color-mode-surface.js'
7
+
8
+ describe('applyDocumentColorModeSurface (node / no DOM)', () => {
9
+ it('returns immediately when document is not available', () => {
10
+ expect(applyDocumentColorModeSurface('default', {}, resolveChronogroveSurfaceColors({}))).toBeUndefined()
11
+ })
12
+ })
@@ -0,0 +1,154 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import React from 'react'
6
+ import { render, act } from '@testing-library/react'
7
+ import { ThemeUIProvider } from 'theme-ui'
8
+
9
+ import { resolveChronogroveSurfaceColors } from './resolve-theme-colors.js'
10
+ import { applyDocumentColorModeSurface, useDocumentColorModeSurface } from './use-document-color-mode-surface.js'
11
+
12
+ function SurfaceHarness() {
13
+ useDocumentColorModeSurface()
14
+ return null
15
+ }
16
+
17
+ const baseTheme = {
18
+ config: { useLocalStorage: true, useColorSchemeMediaQuery: false },
19
+ colors: {
20
+ background: '#fdf8f5',
21
+ modes: {
22
+ dark: { background: '#14141F' }
23
+ }
24
+ }
25
+ }
26
+
27
+ describe('useDocumentColorModeSurface', () => {
28
+ beforeEach(() => {
29
+ document.documentElement.className = 'theme-ui-stale'
30
+ document.documentElement.removeAttribute('data-theme-ui-color-mode')
31
+ document.documentElement.style.backgroundColor = ''
32
+ window.localStorage.removeItem('theme-ui-color-mode')
33
+ })
34
+
35
+ it('syncs documentElement class, data attribute, and background from resolved theme', () => {
36
+ window.localStorage.setItem('theme-ui-color-mode', 'default')
37
+
38
+ act(() => {
39
+ render(
40
+ <ThemeUIProvider theme={baseTheme}>
41
+ <SurfaceHarness />
42
+ </ThemeUIProvider>
43
+ )
44
+ })
45
+
46
+ expect(document.documentElement.classList.contains('theme-ui-stale')).toBe(false)
47
+ expect(document.documentElement.classList.contains('theme-ui-default')).toBe(true)
48
+ expect(document.documentElement.getAttribute('data-theme-ui-color-mode')).toBe('default')
49
+ expect(document.documentElement.style.backgroundColor).toBeTruthy()
50
+ })
51
+
52
+ it('maps light color mode to theme-ui-default', () => {
53
+ window.localStorage.setItem('theme-ui-color-mode', 'light')
54
+
55
+ act(() => {
56
+ render(
57
+ <ThemeUIProvider theme={baseTheme}>
58
+ <SurfaceHarness />
59
+ </ThemeUIProvider>
60
+ )
61
+ })
62
+
63
+ expect(document.documentElement.classList.contains('theme-ui-default')).toBe(true)
64
+ expect(document.documentElement.getAttribute('data-theme-ui-color-mode')).toBe('default')
65
+ })
66
+
67
+ it('applies dark surface when color mode is dark', () => {
68
+ window.localStorage.setItem('theme-ui-color-mode', 'dark')
69
+
70
+ act(() => {
71
+ render(
72
+ <ThemeUIProvider theme={baseTheme}>
73
+ <SurfaceHarness />
74
+ </ThemeUIProvider>
75
+ )
76
+ })
77
+
78
+ expect(document.documentElement.classList.contains('theme-ui-dark')).toBe(true)
79
+ expect(document.documentElement.getAttribute('data-theme-ui-color-mode')).toBe('dark')
80
+ })
81
+
82
+ it('prefers rawColors.background when set', () => {
83
+ window.localStorage.setItem('theme-ui-color-mode', 'default')
84
+ const theme = {
85
+ config: { useLocalStorage: true, useColorSchemeMediaQuery: false },
86
+ rawColors: { background: '#abcdef' },
87
+ colors: {
88
+ modes: {
89
+ dark: { background: '#14141F' }
90
+ }
91
+ }
92
+ }
93
+
94
+ act(() => {
95
+ render(
96
+ <ThemeUIProvider theme={theme}>
97
+ <SurfaceHarness />
98
+ </ThemeUIProvider>
99
+ )
100
+ })
101
+
102
+ expect(document.documentElement.style.backgroundColor).toBe('rgb(171, 205, 239)')
103
+ })
104
+
105
+ it('normalizes light and missing color modes when applying to the document', () => {
106
+ window.localStorage.setItem('theme-ui-color-mode', 'default')
107
+ document.documentElement.className = ''
108
+ act(() => {
109
+ applyDocumentColorModeSurface('light', baseTheme, resolveChronogroveSurfaceColors(baseTheme))
110
+ })
111
+ expect(document.documentElement.getAttribute('data-theme-ui-color-mode')).toBe('default')
112
+
113
+ document.documentElement.removeAttribute('data-theme-ui-color-mode')
114
+ act(() => {
115
+ applyDocumentColorModeSurface(undefined, baseTheme, resolveChronogroveSurfaceColors(baseTheme))
116
+ })
117
+ expect(document.documentElement.getAttribute('data-theme-ui-color-mode')).toBe('default')
118
+ })
119
+
120
+ it('prefers rawColors.background over colors.background', () => {
121
+ act(() => {
122
+ applyDocumentColorModeSurface(
123
+ 'default',
124
+ { rawColors: { background: '#112233' }, colors: { background: '#eeeeee' } },
125
+ resolveChronogroveSurfaceColors({})
126
+ )
127
+ })
128
+ expect(document.documentElement.style.backgroundColor).toBe('rgb(17, 34, 51)')
129
+ })
130
+
131
+ it('uses colors.background when raw background is absent', () => {
132
+ act(() => {
133
+ applyDocumentColorModeSurface(
134
+ 'default',
135
+ { colors: { background: '#00ff00' } },
136
+ resolveChronogroveSurfaceColors({})
137
+ )
138
+ })
139
+ expect(document.documentElement.style.backgroundColor).toBe('rgb(0, 255, 0)')
140
+ })
141
+
142
+ it('uses surface fallbacks when theme omits background colors', () => {
143
+ const surface = resolveChronogroveSurfaceColors(null)
144
+ act(() => {
145
+ applyDocumentColorModeSurface('default', null, surface)
146
+ })
147
+ expect(document.documentElement.style.backgroundColor).toBe('rgb(253, 248, 245)')
148
+
149
+ act(() => {
150
+ applyDocumentColorModeSurface('dark', null, surface)
151
+ })
152
+ expect(document.documentElement.style.backgroundColor).toBe('rgb(20, 20, 31)')
153
+ })
154
+ })
@@ -0,0 +1,10 @@
1
+ /* Styles for `@chronogrove/ui/color-toggle` (`@theme-toggles/react` Expand). Import once in global CSS, e.g. `@import '@chronogrove/ui/color-toggle-styles';` */
2
+ @import '@theme-toggles/react/css/Expand.css';
3
+
4
+ .theme-toggle,
5
+ .theme-toggle svg {
6
+ color: inherit;
7
+ fill: currentColor;
8
+ width: 16px;
9
+ height: 16px;
10
+ }
@@ -1,16 +1,25 @@
1
- import React from 'react'
1
+ import React, { useEffect } from 'react'
2
2
  import { useColorMode } from 'theme-ui'
3
3
  import { Expand } from '@theme-toggles/react'
4
- import isDarkMode from '@chronogrove/ui/is-dark-mode'
4
+ import { scheduleThemeUiColorModeSync } from './color-mode/browser-sync.js'
5
+ import isDarkMode from './helpers/isDarkMode.js'
5
6
 
6
7
  export default function ColorToggle() {
7
8
  const [colorMode, setColorMode] = useColorMode()
8
9
 
10
+ // Theme UI updates Emotion + localStorage on toggle but does not sync `document.documentElement`
11
+ // (`theme-ui-*` classes, `data-theme-ui-color-mode`). Chronogrove's head fallback CSS targets
12
+ // `:root[data-theme-ui-color-mode="..."]` with `!important` (see head-inline.js), so a stale
13
+ // attribute makes toggles appear to do nothing. Run after ancestor effects so localStorage matches.
14
+ useEffect(() => {
15
+ scheduleThemeUiColorModeSync()
16
+ }, [colorMode])
17
+
9
18
  return (
10
19
  <Expand
11
20
  className='theme-toggle'
12
21
  toggled={isDarkMode(colorMode)}
13
- toggle={() => setColorMode(colorMode === 'default' ? 'dark' : 'default')}
22
+ toggle={preferDark => setColorMode(preferDark ? 'dark' : 'default')}
14
23
  duration={750}
15
24
  aria-label='Toggle color mode'
16
25
  id='theme-toggle'
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ describe('createChronogroveEmotionCache (node / no DOM)', () => {
6
+ it('creates a cache without a document insertion point', () => {
7
+ jest.isolateModules(() => {
8
+ const { createChronogroveEmotionCache } = require('./emotion-cache.js')
9
+ const cache = createChronogroveEmotionCache()
10
+ expect(cache.key).toBe('css')
11
+ })
12
+ })
13
+ })
@@ -27,4 +27,16 @@ describe('getChronogroveEmotionCache', () => {
27
27
  })
28
28
  document.head.removeChild(meta)
29
29
  })
30
+
31
+ it('passes a real insertion point meta when present', () => {
32
+ const meta = document.createElement('meta')
33
+ meta.setAttribute('name', 'emotion-insertion-point')
34
+ document.head.appendChild(meta)
35
+ jest.isolateModules(() => {
36
+ const { createChronogroveEmotionCache } = require('./emotion-cache')
37
+ const cache = createChronogroveEmotionCache()
38
+ expect(cache).toBeTruthy()
39
+ })
40
+ document.head.removeChild(meta)
41
+ })
30
42
  })
@@ -22,10 +22,16 @@ export function buildThemeUiColorModeHeadComponents({ theme }) {
22
22
  darkBackgroundHex: surface.darkBackgroundHex
23
23
  })
24
24
  const colorModeFallbackCSS = buildThemeUiColorModeFallbackCss({
25
+ defaultBackgroundHex: surface.defaultBackgroundHex,
26
+ darkBackgroundHex: surface.darkBackgroundHex,
25
27
  defaultTextHex: surface.defaultTextHex,
26
28
  defaultTextMutedHex: surface.defaultTextMutedHex,
27
29
  darkTextHex: surface.darkTextHex,
28
- darkTextMutedHex: surface.darkTextMutedHex
30
+ darkTextMutedHex: surface.darkTextMutedHex,
31
+ defaultPanelBackground: surface.defaultPanelBackground,
32
+ darkPanelBackground: surface.darkPanelBackground,
33
+ defaultPanelText: surface.defaultPanelText,
34
+ darkPanelText: surface.darkPanelText
29
35
  })
30
36
 
31
37
  return [
@@ -40,6 +40,8 @@ describe('@chronogrove/ui/gatsby', () => {
40
40
  const fallbackStyle = fallbackStyleContainer.querySelector('style')
41
41
  expect(fallbackStyle).toHaveTextContent(/:root\[data-theme-ui-color-mode="default"\]/)
42
42
  expect(fallbackStyle).toHaveTextContent(/--theme-ui-colors-text: #111 !important/)
43
+ expect(fallbackStyle).toHaveTextContent(/--theme-ui-colors-panel-background:/)
44
+ expect(fallbackStyle).toHaveTextContent(/--theme-ui-colors-panel-text:/)
43
45
  })
44
46
  })
45
47
 
@@ -100,6 +102,46 @@ describe('@chronogrove/ui/gatsby', () => {
100
102
  onPreRenderHTMLSortThemeUiColorModeFirst({ getHeadComponents, replaceHeadComponents })
101
103
  expect(replaceHeadComponents.mock.calls[0][0].map(c => c.key)).toEqual(['html-bg-color', 'theme-ui-no-flash'])
102
104
  })
105
+
106
+ it('treats missing component keys as empty string', () => {
107
+ const getHeadComponents = jest.fn(() => [{ type: 'meta' }, { key: 'theme-ui-no-flash', type: 'script' }])
108
+ const replaceHeadComponents = jest.fn()
109
+ onPreRenderHTMLSortThemeUiColorModeFirst({ getHeadComponents, replaceHeadComponents })
110
+ const sorted = replaceHeadComponents.mock.calls[0][0]
111
+ expect(sorted[0].key).toBe('theme-ui-no-flash')
112
+ })
113
+
114
+ it('handles null or undefined head entries when sorting', () => {
115
+ const getHeadComponents = jest.fn(() => [
116
+ null,
117
+ undefined,
118
+ { key: 'theme-ui-no-flash', type: 'script' },
119
+ { key: 'other', type: 'meta' }
120
+ ])
121
+ const replaceHeadComponents = jest.fn()
122
+ onPreRenderHTMLSortThemeUiColorModeFirst({ getHeadComponents, replaceHeadComponents })
123
+ const sorted = replaceHeadComponents.mock.calls[0][0]
124
+ expect(sorted[0].key).toBe('theme-ui-no-flash')
125
+ })
126
+
127
+ it('treats explicit null keys like missing keys', () => {
128
+ const getHeadComponents = jest.fn(() => [
129
+ { key: null, type: 'meta' },
130
+ { key: 'theme-ui-no-flash', type: 'script' }
131
+ ])
132
+ const replaceHeadComponents = jest.fn()
133
+ onPreRenderHTMLSortThemeUiColorModeFirst({ getHeadComponents, replaceHeadComponents })
134
+ const sorted = replaceHeadComponents.mock.calls[0][0]
135
+ expect(sorted[0].key).toBe('theme-ui-no-flash')
136
+ })
137
+
138
+ it('sorts when the right-hand entry is null', () => {
139
+ const getHeadComponents = jest.fn(() => [{ key: 'theme-ui-no-flash', type: 'script' }, null])
140
+ const replaceHeadComponents = jest.fn()
141
+ onPreRenderHTMLSortThemeUiColorModeFirst({ getHeadComponents, replaceHeadComponents })
142
+ const sorted = replaceHeadComponents.mock.calls[0][0]
143
+ expect(sorted[0].key).toBe('theme-ui-no-flash')
144
+ })
103
145
  })
104
146
 
105
147
  describe('onRouteUpdateThemeUiColorMode', () => {
@@ -1,14 +1 @@
1
- import { RECONCILE_COLOR_MODE_EVENT } from '../color-mode/constants.js'
2
- import { scheduleThemeUiColorModeSync } from '../color-mode/browser-sync.js'
3
-
4
- /**
5
- * Call from Gatsby `onRouteUpdate` so Theme UI color mode stays aligned with `localStorage` and the
6
- * document after client-side navigations. Dispatches {@link RECONCILE_COLOR_MODE_EVENT} for app code
7
- * that listens (e.g. React context reconciliation).
8
- */
9
- export function onRouteUpdateThemeUiColorMode() {
10
- scheduleThemeUiColorModeSync()
11
- if (typeof window !== 'undefined' && typeof window.CustomEvent === 'function') {
12
- window.dispatchEvent(new window.CustomEvent(RECONCILE_COLOR_MODE_EVENT))
13
- }
14
- }
1
+ export { reconcileThemeUiColorModeOnNavigation as onRouteUpdateThemeUiColorMode } from '../color-mode/spa-navigation.js'
package/src/header.js CHANGED
@@ -1,5 +1,4 @@
1
- /** @jsx jsx */
2
- import { jsx } from 'theme-ui'
1
+ import { Box } from '@theme-ui/components'
3
2
 
4
3
  /**
5
4
  * Header
@@ -8,20 +7,9 @@ import { jsx } from 'theme-ui'
8
7
  */
9
8
  const Header = ({ children, styles }) => {
10
9
  return (
11
- <header
12
- role='banner'
13
- sx={{
14
- variant: 'styles.Header'
15
- }}
16
- >
17
- <div
18
- sx={{
19
- ...(styles ? styles : {})
20
- }}
21
- >
22
- {children}
23
- </div>
24
- </header>
10
+ <Box as='header' role='banner' sx={{ variant: 'styles.Header' }}>
11
+ <Box sx={{ ...(styles ? styles : {}) }}>{children}</Box>
12
+ </Box>
25
13
  )
26
14
  }
27
15
 
package/src/lazy-load.js CHANGED
@@ -1,10 +1,9 @@
1
- /** @jsx jsx */
2
- import { jsx } from 'theme-ui'
3
1
  import { useState, useEffect } from 'react'
2
+ import { Box } from '@theme-ui/components'
4
3
  import { useInView } from 'react-intersection-observer'
5
4
 
6
5
  const DefaultPlaceholder = ({ height = '100%', width = '100%' }) => (
7
- <div
6
+ <Box
8
7
  data-testid='default-placeholder'
9
8
  sx={{
10
9
  minHeight: '1px',
@@ -14,28 +13,48 @@ const DefaultPlaceholder = ({ height = '100%', width = '100%' }) => (
14
13
  }}
15
14
  >
16
15
  {' '}
17
- </div>
16
+ </Box>
18
17
  )
19
18
 
20
19
  /**
21
20
  * Lazy Loader
22
21
  *
23
22
  * Hides a component until it's been visible in the viewport.
23
+ *
24
+ * IntersectionObserver does not run on the server; children are never included in SSR HTML.
25
+ * On the client, observation is deferred until after mount so Next.js (and other SSR
26
+ * frameworks) hydrate the placeholder first, then attach the observer — avoiding a
27
+ * first-paint flash of real content when the block is already in view.
28
+ *
29
+ * @param {object} [props.useInViewOptions] -
30
+ * Passed to `useInView` after defaults (`triggerOnce: true`, `threshold: 0`,
31
+ * `initialInView: false`). Use `rootMargin` or `threshold` for stricter visibility.
32
+ * Pass `skip` to disable observation (merged with the internal client-only `skip`).
24
33
  */
25
- const LazyLoad = ({ children, placeholder = <DefaultPlaceholder /> }) => {
26
- const [hasBeenVisible, setHasBeenVisible] = useState(false)
34
+ const LazyLoad = ({ children, placeholder = <DefaultPlaceholder />, useInViewOptions = {} }) => {
35
+ const [mounted, setMounted] = useState(false)
36
+ const [revealed, setRevealed] = useState(false)
37
+ const { skip: skipFromOptions, ...restInViewOptions } = useInViewOptions
38
+
27
39
  const { ref, inView } = useInView({
28
40
  triggerOnce: true,
29
- threshold: 0
41
+ threshold: 0,
42
+ initialInView: false,
43
+ ...restInViewOptions,
44
+ skip: !mounted || !!skipFromOptions
30
45
  })
31
46
 
32
47
  useEffect(() => {
33
- if (inView && !hasBeenVisible) {
34
- setHasBeenVisible(true)
48
+ setMounted(true)
49
+ }, [])
50
+
51
+ useEffect(() => {
52
+ if (mounted && inView) {
53
+ setRevealed(true)
35
54
  }
36
- }, [inView, hasBeenVisible])
55
+ }, [mounted, inView])
37
56
 
38
- return <div ref={ref}>{hasBeenVisible ? children : placeholder}</div>
57
+ return <Box ref={ref}>{revealed ? children : placeholder}</Box>
39
58
  }
40
59
 
41
60
  export default LazyLoad