@pie-players/pie-theme 0.3.64 → 0.3.66

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.
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Enough colour maths to keep a resolved token legible: WCAG relative luminance,
3
+ * a contrast ratio, and the largest share of a hue that still clears a threshold
4
+ * against the surface it is painted on.
5
+ *
6
+ * Colour parsing is injected rather than implemented here. Provider slots resolve
7
+ * to `oklch()` under DaisyUI 5, so an oklch-to-sRGB implementation in this file
8
+ * would be a second opinion about colours the browser has already decided;
9
+ * `createCanvasColorMeasure` asks the browser instead.
10
+ */
11
+ /** WCAG 2.2 1.4.3 for text. */
12
+ export const LEGIBLE_TEXT_MINIMUM = 4.5;
13
+ /** WCAG 2.2 1.4.11 for component boundaries, states and graphical objects. */
14
+ export const LEGIBLE_NON_TEXT_MINIMUM = 3;
15
+ /**
16
+ * The hue share used when contrast cannot be measured. 30% is the largest 5%
17
+ * step that clears 4.5:1 for every success, error and warning slot across
18
+ * DaisyUI's 28 shipped themes — measured across all 84 combinations rather than
19
+ * picked. It is deliberately pessimistic: a theme whose slot needed no
20
+ * correction at all still gets pulled most of the way to the text colour.
21
+ */
22
+ export const UNMEASURED_HUE_WEIGHT = 30;
23
+ /**
24
+ * The same pessimistic fallback for a 3:1 target: 35% is the largest 5% step
25
+ * that clears 3:1 for `--color-base-300` and `--color-neutral` in all 28 themes.
26
+ */
27
+ export const UNMEASURED_NON_TEXT_HUE_WEIGHT = 35;
28
+ const HUE_WEIGHT_STEP = 5;
29
+ function channelLuminance(channel) {
30
+ const c = channel / 255;
31
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
32
+ }
33
+ /** WCAG relative luminance. Alpha is ignored; composite before calling. */
34
+ export function relativeLuminance(color) {
35
+ return (0.2126 * channelLuminance(color.r) +
36
+ 0.7152 * channelLuminance(color.g) +
37
+ 0.0722 * channelLuminance(color.b));
38
+ }
39
+ export function contrastRatio(foreground, background) {
40
+ const a = relativeLuminance(foreground);
41
+ const b = relativeLuminance(background);
42
+ return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
43
+ }
44
+ export function mixTowards(hue, target, hueWeight) {
45
+ return `color-mix(in srgb, ${hue} ${hueWeight}%, ${target})`;
46
+ }
47
+ /**
48
+ * Resolves any CSS colour the browser understands to 8-bit sRGB by painting one
49
+ * pixel and reading it back, so `oklch()` and `color-mix()` need no parser here.
50
+ *
51
+ * Returns `null` where there is no canvas to paint on — a server render, or a
52
+ * DOM shim in tests — which callers treat as "contrast is unmeasurable" rather
53
+ * than as an error.
54
+ */
55
+ export function createCanvasColorMeasure() {
56
+ if (typeof document === "undefined") {
57
+ return null;
58
+ }
59
+ const canvas = document.createElement("canvas");
60
+ canvas.width = 1;
61
+ canvas.height = 1;
62
+ const context = canvas.getContext?.("2d", { willReadFrequently: true });
63
+ if (!context) {
64
+ return null;
65
+ }
66
+ /**
67
+ * `fillStyle` silently ignores a value it cannot parse, leaving the previous
68
+ * one in place. Assigning over two different sentinels separates the cases
69
+ * exactly: a parsed colour serializes the same both times, an ignored one
70
+ * keeps whichever sentinel it started from.
71
+ */
72
+ return (value) => {
73
+ const trimmed = value.trim();
74
+ if (!trimmed) {
75
+ return null;
76
+ }
77
+ try {
78
+ context.fillStyle = "#000000";
79
+ context.fillStyle = trimmed;
80
+ const fromBlack = context.fillStyle;
81
+ context.fillStyle = "#ffffff";
82
+ context.fillStyle = trimmed;
83
+ if (context.fillStyle !== fromBlack) {
84
+ return null;
85
+ }
86
+ context.clearRect(0, 0, 1, 1);
87
+ context.fillRect(0, 0, 1, 1);
88
+ const data = context.getImageData(0, 0, 1, 1).data;
89
+ return {
90
+ r: data[0],
91
+ g: data[1],
92
+ b: data[2],
93
+ a: data[3] / 255,
94
+ };
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ };
100
+ }
101
+ /**
102
+ * The given hue if it already clears `minimum` against `background`, otherwise
103
+ * the largest share of it that does, mixed toward `text`.
104
+ *
105
+ * Mixing toward the theme's own text colour rather than toward black or white
106
+ * uses the theme's own guarantee: `text` is what that theme chose to be readable
107
+ * on that surface, in a light theme and a dark one alike. Stepping down from the
108
+ * top keeps as much hue as the threshold allows, so a slot that was already fine
109
+ * comes back untouched and a slot that was invisible loses only what it must.
110
+ */
111
+ export function legibleColorAgainst(args) {
112
+ const { hue, text, background, measure } = args;
113
+ if (!hue) {
114
+ return undefined;
115
+ }
116
+ if (!text) {
117
+ return hue;
118
+ }
119
+ const minimum = args.minimum ?? LEGIBLE_TEXT_MINIMUM;
120
+ const fallbackWeight = args.unmeasuredHueWeight ?? UNMEASURED_HUE_WEIGHT;
121
+ const unmeasured = () => mixTowards(hue, text, fallbackWeight);
122
+ if (!measure || !background) {
123
+ return unmeasured();
124
+ }
125
+ const surface = measure(background);
126
+ const raw = measure(hue);
127
+ // Compositing a translucent value needs the surface behind the surface, which
128
+ // a provider adapter reading one element does not have. Correcting against a
129
+ // guess is worse than taking the pessimistic weight.
130
+ if (!surface || !raw || surface.a < 1 || raw.a < 1) {
131
+ return unmeasured();
132
+ }
133
+ if (contrastRatio(raw, surface) >= minimum) {
134
+ return hue;
135
+ }
136
+ for (let weight = 100 - HUE_WEIGHT_STEP; weight >= HUE_WEIGHT_STEP; weight -= HUE_WEIGHT_STEP) {
137
+ const candidate = mixTowards(hue, text, weight);
138
+ const resolved = measure(candidate);
139
+ // The measurer cannot resolve `color-mix`, so every further step is
140
+ // unmeasurable too. Stop rather than walking down to the text colour.
141
+ if (!resolved) {
142
+ return unmeasured();
143
+ }
144
+ if (contrastRatio(resolved, surface) >= minimum) {
145
+ return candidate;
146
+ }
147
+ }
148
+ // Nothing with any hue left in it passes, so fall back to the one colour the
149
+ // theme guarantees against this surface.
150
+ return text;
151
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The one table that says which DaisyUI slot each `--pie-*` token comes from,
3
+ * and one renderer that turns it into variables.
4
+ *
5
+ * It exists because the same 47-row table was written out four times — the
6
+ * provider adapter here, two mappers in `@pie-players/pie-theme-daisyui`, and
7
+ * that package's `bridge.css` — and copies drift. Two defects lived in the drift:
8
+ * `--pie-missing` was corrected to `--color-warning` in one copy while three kept
9
+ * it on `--color-error`, and the parity test that was supposed to catch this
10
+ * compared only the token names, never the slot each one derived from.
11
+ *
12
+ * CSS cannot import a table, so `bridge.css` stays hand-written and is held to
13
+ * this one by `tests/daisyui-mapping-parity.test.mjs` instead.
14
+ */
15
+ import { type ColorMeasure } from "./contrast.js";
16
+ /** The DaisyUI slots this mapping reads. Not all of DaisyUI's palette. */
17
+ export type DaisySlot = "base100" | "base200" | "base300" | "baseContent" | "primary" | "secondary" | "accent" | "neutral" | "neutralContent" | "success" | "error" | "warning";
18
+ export declare const DAISY_SLOT_CSS_VARIABLES: Record<DaisySlot, string>;
19
+ export type DaisyMappingEntry =
20
+ /** The slot, verbatim. */
21
+ {
22
+ token: string;
23
+ kind: "direct";
24
+ from: DaisySlot;
25
+ }
26
+ /** A fixed blend, for tints and shades that are not contrast-critical. */
27
+ | {
28
+ token: string;
29
+ kind: "mix";
30
+ from: DaisySlot;
31
+ towards: DaisySlot;
32
+ weight: number;
33
+ }
34
+ /**
35
+ * The slot if it already clears `minimum` against the page, otherwise the
36
+ * largest share of it that does. For the tokens PIE paints as a foreground or
37
+ * as the boundary of a control, where a DaisyUI surface slot taken verbatim is
38
+ * unreadable.
39
+ */
40
+ | {
41
+ token: string;
42
+ kind: "legible";
43
+ from: DaisySlot;
44
+ minimum: number;
45
+ fallbackWeight: number;
46
+ };
47
+ export declare const DAISYUI_PIE_TOKEN_MAP: readonly DaisyMappingEntry[];
48
+ /**
49
+ * @param read one DaisyUI slot's value, or `undefined` when this source has none
50
+ * @param measure resolves a colour so `legible` entries can be corrected against
51
+ * a measured ratio. Omit it where the values are `var()` references rather than
52
+ * colours: those cannot be measured, and every `legible` entry then takes its
53
+ * pessimistic fixed weight instead.
54
+ */
55
+ export declare function resolveDaisyPieVariables(args: {
56
+ read: (slot: DaisySlot) => string | undefined;
57
+ measure?: ColorMeasure | null;
58
+ }): Record<string, string>;
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The one table that says which DaisyUI slot each `--pie-*` token comes from,
3
+ * and one renderer that turns it into variables.
4
+ *
5
+ * It exists because the same 47-row table was written out four times — the
6
+ * provider adapter here, two mappers in `@pie-players/pie-theme-daisyui`, and
7
+ * that package's `bridge.css` — and copies drift. Two defects lived in the drift:
8
+ * `--pie-missing` was corrected to `--color-warning` in one copy while three kept
9
+ * it on `--color-error`, and the parity test that was supposed to catch this
10
+ * compared only the token names, never the slot each one derived from.
11
+ *
12
+ * CSS cannot import a table, so `bridge.css` stays hand-written and is held to
13
+ * this one by `tests/daisyui-mapping-parity.test.mjs` instead.
14
+ */
15
+ import { LEGIBLE_NON_TEXT_MINIMUM, LEGIBLE_TEXT_MINIMUM, UNMEASURED_HUE_WEIGHT, UNMEASURED_NON_TEXT_HUE_WEIGHT, legibleColorAgainst, } from "./contrast.js";
16
+ export const DAISY_SLOT_CSS_VARIABLES = {
17
+ base100: "--color-base-100",
18
+ base200: "--color-base-200",
19
+ base300: "--color-base-300",
20
+ baseContent: "--color-base-content",
21
+ primary: "--color-primary",
22
+ secondary: "--color-secondary",
23
+ accent: "--color-accent",
24
+ neutral: "--color-neutral",
25
+ neutralContent: "--color-neutral-content",
26
+ success: "--color-success",
27
+ error: "--color-error",
28
+ warning: "--color-warning",
29
+ };
30
+ const legible = (token, from) => ({
31
+ token,
32
+ kind: "legible",
33
+ from,
34
+ minimum: LEGIBLE_TEXT_MINIMUM,
35
+ fallbackWeight: UNMEASURED_HUE_WEIGHT,
36
+ });
37
+ const boundary = (token, from) => ({
38
+ token,
39
+ kind: "legible",
40
+ from,
41
+ minimum: LEGIBLE_NON_TEXT_MINIMUM,
42
+ fallbackWeight: UNMEASURED_NON_TEXT_HUE_WEIGHT,
43
+ });
44
+ const direct = (token, from) => ({
45
+ token,
46
+ kind: "direct",
47
+ from,
48
+ });
49
+ const mix = (token, from, towards, weight) => ({ token, kind: "mix", from, towards, weight });
50
+ export const DAISYUI_PIE_TOKEN_MAP = [
51
+ direct("--pie-background", "base100"),
52
+ direct("--pie-background-dark", "base200"),
53
+ direct("--pie-secondary-background", "base200"),
54
+ direct("--pie-dropdown-background", "base300"),
55
+ direct("--pie-text", "baseContent"),
56
+ direct("--pie-primary", "primary"),
57
+ mix("--pie-primary-light", "primary", "base100", 60),
58
+ mix("--pie-primary-dark", "primary", "baseContent", 75),
59
+ mix("--pie-faded-primary", "primary", "base100", 20),
60
+ direct("--pie-secondary", "secondary"),
61
+ mix("--pie-secondary-light", "secondary", "base100", 60),
62
+ mix("--pie-secondary-dark", "secondary", "baseContent", 75),
63
+ direct("--pie-tertiary", "accent"),
64
+ mix("--pie-tertiary-light", "accent", "base100", 60),
65
+ // Boundaries. `--color-base-300` is a surface tint, so an outline painted with
66
+ // it sits at 1.09:1 to 1.53:1 across the shipped themes against the 3:1 SC
67
+ // 1.4.11 asks -- and since `--pie-button-bg` is `--color-base-100`, the page's
68
+ // own colour, that outline is the only thing separating a button from the page.
69
+ boundary("--pie-border", "base300"),
70
+ // Not corrected: the players use this one for card edges and pane dividers,
71
+ // which 1.4.11 exempts, and a 3:1 outline around every item card would be a
72
+ // visual regression rather than a fix.
73
+ direct("--pie-border-light", "base200"),
74
+ boundary("--pie-border-dark", "neutral"),
75
+ boundary("--pie-border-gray", "base300"),
76
+ // Foregrounds. DaisyUI's semantic slots are chosen to sit behind their
77
+ // `-content` counterparts; PIE paints these as `color:`.
78
+ legible("--pie-correct", "success"),
79
+ mix("--pie-correct-secondary", "success", "base100", 20),
80
+ legible("--pie-correct-tertiary", "success"),
81
+ legible("--pie-correct-icon", "success"),
82
+ // Authored red emphasis inside content. Taken from the error slot through the
83
+ // legible correction rather than mixed from a bare red: a red-toward-ink mix
84
+ // falls under 4.5:1 on seven of the 35 shipped themes (2.91:1 on `aqua`),
85
+ // while the corrected error family clears it on all of them.
86
+ legible("--pie-content-emphasis", "error"),
87
+ legible("--pie-incorrect", "error"),
88
+ mix("--pie-incorrect-secondary", "error", "base100", 20),
89
+ legible("--pie-incorrect-icon", "error"),
90
+ // Warning, not error: an unanswered question is not a wrong one, and both on
91
+ // `--color-error` made the two states the same colour in every theme. This is
92
+ // the mapping the rest of PIE declares -- pie-elements-ng keys `--pie-missing`
93
+ // to `warning`, and the assessment toolkit's `.pie-warning` rule paints it.
94
+ legible("--pie-missing", "warning"),
95
+ legible("--pie-missing-icon", "warning"),
96
+ direct("--pie-disabled", "base300"),
97
+ direct("--pie-disabled-secondary", "base200"),
98
+ mix("--pie-focus-checked", "primary", "base100", 20),
99
+ direct("--pie-focus-checked-border", "primary"),
100
+ direct("--pie-focus-unchecked", "base200"),
101
+ direct("--pie-focus-unchecked-border", "base300"),
102
+ direct("--pie-blue-grey-100", "base100"),
103
+ direct("--pie-blue-grey-300", "base200"),
104
+ direct("--pie-blue-grey-600", "base300"),
105
+ direct("--pie-blue-grey-900", "baseContent"),
106
+ direct("--pie-black", "neutralContent"),
107
+ direct("--pie-white", "base100"),
108
+ direct("--pie-button-bg", "base100"),
109
+ boundary("--pie-button-border", "base300"),
110
+ direct("--pie-button-color", "baseContent"),
111
+ direct("--pie-button-hover-bg", "base200"),
112
+ boundary("--pie-button-hover-border", "base300"),
113
+ direct("--pie-button-hover-color", "baseContent"),
114
+ // DaisyUI guarantees base-content against the page, not every deeper surface.
115
+ // `valentine` is 4.17:1 on base-300. A 70% share is the nearest 5% step
116
+ // toward base-100 that keeps the pair at 4.5:1 across all shipped themes.
117
+ mix("--pie-button-active-bg", "base300", "base100", 70),
118
+ // The keyboard focus indicator, and the one token here that is only ever an
119
+ // `outline` -- eight declarations across the tools, never a fill. It takes
120
+ // `--color-primary`, which DaisyUI pairs with `--color-primary-content` for
121
+ // fills rather than choosing for contrast against the page, so it inherited
122
+ // 1.90:1 in `business`. `--pie-primary` itself cannot be corrected the same
123
+ // way: PIE paints it as a foreground in some places and as a button fill in
124
+ // others, and a value legible against the page is the wrong one behind
125
+ // `--color-primary-content`.
126
+ boundary("--pie-button-focus-outline", "primary"),
127
+ ];
128
+ /**
129
+ * @param read one DaisyUI slot's value, or `undefined` when this source has none
130
+ * @param measure resolves a colour so `legible` entries can be corrected against
131
+ * a measured ratio. Omit it where the values are `var()` references rather than
132
+ * colours: those cannot be measured, and every `legible` entry then takes its
133
+ * pessimistic fixed weight instead.
134
+ */
135
+ export function resolveDaisyPieVariables(args) {
136
+ const { read, measure } = args;
137
+ const resolved = {};
138
+ for (const entry of DAISYUI_PIE_TOKEN_MAP) {
139
+ const from = read(entry.from);
140
+ if (!from) {
141
+ continue;
142
+ }
143
+ let value;
144
+ if (entry.kind === "direct") {
145
+ value = from;
146
+ }
147
+ else if (entry.kind === "mix") {
148
+ const towards = read(entry.towards);
149
+ value = towards
150
+ ? `color-mix(in srgb, ${from} ${entry.weight}%, ${towards})`
151
+ : undefined;
152
+ }
153
+ else {
154
+ value = legibleColorAgainst({
155
+ hue: from,
156
+ text: read("baseContent"),
157
+ background: read("base100"),
158
+ measure,
159
+ minimum: entry.minimum,
160
+ unmeasuredHueWeight: entry.fallbackWeight,
161
+ });
162
+ }
163
+ if (value) {
164
+ resolved[entry.token] = value;
165
+ }
166
+ }
167
+ return resolved;
168
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { PieThemeElement, definePieTheme } from "./theme-element.js";
2
2
  export { PieThemeElement, definePieTheme };
3
- export { DAISYUI_THEME_PROVIDER_ADAPTER, getPieThemeProvider, listPieThemeProviders, registerPieThemeProvider, resolveProviderVariables, unregisterPieThemeProvider, type ThemeProviderAdapter, } from "./providers.js";
4
- export { isThemeMode, isThemeScope, normalizePieThemeVariables, type ThemeMode, type ThemeScope, type ThemeVariables, } from "./theme-types.js";
5
- export { DARK_THEME_VARS, LIGHT_THEME_VARS } from "./theme-defaults.js";
6
- export { BUILTIN_PIE_COLOR_SCHEMES, getPieColorScheme, listPieColorSchemes, registerPieColorSchemes, resolvePieColorSchemeVariables, unregisterPieColorScheme, type PieColorSchemeDefinition, type PieColorSchemePreview, } from "./color-schemes.js";
3
+ export { DAISYUI_THEME_PROVIDER_ADAPTER, getPieThemeProvider, PIE_THEME_PROVIDER_NONE, listPieThemeProviders, registerPieThemeProvider, resolveProviderVariables, unregisterPieThemeProvider, type ThemeProviderAdapter, } from "./providers.js";
4
+ export { DAISY_SLOT_CSS_VARIABLES, DAISYUI_PIE_TOKEN_MAP, resolveDaisyPieVariables, type DaisyMappingEntry, type DaisySlot, } from "./daisyui-mapping.js";
5
+ export { createCanvasColorMeasure, type ColorMeasure, } from "./contrast.js";
6
+ export { isThemeMode, isThemeScope, normalizePieThemeVariables, type ColorSchemeSnapshot, type PieColorSchemeDescriptor, type PieColorSchemePreview, type PieThemeDiagnostic, type PieThemeDiagnosticCode, type PieThemeObserver, type PieThemeResolutionStatus, type RegisteredPieColorScheme, type RegistrationReceipt, type ResolvePieThemeInput, type ThemeResolution, type ThemeMode, type ThemeScope, type ThemeTokenName, type ThemeVariables, type Unsubscribe, } from "./theme-types.js";
7
+ export type { PieThemeSchemeParticipation, PieThemeTokenRegistry, PieThemeTokenRegistryEntry, PieThemeTokenScope, PieThemeTokenStatus, } from "./token-registry-types.js";
8
+ export { listPieColorSchemes, observePieColorSchemes, registerPieColorSchemes, resolvePieTheme, } from "./color-schemes.js";
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { PieThemeElement, definePieTheme } from "./theme-element.js";
2
2
  export { PieThemeElement, definePieTheme };
3
- export { DAISYUI_THEME_PROVIDER_ADAPTER, getPieThemeProvider, listPieThemeProviders, registerPieThemeProvider, resolveProviderVariables, unregisterPieThemeProvider, } from "./providers.js";
3
+ export { DAISYUI_THEME_PROVIDER_ADAPTER, getPieThemeProvider, PIE_THEME_PROVIDER_NONE, listPieThemeProviders, registerPieThemeProvider, resolveProviderVariables, unregisterPieThemeProvider, } from "./providers.js";
4
+ export { DAISY_SLOT_CSS_VARIABLES, DAISYUI_PIE_TOKEN_MAP, resolveDaisyPieVariables, } from "./daisyui-mapping.js";
5
+ export { createCanvasColorMeasure, } from "./contrast.js";
4
6
  export { isThemeMode, isThemeScope, normalizePieThemeVariables, } from "./theme-types.js";
5
- export { DARK_THEME_VARS, LIGHT_THEME_VARS } from "./theme-defaults.js";
6
- export { BUILTIN_PIE_COLOR_SCHEMES, getPieColorScheme, listPieColorSchemes, registerPieColorSchemes, resolvePieColorSchemeVariables, unregisterPieColorScheme, } from "./color-schemes.js";
7
+ export { listPieColorSchemes, observePieColorSchemes, registerPieColorSchemes, resolvePieTheme, } from "./color-schemes.js";
7
8
  definePieTheme();
@@ -4,9 +4,25 @@ export interface ThemeProviderAdapter {
4
4
  canRead(target: HTMLElement): boolean;
5
5
  read(target: HTMLElement): ThemeVariables;
6
6
  }
7
+ /**
8
+ * Provider mode that resolves nothing, leaving this package's shipped defaults
9
+ * in place.
10
+ *
11
+ * `"auto"` lets any registered adapter that can read the target win, which on a
12
+ * DaisyUI page means PIE tokens follow `--color-*`. This is how a host asks for
13
+ * the palette it would have had before adopting a provider — the first question
14
+ * to answer when colours differ between two environments.
15
+ *
16
+ * Distinct from naming an unregistered provider, which lands in the same place
17
+ * by accident. `unregisterPieThemeProvider` cannot remove this one because it is
18
+ * not in the registry at all, so the mode cannot be taken away from a host.
19
+ */
20
+ export declare const PIE_THEME_PROVIDER_NONE = "none";
7
21
  export declare const DAISYUI_THEME_PROVIDER_ADAPTER: ThemeProviderAdapter;
8
22
  export declare function registerPieThemeProvider(adapter: ThemeProviderAdapter): void;
9
23
  export declare function unregisterPieThemeProvider(providerId: string): void;
24
+ /** Package-internal invalidation used by connected pie-theme elements. */
25
+ export declare function observePieThemeProviders(listener: () => void): () => void;
10
26
  export declare function listPieThemeProviders(): ThemeProviderAdapter[];
11
27
  export declare function getPieThemeProvider(providerId: string): ThemeProviderAdapter | undefined;
12
28
  export declare function resolveProviderVariables(args: {
package/dist/providers.js CHANGED
@@ -1,102 +1,52 @@
1
+ import { createCanvasColorMeasure } from "./contrast.js";
2
+ import { DAISY_SLOT_CSS_VARIABLES, resolveDaisyPieVariables, } from "./daisyui-mapping.js";
1
3
  import { normalizePieThemeVariables, } from "./theme-types.js";
2
4
  const themeProviderRegistry = new Map();
5
+ const themeProviderObservers = new Set();
6
+ function notifyThemeProviderObservers() {
7
+ for (const listener of [...themeProviderObservers]) {
8
+ try {
9
+ listener();
10
+ }
11
+ catch {
12
+ console.warn("[pie-theme] A theme-provider observer threw while receiving an update.");
13
+ }
14
+ }
15
+ }
16
+ /**
17
+ * Provider mode that resolves nothing, leaving this package's shipped defaults
18
+ * in place.
19
+ *
20
+ * `"auto"` lets any registered adapter that can read the target win, which on a
21
+ * DaisyUI page means PIE tokens follow `--color-*`. This is how a host asks for
22
+ * the palette it would have had before adopting a provider — the first question
23
+ * to answer when colours differ between two environments.
24
+ *
25
+ * Distinct from naming an unregistered provider, which lands in the same place
26
+ * by accident. `unregisterPieThemeProvider` cannot remove this one because it is
27
+ * not in the registry at all, so the mode cannot be taken away from a host.
28
+ */
29
+ export const PIE_THEME_PROVIDER_NONE = "none";
3
30
  function trimCssVar(value) {
4
31
  const trimmed = value.trim();
5
32
  return trimmed ? trimmed : undefined;
6
33
  }
7
- function mixResolvedColors(args) {
8
- if (!args.left || !args.right) {
9
- return undefined;
34
+ let cachedColorMeasure;
35
+ /**
36
+ * One measurer for the lifetime of the page. Parsing a colour does not depend on
37
+ * which document asked, and a resolution pass touches every corrected slot.
38
+ */
39
+ function colorMeasure() {
40
+ if (cachedColorMeasure === undefined) {
41
+ cachedColorMeasure = createCanvasColorMeasure();
10
42
  }
11
- return `color-mix(in srgb, ${args.left} ${args.leftWeight}, ${args.right})`;
43
+ return cachedColorMeasure;
12
44
  }
13
45
  function mapComputedDaisyVars(computed) {
14
- const value = (key) => trimCssVar(computed.getPropertyValue(key));
15
- return normalizePieThemeVariables({
16
- "--pie-background": value("--color-base-100"),
17
- "--pie-background-dark": value("--color-base-200"),
18
- "--pie-secondary-background": value("--color-base-200"),
19
- "--pie-dropdown-background": value("--color-base-300"),
20
- "--pie-text": value("--color-base-content"),
21
- "--pie-primary": value("--color-primary"),
22
- "--pie-primary-light": mixResolvedColors({
23
- left: value("--color-primary"),
24
- right: value("--color-base-100"),
25
- leftWeight: "60%",
26
- }),
27
- "--pie-primary-dark": mixResolvedColors({
28
- left: value("--color-primary"),
29
- right: value("--color-base-content"),
30
- leftWeight: "75%",
31
- }),
32
- "--pie-faded-primary": mixResolvedColors({
33
- left: value("--color-primary"),
34
- right: value("--color-base-100"),
35
- leftWeight: "20%",
36
- }),
37
- "--pie-secondary": value("--color-secondary"),
38
- "--pie-secondary-light": mixResolvedColors({
39
- left: value("--color-secondary"),
40
- right: value("--color-base-100"),
41
- leftWeight: "60%",
42
- }),
43
- "--pie-secondary-dark": mixResolvedColors({
44
- left: value("--color-secondary"),
45
- right: value("--color-base-content"),
46
- leftWeight: "75%",
47
- }),
48
- "--pie-tertiary": value("--color-accent"),
49
- "--pie-tertiary-light": mixResolvedColors({
50
- left: value("--color-accent"),
51
- right: value("--color-base-100"),
52
- leftWeight: "60%",
53
- }),
54
- "--pie-border": value("--color-base-300"),
55
- "--pie-border-light": value("--color-base-200"),
56
- "--pie-border-dark": value("--color-neutral"),
57
- "--pie-border-gray": value("--color-base-300"),
58
- "--pie-correct": value("--color-success"),
59
- "--pie-correct-secondary": mixResolvedColors({
60
- left: value("--color-success"),
61
- right: value("--color-base-100"),
62
- leftWeight: "20%",
63
- }),
64
- "--pie-correct-tertiary": value("--color-success"),
65
- "--pie-correct-icon": value("--color-success"),
66
- "--pie-incorrect": value("--color-error"),
67
- "--pie-incorrect-secondary": mixResolvedColors({
68
- left: value("--color-error"),
69
- right: value("--color-base-100"),
70
- leftWeight: "20%",
71
- }),
72
- "--pie-incorrect-icon": value("--color-error"),
73
- "--pie-missing": value("--color-error"),
74
- "--pie-missing-icon": value("--color-error"),
75
- "--pie-disabled": value("--color-base-300"),
76
- "--pie-disabled-secondary": value("--color-base-200"),
77
- "--pie-focus-checked": mixResolvedColors({
78
- left: value("--color-primary"),
79
- right: value("--color-base-100"),
80
- leftWeight: "20%",
81
- }),
82
- "--pie-focus-checked-border": value("--color-primary"),
83
- "--pie-focus-unchecked": value("--color-base-200"),
84
- "--pie-focus-unchecked-border": value("--color-base-300"),
85
- "--pie-blue-grey-100": value("--color-base-100"),
86
- "--pie-blue-grey-300": value("--color-base-200"),
87
- "--pie-blue-grey-600": value("--color-base-300"),
88
- "--pie-blue-grey-900": value("--color-base-content"),
89
- "--pie-black": value("--color-neutral-content"),
90
- "--pie-white": value("--color-base-100"),
91
- "--pie-button-bg": value("--color-base-100"),
92
- "--pie-button-border": value("--color-base-300"),
93
- "--pie-button-color": value("--color-base-content"),
94
- "--pie-button-hover-bg": value("--color-base-200"),
95
- "--pie-button-hover-border": value("--color-base-300"),
96
- "--pie-button-hover-color": value("--color-base-content"),
97
- "--pie-button-active-bg": value("--color-base-300"),
98
- "--pie-button-focus-outline": value("--color-primary"),
99
- });
46
+ return normalizePieThemeVariables(resolveDaisyPieVariables({
47
+ read: (slot) => trimCssVar(computed.getPropertyValue(DAISY_SLOT_CSS_VARIABLES[slot])),
48
+ measure: colorMeasure(),
49
+ }));
100
50
  }
101
51
  export const DAISYUI_THEME_PROVIDER_ADAPTER = {
102
52
  id: "daisyui",
@@ -115,7 +65,15 @@ export function registerPieThemeProvider(adapter) {
115
65
  if (!adapter?.id) {
116
66
  return;
117
67
  }
68
+ if (adapter.id === PIE_THEME_PROVIDER_NONE) {
69
+ console.warn(`[pie-theme] Theme-provider id "${PIE_THEME_PROVIDER_NONE}" is reserved.`);
70
+ return;
71
+ }
72
+ if (themeProviderRegistry.get(adapter.id) === adapter) {
73
+ return;
74
+ }
118
75
  themeProviderRegistry.set(adapter.id, adapter);
76
+ notifyThemeProviderObservers();
119
77
  }
120
78
  export function unregisterPieThemeProvider(providerId) {
121
79
  if (!providerId) {
@@ -124,7 +82,20 @@ export function unregisterPieThemeProvider(providerId) {
124
82
  if (providerId === DAISYUI_THEME_PROVIDER_ADAPTER.id) {
125
83
  return;
126
84
  }
127
- themeProviderRegistry.delete(providerId);
85
+ if (themeProviderRegistry.delete(providerId)) {
86
+ notifyThemeProviderObservers();
87
+ }
88
+ }
89
+ /** Package-internal invalidation used by connected pie-theme elements. */
90
+ export function observePieThemeProviders(listener) {
91
+ themeProviderObservers.add(listener);
92
+ let active = true;
93
+ return () => {
94
+ if (!active)
95
+ return;
96
+ active = false;
97
+ themeProviderObservers.delete(listener);
98
+ };
128
99
  }
129
100
  export function listPieThemeProviders() {
130
101
  return [...themeProviderRegistry.values()];
@@ -134,6 +105,12 @@ export function getPieThemeProvider(providerId) {
134
105
  }
135
106
  export function resolveProviderVariables(args) {
136
107
  const providerMode = args.provider?.trim() || "auto";
108
+ // Before the registry lookup and before the documentElement retry below: the
109
+ // point of this mode is that nothing resolves, and a retry against a themed
110
+ // <html> would put the provider's values back.
111
+ if (providerMode === PIE_THEME_PROVIDER_NONE) {
112
+ return {};
113
+ }
137
114
  const resolveFromTarget = (target) => {
138
115
  if (providerMode && providerMode !== "auto") {
139
116
  const provider = themeProviderRegistry.get(providerMode);