@pie-players/pie-theme 0.3.65 → 0.3.67

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.
@@ -8,7 +8,11 @@ export declare class PieThemeElement extends HTMLElementBase {
8
8
  private mediaQuery;
9
9
  private readonly onMediaChange;
10
10
  private previousKeys;
11
+ private previousTarget;
11
12
  private variablesOverride;
13
+ private promoteDocumentOwnership;
14
+ private stopObservingSchemes;
15
+ private stopObservingProviders;
12
16
  get theme(): ThemeMode;
13
17
  set theme(value: ThemeMode);
14
18
  get scope(): ThemeScope;
@@ -25,6 +29,7 @@ export declare class PieThemeElement extends HTMLElementBase {
25
29
  protected getTarget(): HTMLElement;
26
30
  protected applyTheme(): void;
27
31
  private resolveThemeState;
32
+ private clearPreviousTarget;
28
33
  private clearPreviousKeys;
29
34
  private setupAutoThemeListener;
30
35
  }
@@ -1,6 +1,5 @@
1
- import { resolvePieColorSchemeVariables } from "./color-schemes.js";
2
- import { DARK_THEME_VARS, LIGHT_THEME_VARS } from "./theme-defaults.js";
3
- import { resolveProviderVariables } from "./providers.js";
1
+ import { observePieColorSchemes, resolvePieTheme } from "./color-schemes.js";
2
+ import { observePieThemeProviders, resolveProviderVariables, } from "./providers.js";
4
3
  import { isThemeMode, isThemeScope, } from "./theme-types.js";
5
4
  const HTMLElementBase = typeof HTMLElement === "undefined"
6
5
  ? class {
@@ -40,15 +39,82 @@ function parseVariableOverrides(value) {
40
39
  if (!key.startsWith("--")) {
41
40
  continue;
42
41
  }
43
- if (typeof rawValue === "string") {
44
- output[key] = rawValue;
42
+ if (typeof rawValue === "string" && rawValue.trim()) {
43
+ output[key] = rawValue.trim();
45
44
  }
46
- else if (typeof rawValue === "number") {
45
+ else if (typeof rawValue === "number" && Number.isFinite(rawValue)) {
47
46
  output[key] = String(rawValue);
48
47
  }
49
48
  }
50
49
  return output;
51
50
  }
51
+ const documentThemeOwners = new Map();
52
+ let documentThemeBaseline = null;
53
+ let documentThemeAppliedKeys = new Set();
54
+ function ensureDocumentThemeBaseline(target, tokens) {
55
+ documentThemeBaseline ??= {
56
+ dataTheme: target.getAttribute("data-theme"),
57
+ dataColorScheme: target.getAttribute("data-color-scheme"),
58
+ variables: new Map(),
59
+ };
60
+ for (const token of tokens) {
61
+ if (documentThemeBaseline.variables.has(token))
62
+ continue;
63
+ const value = target.style.getPropertyValue(token);
64
+ documentThemeBaseline.variables.set(token, value
65
+ ? { value, priority: target.style.getPropertyPriority(token) }
66
+ : null);
67
+ }
68
+ }
69
+ function restoreDocumentThemeVariables(target) {
70
+ for (const token of documentThemeAppliedKeys) {
71
+ target.style.removeProperty(token);
72
+ }
73
+ const baseline = documentThemeBaseline;
74
+ if (!baseline)
75
+ return;
76
+ for (const [token, original] of baseline.variables) {
77
+ if (original) {
78
+ target.style.setProperty(token, original.value, original.priority);
79
+ }
80
+ else {
81
+ target.style.removeProperty(token);
82
+ }
83
+ }
84
+ }
85
+ function applyDocumentThemeState(target, state) {
86
+ ensureDocumentThemeBaseline(target, Object.keys(state.variables));
87
+ restoreDocumentThemeVariables(target);
88
+ target.setAttribute("data-theme", state.dataTheme);
89
+ if (state.dataColorScheme) {
90
+ target.setAttribute("data-color-scheme", state.dataColorScheme);
91
+ }
92
+ else {
93
+ target.removeAttribute("data-color-scheme");
94
+ }
95
+ for (const [token, value] of Object.entries(state.variables)) {
96
+ target.style.setProperty(token, value);
97
+ }
98
+ documentThemeAppliedKeys = new Set(Object.keys(state.variables));
99
+ }
100
+ function restoreDocumentThemeBaseline(target) {
101
+ const baseline = documentThemeBaseline;
102
+ if (!baseline)
103
+ return;
104
+ if (baseline.dataTheme === null)
105
+ target.removeAttribute("data-theme");
106
+ else
107
+ target.setAttribute("data-theme", baseline.dataTheme);
108
+ if (baseline.dataColorScheme === null) {
109
+ target.removeAttribute("data-color-scheme");
110
+ }
111
+ else {
112
+ target.setAttribute("data-color-scheme", baseline.dataColorScheme);
113
+ }
114
+ restoreDocumentThemeVariables(target);
115
+ documentThemeBaseline = null;
116
+ documentThemeAppliedKeys.clear();
117
+ }
52
118
  export class PieThemeElement extends HTMLElementBase {
53
119
  static get observedAttributes() {
54
120
  return ["theme", "scope", "provider", "scheme", "variables"];
@@ -56,7 +122,11 @@ export class PieThemeElement extends HTMLElementBase {
56
122
  mediaQuery = null;
57
123
  onMediaChange = () => this.applyTheme();
58
124
  previousKeys = new Set();
125
+ previousTarget = null;
59
126
  variablesOverride = {};
127
+ promoteDocumentOwnership = false;
128
+ stopObservingSchemes = null;
129
+ stopObservingProviders = null;
60
130
  get theme() {
61
131
  const value = this.getAttribute("theme");
62
132
  return isThemeMode(value) ? value : "light";
@@ -77,7 +147,6 @@ export class PieThemeElement extends HTMLElementBase {
77
147
  set variables(value) {
78
148
  this.variablesOverride = parseVariableOverrides(value);
79
149
  this.setAttribute("variables", JSON.stringify(this.variablesOverride));
80
- this.applyTheme();
81
150
  }
82
151
  get provider() {
83
152
  return this.getAttribute("provider")?.trim() || "auto";
@@ -96,18 +165,19 @@ export class PieThemeElement extends HTMLElementBase {
96
165
  this.style.display = "contents";
97
166
  }
98
167
  this.setupAutoThemeListener();
99
- this.applyTheme();
168
+ this.stopObservingProviders ??= observePieThemeProviders(() => this.applyTheme());
169
+ this.stopObservingSchemes ??= observePieColorSchemes(() => this.applyTheme());
100
170
  }
101
171
  disconnectedCallback() {
102
172
  if (this.mediaQuery) {
103
173
  this.mediaQuery.removeEventListener("change", this.onMediaChange);
104
174
  }
105
175
  this.mediaQuery = null;
106
- if (this.scope === "self") {
107
- this.clearPreviousKeys(this);
108
- this.removeAttribute("data-theme");
109
- this.removeAttribute("data-color-scheme");
110
- }
176
+ this.stopObservingSchemes?.();
177
+ this.stopObservingSchemes = null;
178
+ this.stopObservingProviders?.();
179
+ this.stopObservingProviders = null;
180
+ this.clearPreviousTarget();
111
181
  }
112
182
  attributeChangedCallback(name, oldValue, newValue) {
113
183
  if (oldValue === newValue) {
@@ -119,6 +189,7 @@ export class PieThemeElement extends HTMLElementBase {
119
189
  if (name === "theme") {
120
190
  this.setupAutoThemeListener();
121
191
  }
192
+ this.promoteDocumentOwnership = true;
122
193
  this.applyTheme();
123
194
  }
124
195
  getTarget() {
@@ -128,35 +199,59 @@ export class PieThemeElement extends HTMLElementBase {
128
199
  return this;
129
200
  }
130
201
  applyTheme() {
131
- if (typeof document === "undefined" || typeof window === "undefined") {
202
+ if (!this.isConnected ||
203
+ typeof document === "undefined" ||
204
+ typeof window === "undefined") {
132
205
  return;
133
206
  }
134
207
  const { effectiveTheme, dataTheme } = this.resolveThemeState();
135
208
  const target = this.getTarget();
136
- target.setAttribute("data-theme", dataTheme);
137
- if (this.scheme && this.scheme !== "default") {
138
- target.setAttribute("data-color-scheme", this.scheme);
139
- }
140
- else {
141
- target.removeAttribute("data-color-scheme");
209
+ if (this.previousTarget && this.previousTarget !== target) {
210
+ this.clearPreviousTarget();
142
211
  }
143
- const themeVars = effectiveTheme === "dark" ? DARK_THEME_VARS : LIGHT_THEME_VARS;
144
- const providerVars = resolveProviderVariables({
212
+ const providerVariables = resolveProviderVariables({
145
213
  target,
146
214
  provider: this.provider,
147
215
  });
148
- const schemeVars = resolvePieColorSchemeVariables(this.scheme);
149
- const vars = {
150
- ...themeVars,
151
- ...providerVars,
152
- ...schemeVars,
153
- ...this.variablesOverride,
154
- };
155
- this.clearPreviousKeys(target);
156
- for (const [key, value] of Object.entries(vars)) {
157
- target.style.setProperty(key, value);
158
- }
159
- this.previousKeys = new Set(Object.keys(vars));
216
+ const resolution = resolvePieTheme({
217
+ baseTheme: effectiveTheme,
218
+ providerVariables,
219
+ requestedScheme: this.scheme,
220
+ variables: this.variablesOverride,
221
+ });
222
+ const state = Object.freeze({
223
+ dataTheme,
224
+ dataColorScheme: this.scheme === "default" ? null : this.scheme,
225
+ variables: resolution.variables,
226
+ });
227
+ if (this.scope === "document") {
228
+ if (this.promoteDocumentOwnership && documentThemeOwners.has(this)) {
229
+ documentThemeOwners.delete(this);
230
+ }
231
+ // Observer-driven re-resolution updates state in place, while a direct
232
+ // attribute/property mutation deliberately makes this the latest owner.
233
+ documentThemeOwners.set(this, state);
234
+ this.promoteDocumentOwnership = false;
235
+ const activeOwner = [...documentThemeOwners.entries()].at(-1);
236
+ if (activeOwner?.[0] === this) {
237
+ applyDocumentThemeState(target, state);
238
+ }
239
+ }
240
+ else {
241
+ target.setAttribute("data-theme", dataTheme);
242
+ if (state.dataColorScheme) {
243
+ target.setAttribute("data-color-scheme", state.dataColorScheme);
244
+ }
245
+ else {
246
+ target.removeAttribute("data-color-scheme");
247
+ }
248
+ this.clearPreviousKeys(target);
249
+ for (const [key, value] of Object.entries(resolution.variables)) {
250
+ target.style.setProperty(key, value);
251
+ }
252
+ }
253
+ this.previousTarget = target;
254
+ this.previousKeys = new Set(Object.keys(resolution.variables));
160
255
  }
161
256
  resolveThemeState() {
162
257
  const rawTheme = this.getAttribute("theme")?.trim();
@@ -168,13 +263,33 @@ export class PieThemeElement extends HTMLElementBase {
168
263
  if (rawTheme === "dark" || rawTheme === "light") {
169
264
  return { effectiveTheme: rawTheme, dataTheme: rawTheme };
170
265
  }
171
- // Non-standard theme ids (for example DaisyUI theme names) map to
172
- // light base defaults while still driving provider resolution.
266
+ // Non-standard ids (for example DaisyUI theme names) keep their provider
267
+ // selector while resolving from PIE's light Base Theme.
173
268
  if (rawTheme) {
174
269
  return { effectiveTheme: "light", dataTheme: rawTheme };
175
270
  }
176
271
  return { effectiveTheme: "light", dataTheme: "light" };
177
272
  }
273
+ clearPreviousTarget() {
274
+ if (!this.previousTarget)
275
+ return;
276
+ const target = this.previousTarget;
277
+ if (target === document.documentElement && documentThemeOwners.has(this)) {
278
+ documentThemeOwners.delete(this);
279
+ const survivingOwner = [...documentThemeOwners.values()].at(-1);
280
+ if (survivingOwner)
281
+ applyDocumentThemeState(target, survivingOwner);
282
+ else
283
+ restoreDocumentThemeBaseline(target);
284
+ }
285
+ else {
286
+ this.clearPreviousKeys(target);
287
+ target.removeAttribute("data-theme");
288
+ target.removeAttribute("data-color-scheme");
289
+ }
290
+ this.previousTarget = null;
291
+ this.previousKeys.clear();
292
+ }
178
293
  clearPreviousKeys(target) {
179
294
  for (const key of this.previousKeys) {
180
295
  target.style.removeProperty(key);
@@ -188,6 +303,9 @@ export class PieThemeElement extends HTMLElementBase {
188
303
  this.mediaQuery.removeEventListener("change", this.onMediaChange);
189
304
  this.mediaQuery = null;
190
305
  }
306
+ if (!this.isConnected) {
307
+ return;
308
+ }
191
309
  if (this.getAttribute("theme")?.trim() === "auto") {
192
310
  this.mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
193
311
  this.mediaQuery.addEventListener("change", this.onMediaChange);
@@ -1,6 +1,60 @@
1
1
  export type ThemeMode = "light" | "dark" | "auto";
2
2
  export type ThemeScope = "self" | "document";
3
+ export type ThemeTokenName = `--pie-${string}`;
3
4
  export type ThemeVariables = Record<string, string>;
5
+ export type PieThemeResolutionStatus = "default" | "built-in" | "custom" | "unavailable";
6
+ export type PieColorSchemePreview = Readonly<{
7
+ bg: string;
8
+ text: string;
9
+ primary: string;
10
+ }>;
11
+ export type PieColorSchemeDescriptor = Readonly<{
12
+ id: string;
13
+ name: string;
14
+ description?: string;
15
+ kind: "default" | "built-in" | "custom";
16
+ preview: PieColorSchemePreview;
17
+ }>;
18
+ export type ColorSchemeSnapshot = Readonly<{
19
+ generation: number;
20
+ schemes: readonly PieColorSchemeDescriptor[];
21
+ }>;
22
+ export type PieThemeObserver = (snapshot: ColorSchemeSnapshot) => void;
23
+ export type PieThemeDiagnosticCode = "unknown-scheme" | "invalid-registration" | "invalid-scheme-id" | "reserved-scheme-id" | "empty-scheme" | "invalid-token-name" | "excluded-token" | "invalid-token-value" | "custom-scheme-replaced" | "contrast-too-low" | "contrast-unmeasurable" | "observer-error";
24
+ export type PieThemeDiagnostic = Readonly<{
25
+ code: PieThemeDiagnosticCode;
26
+ severity: "warning" | "error";
27
+ message: string;
28
+ index?: number;
29
+ schemeId?: string;
30
+ token?: string;
31
+ }>;
32
+ export type ResolvePieThemeInput = Readonly<{
33
+ baseTheme?: "light" | "dark";
34
+ requestedScheme?: string | null;
35
+ providerVariables?: Readonly<ThemeVariables>;
36
+ variables?: Readonly<ThemeVariables>;
37
+ }>;
38
+ export type ThemeResolution = Readonly<{
39
+ baseTheme: "light" | "dark";
40
+ requestedScheme: string;
41
+ resolvedScheme: PieColorSchemeDescriptor | null;
42
+ status: PieThemeResolutionStatus;
43
+ variables: Readonly<ThemeVariables>;
44
+ diagnostics: readonly PieThemeDiagnostic[];
45
+ }>;
46
+ export type RegisteredPieColorScheme = Readonly<{
47
+ id: string;
48
+ name?: string;
49
+ description?: string;
50
+ variables: Readonly<Record<string, string | number>>;
51
+ }>;
52
+ export type RegistrationReceipt = Readonly<{
53
+ acceptedSchemeIds: readonly string[];
54
+ diagnostics: readonly PieThemeDiagnostic[];
55
+ unregister(): void;
56
+ }>;
57
+ export type Unsubscribe = () => void;
4
58
  export declare function isThemeMode(value: string | null): value is ThemeMode;
5
59
  export declare function isThemeScope(value: string | null): value is ThemeScope;
6
60
  export declare function normalizePieThemeVariables(value: unknown): ThemeVariables;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Types for `token-registry.json`, which this package publishes as
3
+ * `@pie-players/pie-theme/token-registry.json`.
4
+ *
5
+ * The registry is the answer to "what is this token for, and who owns it" for
6
+ * every `--pie-*` name in the repo. `check:theme-tokens` and the registry
7
+ * contract test read it to hold source and registry in agreement; it is exported
8
+ * so a host can render the same answer to a person, rather than maintaining its
9
+ * own list of tokens that drifts the moment one is added here.
10
+ *
11
+ * The JSON is the single source of truth and is emitted to `dist` unchanged.
12
+ * These types describe it; they do not restate it, so there is nothing to keep
13
+ * in sync beyond the unions below, which the contract test pins.
14
+ */
15
+ /**
16
+ * How widely a token may be set.
17
+ *
18
+ * `canonical-semantic` is the themeable contract: a host sets these. Everything
19
+ * else narrows — `component-public` is a per-component hook falling back through
20
+ * a canonical token, `package-private` is internal, and `unsupported` and
21
+ * `legacy` name tokens that exist but must not be adopted.
22
+ */
23
+ export type PieThemeTokenScope = "canonical-semantic" | "component-public" | "package-private" | "unsupported" | "legacy";
24
+ /** Whether the token is live. Non-`active` entries document a decision. */
25
+ export type PieThemeTokenStatus = "active" | "deprecated" | "intentional-gap" | "planned";
26
+ /**
27
+ * Whether a color scheme must, may, or must not set a token.
28
+ *
29
+ * Built-in schemes are complete for `required` tokens. Registered custom
30
+ * schemes may set `required` and `optional` tokens, while `excluded` tokens stay
31
+ * under their existing owner or fallback chain.
32
+ */
33
+ export type PieThemeSchemeParticipation = "required" | "optional" | "excluded";
34
+ export interface PieThemeTokenRegistryEntry {
35
+ /** The custom property, including the leading `--`. */
36
+ name: string;
37
+ /** Package that owns the token's meaning and may change it. */
38
+ owner: string;
39
+ scope: PieThemeTokenScope;
40
+ /**
41
+ * What the token is for — `surface`, `button`, `feedback`, `focus` and so on.
42
+ * Deliberately a plain string rather than a union: a new component category
43
+ * arrives with the component, and pinning the set here would make adding one a
44
+ * change to this file.
45
+ */
46
+ category: string;
47
+ status: PieThemeTokenStatus;
48
+ /** The token's role in built-in and registered custom color schemes. */
49
+ schemeParticipation: PieThemeSchemeParticipation;
50
+ /** Repo-relative paths that define the token. */
51
+ definedIn: string[];
52
+ /** Repo-relative paths that document it. */
53
+ documentedIn?: string[];
54
+ /** Why the token exists and what it must fall back through. */
55
+ fallbackPolicy?: string;
56
+ }
57
+ export type PieThemeTokenRegistry = PieThemeTokenRegistryEntry[];
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Types for `token-registry.json`, which this package publishes as
3
+ * `@pie-players/pie-theme/token-registry.json`.
4
+ *
5
+ * The registry is the answer to "what is this token for, and who owns it" for
6
+ * every `--pie-*` name in the repo. `check:theme-tokens` and the registry
7
+ * contract test read it to hold source and registry in agreement; it is exported
8
+ * so a host can render the same answer to a person, rather than maintaining its
9
+ * own list of tokens that drifts the moment one is added here.
10
+ *
11
+ * The JSON is the single source of truth and is emitted to `dist` unchanged.
12
+ * These types describe it; they do not restate it, so there is nothing to keep
13
+ * in sync beyond the unions below, which the contract test pins.
14
+ */
15
+ export {};