@matter-server/dashboard 0.2.5 → 0.2.7-alpha.0-20260118-993a1c7

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 (57) hide show
  1. package/README.md +16 -0
  2. package/dist/esm/client/models/descriptions.js +1754 -1754
  3. package/dist/esm/components/dialogs/binding/node-binding-dialog.d.ts.map +1 -1
  4. package/dist/esm/components/dialogs/binding/node-binding-dialog.js +8 -4
  5. package/dist/esm/components/dialogs/binding/node-binding-dialog.js.map +1 -1
  6. package/dist/esm/entrypoint/main.d.ts +1 -1
  7. package/dist/esm/entrypoint/main.d.ts.map +1 -1
  8. package/dist/esm/entrypoint/main.js +1 -0
  9. package/dist/esm/entrypoint/main.js.map +1 -1
  10. package/dist/esm/pages/components/header.d.ts +8 -0
  11. package/dist/esm/pages/components/header.d.ts.map +1 -1
  12. package/dist/esm/pages/components/header.js +56 -6
  13. package/dist/esm/pages/components/header.js.map +1 -1
  14. package/dist/esm/pages/components/node-details.d.ts.map +1 -1
  15. package/dist/esm/pages/components/node-details.js +27 -8
  16. package/dist/esm/pages/components/node-details.js.map +2 -2
  17. package/dist/esm/pages/matter-cluster-view.d.ts.map +1 -1
  18. package/dist/esm/pages/matter-cluster-view.js +3 -2
  19. package/dist/esm/pages/matter-cluster-view.js.map +1 -1
  20. package/dist/esm/pages/matter-dashboard-app.d.ts +6 -1
  21. package/dist/esm/pages/matter-dashboard-app.d.ts.map +1 -1
  22. package/dist/esm/pages/matter-dashboard-app.js +119 -24
  23. package/dist/esm/pages/matter-dashboard-app.js.map +1 -1
  24. package/dist/esm/pages/matter-endpoint-view.d.ts.map +1 -1
  25. package/dist/esm/pages/matter-endpoint-view.js +2 -1
  26. package/dist/esm/pages/matter-endpoint-view.js.map +1 -1
  27. package/dist/esm/util/format_hex.d.ts +17 -0
  28. package/dist/esm/util/format_hex.d.ts.map +1 -0
  29. package/dist/esm/util/format_hex.js +18 -0
  30. package/dist/esm/util/format_hex.js.map +6 -0
  31. package/dist/esm/util/theme-service.d.ts +27 -0
  32. package/dist/esm/util/theme-service.d.ts.map +1 -0
  33. package/dist/esm/util/theme-service.js +71 -0
  34. package/dist/esm/util/theme-service.js.map +6 -0
  35. package/dist/web/index.html +35 -0
  36. package/dist/web/js/{commission-node-dialog-30lGnJcb.js → commission-node-dialog-DGw5qDgH.js} +5 -5
  37. package/dist/web/js/{commission-node-existing-RgaziosB.js → commission-node-existing-CHyyeC8y.js} +4 -4
  38. package/dist/web/js/{commission-node-thread-CJn6OYQX.js → commission-node-thread-iRDSlidy.js} +4 -4
  39. package/dist/web/js/{commission-node-wifi-CCBBvBEh.js → commission-node-wifi-C4YNR3bG.js} +4 -4
  40. package/dist/web/js/{dialog-box-CFO9GMyG.js → dialog-box-ag-xOaYh.js} +2 -2
  41. package/dist/web/js/{fire_event-CdvT7FSP.js → fire_event-BeiEbHcE.js} +1 -1
  42. package/dist/web/js/main.js +140 -8
  43. package/dist/web/js/{matter-dashboard-app-DwI2RvT1.js → matter-dashboard-app-BxQ4W_uT.js} +3652 -3483
  44. package/dist/web/js/{node-binding-dialog-CmTgtqz1.js → node-binding-dialog-ClziphM0.js} +11 -7
  45. package/dist/web/js/{outlined-text-field-DeeCilzP.js → outlined-text-field-B-CiqgEJ.js} +2 -2
  46. package/dist/web/js/{prevent_default--haJaAsZ.js → prevent_default-Bs2sUnny.js} +1 -1
  47. package/package.json +3 -3
  48. package/src/client/models/descriptions.ts +1754 -1754
  49. package/src/components/dialogs/binding/node-binding-dialog.ts +8 -4
  50. package/src/entrypoint/main.ts +1 -0
  51. package/src/pages/components/header.ts +57 -8
  52. package/src/pages/components/node-details.ts +33 -8
  53. package/src/pages/matter-cluster-view.ts +3 -2
  54. package/src/pages/matter-dashboard-app.ts +123 -26
  55. package/src/pages/matter-endpoint-view.ts +2 -1
  56. package/src/util/format_hex.ts +30 -0
  57. package/src/util/theme-service.ts +98 -0
@@ -306,7 +306,7 @@ export class NodeBindingDialog extends LitElement {
306
306
  <md-list style="padding-bottom:18px;">
307
307
  ${Object.values(bindings).map(
308
308
  (entry, index) => html`
309
- <md-list-item style="background:cornsilk;">
309
+ <md-list-item class="binding-item">
310
310
  <div style="display:flex;gap:10px;">
311
311
  <div>node:${entry["node"]}</div>
312
312
  <div>endpoint:${entry["endpoint"]}</div>
@@ -376,9 +376,13 @@ export class NodeBindingDialog extends LitElement {
376
376
  }
377
377
 
378
378
  static override styles = css`
379
+ .binding-item {
380
+ background: var(--md-sys-color-surface-container-high);
381
+ }
382
+
379
383
  .inline-group {
380
384
  display: flex;
381
- border: 2px solid #673ab7;
385
+ border: 2px solid var(--md-sys-color-primary);
382
386
  padding: 1px;
383
387
  border-radius: 8px;
384
388
  position: relative;
@@ -404,8 +408,8 @@ export class NodeBindingDialog extends LitElement {
404
408
  position: absolute;
405
409
  left: 15px;
406
410
  top: -12px;
407
- background: #673ab7;
408
- color: white;
411
+ background: var(--md-sys-color-primary);
412
+ color: var(--md-sys-color-on-primary);
409
413
  padding: 3px 15px;
410
414
  border-radius: 4px;
411
415
  }
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { MatterClient } from "@matter-server/ws-client";
8
+ import "../util/theme-service.js"; // Initialize theme service early
8
9
 
9
10
  async function main() {
10
11
  import("../pages/matter-dashboard-app.js");
@@ -10,10 +10,11 @@ import "@material/web/iconbutton/icon-button";
10
10
  import "@material/web/list/list";
11
11
  import "@material/web/list/list-item";
12
12
  import { MatterClient } from "@matter-server/ws-client";
13
- import { mdiArrowLeft, mdiLogout } from "@mdi/js";
13
+ import { mdiArrowLeft, mdiBrightnessAuto, mdiLogout, mdiWeatherNight, mdiWeatherSunny } from "@mdi/js";
14
14
  import { LitElement, css, html, nothing } from "lit";
15
- import { customElement, property } from "lit/decorators.js";
15
+ import { customElement, property, state } from "lit/decorators.js";
16
16
  import "../../components/ha-svg-icon";
17
+ import { EffectiveTheme, ThemePreference, ThemeService } from "../../util/theme-service.js";
17
18
 
18
19
  interface HeaderAction {
19
20
  label: string;
@@ -28,6 +29,50 @@ export class DashboardHeader extends LitElement {
28
29
 
29
30
  public client?: MatterClient;
30
31
 
32
+ @state() private _themePreference: ThemePreference = ThemeService.preference;
33
+ @state() private _effectiveTheme: EffectiveTheme = ThemeService.effectiveTheme;
34
+
35
+ private _unsubscribeTheme?: () => void;
36
+
37
+ override connectedCallback() {
38
+ super.connectedCallback();
39
+ this._unsubscribeTheme = ThemeService.subscribe(theme => {
40
+ this._effectiveTheme = theme;
41
+ this._themePreference = ThemeService.preference;
42
+ });
43
+ }
44
+
45
+ override disconnectedCallback() {
46
+ super.disconnectedCallback();
47
+ this._unsubscribeTheme?.();
48
+ }
49
+
50
+ private _cycleTheme() {
51
+ ThemeService.cycleTheme();
52
+ }
53
+
54
+ private _getThemeIcon(): string {
55
+ switch (this._themePreference) {
56
+ case "light":
57
+ return mdiWeatherSunny;
58
+ case "dark":
59
+ return mdiWeatherNight;
60
+ case "system":
61
+ return mdiBrightnessAuto;
62
+ }
63
+ }
64
+
65
+ private _getThemeTooltip(): string {
66
+ switch (this._themePreference) {
67
+ case "light":
68
+ return "Theme: Light";
69
+ case "dark":
70
+ return "Theme: Dark";
71
+ case "system":
72
+ return `Theme: System (${this._effectiveTheme})`;
73
+ }
74
+ }
75
+
31
76
  protected override render() {
32
77
  return html`
33
78
  <div class="header">
@@ -50,14 +95,18 @@ export class DashboardHeader extends LitElement {
50
95
  </md-icon-button>
51
96
  `;
52
97
  })}
53
- <!-- optional logout button -->
54
- ${this.client?.isProduction
55
- ? nothing
56
- : html`
57
- <md-icon-button @click=${this.client?.disconnect}>
98
+ <!-- theme toggle button -->
99
+ <md-icon-button @click=${this._cycleTheme} .title=${this._getThemeTooltip()}>
100
+ <ha-svg-icon .path=${this._getThemeIcon()}></ha-svg-icon>
101
+ </md-icon-button>
102
+ <!-- optional logout button (only when client exists and not in production) -->
103
+ ${this.client && !this.client.isProduction
104
+ ? html`
105
+ <md-icon-button @click=${this.client.disconnect}>
58
106
  <ha-svg-icon .path=${mdiLogout}></ha-svg-icon>
59
107
  </md-icon-button>
60
- `}
108
+ `
109
+ : nothing}
61
110
  </div>
62
111
  </div>
63
112
  `;
@@ -24,6 +24,27 @@ import "../../components/ha-svg-icon";
24
24
  import { getEndpointDeviceTypes } from "../matter-endpoint-view.js";
25
25
  import { bindingContext } from "./context.js";
26
26
 
27
+ /** Map updateState values to user-friendly labels */
28
+ const UPDATE_STATE_LABELS: Record<number, string> = {
29
+ 1: "Idle",
30
+ 2: "Querying",
31
+ 3: "Waiting (Querying)",
32
+ 4: "Downloading",
33
+ 5: "Applying",
34
+ 6: "Waiting (Applying)",
35
+ 7: "Rolling back",
36
+ 8: "Waiting for consent",
37
+ };
38
+
39
+ function getUpdateStateLabel(state: number, progress?: number): string {
40
+ const label = UPDATE_STATE_LABELS[state] || `Unknown (${state})`;
41
+ // Show progress only for downloading state
42
+ if (state === 4 && progress !== undefined) {
43
+ return `${label} (${progress}%)`;
44
+ }
45
+ return label;
46
+ }
47
+
27
48
  function getNodeDeviceTypes(node: MatterNode): DeviceType[] {
28
49
  const uniqueEndpoints = new Set(Object.keys(node.attributes).map(key => Number(key.split("/")[0])));
29
50
  const allDeviceTypes: Set<DeviceType> = new Set();
@@ -86,16 +107,20 @@ export class NodeDetails extends LitElement {
86
107
  <md-outlined-button @click=${this._reinterview}
87
108
  >Interview<ha-svg-icon slot="icon" .path=${mdiChatProcessing}></ha-svg-icon
88
109
  ></md-outlined-button>
89
- ${this._updateInitiated || (this.node.updateState || 0) > 1
110
+ ${this._updateInitiated
90
111
  ? html` <md-outlined-button disabled
91
- >Update in progress (${this.node.updateStateProgress || 0}%)<ha-svg-icon
92
- slot="icon"
93
- .path=${mdiUpdate}
94
- ></ha-svg-icon
112
+ >Checking for updates<ha-svg-icon slot="icon" .path=${mdiUpdate}></ha-svg-icon
95
113
  ></md-outlined-button>`
96
- : html`<md-outlined-button @click=${this._searchUpdate}
97
- >Update<ha-svg-icon slot="icon" .path=${mdiUpdate}></ha-svg-icon
98
- ></md-outlined-button>`}
114
+ : (this.node.updateState || 0) > 1
115
+ ? html` <md-outlined-button disabled
116
+ >${getUpdateStateLabel(
117
+ this.node.updateState!,
118
+ this.node.updateStateProgress,
119
+ )}<ha-svg-icon slot="icon" .path=${mdiUpdate}></ha-svg-icon
120
+ ></md-outlined-button>`
121
+ : html`<md-outlined-button @click=${this._searchUpdate}
122
+ >Update<ha-svg-icon slot="icon" .path=${mdiUpdate}></ha-svg-icon
123
+ ></md-outlined-button>`}
99
124
  ${bindings
100
125
  ? html`
101
126
  <md-outlined-button @click=${this._binding}>
@@ -19,6 +19,7 @@ import "../components/ha-svg-icon";
19
19
  import "../pages/components/node-details";
20
20
  import { bindingContext } from "./components/context.js";
21
21
  // Cluster command components (auto-register on import)
22
+ import { formatHex } from "../util/format_hex.js";
22
23
  import { getClusterCommandsTag } from "./cluster-commands/index.js";
23
24
 
24
25
  declare global {
@@ -103,7 +104,7 @@ class MatterClusterView extends LitElement {
103
104
  Endpoint ${this.endpoint}</b
104
105
  >
105
106
  </div>
106
- <div slot="supporting-text">ClusterId ${this.cluster} (0x00${this.cluster.toString(16)})</div>
107
+ <div slot="supporting-text">ClusterId ${this.cluster} (${formatHex(this.cluster)})</div>
107
108
  </md-list-item>
108
109
  <md-divider></md-divider>
109
110
  ${clusterAttributes(this.node.attributes, this.endpoint, this.cluster).map(
@@ -114,7 +115,7 @@ class MatterClusterView extends LitElement {
114
115
  "Custom/Unknown Attribute"}
115
116
  </div>
116
117
  <div slot="supporting-text">
117
- AttributeId: ${attribute.key} (0x00${attribute.key.toString(16)}) - Value type:
118
+ AttributeId: ${attribute.key} (${formatHex(attribute.key)}) - Value type:
118
119
  ${clusters[this.cluster!]?.attributes[attribute.key]?.type || "unknown"}
119
120
  </div>
120
121
  <div slot="end">
@@ -6,11 +6,14 @@
6
6
 
7
7
  import { ContextProvider } from "@lit/context";
8
8
  import { MatterClient, MatterError } from "@matter-server/ws-client";
9
- import { LitElement, PropertyValueMap, html } from "lit";
9
+ import { mdiRefresh } from "@mdi/js";
10
+ import { LitElement, PropertyValueMap, css, html } from "lit";
10
11
  import { customElement, state } from "lit/decorators.js";
11
12
  import { clientContext } from "../client/client-context.js";
13
+ import "../components/ha-svg-icon";
12
14
  import { clone } from "../util/clone_class.js";
13
15
  import type { Route } from "../util/routing.js";
16
+ import "./components/header";
14
17
  import "./matter-cluster-view";
15
18
  import "./matter-endpoint-view";
16
19
  import "./matter-node-view";
@@ -34,31 +37,11 @@ class MatterDashboardApp extends LitElement {
34
37
  @state()
35
38
  private _state: "connecting" | "connected" | "error" | "disconnected" = "connecting";
36
39
 
37
- private _error: string | undefined;
38
-
39
40
  private provider = new ContextProvider(this, { context: clientContext, initialValue: this.client });
40
41
 
41
42
  protected override firstUpdated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
42
43
  super.firstUpdated(_changedProperties);
43
- this.client.startListening().then(
44
- () => {
45
- this._state = "connected";
46
- this.client.addEventListener("nodes_changed", () => {
47
- this.requestUpdate();
48
- this.provider.setValue(clone(this.client));
49
- });
50
- this.client.addEventListener("server_info_updated", () => {
51
- this.provider.setValue(clone(this.client));
52
- });
53
- this.client.addEventListener("connection_lost", () => {
54
- this._state = "disconnected";
55
- });
56
- },
57
- (err: MatterError) => {
58
- this._state = "error";
59
- this._error = err.message;
60
- },
61
- );
44
+ this._connect();
62
45
 
63
46
  // Handle history changes
64
47
  const updateRoute = () => {
@@ -72,17 +55,73 @@ class MatterDashboardApp extends LitElement {
72
55
  updateRoute();
73
56
  }
74
57
 
58
+ private _connect() {
59
+ this.client.startListening().then(
60
+ () => {
61
+ this._state = "connected";
62
+ this._setupEventListeners();
63
+ },
64
+ (_err: MatterError) => {
65
+ this._state = "error";
66
+ },
67
+ );
68
+ }
69
+
70
+ private _setupEventListeners() {
71
+ this.client.addEventListener("nodes_changed", () => {
72
+ this.requestUpdate();
73
+ this.provider.setValue(clone(this.client));
74
+ });
75
+ this.client.addEventListener("server_info_updated", () => {
76
+ this.provider.setValue(clone(this.client));
77
+ });
78
+ this.client.addEventListener("connection_lost", () => {
79
+ this._state = "disconnected";
80
+ });
81
+ }
82
+
83
+ private _reconnect = () => {
84
+ this._state = "connecting";
85
+ this._connect();
86
+ };
87
+
75
88
  override render() {
76
89
  if (this._state === "connecting") {
77
- return html`<p>Connecting...</p>`;
90
+ return html`
91
+ <dashboard-header title="Matter Server"></dashboard-header>
92
+ <div class="status-page">
93
+ <p class="status-message">Connecting...</p>
94
+ </div>
95
+ `;
78
96
  }
79
97
  if (this._state === "disconnected") {
80
- return html`<p>Connection lost</p>`;
98
+ return html`
99
+ <dashboard-header title="Matter Server"></dashboard-header>
100
+ <div class="status-page">
101
+ <p class="status-message error">Connection lost</p>
102
+ <p class="status-hint">
103
+ The connection to the Matter Server was lost. Please check if the server is running.
104
+ </p>
105
+ <button class="retry-button" @click=${this._reconnect}>
106
+ <ha-svg-icon .path=${mdiRefresh}></ha-svg-icon>
107
+ Reconnect
108
+ </button>
109
+ </div>
110
+ `;
81
111
  }
82
112
  if (this._state === "error") {
83
113
  return html`
84
- <p>Error: ${this._error}</p>
85
- <button @click=${this.client.disconnect}>Clear stored URL</button>
114
+ <dashboard-header title="Matter Server"></dashboard-header>
115
+ <div class="status-page">
116
+ <p class="status-message error">No connection</p>
117
+ <p class="status-hint">
118
+ Unable to connect to the Matter Server. Please check if the server is running.
119
+ </p>
120
+ <button class="retry-button" @click=${this._reconnect}>
121
+ <ha-svg-icon .path=${mdiRefresh}></ha-svg-icon>
122
+ Reconnect
123
+ </button>
124
+ </div>
86
125
  `;
87
126
  }
88
127
  if (this._route.prefix === "node" && this._route.path.length == 3) {
@@ -122,4 +161,62 @@ class MatterDashboardApp extends LitElement {
122
161
  .route=${this._route}
123
162
  ></matter-server-view>`;
124
163
  }
164
+
165
+ static override styles = css`
166
+ :host {
167
+ display: block;
168
+ min-height: 100vh;
169
+ background-color: var(--md-sys-color-background);
170
+ }
171
+
172
+ .status-page {
173
+ display: flex;
174
+ flex-direction: column;
175
+ align-items: center;
176
+ justify-content: center;
177
+ padding: 48px 24px;
178
+ text-align: center;
179
+ }
180
+
181
+ .status-message {
182
+ font-size: 1.5rem;
183
+ color: var(--md-sys-color-on-background);
184
+ margin: 0 0 16px 0;
185
+ }
186
+
187
+ .status-message.error {
188
+ color: var(--danger-color);
189
+ }
190
+
191
+ .status-hint {
192
+ font-size: 1rem;
193
+ color: var(--md-sys-color-on-surface-variant);
194
+ margin: 0;
195
+ max-width: 400px;
196
+ }
197
+
198
+ .retry-button {
199
+ display: inline-flex;
200
+ align-items: center;
201
+ gap: 8px;
202
+ margin-top: 24px;
203
+ padding: 12px 24px;
204
+ font-size: 1rem;
205
+ background-color: var(--md-sys-color-primary);
206
+ color: var(--md-sys-color-on-primary);
207
+ --icon-primary-color: var(--md-sys-color-on-primary);
208
+ border: none;
209
+ border-radius: 4px;
210
+ cursor: pointer;
211
+ }
212
+
213
+ .retry-button:hover {
214
+ opacity: 0.9;
215
+ }
216
+
217
+ .retry-button ha-svg-icon {
218
+ width: 20px;
219
+ height: 20px;
220
+ }
221
+ `;
125
222
  }
@@ -15,6 +15,7 @@ import { customElement, property } from "lit/decorators.js";
15
15
  import { guard } from "lit/directives/guard.js";
16
16
  import { DeviceType, clusters, device_types } from "../client/models/descriptions.js";
17
17
  import "../components/ha-svg-icon";
18
+ import { formatHex } from "../util/format_hex.js";
18
19
 
19
20
  declare global {
20
21
  interface HTMLElementTagNameMap {
@@ -97,7 +98,7 @@ class MatterEndpointView extends LitElement {
97
98
  href=${`#node/${this.node!.node_id}/${this.endpoint}/${cluster}`}
98
99
  >
99
100
  <div slot="headline">${clusters[cluster]?.label || "Custom/Unknown Cluster"}</div>
100
- <div slot="supporting-text">ClusterId ${cluster} (0x00${cluster.toString(16)})</div>
101
+ <div slot="supporting-text">ClusterId ${cluster} (${formatHex(cluster)})</div>
101
102
  <ha-svg-icon slot="end" .path=${mdiChevronRight}></ha-svg-icon>
102
103
  </md-list-item>
103
104
  `;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ /**
8
+ * Format a number as a hex string with 0x prefix.
9
+ * Ensures even number of digits with a minimum of 4 digits.
10
+ *
11
+ * Examples:
12
+ * 0 -> "0x0000"
13
+ * 255 -> "0x00FF"
14
+ * 65535 -> "0xFFFF"
15
+ * 65536 -> "0x010000"
16
+ */
17
+ export function formatHex(value: number): string {
18
+ let hex = value.toString(16).toUpperCase();
19
+
20
+ // Ensure minimum 4 digits
21
+ if (hex.length < 4) {
22
+ hex = hex.padStart(4, "0");
23
+ }
24
+ // Ensure even number of digits
25
+ else if (hex.length % 2 !== 0) {
26
+ hex = "0" + hex;
27
+ }
28
+
29
+ return `0x${hex}`;
30
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ /**
8
+ * Theme service for managing dark/light mode preferences.
9
+ * Supports three modes: light, dark, and system (auto-detect from OS).
10
+ */
11
+
12
+ export type ThemePreference = "light" | "dark" | "system";
13
+ export type EffectiveTheme = "light" | "dark";
14
+
15
+ const STORAGE_KEY = "matterTheme";
16
+
17
+ class ThemeServiceImpl {
18
+ private _preference: ThemePreference = "system";
19
+ private _mediaQuery: MediaQueryList;
20
+ private _listeners: Set<(theme: EffectiveTheme) => void> = new Set();
21
+
22
+ constructor() {
23
+ this._mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
24
+ this._mediaQuery.addEventListener("change", () => this._applyTheme());
25
+ this._loadPreference();
26
+ this._applyTheme();
27
+ }
28
+
29
+ get preference(): ThemePreference {
30
+ return this._preference;
31
+ }
32
+
33
+ get effectiveTheme(): EffectiveTheme {
34
+ if (this._preference === "system") {
35
+ return this._mediaQuery.matches ? "dark" : "light";
36
+ }
37
+ return this._preference;
38
+ }
39
+
40
+ setPreference(pref: ThemePreference): void {
41
+ this._preference = pref;
42
+ localStorage.setItem(STORAGE_KEY, pref);
43
+ this._applyTheme();
44
+ }
45
+
46
+ cycleTheme(): ThemePreference {
47
+ const cycle: ThemePreference[] = ["light", "dark", "system"];
48
+ const currentIndex = cycle.indexOf(this._preference);
49
+ const nextIndex = (currentIndex + 1) % cycle.length;
50
+ this.setPreference(cycle[nextIndex]);
51
+ return this._preference;
52
+ }
53
+
54
+ subscribe(callback: (theme: EffectiveTheme) => void): () => void {
55
+ this._listeners.add(callback);
56
+ return () => this._listeners.delete(callback);
57
+ }
58
+
59
+ private _loadPreference(): void {
60
+ // Check for query parameter override (e.g., ?theme=dark)
61
+ const urlParams = new URLSearchParams(window.location.search);
62
+ const themeParam = urlParams.get("theme") as ThemePreference | null;
63
+ if (themeParam && ["light", "dark", "system"].includes(themeParam)) {
64
+ // Use query parameter value and save to localStorage
65
+ this._preference = themeParam;
66
+ localStorage.setItem(STORAGE_KEY, themeParam);
67
+ // Remove the query parameter from URL without reload
68
+ urlParams.delete("theme");
69
+ const newUrl = urlParams.toString()
70
+ ? `${window.location.pathname}?${urlParams.toString()}${window.location.hash}`
71
+ : `${window.location.pathname}${window.location.hash}`;
72
+ history.replaceState({}, "", newUrl);
73
+ return;
74
+ }
75
+
76
+ // Fall back to localStorage
77
+ const stored = localStorage.getItem(STORAGE_KEY) as ThemePreference | null;
78
+ if (stored && ["light", "dark", "system"].includes(stored)) {
79
+ this._preference = stored;
80
+ }
81
+ // Default is "system" if nothing stored
82
+ }
83
+
84
+ private _applyTheme(): void {
85
+ const effective = this.effectiveTheme;
86
+ document.documentElement.classList.toggle("dark-theme", effective === "dark");
87
+
88
+ // Update meta theme-color for mobile browsers
89
+ const metaThemeColor = document.querySelector('meta[name="theme-color"]');
90
+ if (metaThemeColor) {
91
+ metaThemeColor.setAttribute("content", effective === "dark" ? "#1e1e1e" : "#03a9f4");
92
+ }
93
+
94
+ this._listeners.forEach(cb => cb(effective));
95
+ }
96
+ }
97
+
98
+ export const ThemeService = new ThemeServiceImpl();