@chronogrove/ui 0.82.1 → 0.83.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 (28) hide show
  1. package/README.md +1 -1
  2. package/package.json +9 -1
  3. package/src/__snapshots__/image-thumbnails.spec.js.snap +61 -0
  4. package/src/__snapshots__/thumbnail-strip.spec.js.snap +61 -0
  5. package/src/color-mode/constants.js +6 -0
  6. package/src/color-mode/cross-domain-color-mode-client-config.js +33 -0
  7. package/src/color-mode/cross-domain-color-mode-client-config.spec.js +45 -0
  8. package/src/color-mode/cross-domain-color-mode-cookie-node.spec.js +28 -0
  9. package/src/color-mode/cross-domain-color-mode-cookie-set-http.spec.js +42 -0
  10. package/src/color-mode/cross-domain-color-mode-cookie-set-https.spec.js +50 -0
  11. package/src/color-mode/cross-domain-color-mode-cookie-set-localhost.spec.js +15 -0
  12. package/src/color-mode/cross-domain-color-mode-cookie.js +91 -0
  13. package/src/color-mode/cross-domain-color-mode-cookie.spec.js +120 -0
  14. package/src/color-mode/head-inline-resolution.spec.js +111 -0
  15. package/src/color-mode/head-inline.js +104 -17
  16. package/src/color-mode/head-inline.spec.js +68 -1
  17. package/src/color-mode/index.js +17 -2
  18. package/src/color-mode/registrable-domain.js +30 -0
  19. package/src/color-mode/registrable-domain.spec.js +28 -0
  20. package/src/color-toggle.js +3 -0
  21. package/src/gatsby/build-theme-ui-color-mode-head-components.js +5 -4
  22. package/src/gatsby/index.spec.js +11 -1
  23. package/src/image-thumbnails.js +89 -0
  24. package/src/image-thumbnails.spec.js +95 -0
  25. package/src/next/app-shell.js +12 -2
  26. package/src/next/root-layout-head.js +6 -3
  27. package/src/thumbnail-strip.js +72 -0
  28. package/src/thumbnail-strip.spec.js +83 -0
@@ -0,0 +1,95 @@
1
+ import { render } from '@testing-library/react'
2
+ import { ThemeUIProvider } from 'theme-ui'
3
+ import React from 'react'
4
+
5
+ import ImageThumbnails from './image-thumbnails.js'
6
+
7
+ const stubTheme = { colors: { background: '#fff' } }
8
+
9
+ const wrap = ui => render(<ThemeUIProvider theme={stubTheme}>{ui}</ThemeUIProvider>)
10
+
11
+ describe('ImageThumbnails', () => {
12
+ const sampleImages = [
13
+ 'https://example.com/image1.jpg',
14
+ 'https://example.com/image2.jpg',
15
+ 'https://example.com/image3.jpg',
16
+ 'https://example.com/image4.jpg',
17
+ 'https://example.com/image5.jpg'
18
+ ]
19
+
20
+ it('renders thumbnails when images are provided', () => {
21
+ const { container } = wrap(<ImageThumbnails images={sampleImages} />)
22
+
23
+ const wrapper = container.firstChild
24
+ expect(wrapper).toBeTruthy()
25
+ expect(wrapper.children).toHaveLength(4)
26
+ })
27
+
28
+ it('renders correct number of thumbnails based on maxImages prop', () => {
29
+ const { container } = wrap(<ImageThumbnails images={sampleImages} maxImages={3} />)
30
+
31
+ const wrapper = container.firstChild
32
+ expect(wrapper).toBeTruthy()
33
+ expect(wrapper.children).toHaveLength(3)
34
+ })
35
+
36
+ it('returns null when images array is empty', () => {
37
+ const { container } = wrap(<ImageThumbnails images={[]} />)
38
+ expect(container.firstChild).toBeNull()
39
+ })
40
+
41
+ it('returns null when images is null', () => {
42
+ const { container } = wrap(<ImageThumbnails images={null} />)
43
+ expect(container.firstChild).toBeNull()
44
+ })
45
+
46
+ it('returns null when images is undefined', () => {
47
+ const { container } = wrap(<ImageThumbnails />)
48
+ expect(container.firstChild).toBeNull()
49
+ })
50
+
51
+ it('renders fewer thumbnails when fewer images are provided than maxImages', () => {
52
+ const twoImages = ['https://example.com/image1.jpg', 'https://example.com/image2.jpg']
53
+ const { container } = wrap(<ImageThumbnails images={twoImages} maxImages={4} />)
54
+
55
+ const wrapper = container.firstChild
56
+ expect(wrapper).toBeTruthy()
57
+ expect(wrapper.children).toHaveLength(2)
58
+ })
59
+
60
+ it('uses optimizeSrc when provided', () => {
61
+ const optimizer = jest.fn(s => `${s}?x=1`)
62
+ const { container } = wrap(<ImageThumbnails images={['https://example.com/a.jpg']} optimizeSrc={optimizer} />)
63
+ expect(optimizer).toHaveBeenCalledWith('https://example.com/a.jpg')
64
+ expect(container.firstChild).toBeTruthy()
65
+ })
66
+
67
+ it('omits inner background when optimizeSrc returns null', () => {
68
+ const { container } = wrap(<ImageThumbnails images={['https://example.com/z.jpg']} optimizeSrc={() => null} />)
69
+ expect(container.firstChild?.firstChild?.firstChild).toBeTruthy()
70
+ })
71
+
72
+ it('omits inner background when optimizeSrc returns undefined', () => {
73
+ const { container } = wrap(<ImageThumbnails images={['https://example.com/z.jpg']} optimizeSrc={() => undefined} />)
74
+ expect(container.firstChild?.firstChild?.firstChild).toBeTruthy()
75
+ })
76
+
77
+ it('handles null and non-string slots as empty thumbnails (same slot count)', () => {
78
+ const mixedImages = ['https://example.com/image1.jpg', null, 'https://example.com/image2.jpg', undefined]
79
+ const { container } = wrap(<ImageThumbnails images={mixedImages} maxImages={4} />)
80
+
81
+ const wrapper = container.firstChild
82
+ expect(wrapper).toBeTruthy()
83
+ expect(wrapper.children).toHaveLength(4)
84
+ })
85
+
86
+ it('matches snapshot with default props', () => {
87
+ const { asFragment } = wrap(<ImageThumbnails images={sampleImages} />)
88
+ expect(asFragment()).toMatchSnapshot()
89
+ })
90
+
91
+ it('matches snapshot with custom maxImages', () => {
92
+ const { asFragment } = wrap(<ImageThumbnails images={sampleImages} maxImages={2} />)
93
+ expect(asFragment()).toMatchSnapshot()
94
+ })
95
+ })
@@ -1,9 +1,11 @@
1
1
  'use client'
2
2
 
3
+ import { useEffect } from 'react'
4
+
3
5
  import { Box } from '@theme-ui/components'
4
6
 
5
7
  import { ChronogroveAnimatedPageBackground } from '../animated-page-background/index.js'
6
- import { useDocumentColorModeSurface } from '../color-mode/index.js'
8
+ import { setChronogroveCrossDomainColorModeClientConfig, useDocumentColorModeSurface } from '../color-mode/index.js'
7
9
  import { ChronogroveThemeProvider } from '../provider.js'
8
10
  import chronogroveTheme from '../theme.js'
9
11
 
@@ -19,8 +21,16 @@ function DocumentColorModeSurface() {
19
21
  * Default Next.js App Router shell: Theme UI provider, three.js Color Bends background (same as
20
22
  * Gatsby home), document surface sync, and soft-navigation color-mode reconcile. Wrap with
21
23
  * {@link ChronogroveNextEmotionRegistry} in `layout.jsx` outside this component.
24
+ *
25
+ * Pass the same `crossDomainColorMode` as {@link ChronogroveNextRootLayoutHead} so toggles write the
26
+ * shared cookie (optional).
22
27
  */
23
- export function ChronogroveNextAppShell({ children, theme = chronogroveTheme }) {
28
+ export function ChronogroveNextAppShell({ children, theme = chronogroveTheme, crossDomainColorMode = null }) {
29
+ useEffect(() => {
30
+ setChronogroveCrossDomainColorModeClientConfig(crossDomainColorMode)
31
+ return () => setChronogroveCrossDomainColorModeClientConfig(null)
32
+ }, [crossDomainColorMode?.cookieName, crossDomainColorMode?.registrableDomain])
33
+
24
34
  return (
25
35
  <ChronogroveThemeProvider theme={theme}>
26
36
  <ChronogroveAnimatedPageBackground />
@@ -10,13 +10,16 @@ import {
10
10
  * Server Component fragment for the root `<head>`: Emotion insertion point, Theme UI no-flash
11
11
  * script, HTML background script, and fallback CSS (same composition as
12
12
  * `buildThemeUiColorModeHeadComponents` for Gatsby).
13
+ *
14
+ * @param {{ crossDomainColorMode?: { registrableDomain?: string, cookieName?: string } | null }} [props]
13
15
  */
14
- export function ChronogroveNextRootLayoutHead() {
16
+ export function ChronogroveNextRootLayoutHead({ crossDomainColorMode = null } = {}) {
15
17
  const surface = resolveChronogroveSurfaceColors(chronogroveHeadTheme)
16
- const colorModeScript = buildThemeUiNoFlashInlineScript()
18
+ const colorModeScript = buildThemeUiNoFlashInlineScript({ crossDomainColorMode })
17
19
  const htmlBackgroundScript = buildHtmlBackgroundInlineScript({
18
20
  defaultBackgroundHex: surface.defaultBackgroundHex,
19
- darkBackgroundHex: surface.darkBackgroundHex
21
+ darkBackgroundHex: surface.darkBackgroundHex,
22
+ crossDomainColorMode
20
23
  })
21
24
  const colorModeFallbackCSS = buildThemeUiColorModeFallbackCss({
22
25
  defaultBackgroundHex: surface.defaultBackgroundHex,
@@ -0,0 +1,72 @@
1
+ import React from 'react'
2
+ import { Box } from '@theme-ui/components'
3
+
4
+ /**
5
+ * Compact vertical strip of thumbnail images — staggered/offset for card side rails.
6
+ */
7
+ const ThumbnailStrip = ({ images = [], maxImages = 4, size = 36 }) => {
8
+ if (!images || images.length === 0) {
9
+ return null
10
+ }
11
+
12
+ const displayImages = images.slice(0, maxImages)
13
+ const overlap = size * 0.35 // 35% overlap for compact stacking
14
+ const stripW = size + 8
15
+ const stripH = displayImages.length * (size - overlap) + overlap
16
+
17
+ return (
18
+ <Box
19
+ sx={{
20
+ display: 'flex',
21
+ flexDirection: 'column',
22
+ alignItems: 'center',
23
+ position: 'relative',
24
+ // Theme UI maps bare numbers in `sx` to theme scales — use explicit px for layout math.
25
+ width: `${stripW}px`,
26
+ height: `${stripH}px`,
27
+ flexShrink: 0
28
+ }}
29
+ >
30
+ {displayImages.map((src, index) => (
31
+ <Box
32
+ key={index}
33
+ sx={{
34
+ position: 'absolute',
35
+ top: `${index * (size - overlap)}px`,
36
+ left: index % 2 === 0 ? 0 : '8px',
37
+ width: `${size}px`,
38
+ height: `${size}px`,
39
+ borderRadius: '6px',
40
+ overflow: 'hidden',
41
+ boxShadow: '0 2px 6px rgba(0, 0, 0, 0.12)',
42
+ border: '2px solid',
43
+ borderColor: 'background',
44
+ transition: 'transform 0.2s ease',
45
+ zIndex: displayImages.length - index,
46
+ '&:hover': {
47
+ transform: 'scale(1.08)',
48
+ zIndex: displayImages.length + 1
49
+ }
50
+ }}
51
+ >
52
+ <Box
53
+ sx={{
54
+ width: '100%',
55
+ height: '100%',
56
+ ...(typeof src === 'string' && src
57
+ ? {
58
+ backgroundImage: `url(${src})`,
59
+ backgroundSize: 'cover',
60
+ backgroundPosition: 'center',
61
+ backgroundRepeat: 'no-repeat'
62
+ }
63
+ : {})
64
+ }}
65
+ />
66
+ </Box>
67
+ ))}
68
+ </Box>
69
+ )
70
+ }
71
+
72
+ export default ThumbnailStrip
@@ -0,0 +1,83 @@
1
+ import { render } from '@testing-library/react'
2
+ import { ThemeUIProvider } from 'theme-ui'
3
+ import React from 'react'
4
+
5
+ import ThumbnailStrip from './thumbnail-strip.js'
6
+
7
+ const stubTheme = { colors: { background: '#fff' } }
8
+
9
+ const wrap = ui => render(<ThemeUIProvider theme={stubTheme}>{ui}</ThemeUIProvider>)
10
+
11
+ describe('ThumbnailStrip', () => {
12
+ const sampleImages = [
13
+ 'https://example.com/image1.jpg',
14
+ 'https://example.com/image2.jpg',
15
+ 'https://example.com/image3.jpg',
16
+ 'https://example.com/image4.jpg',
17
+ 'https://example.com/image5.jpg'
18
+ ]
19
+
20
+ it('renders thumbnails when images are provided', () => {
21
+ const { container } = wrap(<ThumbnailStrip images={sampleImages} />)
22
+
23
+ const wrapper = container.firstChild
24
+ expect(wrapper).toBeTruthy()
25
+ expect(wrapper.children).toHaveLength(4)
26
+ })
27
+
28
+ it('renders correct number of thumbnails based on maxImages prop', () => {
29
+ const { container } = wrap(<ThumbnailStrip images={sampleImages} maxImages={3} />)
30
+
31
+ const wrapper = container.firstChild
32
+ expect(wrapper).toBeTruthy()
33
+ expect(wrapper.children).toHaveLength(3)
34
+ })
35
+
36
+ it('returns null when images array is empty', () => {
37
+ const { container } = wrap(<ThumbnailStrip images={[]} />)
38
+ expect(container.firstChild).toBeNull()
39
+ })
40
+
41
+ it('returns null when images is null', () => {
42
+ const { container } = wrap(<ThumbnailStrip images={null} />)
43
+ expect(container.firstChild).toBeNull()
44
+ })
45
+
46
+ it('returns null when images is undefined', () => {
47
+ const { container } = wrap(<ThumbnailStrip />)
48
+ expect(container.firstChild).toBeNull()
49
+ })
50
+
51
+ it('renders fewer thumbnails when fewer images are provided than maxImages', () => {
52
+ const twoImages = ['https://example.com/image1.jpg', 'https://example.com/image2.jpg']
53
+ const { container } = wrap(<ThumbnailStrip images={twoImages} maxImages={4} />)
54
+
55
+ const wrapper = container.firstChild
56
+ expect(wrapper).toBeTruthy()
57
+ expect(wrapper.children).toHaveLength(2)
58
+ })
59
+
60
+ it('accepts custom size prop', () => {
61
+ const { container } = wrap(<ThumbnailStrip images={sampleImages} size={50} />)
62
+
63
+ const wrapper = container.firstChild
64
+ expect(wrapper).toBeTruthy()
65
+ expect(wrapper.children).toHaveLength(4)
66
+ })
67
+
68
+ it('omits background when an entry is not a non-empty string', () => {
69
+ const mixed = ['https://example.com/a.jpg', '', null]
70
+ const { container } = wrap(<ThumbnailStrip images={mixed} maxImages={4} />)
71
+ expect(container.firstChild?.children?.length).toBe(3)
72
+ })
73
+
74
+ it('matches snapshot with default props', () => {
75
+ const { asFragment } = wrap(<ThumbnailStrip images={sampleImages} />)
76
+ expect(asFragment()).toMatchSnapshot()
77
+ })
78
+
79
+ it('matches snapshot with custom maxImages and size', () => {
80
+ const { asFragment } = wrap(<ThumbnailStrip images={sampleImages} maxImages={2} size={48} />)
81
+ expect(asFragment()).toMatchSnapshot()
82
+ })
83
+ })