@hadoku/themes 0.0.0-stage → 5.12.15

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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE-lucide +43 -0
  3. package/README.md +433 -2
  4. package/THEME_USAGE_GUIDE.md +308 -0
  5. package/dist/HadokuThemeRoot.d.ts +53 -0
  6. package/dist/HadokuThemeRoot.js +66 -0
  7. package/dist/ThemeIcons.d.ts +31 -0
  8. package/dist/ThemeIcons.js +91 -0
  9. package/dist/effects.d.ts +25 -0
  10. package/dist/effects.js +25 -0
  11. package/dist/icons/Icon.d.ts +36 -0
  12. package/dist/icons/Icon.js +89 -0
  13. package/dist/icons/index.d.ts +61 -0
  14. package/dist/icons/index.js +87 -0
  15. package/dist/icons/registry.generated.d.ts +158 -0
  16. package/dist/icons/registry.generated.js +446 -0
  17. package/dist/index.d.ts +82 -0
  18. package/dist/index.js +169 -0
  19. package/dist/metadata.d.ts +35 -0
  20. package/dist/metadata.js +108 -0
  21. package/dist/platform/index.d.ts +94 -0
  22. package/dist/platform/index.js +149 -0
  23. package/dist/platform/usePlatform.d.ts +29 -0
  24. package/dist/platform/usePlatform.js +65 -0
  25. package/dist/prefsApiBase.d.ts +37 -0
  26. package/dist/prefsApiBase.js +40 -0
  27. package/dist/theme-types.d.ts +34 -0
  28. package/dist/theme-types.js +1 -0
  29. package/dist/themeContext.d.ts +62 -0
  30. package/dist/themeContext.js +70 -0
  31. package/dist/themePrefs.d.ts +29 -0
  32. package/dist/themePrefs.js +48 -0
  33. package/dist/useRefElement.d.ts +19 -0
  34. package/dist/useRefElement.js +52 -0
  35. package/dist/useTheme.d.ts +46 -0
  36. package/dist/useTheme.js +216 -0
  37. package/package.json +83 -4
  38. package/scripts/audit-tokens.mjs +126 -0
  39. package/scripts/check-contrast.mjs +24 -0
  40. package/scripts/check-docs.mjs +53 -0
  41. package/scripts/check-icons.mjs +473 -0
  42. package/scripts/check-usage.mjs +297 -0
  43. package/scripts/codemod-icons.mjs +396 -0
  44. package/scripts/generate-icons.mjs +199 -0
  45. package/scripts/generate-preview.mjs +374 -0
  46. package/scripts/generate-tailwind-colors.mjs +109 -0
  47. package/scripts/lib/color.mjs +184 -0
  48. package/scripts/lib/contract.d.mts +58 -0
  49. package/scripts/lib/contract.mjs +348 -0
  50. package/scripts/lib/derive.mjs +324 -0
  51. package/scripts/lib/icon-registry.mjs +92 -0
  52. package/scripts/lib/parse-themes.mjs +19 -0
  53. package/scripts/lib/tokens.mjs +188 -0
  54. package/scripts/normalize-tokens.mjs +161 -0
  55. package/src/advanced.css +218 -0
  56. package/src/icons/Icon.tsx +151 -0
  57. package/src/icons/emoji-map.json +202 -0
  58. package/src/icons/index.ts +151 -0
  59. package/src/icons/registry.generated.ts +455 -0
  60. package/src/icons/sources.json +150 -0
  61. package/src/icons.css +145 -0
  62. package/src/style.css +1421 -0
  63. package/src/tailwind-colors.css +88 -0
  64. 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
+ };
@@ -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
+ }