@hadoku/themes 0.0.0-stage → 5.12.16
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/LICENSE +21 -0
- package/LICENSE-lucide +43 -0
- package/README.md +433 -2
- package/THEME_USAGE_GUIDE.md +308 -0
- package/dist/HadokuThemeRoot.d.ts +53 -0
- package/dist/HadokuThemeRoot.js +66 -0
- package/dist/ThemeIcons.d.ts +31 -0
- package/dist/ThemeIcons.js +91 -0
- package/dist/effects.d.ts +25 -0
- package/dist/effects.js +25 -0
- package/dist/icons/Icon.d.ts +36 -0
- package/dist/icons/Icon.js +89 -0
- package/dist/icons/index.d.ts +61 -0
- package/dist/icons/index.js +87 -0
- package/dist/icons/registry.generated.d.ts +158 -0
- package/dist/icons/registry.generated.js +446 -0
- package/dist/index.d.ts +82 -0
- package/dist/index.js +169 -0
- package/dist/metadata.d.ts +35 -0
- package/dist/metadata.js +108 -0
- package/dist/platform/index.d.ts +94 -0
- package/dist/platform/index.js +149 -0
- package/dist/platform/usePlatform.d.ts +29 -0
- package/dist/platform/usePlatform.js +65 -0
- package/dist/prefsApiBase.d.ts +37 -0
- package/dist/prefsApiBase.js +40 -0
- package/dist/theme-types.d.ts +34 -0
- package/dist/theme-types.js +1 -0
- package/dist/themeContext.d.ts +62 -0
- package/dist/themeContext.js +70 -0
- package/dist/themePrefs.d.ts +29 -0
- package/dist/themePrefs.js +48 -0
- package/dist/useRefElement.d.ts +19 -0
- package/dist/useRefElement.js +52 -0
- package/dist/useTheme.d.ts +46 -0
- package/dist/useTheme.js +216 -0
- package/package.json +83 -4
- package/scripts/audit-tokens.mjs +126 -0
- package/scripts/check-contrast.mjs +24 -0
- package/scripts/check-docs.mjs +53 -0
- package/scripts/check-icons.mjs +473 -0
- package/scripts/check-usage.mjs +297 -0
- package/scripts/codemod-icons.mjs +396 -0
- package/scripts/generate-icons.mjs +199 -0
- package/scripts/generate-preview.mjs +374 -0
- package/scripts/generate-tailwind-colors.mjs +109 -0
- package/scripts/lib/color.mjs +184 -0
- package/scripts/lib/contract.d.mts +58 -0
- package/scripts/lib/contract.mjs +348 -0
- package/scripts/lib/derive.mjs +324 -0
- package/scripts/lib/icon-registry.mjs +92 -0
- package/scripts/lib/parse-themes.mjs +19 -0
- package/scripts/lib/tokens.mjs +188 -0
- package/scripts/normalize-tokens.mjs +161 -0
- package/src/advanced.css +218 -0
- package/src/icons/Icon.tsx +151 -0
- package/src/icons/emoji-map.json +202 -0
- package/src/icons/index.ts +151 -0
- package/src/icons/registry.generated.ts +455 -0
- package/src/icons/sources.json +150 -0
- package/src/icons.css +145 -0
- package/src/style.css +1421 -0
- package/src/tailwind-colors.css +88 -0
- package/src/tailwind-integration.css +49 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the prefs SDK should talk to.
|
|
3
|
+
*
|
|
4
|
+
* @wolffm/prefs-client defaults to `https://hadoku.me/prefs`, which is correct
|
|
5
|
+
* everywhere except a local dev or E2E run — there it means the browser reaches
|
|
6
|
+
* PRODUCTION for every preference read and write.
|
|
7
|
+
*
|
|
8
|
+
* That default is why the theme specs mocked `/prefs/api/v1/*` at the network
|
|
9
|
+
* layer in the first place, and the mocks then hid a real bug: the shared row's
|
|
10
|
+
* request escaped to hadoku.me unrouted, where a row that FAILS to resolve is
|
|
11
|
+
* indistinguishable from an empty one, so `useThemePrefsMigration` looked fine
|
|
12
|
+
* under test while being permanently disabled in the field.
|
|
13
|
+
*
|
|
14
|
+
* An override lets the dev stack run the REAL prefs-api worker locally and point
|
|
15
|
+
* the SDK at it, so the specs exercise the true fetch → auth → D1 path instead of
|
|
16
|
+
* an interceptor's idea of it. Route mocking is what we are removing; this is the
|
|
17
|
+
* seam that makes removing it possible.
|
|
18
|
+
*
|
|
19
|
+
* Set it before app code runs — a Playwright `addInitScript`, or an inline
|
|
20
|
+
* <head> script in a dev shell:
|
|
21
|
+
*
|
|
22
|
+
* window.__HADOKU_PREFS_API_BASE__ = 'http://localhost:3003/prefs'
|
|
23
|
+
*
|
|
24
|
+
* Unset (the normal case, including all of production) returns undefined, and
|
|
25
|
+
* every client falls through to the SDK's own default.
|
|
26
|
+
*/
|
|
27
|
+
/** The global the override is read from. Exported so callers agree on the name. */
|
|
28
|
+
export declare const PREFS_API_BASE_GLOBAL = "__HADOKU_PREFS_API_BASE__";
|
|
29
|
+
/**
|
|
30
|
+
* The prefs API base to use, or undefined to accept the SDK's default.
|
|
31
|
+
*
|
|
32
|
+
* Deliberately returns `undefined` rather than the production URL: passing
|
|
33
|
+
* `apiBase: undefined` to createPrefsClient lets its own `?? PREFS_API_BASE`
|
|
34
|
+
* default apply, so this module never has to restate — and never drifts from —
|
|
35
|
+
* whatever the SDK considers canonical.
|
|
36
|
+
*/
|
|
37
|
+
export declare function resolvePrefsApiBase(): string | undefined;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the prefs SDK should talk to.
|
|
3
|
+
*
|
|
4
|
+
* @wolffm/prefs-client defaults to `https://hadoku.me/prefs`, which is correct
|
|
5
|
+
* everywhere except a local dev or E2E run — there it means the browser reaches
|
|
6
|
+
* PRODUCTION for every preference read and write.
|
|
7
|
+
*
|
|
8
|
+
* That default is why the theme specs mocked `/prefs/api/v1/*` at the network
|
|
9
|
+
* layer in the first place, and the mocks then hid a real bug: the shared row's
|
|
10
|
+
* request escaped to hadoku.me unrouted, where a row that FAILS to resolve is
|
|
11
|
+
* indistinguishable from an empty one, so `useThemePrefsMigration` looked fine
|
|
12
|
+
* under test while being permanently disabled in the field.
|
|
13
|
+
*
|
|
14
|
+
* An override lets the dev stack run the REAL prefs-api worker locally and point
|
|
15
|
+
* the SDK at it, so the specs exercise the true fetch → auth → D1 path instead of
|
|
16
|
+
* an interceptor's idea of it. Route mocking is what we are removing; this is the
|
|
17
|
+
* seam that makes removing it possible.
|
|
18
|
+
*
|
|
19
|
+
* Set it before app code runs — a Playwright `addInitScript`, or an inline
|
|
20
|
+
* <head> script in a dev shell:
|
|
21
|
+
*
|
|
22
|
+
* window.__HADOKU_PREFS_API_BASE__ = 'http://localhost:3003/prefs'
|
|
23
|
+
*
|
|
24
|
+
* Unset (the normal case, including all of production) returns undefined, and
|
|
25
|
+
* every client falls through to the SDK's own default.
|
|
26
|
+
*/
|
|
27
|
+
/** The global the override is read from. Exported so callers agree on the name. */
|
|
28
|
+
export const PREFS_API_BASE_GLOBAL = '__HADOKU_PREFS_API_BASE__';
|
|
29
|
+
/**
|
|
30
|
+
* The prefs API base to use, or undefined to accept the SDK's default.
|
|
31
|
+
*
|
|
32
|
+
* Deliberately returns `undefined` rather than the production URL: passing
|
|
33
|
+
* `apiBase: undefined` to createPrefsClient lets its own `?? PREFS_API_BASE`
|
|
34
|
+
* default apply, so this module never has to restate — and never drifts from —
|
|
35
|
+
* whatever the SDK considers canonical.
|
|
36
|
+
*/
|
|
37
|
+
export function resolvePrefsApiBase() {
|
|
38
|
+
const override = globalThis[PREFS_API_BASE_GLOBAL];
|
|
39
|
+
return typeof override === 'string' && override.length > 0 ? override : undefined;
|
|
40
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme-domain types.
|
|
3
|
+
*
|
|
4
|
+
* These described the theme model but lived in @wolffm/task-ui-components, so
|
|
5
|
+
* @wolffm/themes had to import them from there — half of the dependency edge
|
|
6
|
+
* that forced the context and its provider into separate packages. A type is
|
|
7
|
+
* erased at runtime and cannot itself cause a duplicate-module bug, but leaving
|
|
8
|
+
* it behind would have kept the import alive for no reason.
|
|
9
|
+
*
|
|
10
|
+
* Picker-specific types (DropdownPlacement, ThemePickerProps) stay in
|
|
11
|
+
* task-ui-components: they describe a component's props, not the theme model.
|
|
12
|
+
*/
|
|
13
|
+
import type { ReactNode } from 'react';
|
|
14
|
+
/** Theme name — any string your theme system recognises. */
|
|
15
|
+
export type ThemeName = string;
|
|
16
|
+
/**
|
|
17
|
+
* A light/dark theme pair with its icons and labels.
|
|
18
|
+
*
|
|
19
|
+
* Icons are optional: the picker falls back to a shape based on the family's
|
|
20
|
+
* position when one is absent.
|
|
21
|
+
*/
|
|
22
|
+
export interface ThemeFamily {
|
|
23
|
+
lightIcon?: ReactNode;
|
|
24
|
+
darkIcon?: ReactNode;
|
|
25
|
+
lightTheme: ThemeName;
|
|
26
|
+
darkTheme: ThemeName;
|
|
27
|
+
lightLabel: string;
|
|
28
|
+
darkLabel: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Simple flattens advanced visuals (gradients, effects) to their solid colour
|
|
32
|
+
* fallback; advanced renders them.
|
|
33
|
+
*/
|
|
34
|
+
export type ThemeMode = 'simple' | 'advanced';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HadokuThemeContext — how AppHeader gets a theme picker without importing one.
|
|
3
|
+
*
|
|
4
|
+
* WHY A CONTEXT AND NOT AN IMPORT
|
|
5
|
+
* ------------------------------
|
|
6
|
+
* The whole point of the locked header is that AppHeader RENDERS the theme
|
|
7
|
+
* picker itself, so no app can pass a different one. To do that it needs the
|
|
8
|
+
* theme families, the icon map and the current theme — all of which live in
|
|
9
|
+
* `@wolffm/themes`.
|
|
10
|
+
*
|
|
11
|
+
* It cannot import them. `@wolffm/themes` peer-depends on THIS package and
|
|
12
|
+
* imports `ThemeFamily` + every icon from it (see themes/src/metadata.tsx), so
|
|
13
|
+
* `tuic -> themes` closes a cycle. React context inverts the data flow without
|
|
14
|
+
* adding a package edge: tuic DEFINES and CONSUMES the context, themes PROVIDES
|
|
15
|
+
* it via `<HadokuThemeRoot>`. The dependency graph stays themes -> tuic.
|
|
16
|
+
*
|
|
17
|
+
* It also keeps this package dependency-free (react peer only), which is a
|
|
18
|
+
* property worth protecting — everything downstream installs it.
|
|
19
|
+
*
|
|
20
|
+
* Child apps never touch this. They mount `<HadokuThemeRoot>` from
|
|
21
|
+
* `@wolffm/themes` once at their root and are done; the theme picker is then
|
|
22
|
+
* identical everywhere by construction rather than by convention.
|
|
23
|
+
*/
|
|
24
|
+
import React from 'react';
|
|
25
|
+
import type { ThemeFamily } from './theme-types';
|
|
26
|
+
export interface HadokuThemeValue {
|
|
27
|
+
/** Active theme name, e.g. 'coffee-dark'. */
|
|
28
|
+
theme: string;
|
|
29
|
+
/** Change the theme. Persists (session + local + cross-device prefs). */
|
|
30
|
+
setTheme: (theme: string) => void;
|
|
31
|
+
/** Selectable families, in picker order. */
|
|
32
|
+
themeFamilies: ThemeFamily[];
|
|
33
|
+
/** Icon for a theme name, for the picker's toggle button. */
|
|
34
|
+
getThemeIcon: (theme: string) => React.ReactNode;
|
|
35
|
+
/** True when the active theme is a dark variant. */
|
|
36
|
+
isDarkTheme: boolean;
|
|
37
|
+
/** False until the theme has been applied to the DOM. Apps gate their first
|
|
38
|
+
* paint on this to avoid a flash of unstyled content. */
|
|
39
|
+
isThemeReady: boolean;
|
|
40
|
+
/** True only during the very first resolve of a mount — lets an app tell
|
|
41
|
+
* "still booting" apart from "theme changing", which need different gates. */
|
|
42
|
+
isInitialThemeLoad: boolean;
|
|
43
|
+
}
|
|
44
|
+
export interface HadokuThemeProviderProps {
|
|
45
|
+
value: HadokuThemeValue;
|
|
46
|
+
children: React.ReactNode;
|
|
47
|
+
}
|
|
48
|
+
/** Low-level provider. Apps should use `<HadokuThemeRoot>` from
|
|
49
|
+
* `@wolffm/themes`, which builds the value; this is the seam it plugs into. */
|
|
50
|
+
export declare function HadokuThemeProvider({ value, children }: HadokuThemeProviderProps): React.JSX.Element;
|
|
51
|
+
/**
|
|
52
|
+
* Read the ambient theme. Throws when no provider is mounted rather than
|
|
53
|
+
* degrading to a default — a silent fallback here is precisely the failure
|
|
54
|
+
* mode the locked header exists to remove. A missing provider means the app
|
|
55
|
+
* root was never wrapped, and it should say so at mount, loudly, with the fix
|
|
56
|
+
* in the message. Guessing 'light' would ship a header that looks fine and
|
|
57
|
+
* silently ignores the user's saved theme.
|
|
58
|
+
*/
|
|
59
|
+
export declare function useHadokuTheme(): HadokuThemeValue;
|
|
60
|
+
/** Non-throwing read, for the rare component that legitimately renders both
|
|
61
|
+
* inside and outside a themed tree (e.g. a standalone error boundary). */
|
|
62
|
+
export declare function useHadokuThemeOptional(): HadokuThemeValue | null;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HadokuThemeContext — how AppHeader gets a theme picker without importing one.
|
|
3
|
+
*
|
|
4
|
+
* WHY A CONTEXT AND NOT AN IMPORT
|
|
5
|
+
* ------------------------------
|
|
6
|
+
* The whole point of the locked header is that AppHeader RENDERS the theme
|
|
7
|
+
* picker itself, so no app can pass a different one. To do that it needs the
|
|
8
|
+
* theme families, the icon map and the current theme — all of which live in
|
|
9
|
+
* `@wolffm/themes`.
|
|
10
|
+
*
|
|
11
|
+
* It cannot import them. `@wolffm/themes` peer-depends on THIS package and
|
|
12
|
+
* imports `ThemeFamily` + every icon from it (see themes/src/metadata.tsx), so
|
|
13
|
+
* `tuic -> themes` closes a cycle. React context inverts the data flow without
|
|
14
|
+
* adding a package edge: tuic DEFINES and CONSUMES the context, themes PROVIDES
|
|
15
|
+
* it via `<HadokuThemeRoot>`. The dependency graph stays themes -> tuic.
|
|
16
|
+
*
|
|
17
|
+
* It also keeps this package dependency-free (react peer only), which is a
|
|
18
|
+
* property worth protecting — everything downstream installs it.
|
|
19
|
+
*
|
|
20
|
+
* Child apps never touch this. They mount `<HadokuThemeRoot>` from
|
|
21
|
+
* `@wolffm/themes` once at their root and are done; the theme picker is then
|
|
22
|
+
* identical everywhere by construction rather than by convention.
|
|
23
|
+
*/
|
|
24
|
+
import React from 'react';
|
|
25
|
+
const HadokuThemeContext = React.createContext(null);
|
|
26
|
+
/** Low-level provider. Apps should use `<HadokuThemeRoot>` from
|
|
27
|
+
* `@wolffm/themes`, which builds the value; this is the seam it plugs into. */
|
|
28
|
+
export function HadokuThemeProvider({ value, children }) {
|
|
29
|
+
return React.createElement(HadokuThemeContext.Provider, { value: value }, children);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Read the ambient theme. Throws when no provider is mounted rather than
|
|
33
|
+
* degrading to a default — a silent fallback here is precisely the failure
|
|
34
|
+
* mode the locked header exists to remove. A missing provider means the app
|
|
35
|
+
* root was never wrapped, and it should say so at mount, loudly, with the fix
|
|
36
|
+
* in the message. Guessing 'light' would ship a header that looks fine and
|
|
37
|
+
* silently ignores the user's saved theme.
|
|
38
|
+
*/
|
|
39
|
+
export function useHadokuTheme() {
|
|
40
|
+
const value = React.useContext(HadokuThemeContext);
|
|
41
|
+
if (!value) {
|
|
42
|
+
// State the OBSERVATION, then both causes — do not assert one.
|
|
43
|
+
//
|
|
44
|
+
// The old message said "No <HadokuThemeRoot> above this component", which is
|
|
45
|
+
// only one of the two ways this reads empty, and on 2026-08-05 it was the
|
|
46
|
+
// wrong one: three apps had the provider plainly mounted and still threw,
|
|
47
|
+
// because a duplicated @wolffm/themes module meant the provider filled a
|
|
48
|
+
// DIFFERENT context object than the consumer read. Everyone who saw that
|
|
49
|
+
// message went looking for a missing provider that was never missing.
|
|
50
|
+
throw new Error('[hadoku] useHadokuTheme() read an empty theme context.\n\n' +
|
|
51
|
+
'Two things cause this:\n\n' +
|
|
52
|
+
'1. No <HadokuThemeRoot> above this component. Wrap the app root once:\n' +
|
|
53
|
+
" import { HadokuThemeRoot } from '@wolffm/themes'\n" +
|
|
54
|
+
' <HadokuThemeRoot>\n <App />\n </HadokuThemeRoot>\n\n' +
|
|
55
|
+
'2. TWO COPIES of @wolffm/themes are loaded, so the provider filled a\n' +
|
|
56
|
+
' different context object than this consumer is reading. The provider\n' +
|
|
57
|
+
' looks correctly mounted in this case — check it before assuming (1).\n' +
|
|
58
|
+
' Usually a bundle inlined @wolffm/themes instead of externalizing it,\n' +
|
|
59
|
+
" while the provider came from the parent page's import map.\n" +
|
|
60
|
+
' Confirm by counting createContext( in the shipped bundles; there must\n' +
|
|
61
|
+
" be exactly one. hadoku_site's `pnpm run check:mf-externals` and\n" +
|
|
62
|
+
" hadoku-task's `pnpm run check:single-context` both assert this.");
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
/** Non-throwing read, for the rare component that legitimately renders both
|
|
67
|
+
* inside and outside a themed tree (e.g. a standalone error boundary). */
|
|
68
|
+
export function useHadokuThemeOptional() {
|
|
69
|
+
return React.useContext(HadokuThemeContext);
|
|
70
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical theme-preferences client for the hadoku ecosystem.
|
|
3
|
+
*
|
|
4
|
+
* This file used to exist, byte-for-byte, in every child app under
|
|
5
|
+
* `src/prefs/themePrefs.ts` — its own header said "This module is identical
|
|
6
|
+
* across every child app and the portfolio shell." A module that documents
|
|
7
|
+
* itself as copy-paste is a module that belongs in a package, so here it is.
|
|
8
|
+
*
|
|
9
|
+
* Theme is platform-global: every app and the portfolio shell read/write the
|
|
10
|
+
* SAME row, so it lives under the shared appId 'portfolio' (NOT any app's own
|
|
11
|
+
* id). A per-app id would fragment the theme across apps. Scope is 'device' so
|
|
12
|
+
* "dark on phone, light on desktop" survives.
|
|
13
|
+
*
|
|
14
|
+
* App-specific settings do NOT belong here — declare them in their own
|
|
15
|
+
* prefs client under THAT app's appId.
|
|
16
|
+
*/
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
export declare const ThemePrefsSchema: z.ZodObject<{
|
|
19
|
+
theme: z.ZodOptional<z.ZodString>;
|
|
20
|
+
themeMode: z.ZodOptional<z.ZodEnum<{
|
|
21
|
+
simple: "simple";
|
|
22
|
+
advanced: "advanced";
|
|
23
|
+
}>>;
|
|
24
|
+
}, z.core.$strip>;
|
|
25
|
+
export type ThemePrefs = z.infer<typeof ThemePrefsSchema>;
|
|
26
|
+
export declare const themePrefs: import("@wolffm/prefs-client").PrefsClient<{
|
|
27
|
+
theme?: string | undefined;
|
|
28
|
+
themeMode?: "simple" | "advanced" | undefined;
|
|
29
|
+
}>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical theme-preferences client for the hadoku ecosystem.
|
|
3
|
+
*
|
|
4
|
+
* This file used to exist, byte-for-byte, in every child app under
|
|
5
|
+
* `src/prefs/themePrefs.ts` — its own header said "This module is identical
|
|
6
|
+
* across every child app and the portfolio shell." A module that documents
|
|
7
|
+
* itself as copy-paste is a module that belongs in a package, so here it is.
|
|
8
|
+
*
|
|
9
|
+
* Theme is platform-global: every app and the portfolio shell read/write the
|
|
10
|
+
* SAME row, so it lives under the shared appId 'portfolio' (NOT any app's own
|
|
11
|
+
* id). A per-app id would fragment the theme across apps. Scope is 'device' so
|
|
12
|
+
* "dark on phone, light on desktop" survives.
|
|
13
|
+
*
|
|
14
|
+
* App-specific settings do NOT belong here — declare them in their own
|
|
15
|
+
* prefs client under THAT app's appId.
|
|
16
|
+
*/
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
import { createPrefsClient } from '@wolffm/prefs-client';
|
|
19
|
+
import { resolvePrefsApiBase } from './prefsApiBase.js';
|
|
20
|
+
// Turn off Zod's JIT before the schema below is CONSTRUCTED. Building a
|
|
21
|
+
// z.object runs Zod v4's one-time `allowsEval` probe — `new Function("")` —
|
|
22
|
+
// and caches the answer. hadoku.me's CSP grants no 'unsafe-eval', so on every
|
|
23
|
+
// page that loads this module the browser recorded a script-src violation
|
|
24
|
+
// (harmless: Zod catches it and uses its non-JIT parser). This module is
|
|
25
|
+
// evaluated before any app code, so only this module can get ahead of the
|
|
26
|
+
// probe: an app calling config() later is too late (watchparty tried it and
|
|
27
|
+
// verified it ineffective, 2026-10-05). The config lives on globalThis and is
|
|
28
|
+
// shared by every Zod copy on the page. Issue: WolffM/hadoku-task#93.
|
|
29
|
+
z.config({ jitless: true });
|
|
30
|
+
export const ThemePrefsSchema = z.object({
|
|
31
|
+
theme: z.string().optional(),
|
|
32
|
+
themeMode: z.enum(['simple', 'advanced']).optional()
|
|
33
|
+
});
|
|
34
|
+
export const themePrefs = createPrefsClient({
|
|
35
|
+
appId: 'portfolio',
|
|
36
|
+
schema: ThemePrefsSchema,
|
|
37
|
+
// Undefined in production, so the SDK's own default applies. Set only by a
|
|
38
|
+
// local dev/E2E stack pointing at the real prefs-api on localhost — see
|
|
39
|
+
// ./prefsApiBase.
|
|
40
|
+
apiBase: resolvePrefsApiBase(),
|
|
41
|
+
// No default theme on purpose: an absent `theme` means "nothing persisted",
|
|
42
|
+
// so a fresh read never overrides the inline FOUC script's browser-preference
|
|
43
|
+
// fallback. We adopt the SDK theme only when it's actually present.
|
|
44
|
+
//
|
|
45
|
+
// Mirror theme to sessionStorage['hadoku-theme'] on every read so the inline
|
|
46
|
+
// <head> FOUC script keeps applying theme before React mounts.
|
|
47
|
+
bootstrapToSessionStorage: { theme: 'hadoku-theme' }
|
|
48
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The element a caller's `RefObject` points at, as state — so an effect can
|
|
3
|
+
* depend on it and re-run when it attaches.
|
|
4
|
+
*
|
|
5
|
+
* `useTheme` and `usePlatform` stamp attributes onto an app's container, and
|
|
6
|
+
* every app hands them a plain `useRef()` object. Assigning `ref.current` does
|
|
7
|
+
* not re-render anything, so an effect keyed on the ref object alone runs once
|
|
8
|
+
* and never learns that the div attached later. Task's container mounts only
|
|
9
|
+
* after its loading skeleton gives way, which left it without `data-theme`
|
|
10
|
+
* whenever the skeleton outlived the hook's first passes (#102).
|
|
11
|
+
*
|
|
12
|
+
* React attaches an object ref by writing `ref.current = el` during commit, so
|
|
13
|
+
* this swaps `current` for an accessor that turns that write into a state
|
|
14
|
+
* update. Callers keep passing what they pass today, which is the point: 16
|
|
15
|
+
* apps hand over a `RefObject`, and none of them has to change. The plain data
|
|
16
|
+
* property comes back on cleanup.
|
|
17
|
+
*/
|
|
18
|
+
import { type RefObject } from 'react';
|
|
19
|
+
export declare function useRefElement<T extends Element>(ref?: RefObject<T | null>): T | null;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The element a caller's `RefObject` points at, as state — so an effect can
|
|
3
|
+
* depend on it and re-run when it attaches.
|
|
4
|
+
*
|
|
5
|
+
* `useTheme` and `usePlatform` stamp attributes onto an app's container, and
|
|
6
|
+
* every app hands them a plain `useRef()` object. Assigning `ref.current` does
|
|
7
|
+
* not re-render anything, so an effect keyed on the ref object alone runs once
|
|
8
|
+
* and never learns that the div attached later. Task's container mounts only
|
|
9
|
+
* after its loading skeleton gives way, which left it without `data-theme`
|
|
10
|
+
* whenever the skeleton outlived the hook's first passes (#102).
|
|
11
|
+
*
|
|
12
|
+
* React attaches an object ref by writing `ref.current = el` during commit, so
|
|
13
|
+
* this swaps `current` for an accessor that turns that write into a state
|
|
14
|
+
* update. Callers keep passing what they pass today, which is the point: 16
|
|
15
|
+
* apps hand over a `RefObject`, and none of them has to change. The plain data
|
|
16
|
+
* property comes back on cleanup.
|
|
17
|
+
*/
|
|
18
|
+
import { useEffect, useLayoutEffect, useState } from 'react';
|
|
19
|
+
// useLayoutEffect, so the element is read before paint; useEffect on the server,
|
|
20
|
+
// where neither runs but React 18 warns about the layout variant.
|
|
21
|
+
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
|
|
22
|
+
export function useRefElement(ref) {
|
|
23
|
+
const [element, setElement] = useState(null);
|
|
24
|
+
useIsomorphicLayoutEffect(() => {
|
|
25
|
+
if (!ref) {
|
|
26
|
+
setElement(null);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
// Refs attach child-first, before this parent's layout effect, so a
|
|
30
|
+
// container rendered in the same commit is already here.
|
|
31
|
+
let current = ref.current;
|
|
32
|
+
Object.defineProperty(ref, 'current', {
|
|
33
|
+
configurable: true,
|
|
34
|
+
enumerable: true,
|
|
35
|
+
get: () => current,
|
|
36
|
+
set: (next) => {
|
|
37
|
+
current = next;
|
|
38
|
+
setElement(next);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
setElement(current);
|
|
42
|
+
return () => {
|
|
43
|
+
Object.defineProperty(ref, 'current', {
|
|
44
|
+
configurable: true,
|
|
45
|
+
enumerable: true,
|
|
46
|
+
writable: true,
|
|
47
|
+
value: current
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
}, [ref]);
|
|
51
|
+
return element;
|
|
52
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useTheme — the one theme hook for the hadoku ecosystem.
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS REPLACES
|
|
5
|
+
* ------------------
|
|
6
|
+
* Eight child apps each carried a hand-copied `src/hooks/useTheme.ts`, because
|
|
7
|
+
* the version this package used to ship was strictly weaker than what an app
|
|
8
|
+
* actually needs: no cross-device persistence, no container mirroring for
|
|
9
|
+
* micro-frontend mounts, no FOUC gating, no system-preference auto-switch, no
|
|
10
|
+
* validation that the theme is even available. So everyone wrote their own, and
|
|
11
|
+
* the copies drifted on real behaviour — `hadoku-pygmalion` wrote localStorage
|
|
12
|
+
* (so a browser restart kept the theme and repainted correctly), while
|
|
13
|
+
* `hadoku-jobplatform` wrote only sessionStorage and lost it. Same file, same
|
|
14
|
+
* comments, one had the fix.
|
|
15
|
+
*
|
|
16
|
+
* This is the union of those copies, with the localStorage behaviour taken as
|
|
17
|
+
* canonical (see `saveTheme` in ./index).
|
|
18
|
+
*
|
|
19
|
+
* PERSISTENCE, THREE LAYERS
|
|
20
|
+
* -------------------------
|
|
21
|
+
* sessionStorage — what the pre-paint inline <head> script reads; per tab.
|
|
22
|
+
* localStorage — what survives a browser restart, so the NEXT cold load
|
|
23
|
+
* paints the right theme directly with no default-then-swap.
|
|
24
|
+
* prefs (device) — cross-device, via @wolffm/prefs-client; resolves async.
|
|
25
|
+
*
|
|
26
|
+
* The async layer is why `userOverrodeRef` exists: a late prefs resolve must
|
|
27
|
+
* not yank the user back to the previously persisted theme if they've already
|
|
28
|
+
* picked a new one this session.
|
|
29
|
+
*/
|
|
30
|
+
import { type RefObject } from 'react';
|
|
31
|
+
export interface UseThemeOptions {
|
|
32
|
+
/** Theme forced by a parent shell. When set, it wins over everything and the
|
|
33
|
+
* cross-device adopt is skipped (the shell already plumbed the SDK read). */
|
|
34
|
+
propsTheme?: string;
|
|
35
|
+
/** Mirror `data-theme` onto a container element as well as <html>, for
|
|
36
|
+
* micro-frontend mounts that are styled within their own subtree. */
|
|
37
|
+
containerRef?: RefObject<HTMLElement | null>;
|
|
38
|
+
}
|
|
39
|
+
export declare function useTheme(options?: UseThemeOptions): {
|
|
40
|
+
theme: string;
|
|
41
|
+
setTheme: (next: string) => void;
|
|
42
|
+
themeFamilies: import("./theme-types").ThemeFamily[];
|
|
43
|
+
isDarkTheme: boolean;
|
|
44
|
+
isThemeReady: boolean;
|
|
45
|
+
isInitialThemeLoad: boolean;
|
|
46
|
+
};
|
package/dist/useTheme.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useTheme — the one theme hook for the hadoku ecosystem.
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS REPLACES
|
|
5
|
+
* ------------------
|
|
6
|
+
* Eight child apps each carried a hand-copied `src/hooks/useTheme.ts`, because
|
|
7
|
+
* the version this package used to ship was strictly weaker than what an app
|
|
8
|
+
* actually needs: no cross-device persistence, no container mirroring for
|
|
9
|
+
* micro-frontend mounts, no FOUC gating, no system-preference auto-switch, no
|
|
10
|
+
* validation that the theme is even available. So everyone wrote their own, and
|
|
11
|
+
* the copies drifted on real behaviour — `hadoku-pygmalion` wrote localStorage
|
|
12
|
+
* (so a browser restart kept the theme and repainted correctly), while
|
|
13
|
+
* `hadoku-jobplatform` wrote only sessionStorage and lost it. Same file, same
|
|
14
|
+
* comments, one had the fix.
|
|
15
|
+
*
|
|
16
|
+
* This is the union of those copies, with the localStorage behaviour taken as
|
|
17
|
+
* canonical (see `saveTheme` in ./index).
|
|
18
|
+
*
|
|
19
|
+
* PERSISTENCE, THREE LAYERS
|
|
20
|
+
* -------------------------
|
|
21
|
+
* sessionStorage — what the pre-paint inline <head> script reads; per tab.
|
|
22
|
+
* localStorage — what survives a browser restart, so the NEXT cold load
|
|
23
|
+
* paints the right theme directly with no default-then-swap.
|
|
24
|
+
* prefs (device) — cross-device, via @wolffm/prefs-client; resolves async.
|
|
25
|
+
*
|
|
26
|
+
* The async layer is why `userOverrodeRef` exists: a late prefs resolve must
|
|
27
|
+
* not yank the user back to the previously persisted theme if they've already
|
|
28
|
+
* picked a new one this session.
|
|
29
|
+
*/
|
|
30
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
31
|
+
import { usePrefs } from '@wolffm/prefs-client/react';
|
|
32
|
+
import { setTheme as applyTheme, saveTheme, setThemeMode } from './index.js';
|
|
33
|
+
import { THEME_FAMILIES } from './metadata.js';
|
|
34
|
+
import { themePrefs } from './themePrefs.js';
|
|
35
|
+
import { useRefElement } from './useRefElement.js';
|
|
36
|
+
/** Bare family token → its light/dark pair, e.g. 'coffee' → coffee-light /
|
|
37
|
+
* coffee-dark. Base 'light'/'dark' are already fully qualified and are matched
|
|
38
|
+
* before this map is consulted. */
|
|
39
|
+
const FAMILY_VARIANTS = new Map(THEME_FAMILIES.map(f => [
|
|
40
|
+
f.lightTheme.replace(/-light$/, ''),
|
|
41
|
+
{ light: f.lightTheme, dark: f.darkTheme }
|
|
42
|
+
]));
|
|
43
|
+
function prefersDark() {
|
|
44
|
+
return typeof window !== 'undefined' && window.matchMedia
|
|
45
|
+
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
46
|
+
: false;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Normalize a raw theme value from the shared 'hadoku-theme' key (or a
|
|
50
|
+
* push-down StorageEvent) to a fully-qualified theme name.
|
|
51
|
+
*
|
|
52
|
+
* The hadoku.me host page may store a BARE FAMILY TOKEN — 'coffee', not
|
|
53
|
+
* 'coffee-light' — leaving the variant to the embedding app's colour scheme.
|
|
54
|
+
* Without this expansion such a value is not a real theme, so the validity
|
|
55
|
+
* check downstream discards it and the app silently lands on 'light',
|
|
56
|
+
* ignoring the theme the host asked for. Returns null for anything
|
|
57
|
+
* unrecognized so callers fall back to their own default rather than to a
|
|
58
|
+
* value that just happens to be in storage.
|
|
59
|
+
*/
|
|
60
|
+
function normalizeThemeName(value) {
|
|
61
|
+
const raw = value?.trim();
|
|
62
|
+
if (!raw)
|
|
63
|
+
return null;
|
|
64
|
+
if (THEME_FAMILIES.some(f => f.lightTheme === raw || f.darkTheme === raw))
|
|
65
|
+
return raw;
|
|
66
|
+
const variants = FAMILY_VARIANTS.get(raw);
|
|
67
|
+
if (variants)
|
|
68
|
+
return prefersDark() ? variants.dark : variants.light;
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
/** Seed the theme before any async source resolves. Order matters: whatever the
|
|
72
|
+
* pre-paint script already put on <html> beats storage, because that is what
|
|
73
|
+
* the user is currently LOOKING at — re-deriving it from storage risks a swap
|
|
74
|
+
* in the first frame for no reason. */
|
|
75
|
+
function seedTheme(propsTheme) {
|
|
76
|
+
if (propsTheme)
|
|
77
|
+
return propsTheme;
|
|
78
|
+
if (typeof document === 'undefined')
|
|
79
|
+
return 'light';
|
|
80
|
+
const painted = normalizeThemeName(document.documentElement.getAttribute('data-theme'));
|
|
81
|
+
if (painted)
|
|
82
|
+
return painted;
|
|
83
|
+
try {
|
|
84
|
+
const stored = normalizeThemeName(sessionStorage.getItem('hadoku-theme') ?? localStorage.getItem('hadoku-theme'));
|
|
85
|
+
if (stored)
|
|
86
|
+
return stored;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
/* storage blocked — fall through to the browser preference */
|
|
90
|
+
}
|
|
91
|
+
return prefersDark() ? 'dark' : 'light';
|
|
92
|
+
}
|
|
93
|
+
export function useTheme(options = {}) {
|
|
94
|
+
const { propsTheme, containerRef } = options;
|
|
95
|
+
// State, not the ref: the container can attach after the first apply (#102).
|
|
96
|
+
const container = useRefElement(containerRef);
|
|
97
|
+
const [theme, setThemeState] = useState(() => seedTheme(propsTheme));
|
|
98
|
+
const [isThemeReady, setIsThemeReady] = useState(false);
|
|
99
|
+
const [isInitialThemeLoad, setIsInitialThemeLoad] = useState(true);
|
|
100
|
+
// Cross-device row. `save` comes from the hook (not the module) so the SDK
|
|
101
|
+
// can update its in-memory cache — using the module-level save let a stale
|
|
102
|
+
// snapshot race back into state.
|
|
103
|
+
const { prefs: persisted, save: savePrefs } = usePrefs(themePrefs);
|
|
104
|
+
// savePrefs' identity is not stable across renders, and `setTheme` is a
|
|
105
|
+
// dependency of the media-query effect below. Reading it through a ref keeps
|
|
106
|
+
// setTheme referentially stable forever, so that effect re-subscribes only
|
|
107
|
+
// when the theme actually changes rather than on every render. Declared up
|
|
108
|
+
// here because every setter below closes over it.
|
|
109
|
+
const savePrefsRef = useRef(savePrefs);
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
savePrefsRef.current = savePrefs;
|
|
112
|
+
});
|
|
113
|
+
// True once the user has picked a theme this session, so a late prefs
|
|
114
|
+
// resolve doesn't overwrite their choice.
|
|
115
|
+
const userOverrodeRef = useRef(false);
|
|
116
|
+
const isThemeAvailable = useCallback((name) => THEME_FAMILIES.some(f => f.lightTheme === name || f.darkTheme === name), []);
|
|
117
|
+
const setTheme = useCallback((next) => {
|
|
118
|
+
setThemeState(next);
|
|
119
|
+
userOverrodeRef.current = true;
|
|
120
|
+
// Synchronous local write first: it is what preserves no-FOUC and same-tab
|
|
121
|
+
// behaviour regardless of whether the network write lands.
|
|
122
|
+
saveTheme(next);
|
|
123
|
+
savePrefsRef.current({ theme: next }, { scope: 'device' }).catch((err) => {
|
|
124
|
+
// Logged, never swallowed — silent save failures were the original
|
|
125
|
+
// "my theme keeps resetting" bug. This package stays free of the logger
|
|
126
|
+
// dependency, so console is the sink.
|
|
127
|
+
console.error('[useTheme] prefs save failed', {
|
|
128
|
+
error: err?.message ?? String(err),
|
|
129
|
+
theme: next
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}, []);
|
|
133
|
+
// Another tab changing the theme should be reflected here.
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
const onStorage = (e) => {
|
|
136
|
+
if (e.key !== 'hadoku-theme' || userOverrodeRef.current)
|
|
137
|
+
return;
|
|
138
|
+
// Same normalization as the seed: the host pushes down bare family
|
|
139
|
+
// tokens too, and an unrecognized value must leave the current theme
|
|
140
|
+
// alone rather than knock it back to the default.
|
|
141
|
+
const next = normalizeThemeName(e.newValue);
|
|
142
|
+
if (next)
|
|
143
|
+
setThemeState(next);
|
|
144
|
+
};
|
|
145
|
+
window.addEventListener('storage', onStorage);
|
|
146
|
+
return () => window.removeEventListener('storage', onStorage);
|
|
147
|
+
}, []);
|
|
148
|
+
// Apply to the DOM.
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
const valid = isThemeAvailable(theme) ? theme : 'light';
|
|
151
|
+
document.documentElement.setAttribute('data-theme', valid);
|
|
152
|
+
// Micro-frontend mounts style within their own subtree.
|
|
153
|
+
container?.setAttribute('data-theme', valid);
|
|
154
|
+
applyTheme(valid);
|
|
155
|
+
// Advanced visuals are switched off platform-wide: the Simple/Advanced
|
|
156
|
+
// toggle is gone from the picker, so nothing can turn them on. The kit
|
|
157
|
+
// (advanced.css, THEME_EFFECTS, the hdk-advanced-* classes in JSX) is
|
|
158
|
+
// still shipped and still keyed off this attribute — pinning it to
|
|
159
|
+
// 'simple' is what keeps every surface flat. Restoring the toggle is the
|
|
160
|
+
// only thing needed to bring advanced back.
|
|
161
|
+
setThemeMode('simple');
|
|
162
|
+
// Delay "ready" on the very first pass so consumers can gate a fade-in
|
|
163
|
+
// and avoid a flash of unstyled content.
|
|
164
|
+
if (isInitialThemeLoad) {
|
|
165
|
+
const timer = setTimeout(() => {
|
|
166
|
+
setIsThemeReady(true);
|
|
167
|
+
setIsInitialThemeLoad(false);
|
|
168
|
+
}, 50);
|
|
169
|
+
return () => clearTimeout(timer);
|
|
170
|
+
}
|
|
171
|
+
setIsThemeReady(true);
|
|
172
|
+
}, [theme, container, isInitialThemeLoad, isThemeAvailable]);
|
|
173
|
+
// Adopt the cross-device theme once the SDK resolves — unless the parent
|
|
174
|
+
// shell forced one, or the user already chose this session.
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (propsTheme)
|
|
177
|
+
return;
|
|
178
|
+
if (userOverrodeRef.current)
|
|
179
|
+
return;
|
|
180
|
+
const remote = persisted?.theme;
|
|
181
|
+
if (remote && remote !== theme && isThemeAvailable(remote)) {
|
|
182
|
+
setThemeState(remote);
|
|
183
|
+
// Cache it locally so the NEXT cold load's pre-paint script paints it
|
|
184
|
+
// directly instead of defaulting and swapping.
|
|
185
|
+
saveTheme(remote);
|
|
186
|
+
}
|
|
187
|
+
}, [persisted?.theme, propsTheme, theme, isThemeAvailable]);
|
|
188
|
+
// Follow the OS light/dark switch WITHIN the active family, so someone on
|
|
189
|
+
// `coffee-light` moves to `coffee-dark` at sunset rather than to plain dark.
|
|
190
|
+
// Base light/dark are left alone — those are explicit choices, not families.
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
// jsdom and SSR have no matchMedia, so there is no OS preference to follow.
|
|
193
|
+
if (typeof window === 'undefined' || !window.matchMedia)
|
|
194
|
+
return;
|
|
195
|
+
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
|
196
|
+
const onChange = (e) => {
|
|
197
|
+
const family = theme.replace(/-light$|-dark$/, '');
|
|
198
|
+
if (family === 'light' || family === 'dark')
|
|
199
|
+
return;
|
|
200
|
+
const current = theme.endsWith('-dark') ? 'dark' : 'light';
|
|
201
|
+
const target = e.matches ? 'dark' : 'light';
|
|
202
|
+
if (current !== target)
|
|
203
|
+
setTheme(`${family}-${target}`);
|
|
204
|
+
};
|
|
205
|
+
mq.addEventListener('change', onChange);
|
|
206
|
+
return () => mq.removeEventListener('change', onChange);
|
|
207
|
+
}, [theme, setTheme]);
|
|
208
|
+
return {
|
|
209
|
+
theme,
|
|
210
|
+
setTheme,
|
|
211
|
+
themeFamilies: THEME_FAMILIES,
|
|
212
|
+
isDarkTheme: theme.endsWith('-dark') || theme === 'dark',
|
|
213
|
+
isThemeReady,
|
|
214
|
+
isInitialThemeLoad
|
|
215
|
+
};
|
|
216
|
+
}
|