diff --git a/calls/frontend/src/App.tsx b/calls/frontend/src/App.tsx
index bc4d6dc..3dfdd3c 100644
--- a/calls/frontend/src/App.tsx
+++ b/calls/frontend/src/App.tsx
@@ -5,6 +5,7 @@ import { CopyButton } from "./CopyButton";
import { retryReady, useAuth } from "./crm";
import { ConfirmDialog } from "./Dialogs";
import { Icon } from "./Icon";
+import { withTheme } from "./theme";
const WS_KEY = "calls_ws_v1:";
@@ -82,8 +83,9 @@ function Room(props: { ws: string }) {
setBusy(true);
try {
const r = await api.join(ws);
- if (w && !w.closed) w.location.href = r.url;
- else setFallback(r.url); // браузер заблокировал окно — ссылка вручную (код живёт 60 секунд)
+ const url = withTheme(r.url);
+ if (w && !w.closed) w.location.href = url;
+ else setFallback(url); // браузер заблокировал окно — ссылка вручную (код живёт 60 секунд)
} catch (e) {
try { w?.close(); } catch { /* уже закрыто */ }
setJoinErr((e as Error).message);
diff --git a/calls/frontend/src/crm.ts b/calls/frontend/src/crm.ts
index 88ffa80..f8f3543 100644
--- a/calls/frontend/src/crm.ts
+++ b/calls/frontend/src/crm.ts
@@ -1,6 +1,7 @@
-// Мост с CRM: postMessage (crm:ready → crm:auth, crm:workspace, crm:refresh, crm:logout).
+// Мост с CRM: postMessage (crm:ready → crm:auth, crm:workspace, crm:theme, crm:refresh, crm:logout).
// Токен живёт только в памяти, никуда не логируется и не выводится.
import { useSyncExternalStore } from "react";
+import { applyTheme, parseTheme } from "./theme";
export type User = { id: string; email: string | null; name: string | null };
export type CrmWorkspace = { id?: string; kind?: string; title?: string };
@@ -150,6 +151,9 @@ function handle(ev: MessageEvent) {
else if (d.type === "crm:workspace") {
lastWs = d.workspace ?? null;
set({ crmWorkspace: wsKey(lastWs, state.user?.id) });
+ } else if (d.type === "crm:theme") {
+ const t = parseTheme(d.theme, d.palette);
+ if (t) applyTheme(t);
} else if (d.type === "crm:logout") {
token = null; expiresAt = null;
set({ status: "logout", user: undefined });
diff --git a/calls/frontend/src/main.tsx b/calls/frontend/src/main.tsx
index 19737b1..0587fe8 100644
--- a/calls/frontend/src/main.tsx
+++ b/calls/frontend/src/main.tsx
@@ -4,13 +4,14 @@ import App from "./App";
import { CallPage } from "./Call";
import { initCrm } from "./crm";
import { GuestPage } from "./Guest";
+import { initCallTheme } from "./theme";
import "./calls.css";
// Три страницы: / — модуль в iframe CRM, /call — окно звонка, /g/<токен> — гость.
const path = location.pathname;
const guest = /^\/g\/([A-Za-z0-9_-]{20,100})\/?$/.exec(path);
let page;
-if (path === "/call" || path === "/call/") page = ;
+if (path === "/call" || path === "/call/") { initCallTheme(); page = ; }
else if (guest) page = ;
else { void initCrm(); page = ; }
diff --git a/calls/frontend/src/theme.ts b/calls/frontend/src/theme.ts
new file mode 100644
index 0000000..8f10b3f
--- /dev/null
+++ b/calls/frontend/src/theme.ts
@@ -0,0 +1,52 @@
+// Тема и палитра CRM: , остальное делают стили tvorymo-ui.css.
+// В iframe их присылает CRM (crm:theme), в окно звонка — модуль параметрами ?theme=&pal= в ссылке.
+
+const THEME_KEY = "calls_theme_v1";
+
+export type Theme = { theme: "light" | "dark"; pal: string };
+
+/** Проверка значений: тема только light|dark, палитра — короткое имя из латиницы (иначе classic). */
+export function parseTheme(theme: unknown, pal: unknown): Theme | null {
+ if (theme !== "light" && theme !== "dark") return null;
+ const p = typeof pal === "string" && /^[a-z][a-z0-9-]{0,31}$/.test(pal) ? pal : "classic";
+ return { theme, pal: p };
+}
+
+export function applyTheme(t: Theme) {
+ const el = document.documentElement;
+ el.dataset.theme = t.theme;
+ el.dataset.pal = t.pal;
+}
+
+/** Текущая тема модуля (если CRM её присылала) — для передачи в окно звонка. */
+export function currentTheme(): Theme | null {
+ const el = document.documentElement;
+ return parseTheme(el.dataset.theme, el.dataset.pal);
+}
+
+/** Окно звонка: тема из ссылки, при перезагрузке — из sessionStorage окна. Без неё — системная. */
+export function initCallTheme() {
+ const q = new URLSearchParams(location.search);
+ let t = parseTheme(q.get("theme"), q.get("pal"));
+ if (t) {
+ try { sessionStorage.setItem(THEME_KEY, JSON.stringify(t)); } catch { /* хранилище недоступно */ }
+ } else {
+ try {
+ const s = JSON.parse(sessionStorage.getItem(THEME_KEY) || "null");
+ t = s && parseTheme(s.theme, s.pal);
+ } catch { /* битое значение */ }
+ }
+ if (t) applyTheme(t);
+}
+
+/** Ссылка на окно звонка с темой модуля (в окно crm:theme не приходит). */
+export function withTheme(url: string): string {
+ const t = currentTheme();
+ if (!t) return url;
+ try {
+ const u = new URL(url, location.href);
+ u.searchParams.set("theme", t.theme);
+ u.searchParams.set("pal", t.pal);
+ return u.toString();
+ } catch { return url; }
+}