@matter-server/dashboard 0.2.6 → 0.2.7-alpha.0-20260118-45c7af0

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 (53) 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/components/dialogs/settings/log-level-dialog.d.ts +33 -0
  7. package/dist/esm/components/dialogs/settings/log-level-dialog.d.ts.map +1 -0
  8. package/dist/esm/components/dialogs/settings/log-level-dialog.js +185 -0
  9. package/dist/esm/components/dialogs/settings/log-level-dialog.js.map +6 -0
  10. package/dist/esm/components/dialogs/settings/show-log-level-dialog.d.ts +8 -0
  11. package/dist/esm/components/dialogs/settings/show-log-level-dialog.d.ts.map +1 -0
  12. package/dist/esm/components/dialogs/settings/show-log-level-dialog.js +15 -0
  13. package/dist/esm/components/dialogs/settings/show-log-level-dialog.js.map +6 -0
  14. package/dist/esm/entrypoint/main.d.ts +1 -1
  15. package/dist/esm/entrypoint/main.d.ts.map +1 -1
  16. package/dist/esm/entrypoint/main.js +1 -0
  17. package/dist/esm/entrypoint/main.js.map +1 -1
  18. package/dist/esm/pages/components/header.d.ts +9 -0
  19. package/dist/esm/pages/components/header.d.ts.map +1 -1
  20. package/dist/esm/pages/components/header.js +68 -6
  21. package/dist/esm/pages/components/header.js.map +1 -1
  22. package/dist/esm/pages/matter-dashboard-app.d.ts +6 -1
  23. package/dist/esm/pages/matter-dashboard-app.d.ts.map +1 -1
  24. package/dist/esm/pages/matter-dashboard-app.js +119 -24
  25. package/dist/esm/pages/matter-dashboard-app.js.map +1 -1
  26. package/dist/esm/util/theme-service.d.ts +27 -0
  27. package/dist/esm/util/theme-service.d.ts.map +1 -0
  28. package/dist/esm/util/theme-service.js +71 -0
  29. package/dist/esm/util/theme-service.js.map +6 -0
  30. package/dist/web/index.html +35 -0
  31. package/dist/web/js/{commission-node-dialog--19-sX9D.js → commission-node-dialog-CoaDIV2Y.js} +5 -5
  32. package/dist/web/js/{commission-node-existing-DY6SnsHb.js → commission-node-existing-DEU_mJjO.js} +5 -4
  33. package/dist/web/js/{commission-node-thread-CXquVvK5.js → commission-node-thread-DZ6DghSs.js} +5 -4
  34. package/dist/web/js/{commission-node-wifi-VQGVOrr7.js → commission-node-wifi-DOyin0q3.js} +5 -4
  35. package/dist/web/js/{dialog-box-qX-alVZJ.js → dialog-box-B5sunUPv.js} +2 -2
  36. package/dist/web/js/{fire_event-B13DcOc9.js → fire_event-C9Duc1j-.js} +1 -1
  37. package/dist/web/js/log-level-dialog-B7LsZYUL.js +3232 -0
  38. package/dist/web/js/main.js +163 -8
  39. package/dist/web/js/{matter-dashboard-app-CU3-L2nl.js → matter-dashboard-app-DlHSE_Qh.js} +13253 -13039
  40. package/dist/web/js/{node-binding-dialog-D4rr_G9I.js → node-binding-dialog-BifZsigR.js} +12 -7
  41. package/dist/web/js/outlined-text-field-D2BOt1yD.js +968 -0
  42. package/dist/web/js/{prevent_default-Dw7ifAL-.js → prevent_default-CuW2EnKR.js} +1 -1
  43. package/dist/web/js/validator-MOJiFndw.js +1122 -0
  44. package/package.json +4 -4
  45. package/src/client/models/descriptions.ts +1754 -1754
  46. package/src/components/dialogs/binding/node-binding-dialog.ts +8 -4
  47. package/src/components/dialogs/settings/log-level-dialog.ts +179 -0
  48. package/src/components/dialogs/settings/show-log-level-dialog.ts +14 -0
  49. package/src/entrypoint/main.ts +1 -0
  50. package/src/pages/components/header.ts +72 -8
  51. package/src/pages/matter-dashboard-app.ts +123 -26
  52. package/src/util/theme-service.ts +98 -0
  53. package/dist/web/js/outlined-text-field-CtlEkpbk.js +0 -2086
@@ -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
  }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import "@material/web/button/text-button";
8
+ import "@material/web/dialog/dialog";
9
+ import type { MdDialog } from "@material/web/dialog/dialog.js";
10
+ import "@material/web/select/outlined-select";
11
+ import type { MdOutlinedSelect } from "@material/web/select/outlined-select.js";
12
+ import "@material/web/select/select-option";
13
+ import { LogLevelString, MatterClient } from "@matter-server/ws-client";
14
+ import { css, html, LitElement, nothing } from "lit";
15
+ import { customElement, property, query, state } from "lit/decorators.js";
16
+ import { preventDefault } from "../../../util/prevent_default.js";
17
+
18
+ const LOG_LEVELS: { value: LogLevelString; label: string }[] = [
19
+ { value: "critical", label: "Critical" },
20
+ { value: "error", label: "Error" },
21
+ { value: "warning", label: "Warning" },
22
+ { value: "info", label: "Info" },
23
+ { value: "debug", label: "Debug" },
24
+ ];
25
+
26
+ @customElement("log-level-dialog")
27
+ export class LogLevelDialog extends LitElement {
28
+ @property({ attribute: false })
29
+ public client!: MatterClient;
30
+
31
+ @state() private _consoleLevel: LogLevelString = "info";
32
+ @state() private _fileLevel: LogLevelString | null = null;
33
+ @state() private _loading = true;
34
+ @state() private _applying = false;
35
+
36
+ @query("md-outlined-select[name='console']")
37
+ private _consoleSelect!: MdOutlinedSelect;
38
+
39
+ @query("md-outlined-select[name='file']")
40
+ private _fileSelect?: MdOutlinedSelect;
41
+
42
+ override connectedCallback() {
43
+ super.connectedCallback();
44
+ void this._loadLogLevels();
45
+ }
46
+
47
+ private async _loadLogLevels() {
48
+ try {
49
+ const result = await this.client.getLogLevel();
50
+ this._consoleLevel = result.console_loglevel;
51
+ this._fileLevel = result.file_loglevel;
52
+ } catch (err) {
53
+ console.error("Failed to load log levels:", err);
54
+ } finally {
55
+ this._loading = false;
56
+ }
57
+ }
58
+
59
+ private async _apply() {
60
+ this._applying = true;
61
+ try {
62
+ const consoleLevel = this._consoleSelect.value as LogLevelString;
63
+ const fileLevel = this._fileSelect?.value as LogLevelString | undefined;
64
+
65
+ const result = await this.client.setLogLevel(
66
+ consoleLevel,
67
+ this._fileLevel !== null ? fileLevel : undefined,
68
+ );
69
+
70
+ this._consoleLevel = result.console_loglevel;
71
+ this._fileLevel = result.file_loglevel;
72
+ this._close();
73
+ } catch (err) {
74
+ console.error("Failed to apply log levels:", err);
75
+ alert("Failed to apply log levels");
76
+ } finally {
77
+ this._applying = false;
78
+ }
79
+ }
80
+
81
+ private _close() {
82
+ this.shadowRoot!.querySelector<MdDialog>("md-dialog")!.close();
83
+ }
84
+
85
+ private _handleClosed() {
86
+ this.parentNode!.removeChild(this);
87
+ }
88
+
89
+ protected override render() {
90
+ return html`
91
+ <md-dialog open @cancel=${preventDefault} @closed=${this._handleClosed}>
92
+ <div slot="headline">Server Log Settings</div>
93
+ <div slot="content">
94
+ ${this._loading
95
+ ? html`<p class="loading">Loading...</p>`
96
+ : html`
97
+ <p class="hint">Changes are temporary and will be reset on the next server restart.</p>
98
+ <div class="form-field">
99
+ <label>Console Log Level</label>
100
+ <md-outlined-select name="console" .value=${this._consoleLevel}>
101
+ ${LOG_LEVELS.map(
102
+ level => html`
103
+ <md-select-option
104
+ value=${level.value}
105
+ ?selected=${level.value === this._consoleLevel}
106
+ >
107
+ <div slot="headline">${level.label}</div>
108
+ </md-select-option>
109
+ `,
110
+ )}
111
+ </md-outlined-select>
112
+ </div>
113
+ ${this._fileLevel !== null
114
+ ? html`
115
+ <div class="form-field">
116
+ <label>File Log Level</label>
117
+ <md-outlined-select name="file" .value=${this._fileLevel}>
118
+ ${LOG_LEVELS.map(
119
+ level => html`
120
+ <md-select-option
121
+ value=${level.value}
122
+ ?selected=${level.value === this._fileLevel}
123
+ >
124
+ <div slot="headline">${level.label}</div>
125
+ </md-select-option>
126
+ `,
127
+ )}
128
+ </md-outlined-select>
129
+ </div>
130
+ `
131
+ : nothing}
132
+ `}
133
+ </div>
134
+ <div slot="actions">
135
+ <md-text-button @click=${this._close}>Cancel</md-text-button>
136
+ <md-text-button @click=${this._apply} ?disabled=${this._loading || this._applying}>
137
+ ${this._applying ? "Applying..." : "Apply"}
138
+ </md-text-button>
139
+ </div>
140
+ </md-dialog>
141
+ `;
142
+ }
143
+
144
+ static override styles = css`
145
+ .loading {
146
+ text-align: center;
147
+ padding: 24px;
148
+ color: var(--md-sys-color-on-surface-variant);
149
+ }
150
+
151
+ .hint {
152
+ font-size: 0.875rem;
153
+ color: var(--md-sys-color-on-surface-variant);
154
+ margin: 0 0 16px 0;
155
+ font-style: italic;
156
+ }
157
+
158
+ .form-field {
159
+ margin-bottom: 16px;
160
+ }
161
+
162
+ .form-field label {
163
+ display: block;
164
+ margin-bottom: 8px;
165
+ font-weight: 500;
166
+ color: var(--md-sys-color-on-surface);
167
+ }
168
+
169
+ md-outlined-select {
170
+ width: 100%;
171
+ }
172
+ `;
173
+ }
174
+
175
+ declare global {
176
+ interface HTMLElementTagNameMap {
177
+ "log-level-dialog": LogLevelDialog;
178
+ }
179
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { MatterClient } from "@matter-server/ws-client";
8
+
9
+ export const showLogLevelDialog = async (client: MatterClient) => {
10
+ await import("./log-level-dialog.js");
11
+ const dialog = document.createElement("log-level-dialog");
12
+ dialog.client = client;
13
+ document.querySelector("matter-dashboard-app")?.renderRoot.appendChild(dialog);
14
+ };
@@ -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,12 @@ 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, mdiCog, 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
+ import { showLogLevelDialog } from "../../components/dialogs/settings/show-log-level-dialog.js";
16
17
  import "../../components/ha-svg-icon";
18
+ import { EffectiveTheme, ThemePreference, ThemeService } from "../../util/theme-service.js";
17
19
 
18
20
  interface HeaderAction {
19
21
  label: string;
@@ -28,6 +30,56 @@ export class DashboardHeader extends LitElement {
28
30
 
29
31
  public client?: MatterClient;
30
32
 
33
+ @state() private _themePreference: ThemePreference = ThemeService.preference;
34
+ @state() private _effectiveTheme: EffectiveTheme = ThemeService.effectiveTheme;
35
+
36
+ private _unsubscribeTheme?: () => void;
37
+
38
+ override connectedCallback() {
39
+ super.connectedCallback();
40
+ this._unsubscribeTheme = ThemeService.subscribe(theme => {
41
+ this._effectiveTheme = theme;
42
+ this._themePreference = ThemeService.preference;
43
+ });
44
+ }
45
+
46
+ override disconnectedCallback() {
47
+ super.disconnectedCallback();
48
+ this._unsubscribeTheme?.();
49
+ }
50
+
51
+ private _cycleTheme() {
52
+ ThemeService.cycleTheme();
53
+ }
54
+
55
+ private _openSettings() {
56
+ if (this.client) {
57
+ showLogLevelDialog(this.client);
58
+ }
59
+ }
60
+
61
+ private _getThemeIcon(): string {
62
+ switch (this._themePreference) {
63
+ case "light":
64
+ return mdiWeatherSunny;
65
+ case "dark":
66
+ return mdiWeatherNight;
67
+ case "system":
68
+ return mdiBrightnessAuto;
69
+ }
70
+ }
71
+
72
+ private _getThemeTooltip(): string {
73
+ switch (this._themePreference) {
74
+ case "light":
75
+ return "Theme: Light";
76
+ case "dark":
77
+ return "Theme: Dark";
78
+ case "system":
79
+ return `Theme: System (${this._effectiveTheme})`;
80
+ }
81
+ }
82
+
31
83
  protected override render() {
32
84
  return html`
33
85
  <div class="header">
@@ -50,14 +102,26 @@ export class DashboardHeader extends LitElement {
50
102
  </md-icon-button>
51
103
  `;
52
104
  })}
53
- <!-- optional logout button -->
54
- ${this.client?.isProduction
55
- ? nothing
56
- : html`
57
- <md-icon-button @click=${this.client?.disconnect}>
105
+ <!-- settings button (only when connected) -->
106
+ ${this.client
107
+ ? html`
108
+ <md-icon-button @click=${this._openSettings} title="Server Settings">
109
+ <ha-svg-icon .path=${mdiCog}></ha-svg-icon>
110
+ </md-icon-button>
111
+ `
112
+ : nothing}
113
+ <!-- theme toggle button -->
114
+ <md-icon-button @click=${this._cycleTheme} .title=${this._getThemeTooltip()}>
115
+ <ha-svg-icon .path=${this._getThemeIcon()}></ha-svg-icon>
116
+ </md-icon-button>
117
+ <!-- optional logout button (only when client exists and not in production) -->
118
+ ${this.client && !this.client.isProduction
119
+ ? html`
120
+ <md-icon-button @click=${this.client.disconnect}>
58
121
  <ha-svg-icon .path=${mdiLogout}></ha-svg-icon>
59
122
  </md-icon-button>
60
- `}
123
+ `
124
+ : nothing}
61
125
  </div>
62
126
  </div>
63
127
  `;
@@ -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
  }
@@ -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();