@chronogrove/ui 0.82.0 → 0.82.3
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 +2 -2
- package/package.json +3 -3
- package/src/animated-page-background/ColorBends.js +7 -4
- 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/next/app-shell.js +12 -2
- package/src/next/root-layout-head.js +6 -3
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ pnpm add @chronogrove/ui
|
|
|
12
12
|
|
|
13
13
|
Use **`pnpm publish`** for releases so `workspace:` dependencies in dependents are rewritten; see [pnpm workspaces — publishing](https://pnpm.io/workspaces#publishing-workspace-packages).
|
|
14
14
|
|
|
15
|
-
**Shared dependencies with `gatsby-theme-chronogrove`:** both packages depend on Theme UI, Emotion, and related libraries, with versions driven by the root [pnpm catalog](../../pnpm-workspace.yaml). When you bump those catalog entries, update **`packages/ui
|
|
15
|
+
**Shared dependencies with `gatsby-theme-chronogrove`:** both packages depend on Theme UI, Emotion, **`three`** (where WebGL backgrounds or artwork import it), and related libraries, with versions driven by the root [pnpm catalog](../../pnpm-workspace.yaml). When you bump those catalog entries, update **`packages/ui`**, **`theme`**, and any other workspace `package.json` files that reference `catalog:` for the same keys in the **same change** so installs stay aligned and you avoid duplicate or mismatched trees.
|
|
16
16
|
|
|
17
17
|
## Subpath exports
|
|
18
18
|
|
|
@@ -24,7 +24,7 @@ Prefer deep imports so bundles stay lean:
|
|
|
24
24
|
| `@chronogrove/ui/theme` | Default Theme UI theme object + named exports |
|
|
25
25
|
| `@chronogrove/ui/provider` | `ChronogroveThemeProvider` |
|
|
26
26
|
| `@chronogrove/ui/color-mode` | Storage key, reconcile event, SSR inline builders, `chronogroveHeadTheme` (RSC-safe), `resolveChronogroveSurfaceColors`, `useDocumentColorModeSurface`, browser sync, `reconcileThemeUiColorModeOnNavigation` |
|
|
27
|
-
| `@chronogrove/ui/animated-page-background` | **`ChronogroveAnimatedPageBackground`** — same stack as the Gatsby home: fixed `z-index: 0`, light = solid theme background, dark = **three.js** Color Bends + scroll-linked gradient overlay and parallax (`three` is a dependency of this package).
|
|
27
|
+
| `@chronogrove/ui/animated-page-background` | **`ChronogroveAnimatedPageBackground`** — same stack as the Gatsby home: fixed `z-index: 0`, light = solid theme background, dark = **three.js** Color Bends + scroll-linked gradient overlay and parallax (`three` is a dependency of this package). Animation timing uses **`THREE.Timer`** (not deprecated `Clock`, three.js r183+). |
|
|
28
28
|
| `@chronogrove/ui/color-bends` | **`ColorBends`** — lower-level three.js gradient background used inside `ChronogroveAnimatedPageBackground`. Prefer the full **`animated-page-background`** or **`@chronogrove/ui/next`** shell unless you need the raw component. |
|
|
29
29
|
| `@chronogrove/ui/next` | **Next.js App Router helpers:** `ChronogroveNextRootLayoutHead` (RSC `<head>` injections), `ChronogroveNextEmotionRegistry`, `ChronogroveNextAppShell` (theme + three.js background + surface sync + soft-nav reconcile), `ChronogroveNextThemeUiColorModeRouteSync` (standalone). Requires `next` (peer, optional for the rest of the package). |
|
|
30
30
|
| `@chronogrove/ui/action-card-layout` | **`actionCardPinnedLayoutSx`** — layout `sx` for `Card variant="actionCard"` (matches GitHub pinned cards). |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chronogrove/ui",
|
|
3
|
-
"version": "0.82.
|
|
3
|
+
"version": "0.82.3",
|
|
4
4
|
"description": "Chronogrove Theme UI theme, color mode helpers, and shared UI primitives",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -144,7 +144,7 @@
|
|
|
144
144
|
}
|
|
145
145
|
},
|
|
146
146
|
"peerDependencies": {
|
|
147
|
-
"next": "^14.0.0 || ^15.0.0",
|
|
147
|
+
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
148
148
|
"react": "^18.0.0 || ^19.0.0",
|
|
149
149
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
150
150
|
},
|
|
@@ -174,7 +174,7 @@
|
|
|
174
174
|
"babel-jest": "^30.3.0",
|
|
175
175
|
"jest": "^30.3.0",
|
|
176
176
|
"jest-environment-jsdom": "^30.3.0",
|
|
177
|
-
"next": "^
|
|
177
|
+
"next": "^16.2.3",
|
|
178
178
|
"react": "^19.2.5",
|
|
179
179
|
"react-dom": "^19.2.5"
|
|
180
180
|
},
|
|
@@ -178,7 +178,8 @@ export default function ColorBends({
|
|
|
178
178
|
|
|
179
179
|
container.appendChild(renderer.domElement)
|
|
180
180
|
|
|
181
|
-
const
|
|
181
|
+
const timer = new THREE.Timer()
|
|
182
|
+
timer.connect(document)
|
|
182
183
|
|
|
183
184
|
const handleResize = () => {
|
|
184
185
|
const w = container.clientWidth || 1
|
|
@@ -197,9 +198,10 @@ export default function ColorBends({
|
|
|
197
198
|
window.addEventListener('resize', handleResize)
|
|
198
199
|
}
|
|
199
200
|
|
|
200
|
-
const loop =
|
|
201
|
-
|
|
202
|
-
const
|
|
201
|
+
const loop = time => {
|
|
202
|
+
timer.update(time)
|
|
203
|
+
const dt = timer.getDelta()
|
|
204
|
+
const elapsed = timer.getElapsed()
|
|
203
205
|
|
|
204
206
|
material.uniforms.uTime.value = elapsed
|
|
205
207
|
|
|
@@ -222,6 +224,7 @@ export default function ColorBends({
|
|
|
222
224
|
rafRef.current = requestAnimationFrame(loop)
|
|
223
225
|
|
|
224
226
|
return () => {
|
|
227
|
+
timer.dispose()
|
|
225
228
|
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
|
|
226
229
|
if (resizeObserverRef.current) resizeObserverRef.current.disconnect()
|
|
227
230
|
else window.removeEventListener('resize', handleResize)
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
/** Theme UI localStorage key (aligned with theme-ui/color-mode). */
|
|
2
2
|
export const THEME_UI_COLOR_MODE_STORAGE_KEY = 'theme-ui-color-mode'
|
|
3
3
|
|
|
4
|
+
/** Default cookie name when `cookieName` is omitted from cross-domain color mode options. */
|
|
5
|
+
export const CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME = 'chronogrove-theme-ui-color-mode'
|
|
6
|
+
|
|
7
|
+
/** ~400 days; browser cookie eviction policies may apply earlier. */
|
|
8
|
+
export const CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_MAX_AGE_SEC = 60 * 60 * 24 * 400
|
|
9
|
+
|
|
4
10
|
/** Dispatched on route changes so hosts can reconcile React color-mode context with `localStorage`. */
|
|
5
11
|
export const RECONCILE_COLOR_MODE_EVENT = 'theme-ui-reconcile-color-mode'
|
|
6
12
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME } from './constants.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Browser-only config for {@link setChronogroveCrossDomainColorModeCookie} (must match the
|
|
5
|
+
* `crossDomainColorMode` passed into head inline scripts at build time). Set via
|
|
6
|
+
* {@link setChronogroveCrossDomainColorModeClientConfig} from Gatsby `onClientEntry`, Next
|
|
7
|
+
* `ChronogroveNextAppShell`, or your own bootstrap.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
let clientConfig = /** @type {{ registrableDomain?: string, cookieName?: string } | null} */ (null)
|
|
11
|
+
|
|
12
|
+
export function setChronogroveCrossDomainColorModeClientConfig(next) {
|
|
13
|
+
if (next == null) {
|
|
14
|
+
clientConfig = null
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
if (typeof next !== 'object') {
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
const { registrableDomain, cookieName } = next
|
|
21
|
+
if (!registrableDomain || typeof registrableDomain !== 'string' || !registrableDomain.trim()) {
|
|
22
|
+
clientConfig = null
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
clientConfig = {
|
|
26
|
+
registrableDomain: registrableDomain.trim(),
|
|
27
|
+
cookieName: cookieName ?? CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getChronogroveCrossDomainColorModeClientConfig() {
|
|
32
|
+
return clientConfig
|
|
33
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME } from './constants.js'
|
|
6
|
+
import {
|
|
7
|
+
getChronogroveCrossDomainColorModeClientConfig,
|
|
8
|
+
setChronogroveCrossDomainColorModeClientConfig
|
|
9
|
+
} from './cross-domain-color-mode-client-config.js'
|
|
10
|
+
|
|
11
|
+
describe('cross-domain-color-mode-client-config', () => {
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
setChronogroveCrossDomainColorModeClientConfig(null)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('stores registrableDomain and optional cookieName', () => {
|
|
17
|
+
setChronogroveCrossDomainColorModeClientConfig({
|
|
18
|
+
registrableDomain: 'example.com',
|
|
19
|
+
cookieName: 'my-mode'
|
|
20
|
+
})
|
|
21
|
+
expect(getChronogroveCrossDomainColorModeClientConfig()).toEqual({
|
|
22
|
+
registrableDomain: 'example.com',
|
|
23
|
+
cookieName: 'my-mode'
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('uses default cookie name when cookieName is omitted', () => {
|
|
28
|
+
setChronogroveCrossDomainColorModeClientConfig({ registrableDomain: 'example.com' })
|
|
29
|
+
expect(getChronogroveCrossDomainColorModeClientConfig()?.cookieName).toBe(
|
|
30
|
+
CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME
|
|
31
|
+
)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('clears config for null, invalid, or empty registrableDomain', () => {
|
|
35
|
+
setChronogroveCrossDomainColorModeClientConfig({ registrableDomain: 'example.com' })
|
|
36
|
+
setChronogroveCrossDomainColorModeClientConfig(null)
|
|
37
|
+
expect(getChronogroveCrossDomainColorModeClientConfig()).toBe(null)
|
|
38
|
+
|
|
39
|
+
setChronogroveCrossDomainColorModeClientConfig({ registrableDomain: ' ' })
|
|
40
|
+
expect(getChronogroveCrossDomainColorModeClientConfig()).toBe(null)
|
|
41
|
+
|
|
42
|
+
setChronogroveCrossDomainColorModeClientConfig('oops')
|
|
43
|
+
expect(getChronogroveCrossDomainColorModeClientConfig()).toBe(null)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
getChronogroveCrossDomainColorModeFromCookie,
|
|
7
|
+
getHostnameForChronogroveCrossDomainCookie,
|
|
8
|
+
setChronogroveCrossDomainColorModeCookie,
|
|
9
|
+
shouldUseSecureChronogroveCrossDomainCookie
|
|
10
|
+
} from './cross-domain-color-mode-cookie.js'
|
|
11
|
+
|
|
12
|
+
describe('cross-domain color mode cookie (Node / SSR)', () => {
|
|
13
|
+
it('setChronogroveCrossDomainColorModeCookie returns without throwing when document is undefined', () => {
|
|
14
|
+
expect(() => setChronogroveCrossDomainColorModeCookie('dark')).not.toThrow()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('getChronogroveCrossDomainColorModeFromCookie uses empty string when document is undefined', () => {
|
|
18
|
+
expect(getChronogroveCrossDomainColorModeFromCookie()).toBe(null)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('getHostnameForChronogroveCrossDomainCookie() returns empty when global window is absent', () => {
|
|
22
|
+
expect(getHostnameForChronogroveCrossDomainCookie()).toBe('')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('shouldUseSecureChronogroveCrossDomainCookie() is false when global window is absent', () => {
|
|
26
|
+
expect(shouldUseSecureChronogroveCrossDomainCookie()).toBe(false)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
* @jest-environment-options {"url":"http://app.example.com/"}
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME } from './constants.js'
|
|
7
|
+
import { setChronogroveCrossDomainColorModeCookie } from './cross-domain-color-mode-cookie.js'
|
|
8
|
+
|
|
9
|
+
const exampleDomain = { registrableDomain: 'example.com' }
|
|
10
|
+
|
|
11
|
+
describe('setChronogroveCrossDomainColorModeCookie (http, cross-domain enabled)', () => {
|
|
12
|
+
let lastAssignment = ''
|
|
13
|
+
let originalDescriptor
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
lastAssignment = ''
|
|
17
|
+
originalDescriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie')
|
|
18
|
+
Object.defineProperty(document, 'cookie', {
|
|
19
|
+
configurable: true,
|
|
20
|
+
get() {
|
|
21
|
+
if (!lastAssignment) return ''
|
|
22
|
+
return lastAssignment.split(';')[0].trim()
|
|
23
|
+
},
|
|
24
|
+
set(value) {
|
|
25
|
+
lastAssignment = value
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
if (originalDescriptor) {
|
|
32
|
+
Object.defineProperty(Document.prototype, 'cookie', originalDescriptor)
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('omits Secure when not on HTTPS', () => {
|
|
37
|
+
expect(window.location.protocol).toBe('http:')
|
|
38
|
+
setChronogroveCrossDomainColorModeCookie('default', exampleDomain)
|
|
39
|
+
expect(lastAssignment).toContain(`${CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME}=default`)
|
|
40
|
+
expect(lastAssignment).not.toContain('Secure')
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
* @jest-environment-options {"url":"https://www.example.com/path"}
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME } from './constants.js'
|
|
7
|
+
import { setChronogroveCrossDomainColorModeCookie } from './cross-domain-color-mode-cookie.js'
|
|
8
|
+
|
|
9
|
+
const exampleDomain = { registrableDomain: 'example.com' }
|
|
10
|
+
|
|
11
|
+
describe('setChronogroveCrossDomainColorModeCookie (https, cross-domain enabled)', () => {
|
|
12
|
+
let lastAssignment = ''
|
|
13
|
+
let originalDescriptor
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
lastAssignment = ''
|
|
17
|
+
originalDescriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie')
|
|
18
|
+
Object.defineProperty(document, 'cookie', {
|
|
19
|
+
configurable: true,
|
|
20
|
+
get() {
|
|
21
|
+
if (!lastAssignment) return ''
|
|
22
|
+
return lastAssignment.split(';')[0].trim()
|
|
23
|
+
},
|
|
24
|
+
set(value) {
|
|
25
|
+
lastAssignment = value
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
if (originalDescriptor) {
|
|
32
|
+
Object.defineProperty(Document.prototype, 'cookie', originalDescriptor)
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('sets Domain, SameSite, Max-Age, and Secure on HTTPS (assignment string)', () => {
|
|
37
|
+
expect(window.location.hostname).toBe('www.example.com')
|
|
38
|
+
setChronogroveCrossDomainColorModeCookie('dark', exampleDomain)
|
|
39
|
+
expect(lastAssignment).toContain(`${CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME}=dark`)
|
|
40
|
+
expect(lastAssignment).toContain('Domain=.example.com')
|
|
41
|
+
expect(lastAssignment).toContain('SameSite=Lax')
|
|
42
|
+
expect(lastAssignment).toContain('Max-Age=')
|
|
43
|
+
expect(lastAssignment).toContain('Secure')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('ignores invalid mode values', () => {
|
|
47
|
+
setChronogroveCrossDomainColorModeCookie('not-a-mode', exampleDomain)
|
|
48
|
+
expect(lastAssignment).toBe('')
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
* @jest-environment-options {"url":"http://localhost:3000/"}
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { setChronogroveCrossDomainColorModeCookie } from './cross-domain-color-mode-cookie.js'
|
|
7
|
+
|
|
8
|
+
describe('setChronogroveCrossDomainColorModeCookie (localhost)', () => {
|
|
9
|
+
it('does not set when hostname is not under the registrable domain', () => {
|
|
10
|
+
expect(window.location.hostname).toBe('localhost')
|
|
11
|
+
document.cookie = ''
|
|
12
|
+
setChronogroveCrossDomainColorModeCookie('dark', { registrableDomain: 'example.com' })
|
|
13
|
+
expect(document.cookie).toBe('')
|
|
14
|
+
})
|
|
15
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_MAX_AGE_SEC,
|
|
3
|
+
CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME
|
|
4
|
+
} from './constants.js'
|
|
5
|
+
import { normalizeThemeUiColorMode } from './normalize.js'
|
|
6
|
+
import { isHostnameUnderRegistrableDomain, validateRegistrableDomain } from './registrable-domain.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Read `window.location.hostname` when `location` exists (for tests, pass a stub `globalWindow`).
|
|
10
|
+
* Call with **no arguments** to use the real `window`; passing `undefined` explicitly is treated as
|
|
11
|
+
* “no window” (tests only).
|
|
12
|
+
*/
|
|
13
|
+
export function getHostnameForChronogroveCrossDomainCookie(globalWindow) {
|
|
14
|
+
const win = arguments.length === 0 ? (typeof window !== 'undefined' ? window : undefined) : globalWindow
|
|
15
|
+
if (win == null || !win.location) {
|
|
16
|
+
return ''
|
|
17
|
+
}
|
|
18
|
+
return win.location.hostname || ''
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Whether to append `Secure` to the cross-domain cookie (HTTPS only). */
|
|
22
|
+
export function shouldUseSecureChronogroveCrossDomainCookie(globalWindow) {
|
|
23
|
+
const win = arguments.length === 0 ? (typeof window !== 'undefined' ? window : undefined) : globalWindow
|
|
24
|
+
return Boolean(win && win.location && win.location.protocol === 'https:')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read a single cookie value from a `document.cookie`-style string (for tests and SSR-safe parsing).
|
|
29
|
+
*/
|
|
30
|
+
export function parseChronogroveColorModeCookie(
|
|
31
|
+
cookieString,
|
|
32
|
+
cookieName = CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME
|
|
33
|
+
) {
|
|
34
|
+
if (!cookieString) {
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
const parts = `; ${cookieString}`.split(`; ${cookieName}=`)
|
|
38
|
+
if (parts.length < 2) {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
const raw = parts.pop().split(';').shift()
|
|
42
|
+
if (raw == null || raw === '') {
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
let decoded
|
|
46
|
+
try {
|
|
47
|
+
decoded = decodeURIComponent(raw.trim())
|
|
48
|
+
} catch {
|
|
49
|
+
decoded = raw.trim()
|
|
50
|
+
}
|
|
51
|
+
return normalizeThemeUiColorMode(decoded)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getChronogroveCrossDomainColorModeFromCookie(
|
|
55
|
+
cookieString = typeof document !== 'undefined' ? document.cookie : ''
|
|
56
|
+
) {
|
|
57
|
+
return parseChronogroveColorModeCookie(cookieString)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Persist color mode for other subdomains of `options.registrableDomain`. No-op without `document`,
|
|
62
|
+
* without a valid `registrableDomain`, or when the current host is not under that domain.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} mode
|
|
65
|
+
* @param {{ registrableDomain: string, cookieName?: string } | null | undefined} [options]
|
|
66
|
+
*/
|
|
67
|
+
export function setChronogroveCrossDomainColorModeCookie(mode, options) {
|
|
68
|
+
if (typeof document === 'undefined') {
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
const normalized = normalizeThemeUiColorMode(mode)
|
|
72
|
+
if (!normalized) {
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
const registrableDomain = options && validateRegistrableDomain(options.registrableDomain)
|
|
76
|
+
if (!registrableDomain) {
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
const hostname = getHostnameForChronogroveCrossDomainCookie()
|
|
80
|
+
if (!isHostnameUnderRegistrableDomain(hostname, registrableDomain)) {
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const cookieName = options.cookieName ?? CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME
|
|
85
|
+
const value = encodeURIComponent(normalized)
|
|
86
|
+
let cookie = `${cookieName}=${value}; Path=/; Max-Age=${CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_MAX_AGE_SEC}; SameSite=Lax; Domain=.${registrableDomain}`
|
|
87
|
+
if (shouldUseSecureChronogroveCrossDomainCookie()) {
|
|
88
|
+
cookie += '; Secure'
|
|
89
|
+
}
|
|
90
|
+
document.cookie = cookie
|
|
91
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME } from './constants.js'
|
|
6
|
+
import {
|
|
7
|
+
getChronogroveCrossDomainColorModeFromCookie,
|
|
8
|
+
getHostnameForChronogroveCrossDomainCookie,
|
|
9
|
+
parseChronogroveColorModeCookie,
|
|
10
|
+
setChronogroveCrossDomainColorModeCookie,
|
|
11
|
+
shouldUseSecureChronogroveCrossDomainCookie
|
|
12
|
+
} from './cross-domain-color-mode-cookie.js'
|
|
13
|
+
|
|
14
|
+
describe('getHostnameForChronogroveCrossDomainCookie', () => {
|
|
15
|
+
it('returns empty string when window is missing or has no location', () => {
|
|
16
|
+
expect(getHostnameForChronogroveCrossDomainCookie(undefined)).toBe('')
|
|
17
|
+
expect(getHostnameForChronogroveCrossDomainCookie(null)).toBe('')
|
|
18
|
+
expect(getHostnameForChronogroveCrossDomainCookie({})).toBe('')
|
|
19
|
+
expect(getHostnameForChronogroveCrossDomainCookie({ location: null })).toBe('')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('returns hostname or empty when location exists', () => {
|
|
23
|
+
expect(getHostnameForChronogroveCrossDomainCookie({ location: { hostname: 'www.example.com' } })).toBe(
|
|
24
|
+
'www.example.com'
|
|
25
|
+
)
|
|
26
|
+
expect(getHostnameForChronogroveCrossDomainCookie({ location: { hostname: '' } })).toBe('')
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('shouldUseSecureChronogroveCrossDomainCookie', () => {
|
|
31
|
+
it('is true only for https:', () => {
|
|
32
|
+
expect(shouldUseSecureChronogroveCrossDomainCookie(undefined)).toBe(false)
|
|
33
|
+
expect(shouldUseSecureChronogroveCrossDomainCookie({})).toBe(false)
|
|
34
|
+
expect(shouldUseSecureChronogroveCrossDomainCookie({ location: { protocol: 'http:' } })).toBe(false)
|
|
35
|
+
expect(shouldUseSecureChronogroveCrossDomainCookie({ location: { protocol: 'https:' } })).toBe(true)
|
|
36
|
+
expect(shouldUseSecureChronogroveCrossDomainCookie({ location: {} })).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('setChronogroveCrossDomainColorModeCookie', () => {
|
|
41
|
+
it('no-ops without options.registrableDomain', () => {
|
|
42
|
+
document.cookie = ''
|
|
43
|
+
setChronogroveCrossDomainColorModeCookie('dark')
|
|
44
|
+
setChronogroveCrossDomainColorModeCookie('dark', {})
|
|
45
|
+
setChronogroveCrossDomainColorModeCookie('dark', { registrableDomain: ' ' })
|
|
46
|
+
expect(document.cookie).toBe('')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('parseChronogroveColorModeCookie', () => {
|
|
51
|
+
it('returns normalized mode when cookie is present', () => {
|
|
52
|
+
expect(
|
|
53
|
+
parseChronogroveColorModeCookie(
|
|
54
|
+
`${CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME}=dark`,
|
|
55
|
+
CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME
|
|
56
|
+
)
|
|
57
|
+
).toBe('dark')
|
|
58
|
+
expect(
|
|
59
|
+
parseChronogroveColorModeCookie(
|
|
60
|
+
`other=1; ${CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME}=default`,
|
|
61
|
+
CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME
|
|
62
|
+
)
|
|
63
|
+
).toBe('default')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('maps light to default', () => {
|
|
67
|
+
expect(
|
|
68
|
+
parseChronogroveColorModeCookie(
|
|
69
|
+
`${CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME}=light`,
|
|
70
|
+
CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME
|
|
71
|
+
)
|
|
72
|
+
).toBe('default')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('returns null for missing or invalid values', () => {
|
|
76
|
+
expect(parseChronogroveColorModeCookie('', CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME)).toBe(null)
|
|
77
|
+
expect(parseChronogroveColorModeCookie('other=1', CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME)).toBe(null)
|
|
78
|
+
expect(
|
|
79
|
+
parseChronogroveColorModeCookie(
|
|
80
|
+
`${CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME}=`,
|
|
81
|
+
CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME
|
|
82
|
+
)
|
|
83
|
+
).toBe(null)
|
|
84
|
+
expect(
|
|
85
|
+
parseChronogroveColorModeCookie(
|
|
86
|
+
`${CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME}=nope`,
|
|
87
|
+
CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME
|
|
88
|
+
)
|
|
89
|
+
).toBe(null)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('falls back to raw when decodeURIComponent throws', () => {
|
|
93
|
+
expect(
|
|
94
|
+
parseChronogroveColorModeCookie(
|
|
95
|
+
`${CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME}=%`,
|
|
96
|
+
CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME
|
|
97
|
+
)
|
|
98
|
+
).toBe(null)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('decodes URI-encoded values', () => {
|
|
102
|
+
expect(
|
|
103
|
+
parseChronogroveColorModeCookie(
|
|
104
|
+
`${CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME}=dark`,
|
|
105
|
+
CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME
|
|
106
|
+
)
|
|
107
|
+
).toBe('dark')
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('getChronogroveCrossDomainColorModeFromCookie', () => {
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
document.cookie = `${CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME}=; Path=/; Max-Age=0`
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('reads from document.cookie when called with no arguments', () => {
|
|
117
|
+
document.cookie = `${CHRONOGROVE_CROSS_DOMAIN_COLOR_MODE_COOKIE_NAME}=dark`
|
|
118
|
+
expect(getChronogroveCrossDomainColorModeFromCookie()).toBe('dark')
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -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', () => {
|
package/src/next/app-shell.js
CHANGED
|
@@ -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,
|