@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 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 system — lives in localStorage
5
- through `@k8ordo/state`; one rule resolves it against `prefers-color-scheme`;
6
- the result is the `dark` class on `<html>`, put there by an inline script
7
- before the first paint and kept there after hydration. One provider does all
8
- of that; a hook reads it. `@k8ordo/ui`'s tokens, and Tailwind's own dark
9
- variant, read the class.
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
- | Package | Version | Needed for |
29
- | --------------- | ------- | ------------------------------------------ |
30
- | `@k8ordo/state` | ≥0.2.0 | where the preference lives (localStorage) |
31
- | `react` | ≥19.3.0 | the provider and the hook |
32
- | `zod` | ^4.4.3 | the one-field schema `@k8ordo/state` reads |
33
- | `typescript` | ≥7.0.2 | the shipped type declarations (optional) |
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')` clears it.
77
- `<ColorSchemeProvider defaultPreference="dark">` starts every visitor dark
78
- until they choose.
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
- This package ships its documentation so an AI coding assistant reads the exact
83
- installed version:
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
- - `node_modules/@k8ordo/color-scheme/docs/GUIDE.md` — the design guide
86
- - `node_modules/@k8ordo/color-scheme/docs/llms.txt` — the index
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 };
@@ -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
- /** What is on screen: the preference, or the system's answer. */
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
- /** Stores a preference, or `'system'` to store none and follow it again. */
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", { children: scriptFor(defaultPreference) }), /* @__PURE__ */ jsx(ColorSchemeContext, {
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
- /** What the visitor asked for: a scheme, or to follow the system. */
5
- type ColorSchemePreference = ColorScheme | 'system';
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
- /** The class `@k8ordo/ui` — and Tailwind's own dark variant — read on `<html>`. */
21
- declare const DARK_CLASS = "dark";
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
- //#endregion
36
- export { ColorScheme, ColorSchemePreference, DARK_CLASS, DARK_QUERY, colorSchemeState, resolve, scriptFor };
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
- /** The class `@k8ordo/ui` — and Tailwind's own dark variant — read on `<html>`. */
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 own dark variant
10
- read. One provider does all of it: it renders the inline script that puts
11
- the class there before the first paint, keeps it right after hydration, and
12
- hands a hook what it reads.
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 it, so the key
24
- and the JSON envelope are never spelled here.
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, and the inline script is what makes the first
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 and follows the system again. |
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
- `colorSchemeState` is the `defineLocalState` definition itself, exported for
103
- anything that wants the row without the hook — `useAppState(colorSchemeState)`,
104
- or `colorSchemeState.storageKey` in a test.
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
- - **Nothing chosen follows the default, and the default follows the
111
- system.** A visitor who never chose is never pinned to what the system
112
- said on their first visit, and a row an older version wrote, or one that
113
- is not JSON, reads as nothing chosen.
114
- - **The server renders the default**, and the provider corrects itself on
115
- hydration without touching the document: the render that hydrates reads
116
- the server's guesses and writes nothing, and the render that follows reads
117
- the store and writes what the script already put there. Until then the
118
- HTML shows the default — a switcher's icon, say — which is the one thing
119
- no server can know; a component that must not show it even for a moment
120
- says so with React's `use(browser())` under a `<Suspense>`.
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 also runs the script the provider renders — a script
130
- element React creates executes — which is idempotent with the effect.
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 system — lives in
5
- > localStorage through `@k8ordo/state`; one rule resolves it against
6
- > `prefers-color-scheme`; the result is the `dark` class on `<html>` (what
7
- > `@k8ordo/ui` and Tailwind's dark variant read). `<ColorSchemeProvider>` in
8
- > the root layout renders the inline script that puts the class on before
9
- > the first paint and keeps it in step afterwards; `useColorScheme()` reads
10
- > it. The server renders the default.
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), placing the provider in the
22
- root layout, reading and changing the preference with the hook, what it
23
- guarantees, and testing.
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. `useColorScheme()` returns
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 row. `colorSchemeState` is the
35
- `@k8ordo/state` local definition itself (`k8ordo-state:color-scheme`).
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": "0.1.0",
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.2.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.1",
46
- "@vitest/browser-playwright": "5.0.0",
47
- "publint": "0.3.24",
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": "8.2.2",
52
- "vite-plus": "0.3.0",
53
- "vitest": "5.0.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.4"
55
+ "zod": "4.6.5"
56
56
  },
57
57
  "peerDependencies": {
58
- "@k8ordo/state": "^0.2.0",
58
+ "@k8ordo/state": "^1.0.0",
59
59
  "@types/react": ">=19.3.0",
60
60
  "react": ">=19.3.0",
61
- "typescript": ">=7.0.2",
61
+ "typescript": ">=7.0.0",
62
62
  "zod": "^4.4.3"
63
63
  },
64
64
  "peerDependenciesMeta": {