@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.
- package/README.md +1 -1
- package/package.json +9 -1
- package/src/__snapshots__/image-thumbnails.spec.js.snap +61 -0
- package/src/__snapshots__/thumbnail-strip.spec.js.snap +61 -0
- package/src/color-mode/constants.js +6 -0
- package/src/color-mode/cross-domain-color-mode-client-config.js +33 -0
- package/src/color-mode/cross-domain-color-mode-client-config.spec.js +45 -0
- package/src/color-mode/cross-domain-color-mode-cookie-node.spec.js +28 -0
- package/src/color-mode/cross-domain-color-mode-cookie-set-http.spec.js +42 -0
- package/src/color-mode/cross-domain-color-mode-cookie-set-https.spec.js +50 -0
- package/src/color-mode/cross-domain-color-mode-cookie-set-localhost.spec.js +15 -0
- package/src/color-mode/cross-domain-color-mode-cookie.js +91 -0
- package/src/color-mode/cross-domain-color-mode-cookie.spec.js +120 -0
- package/src/color-mode/head-inline-resolution.spec.js +111 -0
- package/src/color-mode/head-inline.js +104 -17
- package/src/color-mode/head-inline.spec.js +68 -1
- package/src/color-mode/index.js +17 -2
- package/src/color-mode/registrable-domain.js +30 -0
- package/src/color-mode/registrable-domain.spec.js +28 -0
- package/src/color-toggle.js +3 -0
- package/src/gatsby/build-theme-ui-color-mode-head-components.js +5 -4
- package/src/gatsby/index.spec.js +11 -1
- package/src/image-thumbnails.js +89 -0
- package/src/image-thumbnails.spec.js +95 -0
- package/src/next/app-shell.js +12 -2
- package/src/next/root-layout-head.js +6 -3
- package/src/thumbnail-strip.js +72 -0
- 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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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',
|
package/src/color-mode/index.js
CHANGED
|
@@ -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
|
+
})
|
package/src/color-toggle.js
CHANGED
|
@@ -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,
|
package/src/gatsby/index.spec.js
CHANGED
|
@@ -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\(
|
|
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
|