@k8ordo/color-scheme 0.1.0 → 1.0.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 +47 -21
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/provider.d.mts +20 -8
- package/dist/provider.mjs +5 -2
- package/dist/scheme.d.mts +24 -11
- package/dist/scheme.mjs +15 -2
- package/docs/GUIDE.md +125 -33
- package/docs/llms.txt +25 -14
- package/package.json +11 -11
package/README.md
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# @k8ordo/color-scheme
|
|
2
2
|
|
|
3
3
|
The colour-scheme axis of an application, owned. The visitor's preference —
|
|
4
|
-
light, dark, or nothing, which follows the
|
|
5
|
-
through `@k8ordo/state`; one rule resolves
|
|
6
|
-
the result is the `dark` class on `<html>`,
|
|
7
|
-
before the first paint and kept there after
|
|
8
|
-
of that; a hook reads it. `@k8ordo/ui`'s
|
|
9
|
-
|
|
4
|
+
light, dark, or nothing, which follows the default (the system unless told
|
|
5
|
+
otherwise) — lives in localStorage through `@k8ordo/state`; one rule resolves
|
|
6
|
+
it against `prefers-color-scheme`; the result is the `dark` class on `<html>`,
|
|
7
|
+
put there by an inline script before the first paint and kept there after
|
|
8
|
+
hydration. One provider does all of that; a hook reads it. `@k8ordo/ui`'s
|
|
9
|
+
tokens read the class; Tailwind's `dark:` does too once the stylesheet declares
|
|
10
|
+
`@custom-variant dark (&:where(.dark, .dark *));` — `@k8ordo/ui/tailwind.css`
|
|
11
|
+
already does.
|
|
10
12
|
|
|
11
13
|
Like every [k8ordo](https://ordo.k8o.me) package it assumes React 19 and Server
|
|
12
14
|
Components, uses only what has reached Baseline newly available, and ships no
|
|
@@ -25,12 +27,17 @@ pnpm add @k8ordo/color-scheme @k8ordo/state zod
|
|
|
25
27
|
|
|
26
28
|
## Peer Dependencies
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
|
31
|
-
|
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
30
|
+
<!-- peers -->
|
|
31
|
+
|
|
32
|
+
| Package | Version | Required | Needed for |
|
|
33
|
+
| --------------- | ------- | -------- | ------------------------------------------ |
|
|
34
|
+
| `@k8ordo/state` | ^1.0.0 | yes | where the preference lives (localStorage) |
|
|
35
|
+
| `react` | ≥19.3.0 | yes | the provider and the hook |
|
|
36
|
+
| `zod` | ^4.4.3 | yes | the one-field schema `@k8ordo/state` reads |
|
|
37
|
+
| `typescript` | ≥7.0.0 | optional | the shipped type declarations |
|
|
38
|
+
| `@types/react` | ≥19.3.0 | optional | the shipped type declarations |
|
|
39
|
+
|
|
40
|
+
<!-- /peers -->
|
|
34
41
|
|
|
35
42
|
## Quick Start
|
|
36
43
|
|
|
@@ -38,7 +45,7 @@ The provider goes in the root layout, inside `<body>`, around everything. It
|
|
|
38
45
|
renders the inline script itself, so nothing goes in `<head>`:
|
|
39
46
|
|
|
40
47
|
```tsx
|
|
41
|
-
// routes/layout.tsx — a Server Component
|
|
48
|
+
// src/routes/layout.tsx — a Server Component
|
|
42
49
|
import { ColorSchemeProvider } from '@k8ordo/color-scheme';
|
|
43
50
|
|
|
44
51
|
export default function RootLayout({ children }) {
|
|
@@ -73,18 +80,37 @@ export function SchemeSwitcher() {
|
|
|
73
80
|
```
|
|
74
81
|
|
|
75
82
|
`scheme` is what is on screen; `preference` is what the visitor asked for
|
|
76
|
-
(`'system'` when nothing is stored); `setPreference('system')`
|
|
77
|
-
|
|
78
|
-
|
|
83
|
+
(`'system'` when nothing is stored); `setPreference('system')` stores none, so
|
|
84
|
+
the provider's default applies again. That default is the system unless told
|
|
85
|
+
otherwise: `<ColorSchemeProvider defaultPreference="dark">` starts every
|
|
86
|
+
visitor dark until they choose.
|
|
79
87
|
|
|
80
88
|
## AI Agent Documentation
|
|
81
89
|
|
|
82
|
-
|
|
83
|
-
installed
|
|
90
|
+
The docs ship **inside the package**, so an agent always reads the exact
|
|
91
|
+
version you installed — there is no snapshot to copy or re-sync on upgrade.
|
|
92
|
+
|
|
93
|
+
Point your agent at them once by pasting this into your project's `CLAUDE.md` /
|
|
94
|
+
`AGENTS.md`:
|
|
95
|
+
|
|
96
|
+
```markdown
|
|
97
|
+
Use `@k8ordo/color-scheme` for light and dark mode. Before touching the
|
|
98
|
+
colour scheme, read `node_modules/@k8ordo/color-scheme/docs/GUIDE.md`. One
|
|
99
|
+
`<ColorSchemeProvider>` wraps everything inside the root layout's `<body>`,
|
|
100
|
+
with `suppressHydrationWarning` on `<html>`; components read and change the
|
|
101
|
+
scheme through `useColorScheme()`. Never toggle the `dark` class, query
|
|
102
|
+
`prefers-color-scheme` for the scheme, or write the stored preference
|
|
103
|
+
yourself — the provider owns all three, and `setPreference` is the one way in.
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
What each surface gives an agent:
|
|
84
107
|
|
|
85
|
-
|
|
86
|
-
|
|
108
|
+
| Surface | Where |
|
|
109
|
+
| -------------------------- | ------------------------------------------------- |
|
|
110
|
+
| Design guide (entry point) | `node_modules/@k8ordo/color-scheme/docs/GUIDE.md` |
|
|
111
|
+
| Docs index for LLMs | `docs/llms.txt` · https://ordo.k8o.me/llms.txt |
|
|
112
|
+
| Markdown twin on the web | https://ordo.k8o.me/color-scheme/docs/GUIDE.md |
|
|
87
113
|
|
|
88
114
|
## License
|
|
89
115
|
|
|
90
|
-
MIT
|
|
116
|
+
MIT License - see [LICENSE](https://github.com/k35o/k8ordo/blob/main/LICENSE) for details.
|
package/dist/index.d.mts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { ColorScheme, ColorSchemePreference, colorSchemeState } from "./scheme.mjs";
|
|
1
|
+
import { ColorScheme, ColorSchemePreference, colorSchemeScriptHash, colorSchemeState } from "./scheme.mjs";
|
|
2
2
|
import { ColorSchemeProvider, ColorSchemeProviderProps, UseColorScheme, useColorScheme } from "./provider.mjs";
|
|
3
|
-
export { type ColorScheme, type ColorSchemePreference, ColorSchemeProvider, type ColorSchemeProviderProps, type UseColorScheme, colorSchemeState, useColorScheme };
|
|
3
|
+
export { type ColorScheme, type ColorSchemePreference, ColorSchemeProvider, type ColorSchemeProviderProps, type UseColorScheme, colorSchemeScriptHash, colorSchemeState, useColorScheme };
|
package/dist/index.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { colorSchemeState } from "./scheme.mjs";
|
|
1
|
+
import { colorSchemeScriptHash, colorSchemeState } from "./scheme.mjs";
|
|
2
2
|
import { ColorSchemeProvider, useColorScheme } from "./provider.mjs";
|
|
3
|
-
export { ColorSchemeProvider, colorSchemeState, useColorScheme };
|
|
3
|
+
export { ColorSchemeProvider, colorSchemeScriptHash, colorSchemeState, useColorScheme };
|
package/dist/provider.d.mts
CHANGED
|
@@ -1,21 +1,34 @@
|
|
|
1
1
|
import { ColorScheme, ColorSchemePreference } from "./scheme.mjs";
|
|
2
2
|
import { ReactNode } from "react";
|
|
3
3
|
//#region src/provider.d.ts
|
|
4
|
-
type UseColorScheme = {
|
|
5
|
-
/**
|
|
4
|
+
export type UseColorScheme = {
|
|
5
|
+
/**
|
|
6
|
+
* What is on screen: the preference, the provider's default, or the
|
|
7
|
+
* system's answer.
|
|
8
|
+
*/
|
|
6
9
|
readonly scheme: ColorScheme;
|
|
7
10
|
/** What the visitor asked for; `'system'` when nothing is stored. */
|
|
8
11
|
readonly preference: ColorSchemePreference;
|
|
9
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Stores a preference, or `'system'` to store none, so the provider's
|
|
14
|
+
* `defaultPreference` applies again.
|
|
15
|
+
*/
|
|
10
16
|
readonly setPreference: (preference: ColorSchemePreference) => void;
|
|
11
17
|
};
|
|
12
|
-
type ColorSchemeProviderProps = {
|
|
18
|
+
export type ColorSchemeProviderProps = {
|
|
13
19
|
/**
|
|
14
20
|
* What applies while the visitor has chosen nothing. `'system'` follows
|
|
15
21
|
* `prefers-color-scheme`; a scheme starts every visitor there until they
|
|
16
22
|
* choose.
|
|
17
23
|
*/
|
|
18
24
|
readonly defaultPreference?: ColorSchemePreference;
|
|
25
|
+
/**
|
|
26
|
+
* The nonce the page's `Content-Security-Policy` lets inline scripts run
|
|
27
|
+
* with, put on the inline script — under `@k8ordo/framework`'s server mode,
|
|
28
|
+
* `nonce()` from `@k8ordo/framework/server`. A policy that allows the script by its hash
|
|
29
|
+
* instead names `colorSchemeScriptHash()`.
|
|
30
|
+
*/
|
|
31
|
+
readonly nonce?: string;
|
|
19
32
|
readonly children: ReactNode;
|
|
20
33
|
};
|
|
21
34
|
/**
|
|
@@ -24,8 +37,7 @@ type ColorSchemeProviderProps = {
|
|
|
24
37
|
* that puts the class on `<html>` before the first paint, keeps the class
|
|
25
38
|
* in step afterwards, and hands `useColorScheme` what it reads.
|
|
26
39
|
*/
|
|
27
|
-
declare function ColorSchemeProvider({ defaultPreference, children }: ColorSchemeProviderProps): ReactNode;
|
|
40
|
+
export declare function ColorSchemeProvider({ defaultPreference, nonce, children }: ColorSchemeProviderProps): ReactNode;
|
|
28
41
|
/** The visitor's colour scheme and a way to change it, from the provider above. */
|
|
29
|
-
declare function useColorScheme(): UseColorScheme;
|
|
30
|
-
//#endregion
|
|
31
|
-
export { ColorSchemeProvider, ColorSchemeProviderProps, UseColorScheme, useColorScheme };
|
|
42
|
+
export declare function useColorScheme(): UseColorScheme;
|
|
43
|
+
//#endregion
|
package/dist/provider.mjs
CHANGED
|
@@ -22,7 +22,7 @@ const useReadsStore = () => useSyncExternalStore(subscribeNever, () => true, ()
|
|
|
22
22
|
* that puts the class on `<html>` before the first paint, keeps the class
|
|
23
23
|
* in step afterwards, and hands `useColorScheme` what it reads.
|
|
24
24
|
*/
|
|
25
|
-
function ColorSchemeProvider({ defaultPreference = "system", children }) {
|
|
25
|
+
function ColorSchemeProvider({ defaultPreference = "system", nonce, children }) {
|
|
26
26
|
const [{ preference }, update] = useAppState(colorSchemeState);
|
|
27
27
|
const systemDark = useSyncExternalStore(subscribeSystem, readSystemDark, serverSystemDark);
|
|
28
28
|
const scheme = resolve(preference, defaultPreference, systemDark);
|
|
@@ -43,7 +43,10 @@ function ColorSchemeProvider({ defaultPreference = "system", children }) {
|
|
|
43
43
|
preference,
|
|
44
44
|
setPreference
|
|
45
45
|
]);
|
|
46
|
-
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("script", {
|
|
46
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("script", {
|
|
47
|
+
nonce,
|
|
48
|
+
children: scriptFor(defaultPreference)
|
|
49
|
+
}), /* @__PURE__ */ jsx(ColorSchemeContext, {
|
|
47
50
|
value,
|
|
48
51
|
children
|
|
49
52
|
})] });
|
package/dist/scheme.d.mts
CHANGED
|
@@ -1,36 +1,49 @@
|
|
|
1
1
|
import * as z from "zod/mini";
|
|
2
2
|
//#region src/scheme.d.ts
|
|
3
|
-
type ColorScheme = 'light' | 'dark';
|
|
4
|
-
/**
|
|
5
|
-
|
|
3
|
+
export type ColorScheme = 'light' | 'dark';
|
|
4
|
+
/**
|
|
5
|
+
* A scheme, or `'system'`. As a visitor's preference, `'system'` is nothing
|
|
6
|
+
* chosen, so the provider's default applies; as that default, it follows the
|
|
7
|
+
* system.
|
|
8
|
+
*/
|
|
9
|
+
export type ColorSchemePreference = ColorScheme | 'system';
|
|
6
10
|
/**
|
|
7
11
|
* Where the preference lives: localStorage, under
|
|
8
12
|
* `k8ordo-state:color-scheme`. An absent `preference` is "nothing chosen",
|
|
9
13
|
* so a visitor who never chose is never pinned to what the system said on
|
|
10
14
|
* their first visit.
|
|
11
15
|
*/
|
|
12
|
-
declare const colorSchemeState: import("@k8ordo/state").LocalState<z.ZodMiniObject<{
|
|
16
|
+
export declare const colorSchemeState: import("@k8ordo/state").LocalState<z.ZodMiniObject<{
|
|
13
17
|
preference: z.ZodMiniOptional<z.ZodMiniEnum<{
|
|
14
18
|
dark: "dark";
|
|
15
19
|
light: "light";
|
|
16
20
|
}>>;
|
|
17
21
|
}, z.core.$strip>>;
|
|
18
22
|
/** The media query the system answers through. */
|
|
19
|
-
declare const DARK_QUERY = "(prefers-color-scheme: dark)";
|
|
20
|
-
/**
|
|
21
|
-
|
|
23
|
+
export declare const DARK_QUERY = "(prefers-color-scheme: dark)";
|
|
24
|
+
/**
|
|
25
|
+
* The class `@k8ordo/ui` reads on `<html>` (and Tailwind's `dark:` under a
|
|
26
|
+
* class-based `@custom-variant dark`).
|
|
27
|
+
*/
|
|
28
|
+
export declare const DARK_CLASS = "dark";
|
|
22
29
|
/**
|
|
23
30
|
* The one rule: what the visitor chose wins, then what the provider was
|
|
24
31
|
* told to start from, and `system` — the default's default — asks the
|
|
25
32
|
* system.
|
|
26
33
|
*/
|
|
27
|
-
declare const resolve: (preference: ColorScheme | undefined, defaultPreference: ColorSchemePreference, systemDark: boolean) => ColorScheme;
|
|
34
|
+
export declare const resolve: (preference: ColorScheme | undefined, defaultPreference: ColorSchemePreference, systemDark: boolean) => ColorScheme;
|
|
28
35
|
/**
|
|
29
36
|
* The inline script the provider renders before its children: the rule
|
|
30
37
|
* above, run before the first paint. The schema does not run there, so the
|
|
31
38
|
* stored value is checked by hand and anything else reads as nothing
|
|
32
39
|
* chosen — the same salvage the store applies.
|
|
33
40
|
*/
|
|
34
|
-
declare const scriptFor: (defaultPreference: ColorSchemePreference) => string;
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
export declare const scriptFor: (defaultPreference: ColorSchemePreference) => string;
|
|
42
|
+
/**
|
|
43
|
+
* The inline script's hash as a CSP source, `'sha256-…'`, for a policy that
|
|
44
|
+
* allows it by what it is rather than by a nonce — the static mode's `csp`
|
|
45
|
+
* option, or a header that names no nonce. Give it the default the provider
|
|
46
|
+
* is given: the script carries it.
|
|
47
|
+
*/
|
|
48
|
+
export declare const colorSchemeScriptHash: (defaultPreference?: ColorSchemePreference) => Promise<string>;
|
|
49
|
+
//#endregion
|
package/dist/scheme.mjs
CHANGED
|
@@ -10,7 +10,10 @@ import * as z from "zod/mini";
|
|
|
10
10
|
const colorSchemeState = defineLocalState("color-scheme", z.object({ preference: z.optional(z.enum(["light", "dark"])) }));
|
|
11
11
|
/** The media query the system answers through. */
|
|
12
12
|
const DARK_QUERY = "(prefers-color-scheme: dark)";
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* The class `@k8ordo/ui` reads on `<html>` (and Tailwind's `dark:` under a
|
|
15
|
+
* class-based `@custom-variant dark`).
|
|
16
|
+
*/
|
|
14
17
|
const DARK_CLASS = "dark";
|
|
15
18
|
/**
|
|
16
19
|
* The one rule: what the visitor chose wins, then what the provider was
|
|
@@ -27,5 +30,15 @@ const resolve = (preference, defaultPreference, systemDark) => {
|
|
|
27
30
|
* chosen — the same salvage the store applies.
|
|
28
31
|
*/
|
|
29
32
|
const scriptFor = (defaultPreference) => `(()=>{const s=${colorSchemeState.inlineRead()};const v=s&&s.preference;const p=v==="dark"||v==="light"?v:${JSON.stringify(defaultPreference)};if(p==="dark"||(p!=="light"&&matchMedia(${JSON.stringify(DARK_QUERY)}).matches))document.documentElement.classList.add(${JSON.stringify(DARK_CLASS)})})()`;
|
|
33
|
+
/**
|
|
34
|
+
* The inline script's hash as a CSP source, `'sha256-…'`, for a policy that
|
|
35
|
+
* allows it by what it is rather than by a nonce — the static mode's `csp`
|
|
36
|
+
* option, or a header that names no nonce. Give it the default the provider
|
|
37
|
+
* is given: the script carries it.
|
|
38
|
+
*/
|
|
39
|
+
const colorSchemeScriptHash = async (defaultPreference = "system") => {
|
|
40
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(scriptFor(defaultPreference)));
|
|
41
|
+
return `'sha256-${btoa(String.fromCodePoint(...new Uint8Array(digest)))}'`;
|
|
42
|
+
};
|
|
30
43
|
//#endregion
|
|
31
|
-
export { DARK_CLASS, DARK_QUERY, colorSchemeState, resolve, scriptFor };
|
|
44
|
+
export { DARK_CLASS, DARK_QUERY, colorSchemeScriptHash, colorSchemeState, resolve, scriptFor };
|
package/docs/GUIDE.md
CHANGED
|
@@ -6,10 +6,11 @@ A visitor's colour scheme is three things that have to agree: what they
|
|
|
6
6
|
asked for, what the system says, and what is on screen. This package keeps
|
|
7
7
|
the first in localStorage (through `@k8ordo/state`), reads the second from
|
|
8
8
|
`prefers-color-scheme`, and resolves both into the third — the `dark` class
|
|
9
|
-
on `<html>`, which `@k8ordo/ui`'s tokens and Tailwind's
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
on `<html>`, which `@k8ordo/ui`'s tokens read, and Tailwind's `dark:` too
|
|
10
|
+
once the stylesheet declares `@custom-variant dark (&:where(.dark, .dark *));`
|
|
11
|
+
(`@k8ordo/ui/tailwind.css` already does). One provider does all of it: it
|
|
12
|
+
renders the inline script that puts the class there before the first paint,
|
|
13
|
+
keeps it right after hydration, and hands a hook what it reads.
|
|
13
14
|
|
|
14
15
|
Like every k8ordo package it assumes React 19 and Server Components, uses
|
|
15
16
|
only what has reached Baseline newly available, and ships no polyfills or
|
|
@@ -18,13 +19,26 @@ legacy fallbacks.
|
|
|
18
19
|
## What it does not own
|
|
19
20
|
|
|
20
21
|
- **The colours.** Which colour `dark` means is the stylesheet's business —
|
|
21
|
-
`@k8ordo/ui`'s tokens, or any other that reads the class.
|
|
22
|
+
`@k8ordo/ui`'s tokens, or any other that reads the class. So is the CSS
|
|
23
|
+
`color-scheme` property (form controls, scrollbars): this package never
|
|
24
|
+
sets it; `@k8ordo/ui`'s stylesheet does, next to its tokens (`light`, and
|
|
25
|
+
`dark` under `.dark`), and a stylesheet of your own declares it beside its
|
|
26
|
+
colours. Declare it by the class, not as `light dark`: that lets the
|
|
27
|
+
browser pick by `prefers-color-scheme`, and the scrollbars would disagree
|
|
28
|
+
with what the visitor chose.
|
|
29
|
+
- **Contrast.** `prefers-contrast: more` and `forced-colors: active` are
|
|
30
|
+
settings the visitor makes in the OS, not something an application stores
|
|
31
|
+
or toggles, so neither is a preference here. `@k8ordo/ui`'s stylesheet
|
|
32
|
+
follows both on its own, in either scheme — its high-contrast values are
|
|
33
|
+
declared for `:root` and for `.dark` — so a visitor who chose dark and asks
|
|
34
|
+
the OS for more contrast gets the dark high-contrast values.
|
|
22
35
|
- **Where the preference lives.** That is `@k8ordo/state`'s
|
|
23
|
-
`defineLocalState`; this package declares one and reads
|
|
24
|
-
and the JSON
|
|
36
|
+
`defineLocalState`; this package declares one and reads and writes
|
|
37
|
+
through it, so the localStorage key and the row's JSON are never spelled
|
|
38
|
+
here.
|
|
25
39
|
- **A guess on the server.** No cookie, no header: a server render shows
|
|
26
|
-
the provider's default
|
|
27
|
-
paint right.
|
|
40
|
+
the provider's default (`light`, when that default is `'system'`), and the
|
|
41
|
+
inline script is what makes the first paint right.
|
|
28
42
|
|
|
29
43
|
## The shape of it
|
|
30
44
|
|
|
@@ -42,7 +56,7 @@ and around everything, so the script it renders comes before anything the
|
|
|
42
56
|
page paints:
|
|
43
57
|
|
|
44
58
|
```tsx
|
|
45
|
-
// routes/layout.tsx
|
|
59
|
+
// src/routes/layout.tsx
|
|
46
60
|
import { ColorSchemeProvider } from '@k8ordo/color-scheme';
|
|
47
61
|
import type { ReactNode } from 'react';
|
|
48
62
|
|
|
@@ -65,6 +79,42 @@ never chooses follows the system, in either direction, for as long as the
|
|
|
65
79
|
page is open. To start every visitor somewhere other than the system, say
|
|
66
80
|
so once: `<ColorSchemeProvider defaultPreference="dark">`.
|
|
67
81
|
|
|
82
|
+
### Under a Content-Security-Policy
|
|
83
|
+
|
|
84
|
+
The script is inline, so a policy that restricts scripts has to allow it,
|
|
85
|
+
by nonce or by hash. Blocked, it costs the first paint and nothing else: the
|
|
86
|
+
provider's effect still writes the class after hydration, but until then
|
|
87
|
+
`<html>` has no `dark` class and the page is painted light, so a visitor who
|
|
88
|
+
gets dark — by choice or by a `'dark'` default — sees the flash the script
|
|
89
|
+
exists to prevent. Do not reach for `'unsafe-inline'`, which allows
|
|
90
|
+
every inline script that reaches the page; name this one.
|
|
91
|
+
|
|
92
|
+
`nonce` puts the answer's nonce on it — in `@k8ordo/framework`'s server
|
|
93
|
+
mode, `nonce()` from `@k8ordo/framework/server`, which the render may read
|
|
94
|
+
(signing a script is not writing the response):
|
|
95
|
+
|
|
96
|
+
```tsx
|
|
97
|
+
<ColorSchemeProvider nonce={nonce()}>{children}</ColorSchemeProvider>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`colorSchemeScriptHash(defaultPreference?)` resolves to its hash as a CSP
|
|
101
|
+
source, `'sha256-…'`, for a policy that allows it by what it is — the
|
|
102
|
+
`csp` option of its static mode, whose files cannot carry a nonce, or a
|
|
103
|
+
header that names none. Pass it the `defaultPreference` the provider is
|
|
104
|
+
given: the script carries it, so the hash depends on it.
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
// vite.config.ts
|
|
108
|
+
framework({
|
|
109
|
+
mode: 'static',
|
|
110
|
+
csp: { 'script-src': ["'self'", await colorSchemeScriptHash()] },
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Compute the hash where the policy is written, every time, rather than
|
|
115
|
+
copying its value: the script's text is the installed version's, and an
|
|
116
|
+
update may change it.
|
|
117
|
+
|
|
68
118
|
## Reading and changing it
|
|
69
119
|
|
|
70
120
|
```tsx
|
|
@@ -89,42 +139,84 @@ export function SchemeSwitcher() {
|
|
|
89
139
|
}
|
|
90
140
|
```
|
|
91
141
|
|
|
92
|
-
| Member | What it is
|
|
93
|
-
| --------------- |
|
|
94
|
-
| `scheme` | `'light' \| 'dark'` — what is on screen.
|
|
95
|
-
| `preference` | `'light' \| 'dark' \| 'system'` — what the visitor asked for.
|
|
96
|
-
| `setPreference` | Stores a preference; `'system'` stores none
|
|
142
|
+
| Member | What it is |
|
|
143
|
+
| --------------- | -------------------------------------------------------------------------- |
|
|
144
|
+
| `scheme` | `'light' \| 'dark'` — what is on screen. |
|
|
145
|
+
| `preference` | `'light' \| 'dark' \| 'system'` — what the visitor asked for. |
|
|
146
|
+
| `setPreference` | Stores a preference; `'system'` stores none, so the default applies again. |
|
|
97
147
|
|
|
98
148
|
A toggle is `setPreference(scheme === 'dark' ? 'light' : 'dark')`. The hook
|
|
99
149
|
reads the provider and touches nothing else, so a switcher and a preview
|
|
100
150
|
agree because there is one provider deciding.
|
|
101
151
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
152
|
+
## Where it is stored
|
|
153
|
+
|
|
154
|
+
`colorSchemeState` is the `defineLocalState` definition itself — key
|
|
155
|
+
`color-scheme`, so the localStorage key is `k8ordo-state:color-scheme`
|
|
156
|
+
(`colorSchemeState.storageKey`) — with one optional field:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
defineLocalState(
|
|
160
|
+
'color-scheme',
|
|
161
|
+
z.object({ preference: z.optional(z.enum(['light', 'dark'])) }),
|
|
162
|
+
);
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
| When | The stored row |
|
|
166
|
+
| ------------------------- | --------------------------------- |
|
|
167
|
+
| Never chose | no row (`getItem` returns `null`) |
|
|
168
|
+
| `setPreference('dark')` | `{"preference":"dark"}` |
|
|
169
|
+
| `setPreference('light')` | `{"preference":"light"}` |
|
|
170
|
+
| `setPreference('system')` | `{}` |
|
|
171
|
+
|
|
172
|
+
The default is never stored, so changing `defaultPreference` later moves
|
|
173
|
+
every visitor who never chose.
|
|
174
|
+
|
|
175
|
+
- **Reading it without the hook.** `useAppState(colorSchemeState)` from any
|
|
176
|
+
client component reads the same store the provider does (`@k8ordo/state`
|
|
177
|
+
keeps one per key). It returns the stored `preference`
|
|
178
|
+
(`'light' | 'dark' | undefined`), not the resolved `scheme` — for what is
|
|
179
|
+
on screen, use `useColorScheme()`.
|
|
180
|
+
- **Before the first paint, in a script of your own.**
|
|
181
|
+
`colorSchemeState.inlineRead()` is a JavaScript expression that evaluates
|
|
182
|
+
to the stored object, or `null`. No schema runs there, so check each field.
|
|
183
|
+
- **Other preferences beside it.** A display preference of the
|
|
184
|
+
application's own is a `defineLocalState` under another key, in a module
|
|
185
|
+
without `'use client'`. Never define another `'color-scheme'`: stores are
|
|
186
|
+
shared by key, and the two definitions would fight over one row.
|
|
105
187
|
|
|
106
188
|
## What it guarantees
|
|
107
189
|
|
|
108
190
|
- **No flash.** The script runs before the first paint and reads the same
|
|
109
|
-
row the provider writes; a dark page loads dark.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
is
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
191
|
+
row the provider writes; a dark page loads dark. It is an inline script,
|
|
192
|
+
so a Content-Security-Policy that restricts scripts has to allow it (see
|
|
193
|
+
"Under a Content-Security-Policy").
|
|
194
|
+
- **Nothing chosen follows the default, and a `'system'` default follows
|
|
195
|
+
the system.** A visitor who never chose is never pinned to what the
|
|
196
|
+
system said on their first visit, and a row that is not a JSON object, or
|
|
197
|
+
whose `preference` is not `'light'` or `'dark'`, reads as nothing chosen;
|
|
198
|
+
no other field in the row is read.
|
|
199
|
+
- **The server renders the default** (`light` when that default is
|
|
200
|
+
`'system'`: a server has no system to ask), and the provider corrects
|
|
201
|
+
itself on hydration without touching the document: the render that
|
|
202
|
+
hydrates reads the server's guesses and writes nothing, and the render
|
|
203
|
+
that follows reads the store and writes what the script already put
|
|
204
|
+
there. Until then the HTML shows the default — a switcher's icon, say —
|
|
205
|
+
which is the one thing no server can know; a component that must not
|
|
206
|
+
show it even for a moment says so with React's `use(browser())` under a
|
|
207
|
+
`<Suspense>`.
|
|
121
208
|
- **Tabs agree.** The preference propagates across tabs through the
|
|
122
|
-
`storage` event, as any `@k8ordo/state` local state does.
|
|
209
|
+
`storage` event, as any `@k8ordo/state` local state does. That event
|
|
210
|
+
never reaches the tab that wrote, so a `localStorage.setItem` on the row
|
|
211
|
+
in the same tab goes unnoticed until a reload: change it through
|
|
212
|
+
`setPreference`.
|
|
123
213
|
|
|
124
214
|
## Testing
|
|
125
215
|
|
|
126
216
|
Under a browser, `localStorage.clear()`, remove the class from
|
|
127
217
|
`document.documentElement`, and `resetStateRegistry()` from `@k8ordo/state`
|
|
128
218
|
between tests; render the hook under `<ColorSchemeProvider>` (a `wrapper`).
|
|
129
|
-
A client-only mount
|
|
130
|
-
|
|
219
|
+
A client-only mount does not run the script the provider renders: React
|
|
220
|
+
never executes an inline `<script>` it creates in the browser (in
|
|
221
|
+
development it logs an error saying so), so the class a test sees is the
|
|
222
|
+
one the provider's effect wrote.
|
package/docs/llms.txt
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# @k8ordo/color-scheme
|
|
2
2
|
|
|
3
3
|
> The colour-scheme axis of an application, owned. The visitor's preference
|
|
4
|
-
> — light, dark, or nothing, which follows the
|
|
5
|
-
> localStorage through `@k8ordo/state`; one rule
|
|
6
|
-
> `prefers-color-scheme`; the result is the `dark` class
|
|
7
|
-
> `@k8ordo/ui` and Tailwind's dark
|
|
8
|
-
>
|
|
9
|
-
> the
|
|
10
|
-
> it
|
|
4
|
+
> — light, dark, or nothing, which follows the default (the system unless
|
|
5
|
+
> told otherwise) — lives in localStorage through `@k8ordo/state`; one rule
|
|
6
|
+
> resolves it against `prefers-color-scheme`; the result is the `dark` class
|
|
7
|
+
> on `<html>` (what `@k8ordo/ui` reads, and Tailwind's `dark:` with a
|
|
8
|
+
> class-based `@custom-variant dark`). `<ColorSchemeProvider>` in the root
|
|
9
|
+
> layout renders the inline script that puts the class on before the first
|
|
10
|
+
> paint and keeps it in step afterwards; `useColorScheme()` reads it. The
|
|
11
|
+
> server renders the default (`light` when that default is `'system'`).
|
|
11
12
|
>
|
|
12
13
|
> Like every k8ordo package it assumes React 19 and Server Components, uses
|
|
13
14
|
> only what has reached Baseline newly available, and ships no polyfills or
|
|
@@ -18,20 +19,30 @@ When installed via npm, these docs live under `node_modules/@k8ordo/color-scheme
|
|
|
18
19
|
## Guide
|
|
19
20
|
|
|
20
21
|
- [Design Guide](GUIDE.md): what the package owns and does not (the class,
|
|
21
|
-
not the colours; the rule, not the storage
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
not the colours or the `color-scheme` property; the rule, not the storage;
|
|
23
|
+
no contrast preference — `prefers-contrast` and `forced-colors` are the
|
|
24
|
+
OS's, which `@k8ordo/ui` follows in either scheme), placing the provider
|
|
25
|
+
in the root layout, allowing its inline script under a
|
|
26
|
+
Content-Security-Policy (by nonce or by hash, and what a blocked script
|
|
27
|
+
costs), reading and changing the preference with the hook, the stored row
|
|
28
|
+
and reading it without the hook, what it guarantees, and testing.
|
|
24
29
|
|
|
25
30
|
## Entry point
|
|
26
31
|
|
|
27
|
-
- `@k8ordo/color-scheme` — `<ColorSchemeProvider defaultPreference?>`
|
|
32
|
+
- `@k8ordo/color-scheme` — `<ColorSchemeProvider defaultPreference? nonce?>`
|
|
28
33
|
(client; goes inside `<body>` around everything) renders the inline
|
|
29
34
|
`<script>` that puts `dark` on `<html>` before the first paint from the
|
|
30
35
|
stored preference, the default (`'system'` unless told otherwise), or the
|
|
31
|
-
system, and keeps the class in step
|
|
36
|
+
system, and keeps the class in step; `nonce` signs that script for a
|
|
37
|
+
Content-Security-Policy (in `@k8ordo/framework`'s server mode, `nonce()`
|
|
38
|
+
from `@k8ordo/framework/server`).
|
|
39
|
+
`colorSchemeScriptHash(defaultPreference?)` resolves to the script's hash
|
|
40
|
+
as a CSP source (`'sha256-…'`), for a policy that allows it by hash —
|
|
41
|
+
the `csp` option of `@k8ordo/framework`'s static mode. `useColorScheme()` returns
|
|
32
42
|
`{ scheme, preference, setPreference }`: `scheme` is `'light' | 'dark'`
|
|
33
43
|
on screen, `preference` is `'light' | 'dark' | 'system'`, and
|
|
34
|
-
`setPreference('system')` clears the stored
|
|
35
|
-
`@k8ordo/state` local definition
|
|
44
|
+
`setPreference('system')` clears the stored preference, so the default
|
|
45
|
+
applies again. `colorSchemeState` is the `@k8ordo/state` local definition
|
|
46
|
+
itself (`k8ordo-state:color-scheme`).
|
|
36
47
|
Types: `ColorScheme`, `ColorSchemePreference`, `ColorSchemeProviderProps`,
|
|
37
48
|
`UseColorScheme`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k8ordo/color-scheme",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "The colour-scheme axis of an application, owned: the visitor's light / dark / system preference, kept in localStorage, resolved against the system, put on <html> before the first paint and kept there.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"color-scheme",
|
|
@@ -39,26 +39,26 @@
|
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@arethetypeswrong/cli": "0.18.5",
|
|
42
|
-
"@k8ordo/state": "^0.
|
|
42
|
+
"@k8ordo/state": "^1.0.0",
|
|
43
43
|
"@types/react": "19.3.0",
|
|
44
44
|
"@types/react-dom": "19.3.0",
|
|
45
|
-
"@vitejs/plugin-react": "6.1.
|
|
46
|
-
"@vitest/browser-playwright": "5.0.
|
|
47
|
-
"publint": "0.3.
|
|
45
|
+
"@vitejs/plugin-react": "6.1.2",
|
|
46
|
+
"@vitest/browser-playwright": "5.0.3",
|
|
47
|
+
"publint": "0.3.25",
|
|
48
48
|
"react": "19.3.0",
|
|
49
49
|
"react-dom": "19.3.0",
|
|
50
50
|
"typescript": "7.0.2",
|
|
51
|
-
"vite": "
|
|
52
|
-
"vite-plus": "
|
|
53
|
-
"vitest": "5.0.
|
|
51
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@1.1.0",
|
|
52
|
+
"vite-plus": "1.1.0",
|
|
53
|
+
"vitest": "5.0.3",
|
|
54
54
|
"vitest-browser-react": "2.3.0",
|
|
55
|
-
"zod": "4.5
|
|
55
|
+
"zod": "4.6.5"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
|
-
"@k8ordo/state": "^0.
|
|
58
|
+
"@k8ordo/state": "^1.0.0",
|
|
59
59
|
"@types/react": ">=19.3.0",
|
|
60
60
|
"react": ">=19.3.0",
|
|
61
|
-
"typescript": ">=7.0.
|
|
61
|
+
"typescript": ">=7.0.0",
|
|
62
62
|
"zod": "^4.4.3"
|
|
63
63
|
},
|
|
64
64
|
"peerDependenciesMeta": {
|