@sukramj/fabric-ui 0.0.0-stage → 0.1.0
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.
- package/LICENSE +202 -0
- package/README.md +91 -2
- package/package.json +92 -4
- package/src/lib/api/client.ts +154 -0
- package/src/lib/api/index.ts +3 -0
- package/src/lib/api/schema.gen.ts +1159 -0
- package/src/lib/api/stream.ts +54 -0
- package/src/lib/context.ts +96 -0
- package/src/lib/i18n/de.json +157 -0
- package/src/lib/i18n/en.json +157 -0
- package/src/lib/i18n/index.ts +50 -0
- package/src/lib/index.ts +10 -0
- package/src/lib/kit/Badge.svelte +26 -0
- package/src/lib/kit/Button.svelte +49 -0
- package/src/lib/kit/Card.svelte +16 -0
- package/src/lib/kit/DataTable.svelte +239 -0
- package/src/lib/kit/EmptyState.svelte +24 -0
- package/src/lib/kit/ErrorState.svelte +22 -0
- package/src/lib/kit/Icon.svelte +12 -0
- package/src/lib/kit/Input.svelte +22 -0
- package/src/lib/kit/LoadingState.svelte +15 -0
- package/src/lib/kit/PageHeader.svelte +32 -0
- package/src/lib/kit/PageShell.svelte +16 -0
- package/src/lib/kit/Select.svelte +49 -0
- package/src/lib/kit/Spinner.svelte +59 -0
- package/src/lib/kit/Tabs.svelte +63 -0
- package/src/lib/kit/data-table.ts +24 -0
- package/src/lib/kit/icons.ts +41 -0
- package/src/lib/kit/index.ts +21 -0
- package/src/lib/kit/tabs.ts +13 -0
- package/src/lib/kit/utils.ts +49 -0
- package/src/lib/pages/index.ts +8 -0
- package/src/lib/pages/node/Diagnostics.svelte +205 -0
- package/src/lib/pages/node/Endpoints.svelte +82 -0
- package/src/lib/pages/node/Fabrics.svelte +169 -0
- package/src/lib/pages/node/Pair.svelte +186 -0
- package/src/lib/pages/node/Selection.svelte +388 -0
- package/src/lib/pages/node/Status.svelte +63 -0
- package/src/lib/qr.ts +58 -0
- package/src/lib/store.svelte.ts +293 -0
- package/src/lib/theme/ha-bridge.ts +156 -0
- package/src/lib/theme/index.ts +1 -0
- package/src/lib/theme/tokens.css +292 -0
package/src/lib/qr.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The QR code of a pairing payload as an inline SVG string, drawn on the
|
|
2
|
+
// client from the payload the API returns. Ported from openccu-loom
|
|
3
|
+
// assets/ui/src/lib/qr.ts:1-95 at b40b289a (qrcode, MIT): the matrix from
|
|
4
|
+
// QRCode.create, one horizontal path segment per run of dark modules.
|
|
5
|
+
// Chosen over a second encoder of our own: the library is small, MIT and
|
|
6
|
+
// already proven on loom's pairing page.
|
|
7
|
+
import { create as qrCreate } from "qrcode";
|
|
8
|
+
|
|
9
|
+
const MARGIN = 4; // quiet-zone modules (the spec minimum)
|
|
10
|
+
|
|
11
|
+
function modulesToPath(data: Uint8Array, size: number, margin: number): string {
|
|
12
|
+
let path = "";
|
|
13
|
+
let moveBy = 0;
|
|
14
|
+
let newRow = false;
|
|
15
|
+
let lineLength = 0;
|
|
16
|
+
for (let i = 0; i < data.length; i++) {
|
|
17
|
+
const col = i % size;
|
|
18
|
+
const row = Math.floor(i / size);
|
|
19
|
+
if (col === 0 && !newRow) newRow = true;
|
|
20
|
+
if (data[i]) {
|
|
21
|
+
lineLength++;
|
|
22
|
+
if (!(i > 0 && col > 0 && data[i - 1])) {
|
|
23
|
+
if (newRow) {
|
|
24
|
+
path += `M${col + margin} ${0.5 + row + margin}`;
|
|
25
|
+
newRow = false;
|
|
26
|
+
} else {
|
|
27
|
+
path += `m${moveBy} 0`;
|
|
28
|
+
}
|
|
29
|
+
moveBy = 0;
|
|
30
|
+
}
|
|
31
|
+
if (!(col + 1 < size && data[i + 1])) {
|
|
32
|
+
path += `h${lineLength}`;
|
|
33
|
+
lineLength = 0;
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
moveBy++;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return path;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A self-contained SVG of `payload` as a QR code. Black on a white plate
|
|
44
|
+
* whatever the theme: a scanner needs a light quiet zone and dark modules.
|
|
45
|
+
*/
|
|
46
|
+
export function renderQrSvg(payload: string, opts?: { size?: number }): string {
|
|
47
|
+
const px = opts?.size ?? 200;
|
|
48
|
+
const qr = qrCreate(payload, { errorCorrectionLevel: "M" });
|
|
49
|
+
const { size, data } = qr.modules;
|
|
50
|
+
const total = size + MARGIN * 2;
|
|
51
|
+
const path = modulesToPath(data, size, MARGIN);
|
|
52
|
+
return (
|
|
53
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${px}" height="${px}" viewBox="0 0 ${total} ${total}" shape-rendering="crispEdges">` +
|
|
54
|
+
`<path fill="white" d="M0 0h${total}v${total}H0z"/>` +
|
|
55
|
+
`<path stroke="black" d="${path}"/>` +
|
|
56
|
+
`</svg>`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// The node family's state: status, fabrics, the selection with its pending
|
|
2
|
+
// changes, and the pairing flow, kept current by the event stream. Ported
|
|
3
|
+
// from openccu-loom assets/ui/src/lib/stores/matter.svelte.ts:23-381 at
|
|
4
|
+
// b40b289a: one instance per fabric-ui context rather than a module
|
|
5
|
+
// singleton, the five-part exposure key replaced by the provider's opaque
|
|
6
|
+
// item key, the WebSocket topics by the stream's event types, the
|
|
7
|
+
// host-readiness gate dropped.
|
|
8
|
+
import { ApiError, type FabricClient, type FabricList, type ExposureSchema, type Item, type Status, type Update, type Window } from "./api/client";
|
|
9
|
+
import { subscribeStream, type StreamEvent } from "./api/stream";
|
|
10
|
+
import type { Translate } from "./i18n";
|
|
11
|
+
|
|
12
|
+
export interface Toast {
|
|
13
|
+
info(message: string): void;
|
|
14
|
+
success(message: string): void;
|
|
15
|
+
error(message: string): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type CommissioningPhase = "idle" | "open" | "success";
|
|
19
|
+
|
|
20
|
+
export interface Commissioning {
|
|
21
|
+
phase: CommissioningPhase;
|
|
22
|
+
window: Window | null;
|
|
23
|
+
remaining: number | null;
|
|
24
|
+
addedFabricLabel: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface NodeStoreDeps {
|
|
28
|
+
t: Translate;
|
|
29
|
+
toast: Toast;
|
|
30
|
+
/** Subscribes to the event stream; the default opens an EventSource on the client's stream URL. */
|
|
31
|
+
subscribe?: (onEvent: (ev: StreamEvent) => void) => () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const IDLE: Commissioning = { phase: "idle", window: null, remaining: null, addedFabricLabel: null };
|
|
35
|
+
|
|
36
|
+
export function createNodeStore(client: FabricClient, deps: NodeStoreDeps) {
|
|
37
|
+
let status = $state<Status | null>(null);
|
|
38
|
+
let statusLoading = $state(false);
|
|
39
|
+
let statusError = $state<string | null>(null);
|
|
40
|
+
let fabrics = $state<FabricList["fabrics"]>([]);
|
|
41
|
+
let fabricsLoading = $state(false);
|
|
42
|
+
let fabricsError = $state<string | null>(null);
|
|
43
|
+
let schema = $state<ExposureSchema | null>(null);
|
|
44
|
+
let items = $state<Item[]>([]);
|
|
45
|
+
let exposureLoading = $state(false);
|
|
46
|
+
let exposureError = $state<string | null>(null);
|
|
47
|
+
let pending = $state<Map<string, Update>>(new Map());
|
|
48
|
+
let commissioning = $state<Commissioning>({ ...IDLE });
|
|
49
|
+
|
|
50
|
+
let unsub: (() => void) | null = null;
|
|
51
|
+
let countdown: ReturnType<typeof setInterval> | null = null;
|
|
52
|
+
let exposureReload: ReturnType<typeof setTimeout> | null = null;
|
|
53
|
+
let closedTimer: ReturnType<typeof setTimeout> | null = null;
|
|
54
|
+
|
|
55
|
+
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
|
56
|
+
|
|
57
|
+
async function loadStatus() {
|
|
58
|
+
statusLoading = true;
|
|
59
|
+
statusError = null;
|
|
60
|
+
try {
|
|
61
|
+
status = await client.status();
|
|
62
|
+
} catch (err) {
|
|
63
|
+
status = null;
|
|
64
|
+
statusError = message(err);
|
|
65
|
+
} finally {
|
|
66
|
+
statusLoading = false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function loadFabrics() {
|
|
71
|
+
fabricsLoading = true;
|
|
72
|
+
fabricsError = null;
|
|
73
|
+
try {
|
|
74
|
+
fabrics = (await client.fabrics()).fabrics;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
fabricsError = message(err);
|
|
77
|
+
} finally {
|
|
78
|
+
fabricsLoading = false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function loadExposure() {
|
|
83
|
+
exposureLoading = true;
|
|
84
|
+
exposureError = null;
|
|
85
|
+
try {
|
|
86
|
+
const [s, list] = await Promise.all([client.exposureSchema(), client.exposure()]);
|
|
87
|
+
schema = s;
|
|
88
|
+
items = list.items;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
exposureError = message(err);
|
|
91
|
+
} finally {
|
|
92
|
+
exposureLoading = false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// One exposure_changed may follow a burst of others (a host applying
|
|
97
|
+
// hundreds of rows); collapse them into one reload.
|
|
98
|
+
function scheduleExposureReload() {
|
|
99
|
+
if (exposureReload) clearTimeout(exposureReload);
|
|
100
|
+
exposureReload = setTimeout(() => {
|
|
101
|
+
exposureReload = null;
|
|
102
|
+
void loadExposure();
|
|
103
|
+
}, 300);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function markDirty(item: Item, patch: { enabled?: boolean; name?: string }) {
|
|
107
|
+
const base: Update = pending.get(item.key) ?? { key: item.key, enabled: item.enabled };
|
|
108
|
+
const next: Update = { ...base, ...patch };
|
|
109
|
+
if (next.name !== undefined && next.name === item.name) delete next.name;
|
|
110
|
+
const map = new Map(pending);
|
|
111
|
+
if (next.enabled === item.enabled && next.name === undefined) map.delete(item.key);
|
|
112
|
+
else map.set(item.key, next);
|
|
113
|
+
pending = map;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function discardDirty() {
|
|
117
|
+
pending = new Map();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function preview(): Promise<Item[]> {
|
|
121
|
+
const updates = Array.from(pending.values());
|
|
122
|
+
if (updates.length === 0) return [];
|
|
123
|
+
return (await client.previewExposure(updates)).items;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function saveBulk(): Promise<number> {
|
|
127
|
+
const updates = Array.from(pending.values());
|
|
128
|
+
if (updates.length === 0) return 0;
|
|
129
|
+
const applied = await client.applyExposure(updates);
|
|
130
|
+
pending = new Map();
|
|
131
|
+
await loadExposure();
|
|
132
|
+
return applied;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function stopCountdown() {
|
|
136
|
+
if (countdown !== null) {
|
|
137
|
+
clearInterval(countdown);
|
|
138
|
+
countdown = null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function startCountdown(seconds: number) {
|
|
143
|
+
stopCountdown();
|
|
144
|
+
let remaining = seconds;
|
|
145
|
+
commissioning = { ...commissioning, remaining };
|
|
146
|
+
countdown = setInterval(() => {
|
|
147
|
+
remaining -= 1;
|
|
148
|
+
commissioning = { ...commissioning, remaining };
|
|
149
|
+
if (remaining <= 0) {
|
|
150
|
+
stopCountdown();
|
|
151
|
+
if (commissioning.phase === "open") commissioning = { ...IDLE };
|
|
152
|
+
}
|
|
153
|
+
}, 1000);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function openWindow(durationSeconds: number) {
|
|
157
|
+
const win = await client.openWindow(durationSeconds);
|
|
158
|
+
commissioning = { phase: "open", window: win, remaining: win.duration_seconds, addedFabricLabel: null };
|
|
159
|
+
startCountdown(win.duration_seconds);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// hydrateCommissioning re-syncs the pairing flow from the node, so a
|
|
163
|
+
// reload or a window opened elsewhere shows the codes and the countdown
|
|
164
|
+
// instead of the empty form (loom matter.svelte.ts:282-318).
|
|
165
|
+
async function hydrateCommissioning() {
|
|
166
|
+
if (commissioning.phase !== "idle") return;
|
|
167
|
+
let st = status;
|
|
168
|
+
if (st === null) {
|
|
169
|
+
st = await client.status();
|
|
170
|
+
status = st;
|
|
171
|
+
}
|
|
172
|
+
if (!st.enabled || !st.commissioning_window_open) return;
|
|
173
|
+
const p = await client.pairing();
|
|
174
|
+
const duration = p.duration_seconds ?? st.commissioning_window_duration_seconds ?? 0;
|
|
175
|
+
commissioning = {
|
|
176
|
+
phase: "open",
|
|
177
|
+
window: {
|
|
178
|
+
discriminator: p.discriminator,
|
|
179
|
+
passcode: p.passcode,
|
|
180
|
+
duration_seconds: duration,
|
|
181
|
+
qr_code: p.qr_code,
|
|
182
|
+
manual_code: p.manual_code,
|
|
183
|
+
},
|
|
184
|
+
// Without a server-side remaining time the full duration is the upper bound.
|
|
185
|
+
remaining: duration > 0 ? duration : null,
|
|
186
|
+
addedFabricLabel: null,
|
|
187
|
+
};
|
|
188
|
+
if (duration > 0) startCountdown(duration);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function closeWindow() {
|
|
192
|
+
await client.closeWindow();
|
|
193
|
+
stopCountdown();
|
|
194
|
+
commissioning = { ...IDLE };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function resetCommissioning() {
|
|
198
|
+
stopCountdown();
|
|
199
|
+
commissioning = { ...IDLE };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function applyEvent(ev: StreamEvent) {
|
|
203
|
+
switch (ev.type) {
|
|
204
|
+
case "fabric_added":
|
|
205
|
+
if (closedTimer) {
|
|
206
|
+
clearTimeout(closedTimer);
|
|
207
|
+
closedTimer = null;
|
|
208
|
+
}
|
|
209
|
+
stopCountdown();
|
|
210
|
+
commissioning = { ...commissioning, phase: "success", addedFabricLabel: ev.data.label || ev.data.vendor_name || null };
|
|
211
|
+
void loadFabrics();
|
|
212
|
+
void loadStatus();
|
|
213
|
+
break;
|
|
214
|
+
case "fabric_removed":
|
|
215
|
+
void loadFabrics();
|
|
216
|
+
void loadStatus();
|
|
217
|
+
break;
|
|
218
|
+
case "window_opened":
|
|
219
|
+
case "endpoints_changed":
|
|
220
|
+
void loadStatus();
|
|
221
|
+
break;
|
|
222
|
+
case "window_closed":
|
|
223
|
+
void loadStatus();
|
|
224
|
+
// A commissioning closes the window just before the fabric is
|
|
225
|
+
// reported; only a close that no fabric follows ends the flow.
|
|
226
|
+
if (commissioning.phase === "open" && !closedTimer) {
|
|
227
|
+
closedTimer = setTimeout(() => {
|
|
228
|
+
closedTimer = null;
|
|
229
|
+
if (commissioning.phase === "open") {
|
|
230
|
+
stopCountdown();
|
|
231
|
+
commissioning = { ...IDLE };
|
|
232
|
+
deps.toast.info(deps.t("node.commissioning.closed"));
|
|
233
|
+
}
|
|
234
|
+
}, 750);
|
|
235
|
+
}
|
|
236
|
+
break;
|
|
237
|
+
case "exposure_changed":
|
|
238
|
+
scheduleExposureReload();
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function connect() {
|
|
244
|
+
if (unsub) return;
|
|
245
|
+
const sub = deps.subscribe ?? ((fn) => subscribeStream(client.streamUrl(), fn));
|
|
246
|
+
unsub = sub(applyEvent);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function close() {
|
|
250
|
+
stopCountdown();
|
|
251
|
+
for (const timer of [exposureReload, closedTimer]) if (timer) clearTimeout(timer);
|
|
252
|
+
exposureReload = closedTimer = null;
|
|
253
|
+
unsub?.();
|
|
254
|
+
unsub = null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
get status() { return status; },
|
|
259
|
+
get statusLoading() { return statusLoading; },
|
|
260
|
+
get statusError() { return statusError; },
|
|
261
|
+
get fabrics() { return fabrics; },
|
|
262
|
+
get fabricsLoading() { return fabricsLoading; },
|
|
263
|
+
get fabricsError() { return fabricsError; },
|
|
264
|
+
get schema() { return schema; },
|
|
265
|
+
get items() { return items; },
|
|
266
|
+
get exposureLoading() { return exposureLoading; },
|
|
267
|
+
get exposureError() { return exposureError; },
|
|
268
|
+
get pending() { return pending; },
|
|
269
|
+
get hasDirty() { return pending.size > 0; },
|
|
270
|
+
get commissioning() { return commissioning; },
|
|
271
|
+
loadStatus,
|
|
272
|
+
loadFabrics,
|
|
273
|
+
loadExposure,
|
|
274
|
+
markDirty,
|
|
275
|
+
discardDirty,
|
|
276
|
+
preview,
|
|
277
|
+
saveBulk,
|
|
278
|
+
openWindow,
|
|
279
|
+
hydrateCommissioning,
|
|
280
|
+
closeWindow,
|
|
281
|
+
resetCommissioning,
|
|
282
|
+
applyEvent,
|
|
283
|
+
connect,
|
|
284
|
+
close,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export type NodeStore = ReturnType<typeof createNodeStore>;
|
|
289
|
+
|
|
290
|
+
/** The message of an error a page shows. */
|
|
291
|
+
export function errorText(err: unknown): string {
|
|
292
|
+
return err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
|
|
293
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// The Home Assistant theme bridge. Inside Home Assistant's Ingress frame
|
|
2
|
+
// (same origin) it mirrors the live theme: it copies Home Assistant's CSS
|
|
3
|
+
// variables off the parent document onto our :root and follows its
|
|
4
|
+
// light/dark choice by the parent's background luminance. Anywhere else it
|
|
5
|
+
// is inert and the static defaults of tokens.css [data-skin="ha"] apply.
|
|
6
|
+
// Ported from openccu-loom assets/ui/src/lib/theme/ha-bridge.ts:1-184 at
|
|
7
|
+
// b40b289a, without the shell-frame detection that is loom's own.
|
|
8
|
+
|
|
9
|
+
// Variables copied from the parent document. The --ha-* tokens read them
|
|
10
|
+
// through var(--…, fallback), so a live value overrides the default.
|
|
11
|
+
export const HA_THEME_VARS = [
|
|
12
|
+
"--primary-color",
|
|
13
|
+
"--accent-color",
|
|
14
|
+
"--primary-background-color",
|
|
15
|
+
"--secondary-background-color",
|
|
16
|
+
"--card-background-color",
|
|
17
|
+
"--primary-text-color",
|
|
18
|
+
"--secondary-text-color",
|
|
19
|
+
"--disabled-text-color",
|
|
20
|
+
"--divider-color",
|
|
21
|
+
"--error-color",
|
|
22
|
+
"--warning-color",
|
|
23
|
+
"--success-color",
|
|
24
|
+
"--info-color",
|
|
25
|
+
"--ha-card-border-radius",
|
|
26
|
+
"--ha-card-box-shadow",
|
|
27
|
+
"--app-header-background-color",
|
|
28
|
+
"--app-header-text-color",
|
|
29
|
+
"--sidebar-background-color",
|
|
30
|
+
"--rgb-primary-color",
|
|
31
|
+
"--text-primary-color",
|
|
32
|
+
"--ha-font-family-body",
|
|
33
|
+
"--header-height",
|
|
34
|
+
"--rgb-accent-color",
|
|
35
|
+
"--rgb-primary-text-color",
|
|
36
|
+
"--rgb-error-color",
|
|
37
|
+
"--rgb-success-color",
|
|
38
|
+
"--rgb-info-color",
|
|
39
|
+
"--shadow-color",
|
|
40
|
+
"--ha-color-text-primary",
|
|
41
|
+
"--ha-color-text-secondary",
|
|
42
|
+
"--ha-color-surface-default",
|
|
43
|
+
] as const;
|
|
44
|
+
|
|
45
|
+
// The path segment the Supervisor serves an add-on's Ingress under.
|
|
46
|
+
const HA_INGRESS_SEGMENT = "/api/hassio_ingress/";
|
|
47
|
+
|
|
48
|
+
export type Skin = "fabric" | "ha";
|
|
49
|
+
|
|
50
|
+
// True when the page runs inside Home Assistant, detected positively: a
|
|
51
|
+
// frame alone is not enough. Either the page is served under the Ingress
|
|
52
|
+
// path, or a same-origin parent document is Home Assistant's frontend
|
|
53
|
+
// (`<home-assistant>`).
|
|
54
|
+
export function isEmbedded(): boolean {
|
|
55
|
+
if (typeof window === "undefined") return false;
|
|
56
|
+
try {
|
|
57
|
+
if (window.self === window.top) return false;
|
|
58
|
+
} catch {
|
|
59
|
+
// top inaccessible — still framed
|
|
60
|
+
}
|
|
61
|
+
if (window.location.pathname.includes(HA_INGRESS_SEGMENT)) return true;
|
|
62
|
+
try {
|
|
63
|
+
return window.parent.document.querySelector("home-assistant") !== null;
|
|
64
|
+
} catch {
|
|
65
|
+
// Cross-origin or unreadable parent — not provably Home Assistant.
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The effective skin: inside Home Assistant always "ha", otherwise the
|
|
71
|
+
// stored choice.
|
|
72
|
+
export function resolveSkin(stored: Skin): Skin {
|
|
73
|
+
return isEmbedded() ? "ha" : stored;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A CSS colour as [r,g,b] (0–255), or null: #rgb, #rrggbb, rgb(), rgba().
|
|
77
|
+
export function parseRgb(value: string): [number, number, number] | null {
|
|
78
|
+
const v = value.trim();
|
|
79
|
+
if (!v) return null;
|
|
80
|
+
if (v[0] === "#") {
|
|
81
|
+
let hex = v.slice(1);
|
|
82
|
+
if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
|
83
|
+
if (hex.length >= 6) {
|
|
84
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
85
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
86
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
87
|
+
if (!Number.isNaN(r) && !Number.isNaN(g) && !Number.isNaN(b)) return [r, g, b];
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const m = v.match(/rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i);
|
|
92
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// WCAG relative luminance (0 black, 1 white) of an sRGB triple.
|
|
96
|
+
export function relativeLuminance([r, g, b]: [number, number, number]): number {
|
|
97
|
+
const lin = (c: number) => {
|
|
98
|
+
const s = c / 255;
|
|
99
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
100
|
+
};
|
|
101
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Starts mirroring the live theme and returns the cleanup. A no-op outside
|
|
105
|
+
// Home Assistant and for a parent that is or becomes inaccessible.
|
|
106
|
+
export function startHaBridge(): () => void {
|
|
107
|
+
if (!isEmbedded()) return () => {};
|
|
108
|
+
try {
|
|
109
|
+
const parentWin = window.parent;
|
|
110
|
+
const parentRoot = parentWin.document.documentElement;
|
|
111
|
+
const ourRoot = document.documentElement;
|
|
112
|
+
|
|
113
|
+
const apply = () => {
|
|
114
|
+
try {
|
|
115
|
+
const ps = parentWin.getComputedStyle(parentRoot);
|
|
116
|
+
for (const name of HA_THEME_VARS) {
|
|
117
|
+
const value = ps.getPropertyValue(name).trim();
|
|
118
|
+
if (value) ourRoot.style.setProperty(name, value);
|
|
119
|
+
}
|
|
120
|
+
// Follow Home Assistant's light/dark by its background luminance;
|
|
121
|
+
// this overrides the page's own choice while embedded.
|
|
122
|
+
const bg = ps.getPropertyValue("--primary-background-color").trim() || ps.backgroundColor;
|
|
123
|
+
const rgb = parseRgb(bg);
|
|
124
|
+
if (rgb) {
|
|
125
|
+
const dark = relativeLuminance(rgb) < 0.5;
|
|
126
|
+
ourRoot.classList.toggle("dark", dark);
|
|
127
|
+
ourRoot.style.colorScheme = dark ? "dark" : "light";
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
// The parent became inaccessible — the last good values stay.
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
apply();
|
|
135
|
+
const observer = new MutationObserver(() => apply());
|
|
136
|
+
observer.observe(parentRoot, { attributes: true, attributeFilter: ["style", "class"] });
|
|
137
|
+
const onFocus = () => apply();
|
|
138
|
+
window.addEventListener("focus", onFocus);
|
|
139
|
+
document.addEventListener("visibilitychange", onFocus);
|
|
140
|
+
return () => {
|
|
141
|
+
observer.disconnect();
|
|
142
|
+
window.removeEventListener("focus", onFocus);
|
|
143
|
+
document.removeEventListener("visibilitychange", onFocus);
|
|
144
|
+
};
|
|
145
|
+
} catch {
|
|
146
|
+
return () => {};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Sets data-skin on <html> to the effective skin and returns it. */
|
|
151
|
+
export function applySkin(stored: Skin = "ha", root: HTMLElement = document.documentElement): Skin {
|
|
152
|
+
const skin = resolveSkin(stored);
|
|
153
|
+
if (skin === "ha") root.dataset.skin = "ha";
|
|
154
|
+
else delete root.dataset.skin;
|
|
155
|
+
return skin;
|
|
156
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { HA_THEME_VARS, applySkin, isEmbedded, parseRgb, relativeLuminance, resolveSkin, startHaBridge, type Skin } from "./ha-bridge";
|