@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,111 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME, THEME_UI_COLOR_MODE_STORAGE_KEY } from './constants.js'
6
+ import { buildInitialThemeUiColorModeResolutionInlineFragment } from './head-inline.js'
7
+
8
+ function expireCrossDomainColorModeCookie() {
9
+ document.cookie = `${CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME}=; Path=/; Max-Age=0`
10
+ }
11
+
12
+ /**
13
+ * Executes the same inline fragment used in no-flash / HTML background scripts to verify
14
+ * cookie-vs-localStorage precedence without a full browser.
15
+ */
16
+ function runResolutionFragment({ cookie, localStorageMap, prefersDark, crossDomainColorMode = null }) {
17
+ expireCrossDomainColorModeCookie()
18
+ document.cookie = cookie || ''
19
+ window.localStorage.clear()
20
+ if (localStorageMap) {
21
+ for (const [k, v] of Object.entries(localStorageMap)) {
22
+ window.localStorage.setItem(k, v)
23
+ }
24
+ }
25
+ window.matchMedia = jest.fn(() => ({
26
+ matches: Boolean(prefersDark),
27
+ media: '(prefers-color-scheme: dark)',
28
+ addListener: jest.fn(),
29
+ removeListener: jest.fn(),
30
+ addEventListener: jest.fn(),
31
+ removeEventListener: jest.fn(),
32
+ dispatchEvent: jest.fn(),
33
+ onchange: null
34
+ }))
35
+
36
+ const fragment = buildInitialThemeUiColorModeResolutionInlineFragment(
37
+ THEME_UI_COLOR_MODE_STORAGE_KEY,
38
+ crossDomainColorMode
39
+ )
40
+
41
+ const getMode = new Function(`
42
+ ${fragment}
43
+ return mode;
44
+ `)
45
+ return getMode()
46
+ }
47
+
48
+ describe('buildInitialThemeUiColorModeResolutionInlineFragment', () => {
49
+ beforeEach(() => {
50
+ expireCrossDomainColorModeCookie()
51
+ window.localStorage.clear()
52
+ })
53
+
54
+ it('prefers cookie over localStorage when both differ', () => {
55
+ const mode = runResolutionFragment({
56
+ cookie: 'chronogrove-theme-ui-color-mode=default',
57
+ localStorageMap: { [THEME_UI_COLOR_MODE_STORAGE_KEY]: 'dark' },
58
+ prefersDark: true,
59
+ crossDomainColorMode: { registrableDomain: 'example.com' }
60
+ })
61
+ expect(mode).toBe('default')
62
+ expect(window.localStorage.getItem(THEME_UI_COLOR_MODE_STORAGE_KEY)).toBe('default')
63
+ })
64
+
65
+ it('ignores cookie when registrableDomain is invalid (falls back to local-only logic)', () => {
66
+ const mode = runResolutionFragment({
67
+ cookie: 'chronogrove-theme-ui-color-mode=default',
68
+ localStorageMap: { [THEME_UI_COLOR_MODE_STORAGE_KEY]: 'dark' },
69
+ prefersDark: true,
70
+ crossDomainColorMode: { registrableDomain: 'bad..tld' }
71
+ })
72
+ expect(mode).toBe('dark')
73
+ })
74
+
75
+ it('uses localStorage when cookie is absent', () => {
76
+ const mode = runResolutionFragment({
77
+ cookie: '',
78
+ localStorageMap: { [THEME_UI_COLOR_MODE_STORAGE_KEY]: 'dark' },
79
+ prefersDark: false
80
+ })
81
+ expect(mode).toBe('dark')
82
+ })
83
+
84
+ it('falls back to prefers-color-scheme when cookie and storage are empty', () => {
85
+ const dark = runResolutionFragment({ cookie: '', localStorageMap: null, prefersDark: true })
86
+ expect(dark).toBe('dark')
87
+ expect(window.localStorage.getItem(THEME_UI_COLOR_MODE_STORAGE_KEY)).toBe('dark')
88
+
89
+ window.localStorage.clear()
90
+ const light = runResolutionFragment({ cookie: '', localStorageMap: null, prefersDark: false })
91
+ expect(light).toBe('default')
92
+ })
93
+
94
+ it('normalizes light in localStorage to default', () => {
95
+ const mode = runResolutionFragment({
96
+ cookie: '',
97
+ localStorageMap: { [THEME_UI_COLOR_MODE_STORAGE_KEY]: 'light' },
98
+ prefersDark: false
99
+ })
100
+ expect(mode).toBe('default')
101
+ })
102
+
103
+ it('handles localStorage getItem throwing in legacy fragment', () => {
104
+ const spy = jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
105
+ throw new Error('blocked')
106
+ })
107
+ const mode = runResolutionFragment({ cookie: '', localStorageMap: null, prefersDark: false })
108
+ expect(mode).toBe('default')
109
+ spy.mockRestore()
110
+ })
111
+ })
@@ -3,26 +3,104 @@ import {
3
3
  chronogroveThemeSurfaceColorsLight
4
4
  } from '../chronogrove-theme-surface-colors.js'
5
5
 
6
- import { THEME_UI_COLOR_MODE_STORAGE_KEY } from './constants.js'
6
+ import { CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME, THEME_UI_COLOR_MODE_STORAGE_KEY } from './constants.js'
7
+ import { validateRegistrableDomain } from './registrable-domain.js'
7
8
 
8
9
  function q(str) {
9
10
  return JSON.stringify(str)
10
11
  }
11
12
 
12
- export function buildThemeUiNoFlashInlineScript(storageKey = THEME_UI_COLOR_MODE_STORAGE_KEY) {
13
- const key = q(storageKey)
14
- return `
15
- (function() {
16
- try {
17
- var mode = localStorage.getItem(${key});
13
+ /**
14
+ * Inline JS that sets `mode` and syncs `localStorage`. When `crossDomainColorMode.registrableDomain`
15
+ * is set (validated), reads the shared cookie first so subdomains stay aligned.
16
+ *
17
+ * @param {string} [storageKey]
18
+ * @param {{ registrableDomain?: string, cookieName?: string } | null} [crossDomainColorMode]
19
+ */
20
+ export function buildInitialThemeUiColorModeResolutionInlineFragment(
21
+ storageKey = THEME_UI_COLOR_MODE_STORAGE_KEY,
22
+ crossDomainColorMode = null
23
+ ) {
24
+ const cookieName = crossDomainColorMode?.cookieName ?? CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME
25
+ const domainOk =
26
+ crossDomainColorMode &&
27
+ typeof crossDomainColorMode.registrableDomain === 'string' &&
28
+ validateRegistrableDomain(crossDomainColorMode.registrableDomain)
29
+
30
+ if (!domainOk) {
31
+ const keyOnly = q(storageKey)
32
+ return `
33
+ var __cgKey = ${keyOnly};
34
+ var mode;
35
+ try {
36
+ mode = localStorage.getItem(__cgKey);
37
+ } catch (e) {
38
+ mode = null;
39
+ }
18
40
  if (!mode) {
19
41
  var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
20
42
  mode = prefersDark ? 'dark' : 'default';
21
- localStorage.setItem(${key}, mode);
22
43
  }
23
44
  if (mode === 'light') {
24
45
  mode = 'default';
25
46
  }
47
+ try { localStorage.setItem(__cgKey, mode); } catch (e2) {}
48
+ `.trim()
49
+ }
50
+
51
+ const key = q(storageKey)
52
+ const cname = q(cookieName)
53
+ return `
54
+ function __cgGetCookie(name) {
55
+ try {
56
+ var parts = ('; ' + document.cookie).split('; ' + name + '=');
57
+ if (parts.length < 2) return null;
58
+ var raw = parts.pop().split(';').shift() || '';
59
+ try { return decodeURIComponent(raw.trim()); } catch (e1) { return raw.trim(); }
60
+ } catch (e) { return null; }
61
+ }
62
+ function __cgNormMode(m) {
63
+ if (m === 'light') return 'default';
64
+ if (m === 'dark' || m === 'default') return m;
65
+ return null;
66
+ }
67
+ var __cgKey = ${key};
68
+ var __cgCookieMode = __cgNormMode(__cgGetCookie(${cname}));
69
+ var mode;
70
+ if (__cgCookieMode) {
71
+ mode = __cgCookieMode;
72
+ try { localStorage.setItem(__cgKey, mode); } catch (e) {}
73
+ } else {
74
+ try {
75
+ mode = localStorage.getItem(__cgKey);
76
+ } catch (e) {
77
+ mode = null;
78
+ }
79
+ if (!mode) {
80
+ var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
81
+ mode = prefersDark ? 'dark' : 'default';
82
+ }
83
+ if (mode === 'light') {
84
+ mode = 'default';
85
+ }
86
+ try { localStorage.setItem(__cgKey, mode); } catch (e2) {}
87
+ }
88
+ if (mode === 'light') {
89
+ mode = 'default';
90
+ }
91
+ `.trim()
92
+ }
93
+
94
+ /**
95
+ * @param {string | { storageKey?: string, crossDomainColorMode?: { registrableDomain?: string, cookieName?: string } | null }} [options]
96
+ */
97
+ export function buildThemeUiNoFlashInlineScript(options) {
98
+ const { storageKey, crossDomainColorMode } = normalizeNoFlashInlineScriptOptions(options)
99
+ const fragment = buildInitialThemeUiColorModeResolutionInlineFragment(storageKey, crossDomainColorMode)
100
+ return `
101
+ (function() {
102
+ try {
103
+ ${fragment}
26
104
  var htmlElement = document.documentElement;
27
105
  var classesToRemove = [];
28
106
  for (var i = 0; i < htmlElement.classList.length; i++) {
@@ -41,23 +119,32 @@ export function buildThemeUiNoFlashInlineScript(storageKey = THEME_UI_COLOR_MODE
41
119
  `
42
120
  }
43
121
 
122
+ function normalizeNoFlashInlineScriptOptions(options) {
123
+ if (options == null || typeof options === 'string') {
124
+ return {
125
+ storageKey: typeof options === 'string' ? options : THEME_UI_COLOR_MODE_STORAGE_KEY,
126
+ crossDomainColorMode: null
127
+ }
128
+ }
129
+ return {
130
+ storageKey: options.storageKey ?? THEME_UI_COLOR_MODE_STORAGE_KEY,
131
+ crossDomainColorMode: options.crossDomainColorMode ?? null
132
+ }
133
+ }
134
+
44
135
  export function buildHtmlBackgroundInlineScript({
45
136
  storageKey = THEME_UI_COLOR_MODE_STORAGE_KEY,
46
137
  defaultBackgroundHex,
47
- darkBackgroundHex
48
- }) {
49
- const key = q(storageKey)
138
+ darkBackgroundHex,
139
+ crossDomainColorMode = null
140
+ } = {}) {
141
+ const fragment = buildInitialThemeUiColorModeResolutionInlineFragment(storageKey, crossDomainColorMode)
50
142
  const lightBg = q(defaultBackgroundHex)
51
143
  const darkBg = q(darkBackgroundHex)
52
144
  return `
53
145
  (function() {
54
146
  try {
55
- var mode = localStorage.getItem(${key});
56
- if (!mode) {
57
- var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
58
- mode = prefersDark ? 'dark' : 'default';
59
- localStorage.setItem(${key}, mode);
60
- }
147
+ ${fragment}
61
148
  var bgColor = mode === 'dark' ? ${darkBg} : ${lightBg};
62
149
  document.documentElement.style.backgroundColor = bgColor;
63
150
  } catch (e) {}
@@ -1,16 +1,68 @@
1
1
  import {
2
2
  buildHtmlBackgroundInlineScript,
3
+ buildInitialThemeUiColorModeResolutionInlineFragment,
3
4
  buildThemeUiColorModeFallbackCss,
4
5
  buildThemeUiNoFlashInlineScript
5
6
  } from './head-inline.js'
6
7
 
7
8
  describe('head-inline scripts', () => {
8
- it('buildThemeUiNoFlashInlineScript references storage and data attribute', () => {
9
+ it('buildInitialThemeUiColorModeResolutionInlineFragment() uses default args (local-only)', () => {
10
+ const fragment = buildInitialThemeUiColorModeResolutionInlineFragment()
11
+ expect(fragment).not.toContain('__cgGetCookie')
12
+ })
13
+
14
+ it('buildThemeUiNoFlashInlineScript uses localStorage-only resolution when cross-domain is off', () => {
9
15
  const s = buildThemeUiNoFlashInlineScript()
10
16
  expect(s).toContain('theme-ui-color-mode')
17
+ expect(s).toContain('localStorage.getItem')
18
+ expect(s).not.toContain('__cgGetCookie')
11
19
  expect(s).toContain('data-theme-ui-color-mode')
12
20
  })
13
21
 
22
+ it('embeds cookie merge when crossDomainColorMode.registrableDomain is set', () => {
23
+ const s = buildThemeUiNoFlashInlineScript({
24
+ crossDomainColorMode: { registrableDomain: 'example.com' }
25
+ })
26
+ expect(s).toContain('__cgGetCookie')
27
+ expect(s).toContain('chronogrove-theme-ui-color-mode')
28
+ })
29
+
30
+ it('respects optional cookieName when registrableDomain is set', () => {
31
+ const s = buildThemeUiNoFlashInlineScript({
32
+ crossDomainColorMode: { registrableDomain: 'example.com', cookieName: 'my-shared-mode' }
33
+ })
34
+ expect(s).toContain('__cgGetCookie')
35
+ expect(s).toContain('my-shared-mode')
36
+ })
37
+
38
+ it('treats crossDomainColorMode without registrableDomain as local-only', () => {
39
+ const s = buildThemeUiNoFlashInlineScript({
40
+ crossDomainColorMode: { cookieName: 'orphan-name' }
41
+ })
42
+ expect(s).not.toContain('__cgGetCookie')
43
+ })
44
+
45
+ it('omits cookie merge when registrableDomain is invalid', () => {
46
+ const s = buildThemeUiNoFlashInlineScript({
47
+ crossDomainColorMode: { registrableDomain: 'a..b' }
48
+ })
49
+ expect(s).not.toContain('__cgGetCookie')
50
+ })
51
+
52
+ it('accepts a custom storage key string (legacy signature)', () => {
53
+ const s = buildThemeUiNoFlashInlineScript('my-mode-key')
54
+ expect(s).toContain('my-mode-key')
55
+ })
56
+
57
+ it('accepts storageKey on the options object with cross-domain config', () => {
58
+ const s = buildThemeUiNoFlashInlineScript({
59
+ storageKey: 'custom-storage',
60
+ crossDomainColorMode: { registrableDomain: 'example.com' }
61
+ })
62
+ expect(s).toContain('custom-storage')
63
+ expect(s).toContain('__cgGetCookie')
64
+ })
65
+
14
66
  it('buildHtmlBackgroundInlineScript embeds background hexes', () => {
15
67
  const s = buildHtmlBackgroundInlineScript({
16
68
  defaultBackgroundHex: '#aaa',
@@ -20,6 +72,21 @@ describe('head-inline scripts', () => {
20
72
  expect(s).toContain('#bbb')
21
73
  })
22
74
 
75
+ it('passes crossDomainColorMode through to the resolution fragment', () => {
76
+ const s = buildHtmlBackgroundInlineScript({
77
+ defaultBackgroundHex: '#aaa',
78
+ darkBackgroundHex: '#bbb',
79
+ crossDomainColorMode: { registrableDomain: 'example.com' }
80
+ })
81
+ expect(s).toContain('__cgGetCookie')
82
+ expect(s).toContain('#aaa')
83
+ })
84
+
85
+ it('applies the default empty options object when buildHtmlBackgroundInlineScript is called with no args', () => {
86
+ const s = buildHtmlBackgroundInlineScript()
87
+ expect(s).toContain('document.documentElement.style.backgroundColor')
88
+ })
89
+
23
90
  it('buildThemeUiColorModeFallbackCss sets CSS vars', () => {
24
91
  const css = buildThemeUiColorModeFallbackCss({
25
92
  defaultBackgroundHex: '#fdf8f5',
@@ -1,15 +1,30 @@
1
1
  export {
2
2
  THEME_UI_COLOR_MODE_STORAGE_KEY,
3
3
  RECONCILE_COLOR_MODE_EVENT,
4
- CHRONOGROVE_COLOR_MODE_HEAD_PRIORITY_KEYS
4
+ CHRONOGROVE_COLOR_MODE_HEAD_PRIORITY_KEYS,
5
+ CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME,
6
+ CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_MAX_AGE_SEC
5
7
  } from './constants.js'
8
+ export {
9
+ getHostnameForChronogroveCrossDomainCookie,
10
+ shouldUseSecureChronogroveCrossDomainCookie,
11
+ parseChronogroveColorModeCookie,
12
+ getChronogroveCrossDomainColorModeFromCookie,
13
+ setChronogroveCrossDomainColorModeCookie
14
+ } from './cross-domain-color-mode-cookie.js'
15
+ export {
16
+ setChronogroveCrossDomainColorModeClientConfig,
17
+ getChronogroveCrossDomainColorModeClientConfig
18
+ } from './cross-domain-color-mode-client-config.js'
19
+ export { validateRegistrableDomain, isHostnameUnderRegistrableDomain } from './registrable-domain.js'
6
20
  export { normalizeThemeUiColorMode } from './normalize.js'
7
21
  export { resolveChronogroveSurfaceColors } from './resolve-theme-colors.js'
8
22
  export { chronogroveHeadTheme } from './chronogrove-head-theme.js'
9
23
  export {
10
24
  buildThemeUiNoFlashInlineScript,
11
25
  buildHtmlBackgroundInlineScript,
12
- buildThemeUiColorModeFallbackCss
26
+ buildThemeUiColorModeFallbackCss,
27
+ buildInitialThemeUiColorModeResolutionInlineFragment
13
28
  } from './head-inline.js'
14
29
  export { resolveThemeUiColorMode, syncThemeUiColorMode, scheduleThemeUiColorModeSync } from './browser-sync.js'
15
30
  export { reconcileThemeUiColorModeOnNavigation } from './spa-navigation.js'
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Public suffix / site registrable domain used for `Domain=` on shared cookies (e.g. `example.com`,
3
+ * not `www.example.com`). Must be safe to embed in a Set-Cookie line.
4
+ */
5
+ export function validateRegistrableDomain(registrableDomain) {
6
+ if (typeof registrableDomain !== 'string') {
7
+ return null
8
+ }
9
+ const t = registrableDomain.trim().toLowerCase()
10
+ if (!t || t.includes('..') || !/^[a-z0-9.-]+$/.test(t)) {
11
+ return null
12
+ }
13
+ return t
14
+ }
15
+
16
+ /**
17
+ * True when `hostname` is the apex or a subdomain of `registrableDomain` (e.g. `www.example.com`
18
+ * under `example.com`). Rejects lookalikes like `notexample.com`.
19
+ */
20
+ export function isHostnameUnderRegistrableDomain(hostname, registrableDomain) {
21
+ const base = validateRegistrableDomain(registrableDomain)
22
+ if (!base) {
23
+ return false
24
+ }
25
+ if (!hostname || typeof hostname !== 'string') {
26
+ return false
27
+ }
28
+ const h = hostname.trim().toLowerCase()
29
+ return h === base || h.endsWith(`.${base}`)
30
+ }
@@ -0,0 +1,28 @@
1
+ import { isHostnameUnderRegistrableDomain, validateRegistrableDomain } from './registrable-domain.js'
2
+
3
+ describe('validateRegistrableDomain', () => {
4
+ it('normalizes and rejects unsafe values', () => {
5
+ expect(validateRegistrableDomain(' Example.COM ')).toBe('example.com')
6
+ expect(validateRegistrableDomain('')).toBe(null)
7
+ expect(validateRegistrableDomain('a..b')).toBe(null)
8
+ expect(validateRegistrableDomain('bad;path')).toBe(null)
9
+ })
10
+ })
11
+
12
+ describe('isHostnameUnderRegistrableDomain', () => {
13
+ it('matches apex and subdomains', () => {
14
+ expect(isHostnameUnderRegistrableDomain('www.example.com', 'example.com')).toBe(true)
15
+ expect(isHostnameUnderRegistrableDomain('example.com', 'example.com')).toBe(true)
16
+ })
17
+
18
+ it('rejects hosts outside the registrable domain', () => {
19
+ expect(isHostnameUnderRegistrableDomain('notexample.com', 'example.com')).toBe(false)
20
+ expect(isHostnameUnderRegistrableDomain('localhost', 'example.com')).toBe(false)
21
+ })
22
+
23
+ it('rejects invalid inputs', () => {
24
+ expect(isHostnameUnderRegistrableDomain('', 'example.com')).toBe(false)
25
+ expect(isHostnameUnderRegistrableDomain('www.example.com', '')).toBe(false)
26
+ expect(isHostnameUnderRegistrableDomain('www.example.com', 'bad..')).toBe(false)
27
+ })
28
+ })
@@ -2,6 +2,8 @@ import React, { useEffect } from 'react'
2
2
  import { useColorMode } from 'theme-ui'
3
3
  import { Expand } from '@theme-toggles/react'
4
4
  import { scheduleThemeUiColorModeSync } from './color-mode/browser-sync.js'
5
+ import { getChronogroveCrossDomainColorModeClientConfig } from './color-mode/cross-domain-color-mode-client-config.js'
6
+ import { setChronogroveCrossDomainColorModeCookie } from './color-mode/cross-domain-color-mode-cookie.js'
5
7
  import isDarkMode from './helpers/isDarkMode.js'
6
8
 
7
9
  export default function ColorToggle() {
@@ -13,6 +15,7 @@ export default function ColorToggle() {
13
15
  // attribute makes toggles appear to do nothing. Run after ancestor effects so localStorage matches.
14
16
  useEffect(() => {
15
17
  scheduleThemeUiColorModeSync()
18
+ setChronogroveCrossDomainColorModeCookie(colorMode, getChronogroveCrossDomainColorModeClientConfig() ?? undefined)
16
19
  }, [colorMode])
17
20
 
18
21
  return (
@@ -11,15 +11,16 @@ import {
11
11
  * React head elements for Theme UI color mode: no-flash script, HTML background script, fallback CSS.
12
12
  * Compose with your own meta tags (e.g. Emotion insertion point) in `onRenderBody`.
13
13
  *
14
- * @param {{ theme: object }} options — Theme UI theme object (same as `ThemeUIProvider`)
14
+ * @param {{ theme: object, crossDomainColorMode?: { registrableDomain?: string, cookieName?: string } | null }} options — Theme UI theme object (same as `ThemeUIProvider`). Optional `crossDomainColorMode` enables subdomain cookie sync (must match the same object passed to `setChronogroveCrossDomainColorModeClientConfig` in the browser).
15
15
  * @returns {import('react').ReactElement[]}
16
16
  */
17
- export function buildThemeUiColorModeHeadComponents({ theme }) {
17
+ export function buildThemeUiColorModeHeadComponents({ theme, crossDomainColorMode = null }) {
18
18
  const surface = resolveChronogroveSurfaceColors(theme)
19
- const colorModeScript = buildThemeUiNoFlashInlineScript()
19
+ const colorModeScript = buildThemeUiNoFlashInlineScript({ crossDomainColorMode })
20
20
  const htmlBackgroundScript = buildHtmlBackgroundInlineScript({
21
21
  defaultBackgroundHex: surface.defaultBackgroundHex,
22
- darkBackgroundHex: surface.darkBackgroundHex
22
+ darkBackgroundHex: surface.darkBackgroundHex,
23
+ crossDomainColorMode
23
24
  })
24
25
  const colorModeFallbackCSS = buildThemeUiColorModeFallbackCss({
25
26
  defaultBackgroundHex: surface.defaultBackgroundHex,
@@ -28,7 +28,8 @@ describe('@chronogrove/ui/gatsby', () => {
28
28
 
29
29
  const { container: colorModeScriptContainer } = render(head[0])
30
30
  const colorModeScriptTag = colorModeScriptContainer.querySelector('script')
31
- expect(colorModeScriptTag).toHaveTextContent(/localStorage\.getItem\(['"]theme-ui-color-mode['"]\)/)
31
+ expect(colorModeScriptTag).toHaveTextContent(/localStorage\.getItem\(__cgKey\)/)
32
+ expect(colorModeScriptTag.textContent).not.toContain('__cgGetCookie')
32
33
  expect(colorModeScriptTag).toHaveTextContent(/data-theme-ui-color-mode/)
33
34
 
34
35
  const { container: htmlBgScriptContainer } = render(head[1])
@@ -43,6 +44,15 @@ describe('@chronogrove/ui/gatsby', () => {
43
44
  expect(fallbackStyle).toHaveTextContent(/--theme-ui-colors-panel-background:/)
44
45
  expect(fallbackStyle).toHaveTextContent(/--theme-ui-colors-panel-text:/)
45
46
  })
47
+
48
+ it('embeds cookie merge when crossDomainColorMode.registrableDomain is set', () => {
49
+ const head = buildThemeUiColorModeHeadComponents({
50
+ theme: chronogroveTheme,
51
+ crossDomainColorMode: { registrableDomain: 'example.com' }
52
+ })
53
+ const { container } = render(head[0])
54
+ expect(container.querySelector('script').textContent).toContain('__cgGetCookie')
55
+ })
46
56
  })
47
57
 
48
58
  describe('onPreRenderHTMLSortThemeUiColorModeFirst', () => {
@@ -0,0 +1,89 @@
1
+ import React from 'react'
2
+ import { Box } from '@theme-ui/components'
3
+
4
+ /** Default cap on thumbnails shown when `images` exceeds this count. */
5
+ export const IMAGE_THUMBNAILS_DEFAULT_MAX = 4
6
+
7
+ /** Thumbnail box size (px); retina-friendly optimizers typically use ~2× for width/height. */
8
+ export const IMAGE_THUMBNAILS_SIZE_PX = 64
9
+
10
+ /**
11
+ * Pass-through optimizer (CDN-agnostic default).
12
+ *
13
+ * @param {string | null | undefined} src
14
+ * @returns {string | null | undefined}
15
+ */
16
+ export const IMAGE_THUMBNAILS_DEFAULT_OPTIMIZE_SRC = src => src
17
+
18
+ /**
19
+ * Horizontal row of small circular image previews — e.g. post cards (`Recap`).
20
+ * Supply `optimizeSrc` for CDN-specific resizing (Gatsby theme uses a Cloudinary helper).
21
+ *
22
+ * @param {object} props
23
+ * @param {Array<string | null | undefined>} [props.images]
24
+ * @param {number} [props.maxImages]
25
+ * @param {(src: string) => string | null | undefined} [props.optimizeSrc]
26
+ */
27
+ const ImageThumbnails = ({
28
+ images = [],
29
+ maxImages = IMAGE_THUMBNAILS_DEFAULT_MAX,
30
+ optimizeSrc = IMAGE_THUMBNAILS_DEFAULT_OPTIMIZE_SRC
31
+ }) => {
32
+ if (!images || images.length === 0) {
33
+ return null
34
+ }
35
+
36
+ const displayImages = images.slice(0, maxImages)
37
+ const size = IMAGE_THUMBNAILS_SIZE_PX
38
+
39
+ return (
40
+ <Box
41
+ sx={{
42
+ display: 'flex',
43
+ gap: 2,
44
+ mb: 2,
45
+ flexWrap: 'wrap'
46
+ }}
47
+ >
48
+ {displayImages.map((src, index) => {
49
+ const input = typeof src === 'string' ? src : ''
50
+ const optimized = optimizeSrc(input)
51
+ const url = optimized !== null && optimized !== undefined ? String(optimized) : ''
52
+
53
+ return (
54
+ <Box
55
+ key={index}
56
+ sx={{
57
+ width: `${size}px`,
58
+ height: `${size}px`,
59
+ borderRadius: '50%',
60
+ overflow: 'hidden',
61
+ flexShrink: 0,
62
+ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
63
+ border: '2px solid',
64
+ borderColor: 'background',
65
+ transform: `translateY(${index % 2 === 0 ? 0 : 5}px)`
66
+ }}
67
+ >
68
+ <Box
69
+ sx={{
70
+ width: '100%',
71
+ height: '100%',
72
+ ...(url
73
+ ? {
74
+ backgroundImage: `url(${url})`,
75
+ backgroundSize: 'cover',
76
+ backgroundPosition: 'center',
77
+ backgroundRepeat: 'no-repeat'
78
+ }
79
+ : {})
80
+ }}
81
+ />
82
+ </Box>
83
+ )
84
+ })}
85
+ </Box>
86
+ )
87
+ }
88
+
89
+ export default ImageThumbnails