@k8ordo/color-scheme

CSP

What the provider runs before the first paint is an inline script. Under a Content-Security-Policy that restricts scripts it does not run unless the policy allows it, by nonce or by hash: under @k8ordo/server with the answer’s nonce, under @k8ordo/static with its hash.

What happens when it is not allowed

The browser does not run the script and reports the violation in the console. The page does not break — after hydration the provider’s effect writes the class, so it ends up right — but until then it paints with the default, and a visitor who chose dark sees a flash of light: exactly what the script is there to prevent.

Adding 'unsafe-inline' makes it run, and every other inline script that reaches the page with it, which is what the policy was for. Name this script alone, by nonce or by hash.

By nonce, under @k8ordo/server

@k8ordo/server makes a new nonce for every answer and signs its own inline scripts with it. nonce() from @k8ordo/server/runtime returns that value, so the root guard.ts writes a policy naming it into the header, and the root layout hands the same value to the provider’s nonce.

ts
// src/routes/guard.ts (@k8ordo/server)
import { nonce, responseHeaders } from '@k8ordo/server/runtime';

export default function guard() {
  responseHeaders().set(
    'content-security-policy',
    `script-src 'nonce-${nonce()}' 'strict-dynamic'; object-src 'none'; base-uri 'none'`,
  );
}
tsx
// src/routes/layout.tsx (@k8ordo/server)
import { ColorSchemeProvider } from '@k8ordo/color-scheme';
import { nonce } from '@k8ordo/server/runtime';
import type { ReactNode } from 'react';

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ColorSchemeProvider nonce={nonce()}>{children}</ColorSchemeProvider>
      </body>
    </html>
  );
}

nonce() can be read in the layout’s render too: signing a script is not writing the response. The framework’s module script carries the same nonce, so under 'strict-dynamic' it loads the rest of the client. A nonce is worth something only while it is new, so an answer that carries one does not belong in a shared cache.

@k8ordo/server: writing a Content-Security-Policy from a guard

By hash, under @k8ordo/static

A file is the same for everyone who reads it, so it cannot carry a nonce. colorSchemeScriptHash() resolves to the script’s SHA-256 as a CSP source, 'sha256-…'. Await it in vite.config.ts and put it in script-src of framework()’s csp option; the framework adds the hashes of its own inline scripts there, page by page.

ts
// vite.config.ts (@k8ordo/static)
import { colorSchemeScriptHash } from '@k8ordo/color-scheme';
import { framework } from '@k8ordo/static';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    framework({
      csp: {
        'script-src': ["'self'", await colorSchemeScriptHash()],
        'object-src': ["'none'"],
        'base-uri': ["'none'"],
      },
    }),
  ],
});

Compute the hash in the config every time rather than copying its value: the script’s text is the installed version’s and may change with an update, and a computed hash cannot fall out of step with it.

@k8ordo/static: a Content-Security-Policy by hash

Pass the same defaultPreference

The script carries the provider’s default in its text, so the hash differs by default. When the provider is given a defaultPreference, give colorSchemeScriptHash() the same one. By nonce, the default makes no difference.

tsx
// src/routes/layout.tsx
<ColorSchemeProvider defaultPreference="dark">{children}</ColorSchemeProvider>

// vite.config.ts
'script-src': ["'self'", await colorSchemeScriptHash('dark')],