Задача 038: тёмная тема UI
Все цвета — CSS-переменные; тёмная палитра по prefers-color-scheme (режим «авто») и принудительно через data-theme; светлая тема не изменилась. Переключатель — пиктограмма в шапке (авто / светлая / тёмная), выбор в localStorage; theme.js ставит тему до отрисовки CSS (без вспышки, без ослабления CSP). QR 2FA на белой подложке. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d3495fb077
commit
a6e70ffd1a
7 files changed
+221
-47
No files matched your search
@@ -151,6 +151,7 @@ docs/changes/ планы и итоги доработок docs/reviews/
|
||||
- В «Пользователях» видна дата и IP последнего входа (изменение 035); «—», если пользователь ещё не входил.
|
||||
- Логин в шапке раскрывает меню «Сменить пароль» / «Двухфакторная аутентификация» / «Выйти» (изменения 036, 037).
|
||||
- В «Пользователях» у записей с включённой 2FA — бейдж «2FA»; в меню строки суперадминистратора для чужих записей — «Сбросить 2FA» (изменение 037).
|
||||
- Тёмная тема (изменение 038): по умолчанию следует настройке ОС/браузера (`prefers-color-scheme`); переключатель-пиктограмма в шапке (солнце/луна/монитор), выбор хранится в `localStorage` браузера.
|
||||
- Переключатель организации — только у `superadmin`; `admin`/`viewer` работают в своей организации.
|
||||
- Строка реестра кликабельна целиком. Действия над строкой — в меню «⋯».
|
||||
- Групповые операции через чекбоксы (кроме «Журнала»): удаление, смена типа устройств, статус префиксов и адресов, доступ пользователей.
|
||||
@@ -203,6 +204,7 @@ docker compose -p ipam_control_006 up -d --build && venv/bin/python -m pytest -q
|
||||
| 035 | Последний вход пользователя: дата и IP | [план](docs/changes/035-user-last-login/PLAN.md) · [итог](docs/changes/035-user-last-login/SUMMARY.md) |
|
||||
| 036 | Меню пользователя в шапке: логин с выпадающим списком | [план](docs/changes/036-user-menu/PLAN.md) · [итог](docs/changes/036-user-menu/SUMMARY.md) |
|
||||
| 037 | Двухфакторная аутентификация TOTP, по выбору пользователя | [план](docs/changes/037-totp-2fa/PLAN.md) · [итог](docs/changes/037-totp-2fa/SUMMARY.md) |
|
||||
| 038 | Тёмная тема UI | [план](docs/changes/038-dark-theme/PLAN.md) · [итог](docs/changes/038-dark-theme/SUMMARY.md) |
|
||||
|
||||
## Отчёты ревью
|
||||
- [Ревью кодовой базы](docs/reviews/2026-09-26-codebase-review.md) (находки → изменения 011–023)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Тёмная тема UI (изменение 038)
|
||||
|
||||
## Context
|
||||
Сейчас у UI только светлая тема. Нужна тёмная. Решение пользователя: по умолчанию тема следует настройке ОС и браузера
|
||||
(`prefers-color-scheme`), в меню логина есть переключатель «Тема: авто / светлая / тёмная». Выбор хранится в браузере (`localStorage`).
|
||||
На экране входа тема тоже берётся по системе или по сохранённому выбору.
|
||||
|
||||
Сейчас в `web/styles.css` есть токены в `:root` (`--bg`, `--card`, `--line`, `--text`, `--muted`, `--primary`, `--head`, `--hover` …),
|
||||
но около 36 цветов прописаны прямо в правилах: бейджи, баннеры, тени, `#fff` у кнопок и `.pop`, `.org-btn` и т. п.
|
||||
Ещё около 16 цветов задано в `web/app.js`: `utilColor`, цвета загрузки, инлайн-стили.
|
||||
|
||||
## Изменения
|
||||
1. **Токены.** Все цвета в `styles.css` перевести на CSS-переменные в `:root`. Для каждого прописанного цвета —
|
||||
семантический токен: `--card`, `--input-bg`, `--shadow`, `--badge-*-bg/fg`, `--danger`, `--warn`, `--ok`, `--primary-soft`, `--overlay` …
|
||||
Светлые значения остаются прежними: светлая тема визуально не меняется.
|
||||
2. **Тёмная палитра.** Та же структура токенов в двух местах:
|
||||
- `@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { … } }` — режим «авто»;
|
||||
- `:root[data-theme="dark"] { … }` — принудительная тёмная.
|
||||
|
||||
Плюс `color-scheme: light` / `dark` — нативные поля ввода, скроллбары, автозаполнение.
|
||||
|
||||
Палитра: фон `#0f1419`–`#151b23`, карточки на ступень светлее, линии приглушённые, текст `#e6edf3`, `--muted` около `#9aa7b8`,
|
||||
`--primary` светлее для контраста около `#6b9bff`. Бейджи — тёмный насыщенный фон и светлый текст.
|
||||
Контраст текста не ниже WCAG AA.
|
||||
3. **Цвета в `web/app.js`.** Прописанные цвета (`utilColor`, инлайн `style="color:#…"`, фон полос загрузки) заменить на
|
||||
`var(--…)` или классы. Логика выбора (порог загрузки → зелёный/жёлтый/красный) не меняется.
|
||||
Особые случаи:
|
||||
- QR-код 2FA остаётся тёмным на белом: сканеры хуже читают инвертированный. Его контейнеру — белый фон с отступом.
|
||||
- Коды восстановления и моноширинные блоки — через токены.
|
||||
4. **Переключатель.**
|
||||
- `web/theme.js` (новый), подключается в `<head>` `web/index.html` обычным синхронным `<script src="theme.js">` до CSS.
|
||||
Скрипт читает `localStorage["ipam.theme"]` (`light`/`dark`; нет значения — «авто») и ставит `data-theme` на `<html>`,
|
||||
поэтому нет вспышки светлой темы. Инлайн-скрипт нельзя: CSP `default-src 'self'` в `app/main.py` его запрещает, ослаблять CSP не нужно.
|
||||
- `web/app.js`: в меню логина пункт «Тема: <текущая>» → вложенный выбор «Авто / Светлая / Тёмная»
|
||||
либо циклическое переключение — выбрать то, что проще вписывается в `popMenu`. Запись в `localStorage`
|
||||
(в `try/catch`, как у `store`), обновление `data-theme` без перезагрузки.
|
||||
- `<meta name="color-scheme" content="light dark">`.
|
||||
5. **Документация.** `README.md` («Интерфейс»: тёмная тема, режимы, где хранится выбор; строка 038 в истории);
|
||||
`SUMMARY.md` по завершении.
|
||||
|
||||
## Исполнение
|
||||
По принятой схеме: код пишет агент на Sonnet, он же пересобирает стенд с `--force-recreate`. Тесты не нужны: API не меняется.
|
||||
Моя часть — ревью: не осталось прописанных цветов, светлые значения совпадают с прежними, CSP не нарушен.
|
||||
Визуальная проверка — пользователь.
|
||||
|
||||
## Проверка
|
||||
- `grep` по `#[0-9a-f]{3,6}` и `rgb(` в `styles.css` вне блоков токенов, а также в `app.js` — пусто или обоснованные исключения (QR).
|
||||
- Светлая тема визуально как раньше. Тёмная: все экраны, диалоги, выпадающие списки, бейджи, журнал, экран входа, 2FA.
|
||||
- Переключатель: авто следует ОС, выбор переживает перезагрузку, нет вспышки при загрузке.
|
||||
- Стенд отдаёт новые `styles.css`, `app.js`, `index.html`; CSP-заголовок не менялся, в консоли нет нарушений CSP.
|
||||
|
||||
## Доработка по проверке пользователя: переключатель темы — отдельная пиктограмма в шапке
|
||||
Переключатель не должен быть частью меню пользователя. Вместо пунктов «Тема: …» в `popMenu("user", …)`:
|
||||
- **Кнопка.** Отдельная кнопка-пиктограмма в шапке (`shell()`) слева от логина; `iconBtn` или `btn ghost` без текста, `aria-label` и `title` —
|
||||
«Тема: авто/светлая/тёмная». Пиктограмма отражает текущий режим: солнце — светлая, луна — тёмная, монитор — авто (по системе).
|
||||
Иконки — inline SVG в стиле существующего набора `I` (`stroke="currentColor"`, 16px).
|
||||
- **Выбор.** По клику — общий выпадающий список `popMenu("theme", […], "row-pop")`: «Авто (как в системе)», «Светлая», «Тёмная»,
|
||||
у текущей — `cls:"sel"`. Выбор — ветка `id === "theme"` в `actions.pick`, существующая `setTheme()`, затем `draw()`.
|
||||
- **Меню пользователя.** Возвращается к трём пунктам: «Сменить пароль», «Двухфакторная аутентификация», «Выйти».
|
||||
- **Экран входа.** Шапки нет, тема берётся по системе или по сохранённому выбору (`theme.js`), без изменений.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Итог: тёмная тема UI (изменение 038)
|
||||
|
||||
План: `PLAN.md` (с разделом доработки по проверке пользователя). У UI появилась тёмная тема. По умолчанию тема следует настройке ОС и браузера (`prefers-color-scheme`),
|
||||
её можно переключить пиктограммой в шапке: авто / светлая / тёмная. Выбор хранится в браузере (`localStorage["ipam.theme"]`).
|
||||
|
||||
## Что сделано
|
||||
| Файл | Изменение |
|
||||
|---|---|
|
||||
| `web/styles.css` | Все цвета — CSS-переменные в `:root`, светлые значения прежние. Тёмная палитра в двух одинаковых блоках: `@media (prefers-color-scheme: dark){:root:not([data-theme="light"])}` для режима «авто» и `:root[data-theme="dark"]` для принудительной тёмной; `color-scheme`. Контраст — WCAG AA; в тёмной теме у основных кнопок тёмный текст на светло-синем фоне (белый дал бы ≈2.7:1) |
|
||||
| `web/theme.js` (новый) | Синхронно в `<head>` до CSS ставит `data-theme` по сохранённому выбору — без вспышки светлой темы. Отдельный файл, так как CSP `default-src 'self'` запрещает инлайн-скрипты; CSP не менялся |
|
||||
| `web/index.html` | `<meta name="color-scheme" content="light dark">`, подключение `theme.js` |
|
||||
| `web/app.js` | Цвета (`utilColor`, полосы загрузки, инлайн-стили, иконка логотипа) — через токены. Кнопка-пиктограмма темы в шапке слева от логина (солнце / луна / монитор) с выпадающим списком «Авто (как в системе) / Светлая / Тёмная». QR 2FA — на белой подложке (`.qr-frame`) ради сканируемости |
|
||||
| `README.md` | «Интерфейс» — тёмная тема и переключатель; строка 038 в истории |
|
||||
|
||||
## Доработка по проверке пользователя
|
||||
Сначала переключатель был пунктами «Тема: …» в меню логина. По замечанию пользователя вынесен в отдельную пиктограмму на панели шапки;
|
||||
в меню пользователя остались «Сменить пароль», «Двухфакторная аутентификация», «Выйти».
|
||||
|
||||
## Проверки
|
||||
- Светлая тема не изменилась: во всех 49 объявлениях с цветом прежнего `styles.css` после подстановки светлых токенов значения совпадают (скриптовое сравнение).
|
||||
- Литеральные цвета вне токенов:
|
||||
- `.qr-frame` (белый) — QR 2FA;
|
||||
- стрелка `<select>` — SVG в `url()`, куда переменную не подставить, поэтому она переопределена в тёмных блоках.
|
||||
|
||||
Оба тёмных блока идентичны; заголовок CSP прежний.
|
||||
- `node --check` — чисто; стенд пересобран (`--force-recreate`), отдаёт новые `styles.css`, `app.js`, `theme.js`.
|
||||
- Визуальная проверка обеих тем и переключателя — пользователь.
|
||||
+35
-10
@@ -27,7 +27,11 @@ const I = {
|
||||
trashJ: () => ic('<path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/>'),
|
||||
calendar: () => ic('<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>'),
|
||||
copy: () => ic('<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h9"/>'),
|
||||
globe: () => `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3.5 9h17M3.5 15h17"/><path d="M12 3a15 15 0 0 1 4 9 15 15 0 0 1-4 9"/><path d="M12 3a15 15 0 0 0-4 9 15 15 0 0 0 4 9"/></svg>`,
|
||||
globe: () => `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3.5 9h17M3.5 15h17"/><path d="M12 3a15 15 0 0 1 4 9 15 15 0 0 1-4 9"/><path d="M12 3a15 15 0 0 0-4 9 15 15 0 0 0 4 9"/></svg>`, // изменение 038: stroke="#fff" → currentColor, цвет задаёт .brand-logo{color:var(--on-primary)}
|
||||
// изменение 038 (доработка): пиктограмма переключателя темы — солнце/луна/монитор по текущему режиму
|
||||
sun: () => ic('<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M2 12h2M20 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"/>'),
|
||||
moon: () => ic('<path d="M21 12.5A9 9 0 1 1 11.5 3a7 7 0 0 0 9.5 9.5Z"/>'),
|
||||
monitor: () => ic('<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/>'),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------- state */
|
||||
@@ -37,6 +41,17 @@ const store = {
|
||||
get orgId() { return Number(localStorage.getItem("ipam.org")) || null; },
|
||||
set orgId(v) { localStorage.setItem("ipam.org", v); },
|
||||
};
|
||||
// изменение 038: тема — "auto" (по умолчанию, следует prefers-color-scheme), "light" или "dark" (выбор пользователя, хранится в localStorage).
|
||||
// Начальное применение — в web/theme.js (до отрисовки CSS, чтобы не было вспышки светлой темы); здесь только переключение из меню.
|
||||
const THEME_LABEL = { auto: "авто", light: "светлая", dark: "тёмная" };
|
||||
function getTheme() {
|
||||
try { return localStorage.getItem("ipam.theme") || "auto"; } catch { return "auto"; }
|
||||
}
|
||||
function setTheme(t) {
|
||||
try { t === "auto" ? localStorage.removeItem("ipam.theme") : localStorage.setItem("ipam.theme", t); } catch { /* localStorage недоступен — тема не сохранится между сессиями */ }
|
||||
if (t === "auto") delete document.documentElement.dataset.theme;
|
||||
else document.documentElement.dataset.theme = t;
|
||||
}
|
||||
let user = null;
|
||||
let orgs = [];
|
||||
let S = {}; // локальное состояние текущего экрана
|
||||
@@ -81,7 +96,8 @@ function toast(msg, err = false) {
|
||||
const badge = (cls, text) => `<span class="badge ${cls}">${esc(text)}</span>`;
|
||||
const PREFIX_STATUS = { active: ["green", "Активен"], reserved: ["amber", "Резерв"], deprecated: ["red", "Устарел"] };
|
||||
const ADDR_STATUS = { assigned: ["green", "Назначен"], reserved: ["amber", "Резерв"], deprecated: ["red", "Устаревший"], free: ["", "Свободен"] };
|
||||
const utilColor = (p) => (p >= 90 ? ["#c62828", "#a32020"] : p >= 75 ? ["#d97706", "#7c4a00"] : ["#2450c8", "#58657a"]);
|
||||
// изменение 038: цвета — CSS-токены вместо литералов; третий элемент — признак «порог не превышен» (обычный/приглушённый вид)
|
||||
const utilColor = (p) => (p >= 90 ? ["var(--danger-strong)", "var(--danger)", false] : p >= 75 ? ["var(--warn-strong)", "var(--warn)", false] : ["var(--primary)", "var(--muted)", true]);
|
||||
const btn = (label, action, { cls = "", data = "", icon = "" } = {}) => `<button type="button" class="btn ${cls}" data-action="${action}" ${data}>${icon}${label}</button>`;
|
||||
const iconBtn = (icon, action, label, data = "", sm = false, disabled = false) =>
|
||||
`<button type="button" class="icon-btn${sm ? " sm" : ""}" data-action="${action}" aria-label="${esc(label)}" title="${esc(label)}" ${data} ${disabled ? "disabled" : ""}>${icon}</button>`;
|
||||
@@ -136,9 +152,17 @@ function shell(active, content) {
|
||||
// изменение 036: логин в шапке — триггер выпадающего меню («Сменить пароль» / «Выйти») вместо отдельных кнопок
|
||||
// изменение 037: пункт «Двухфакторная аутентификация» между «Сменить пароль» и «Выйти»
|
||||
const userMenu = `<span class="rel user-menu"><button type="button" class="btn ghost" data-action="menu" data-menu="user" title="${esc(ROLE_RU[user?.role] ?? "")}">${esc(user?.username ?? "")}${I.chevD(14)}</button>${popMenu("user", [{ label: "Сменить пароль", value: "pw" }, { label: "Двухфакторная аутентификация", value: "2fa" }, { label: "Выйти", value: "logout" }], "row-pop")}</span>`;
|
||||
// изменение 038 (доработка по проверке): переключатель темы — отдельная пиктограмма в шапке (не пункт меню пользователя)
|
||||
const curTheme = getTheme();
|
||||
const THEME_ICON = { auto: I.monitor(), light: I.sun(), dark: I.moon() };
|
||||
const themeBtn = `<span class="rel"><button type="button" class="icon-btn" data-action="menu" data-menu="theme" aria-label="Тема: ${esc(THEME_LABEL[curTheme])}" title="Тема: ${esc(THEME_LABEL[curTheme])}">${THEME_ICON[curTheme]}</button>${popMenu("theme", [
|
||||
{ label: "Авто (как в системе)", value: "auto", cls: curTheme === "auto" ? "sel" : "" },
|
||||
{ label: "Светлая", value: "light", cls: curTheme === "light" ? "sel" : "" },
|
||||
{ label: "Тёмная", value: "dark", cls: curTheme === "dark" ? "sel" : "" },
|
||||
], "row-pop")}</span>`;
|
||||
return `<div class="appbar"><div class="brand"><span class="brand-logo">${I.globe()}</span>ipam_manager</div>
|
||||
<nav class="nav">${nav.map(([k, l]) => `<a href="#/${k}" class="${k === active ? "active" : ""}">${l}</a>`).join("")}</nav>
|
||||
<div class="grow"></div>${userMenu}</div>
|
||||
<div class="grow"></div><span style="display:flex;align-items:center;gap:8px">${themeBtn}${userMenu}</span></div>
|
||||
<div class="page">${content}</div>`;
|
||||
}
|
||||
|
||||
@@ -203,8 +227,8 @@ screens.overview = async () => {
|
||||
const [, tc] = utilColor(o.utilization);
|
||||
const actionBadge = { created: ["blue", "создан"], assigned: ["green", "назначен"], deleted: ["red", "удалён"], updated: ["amber", "изменён"], delete_blocked: ["amber", "отклонено"] };
|
||||
const top = o.top_prefixes.map((p, i, a) => {
|
||||
const [bar, txt] = utilColor(p.utilization);
|
||||
return `<div class="tr${i === a.length - 1 ? " last" : ""}" style="--cols:2fr 1fr 1fr;--h:48px"><span class="m13">${esc(p.prefix)}</span><span style="color:${txt === "#58657a" ? "var(--muted)" : txt};font-weight:${txt === "#58657a" ? 400 : 500}">${fmtNum(p.used)}/${fmtNum(p.capacity)} (${p.utilization}%)</span><div class="bar"><div class="track" style="height:4px"><div class="fill" style="width:${p.utilization}%;background:${bar}"></div></div></div></div>`;
|
||||
const [bar, txt, isDefault] = utilColor(p.utilization); // изменение 038: сравнение с литералом цвета заменено на флаг isDefault
|
||||
return `<div class="tr${i === a.length - 1 ? " last" : ""}" style="--cols:2fr 1fr 1fr;--h:48px"><span class="m13">${esc(p.prefix)}</span><span style="color:${txt};font-weight:${isDefault ? 400 : 500}">${fmtNum(p.used)}/${fmtNum(p.capacity)} (${p.utilization}%)</span><div class="bar"><div class="track" style="height:4px"><div class="fill" style="width:${p.utilization}%;background:${bar}"></div></div></div></div>`;
|
||||
}).join("");
|
||||
const recent = o.recent_changes.map((r, i, a) => {
|
||||
const [c, t] = actionBadge[r.action] || ["", r.action];
|
||||
@@ -215,7 +239,7 @@ screens.overview = async () => {
|
||||
<div class="card stat"><div class="l">Префиксов</div><div class="v">${fmtNum(o.prefixes)}</div><div class="s">${o.vrfs} VRF</div></div>
|
||||
<div class="card stat"><div class="l">Адресов назначено</div><div class="v">${fmtNum(o.assigned)}</div><div class="s">из ${fmtNum(o.capacity)} возможных</div></div>
|
||||
<div class="card stat"><div class="l">Использование</div><div class="v">${o.utilization}%</div><div class="track"><div style="width:${o.utilization}%"></div></div></div>
|
||||
<div class="card stat"><div class="l">Зарезервировано</div><div class="v" style="color:#7c4a00">${fmtNum(o.reserved)}</div><div class="s">адресов</div></div></div>
|
||||
<div class="card stat"><div class="l">Зарезервировано</div><div class="v" style="color:var(--warn)">${fmtNum(o.reserved)}</div><div class="s">адресов</div></div></div>
|
||||
<div class="two"><div class="card"><div class="card-title">Высокая загрузка</div>
|
||||
<div class="tr th" style="--cols:2fr 1fr 1fr;--h:36px"><span>Префикс</span><span>Использовано</span><span></span></div>${top || '<div class="empty">Нет данных</div>'}</div>
|
||||
<div class="card"><div class="card-title">Последние изменения</div>
|
||||
@@ -560,8 +584,8 @@ screens.address = async (id) => {
|
||||
return shell("prefixes", `<div class="crumbs" style="font-weight:400;margin-top:16px;padding-top:0"><a href="#/prefixes">Префиксы</a>${chain.map((x) => `${I.chevR(14)}<span>${esc(x.prefix)}</span>`).join("")}${I.chevR(14)}<span style="color:var(--text);font-weight:500">${esc(p.prefix)}</span></div>
|
||||
<div class="head" style="margin:12px 0 16px"><div><h1 class="mono" style="font-family:var(--sans)">${esc(p.prefix)}</h1><div class="sub">${esc(p.description)}${p.description ? " · " : ""}${sm.assigned} назначено · ${sm.free} свободно · VRF ${esc(p.vrf_name)}</div></div>
|
||||
<div class="actions">${btn("Редактировать", "pfx-edit", { icon: I.edit(16) })}${btn("Назначить адрес", "adr-new", { cls: "primary", icon: I.plus() })}</div></div>
|
||||
<div class="card usage"><div style="flex:1"><div class="track"><div style="width:${pct(sm.assigned)}%;background:#2450c8"></div><div style="width:${pct(sm.reserved)}%;background:#d97706"></div></div></div>
|
||||
<div class="legend"><span><i style="background:#2450c8"></i>Назначено ${sm.assigned}</span><span><i style="background:#d97706"></i>Зарезервировано ${sm.reserved}</span><span><i style="background:#dde3ea"></i>Свободно ${sm.free}</span></div></div>
|
||||
<div class="card usage"><div style="flex:1"><div class="track"><div style="width:${pct(sm.assigned)}%;background:var(--primary)"></div><div style="width:${pct(sm.reserved)}%;background:var(--warn-strong)"></div></div></div>
|
||||
<div class="legend"><span><i style="background:var(--primary)"></i>Назначено ${sm.assigned}</span><span><i style="background:var(--warn-strong)"></i>Зарезервировано ${sm.reserved}</span><span><i style="background:var(--line)"></i>Свободно ${sm.free}</span></div></div>
|
||||
<div class="card"><div class="filters">${searchBox(S.q || "", "Поиск: адрес, DNS, описание", 260)}${filterBtn("astatus", "Статус", stItems, S.astatus ?? "", 190)}</div>
|
||||
${bulkBar(bulkMenu("bulk-status", "Статус", ["assigned", "reserved", "deprecated"].map((value) => ({ label: ADDR_STATUS[value][1], value }))))}<div class="tr th" style="--cols:${cols};--h:40px">${selAllCell()}<span>IP-адрес</span><span>DNS-имя</span><span>Описание</span><span>Статус</span><span>Изменён (UTC)</span><span></span></div>
|
||||
${rows || '<div class="empty">Адресов нет</div>'}<div class="foot">Показано ${shown} из ${page.total} ${plural(page.total, "адреса", "адресов", "адресов")}${more}</div></div>`);
|
||||
@@ -678,7 +702,7 @@ function journalClearDialog(state = {}) {
|
||||
body: `<div class="dialog-body" id="clear-form"><div class="warn">Будет удалено <b>${fmtNum(n)} ${plural(n, "запись", "записи", "записей")}</b> без возможности восстановления. В журнале останется одна запись об очистке: кто и когда её выполнил.</div>
|
||||
<label class="field"><span>Пароль пользователя <b style="font-weight:600">${esc(user.username)}</b>${opt("подтверждение нужно только для этого действия")}</span>
|
||||
<input class="input${state.wrong ? " bad" : ""}" type="password" id="clear-pw" autocomplete="current-password" placeholder="${locked ? "" : "Введите пароль"}" ${locked ? "disabled" : ""} value=""></label>
|
||||
${state.wrong ? `<div style="font:500 13px/1.4 var(--sans);color:#a32020">Неверный пароль. Осталось попыток: ${state.left}</div>` : ""}
|
||||
${state.wrong ? `<div style="font:500 13px/1.4 var(--sans);color:var(--danger)">Неверный пароль. Осталось попыток: ${state.left}</div>` : ""}
|
||||
${locked ? `<div class="err-banner">Слишком много неверных попыток. Повторите через ${minutes} мин.</div>` : ""}</div>`,
|
||||
foot: `<div class="dialog-foot">${btn("Отмена", "close-dialog", { cls: "ghost" })}<button type="button" class="btn danger" id="clear-go" data-action="jr-clear-do" disabled>${I.trashJ()}Очистить журнал</button></div>`,
|
||||
});
|
||||
@@ -813,7 +837,7 @@ function totpConfirmDialog(setup) {
|
||||
title: "Сканируйте QR-код",
|
||||
width: 460,
|
||||
note: "Отсканируйте код в приложении-аутентификаторе или введите секрет вручную, затем введите код из приложения.",
|
||||
body: formBody(`<div style="display:flex;justify-content:center;padding:8px 0"><img src="${setup.qr}" alt="QR-код" width="220" height="220"></div>
|
||||
body: formBody(`<div style="display:flex;justify-content:center;padding:8px 0"><span class="qr-frame"><img src="${setup.qr}" alt="QR-код" width="220" height="220"></span></div>
|
||||
<label class="field"><span>Секрет для ручного ввода</span><input class="input mono" value="${esc(setup.secret)}" readonly onclick="this.select()"></label>
|
||||
${fInput("code", "Код из приложения", { ph: "000000" })}`),
|
||||
foot: dlgFoot("Подтвердить"),
|
||||
@@ -1009,6 +1033,7 @@ const actions = {
|
||||
else if (id === "jactor") { S.jactor = value; S.limit = 100; }
|
||||
else if (id === "jentity") { S.jentity = value; S.limit = 100; }
|
||||
else if (id === "bulk-status" || id === "bulk-type") return bulkPick(id, value);
|
||||
else if (id === "theme") { setTheme(value); return draw(); } // изменение 038 (доработка): пиктограмма темы в шапке, отдельная от меню пользователя
|
||||
else if (id === "user") { // изменение 036: меню логина в шапке
|
||||
document.querySelectorAll(".pop").forEach((el) => el.remove());
|
||||
if (value === "logout") return actions.logout();
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>IPAM Manager</title>
|
||||
<!-- изменение 038: тема — до CSS и синхронно, чтобы не было вспышки светлой темы; инлайн-скрипт запрещён CSP -->
|
||||
<script src="theme.js"></script>
|
||||
<link rel="stylesheet" href="fonts/fonts.css">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
|
||||
+83
-37
@@ -1,5 +1,16 @@
|
||||
:root{--bg:#f4f6f9;--card:#fff;--line:#dde3ea;--line-strong:#c5ced9;--text:#18212f;--muted:#58657a;--faint:#8a95a5;--primary:#2450c8;--head:#f8fafc;--hover:#f3f7ff;
|
||||
--sans:'IBM Plex Sans',system-ui,sans-serif;--mono:'IBM Plex Mono',ui-monospace,monospace}
|
||||
--sans:'IBM Plex Sans',system-ui,sans-serif;--mono:'IBM Plex Mono',ui-monospace,monospace;
|
||||
/* изменение 038: токены поверхностей/акцентов — вынесены прежние прописанные цвета, значения не изменились */
|
||||
--input-bg:#fff;--primary-soft:#f3f7ff;--selected-bg:#eef3ff;--row-alt:#fbfcfd;--neutral-bg:#eef1f5;--badge-fg:#3c4859;
|
||||
--on-primary:#fff;--toast-bg:#18212f;--toast-fg:#fff;
|
||||
--overlay:rgba(24,33,47,.45);--shadow-sm:rgba(20,33,60,.16);--shadow-lg:rgba(20,33,60,.25);--focus-ring:rgba(36,80,200,.15);
|
||||
--danger:#a32020;--danger-strong:#c62828;--danger-bg:#fbe6e6;
|
||||
--warn:#7c4a00;--warn-strong:#d97706;--warn-bg:#fdf0d5;
|
||||
--ok:#14683a;--ok-bg:#e3f4ea;
|
||||
--info:#1e449e;--info-bg:#eaf0ff;
|
||||
--purple:#5b2a9e;--purple-bg:#efe8fb;
|
||||
--select-arrow:#18212f; /* совпадает со --text; для тёмной темы переопределяется вместе с фоном .select ниже */
|
||||
color-scheme:light dark}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);font-family:var(--sans);color:var(--text)}
|
||||
a{color:var(--primary);text-decoration:none}
|
||||
@@ -7,9 +18,9 @@ h1,h2{margin:0}
|
||||
button{font-family:var(--sans)}
|
||||
|
||||
/* app bar */
|
||||
.appbar{height:56px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;padding:0 24px;gap:32px}
|
||||
.appbar{height:56px;background:var(--card);border-bottom:1px solid var(--line);display:flex;align-items:center;padding:0 24px;gap:32px}
|
||||
.brand{display:flex;align-items:center;gap:10px;font:600 16px/1 var(--sans)}
|
||||
.brand-logo{width:28px;height:28px;border-radius:8px;background:var(--primary);display:flex;align-items:center;justify-content:center}
|
||||
.brand-logo{width:28px;height:28px;border-radius:8px;background:var(--primary);color:var(--on-primary);display:flex;align-items:center;justify-content:center}
|
||||
.nav{display:flex;height:100%;gap:4px}
|
||||
.nav a{display:flex;align-items:center;height:100%;padding:0 14px;font:500 14px/1 var(--sans);color:var(--muted);border-bottom:2px solid transparent}
|
||||
.nav a.active{color:var(--text);border-bottom-color:var(--primary)}
|
||||
@@ -17,14 +28,14 @@ button{font-family:var(--sans)}
|
||||
/* изменение 036: .appbar .user удалено — логин заменён на триггер меню (.user-menu .btn.ghost) */
|
||||
|
||||
/* buttons */
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;height:36px;margin:0;border-radius:8px;font:500 14px/1 var(--sans);white-space:nowrap;cursor:pointer;padding:0 14px;background:#fff;color:var(--text);border:1px solid var(--line-strong)}
|
||||
.btn.primary{background:var(--primary);color:#fff;border-color:var(--primary)}
|
||||
.btn.outline-primary{background:#fff;color:var(--primary);border-color:var(--primary)}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;height:36px;margin:0;border-radius:8px;font:500 14px/1 var(--sans);white-space:nowrap;cursor:pointer;padding:0 14px;background:var(--card);color:var(--text);border:1px solid var(--line-strong)}
|
||||
.btn.primary{background:var(--primary);color:var(--on-primary);border-color:var(--primary)}
|
||||
.btn.outline-primary{background:var(--card);color:var(--primary);border-color:var(--primary)}
|
||||
.btn.ghost{background:transparent;color:var(--muted);border-color:transparent;padding:0 10px}
|
||||
.btn.danger{color:#a32020}
|
||||
.btn.danger{color:var(--danger)}
|
||||
.btn:disabled{opacity:.6;cursor:default}
|
||||
.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;margin:0;border-radius:8px;cursor:pointer;background:transparent;color:var(--muted);border:1px solid transparent;padding:0}
|
||||
.icon-btn:hover{background:#eef1f5}
|
||||
.icon-btn:hover{background:var(--neutral-bg)}
|
||||
.icon-btn.sm{width:28px;height:28px;border-radius:6px}
|
||||
.icon-btn:disabled{color:var(--line-strong);cursor:default;background:transparent}
|
||||
svg{flex:none}
|
||||
@@ -38,7 +49,7 @@ svg{flex:none}
|
||||
.head h1{font:600 24px/1.2 var(--sans);color:var(--text)}
|
||||
.head .sub{margin-top:4px;font:400 14px/1.4 var(--sans);color:var(--muted)}
|
||||
.head .actions{display:flex;gap:8px}
|
||||
.card{background:#fff;border:1px solid var(--line);border-radius:12px}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:12px}
|
||||
.right-link{display:flex;justify-content:flex-end;margin-bottom:12px}
|
||||
.right-link a,.link-muted{display:flex;align-items:center;gap:6px;font:500 13px/1 var(--sans);color:var(--muted);cursor:pointer;background:none;border:0;padding:0}
|
||||
|
||||
@@ -54,16 +65,16 @@ svg{flex:none}
|
||||
.vsep{width:1px;height:24px;background:var(--line);flex:none}
|
||||
.search{position:relative;display:inline-flex;align-items:center;flex:none}
|
||||
.search .ico{position:absolute;left:12px;color:var(--muted);display:flex}
|
||||
.search input{width:100%;height:36px;padding:0 12px 0 36px;border:1px solid var(--line-strong);border-radius:8px;background:#fff;color:var(--text);font:400 14px/1 var(--sans);margin:0}
|
||||
.search input{width:100%;height:36px;padding:0 12px 0 36px;border:1px solid var(--line-strong);border-radius:8px;background:var(--input-bg);color:var(--text);font:400 14px/1 var(--sans);margin:0}
|
||||
.filter-btn{justify-content:space-between;width:180px}
|
||||
.org-btn{display:inline-flex;align-items:center;gap:8px;height:36px;border-radius:8px;font:500 14px/1 var(--sans);cursor:pointer;background:#f3f7ff;color:var(--text);border:1px solid var(--primary);padding:0 12px;flex:none;min-width:260px}
|
||||
.org-btn{display:inline-flex;align-items:center;gap:8px;height:36px;border-radius:8px;font:500 14px/1 var(--sans);cursor:pointer;background:var(--primary-soft);color:var(--text);border:1px solid var(--primary);padding:0 12px;flex:none;min-width:260px}
|
||||
.org-btn .lbl{color:var(--muted);font-weight:400}
|
||||
.org-btn .val{font-weight:600;flex:1;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.pop{position:absolute;top:52px;z-index:20;min-width:220px;max-height:320px;overflow:auto;background:#fff;border:1px solid var(--line);border-radius:8px;box-shadow:0 8px 24px rgba(20,33,60,.16);padding:4px}
|
||||
.pop{position:absolute;top:52px;z-index:20;min-width:220px;max-height:320px;overflow:auto;background:var(--card);border:1px solid var(--line);border-radius:8px;box-shadow:0 8px 24px var(--shadow-sm);padding:4px}
|
||||
.pop button{display:block;width:100%;text-align:left;height:36px;padding:0 12px;border:0;background:none;border-radius:6px;font:400 14px/1 var(--sans);color:var(--text);cursor:pointer;white-space:nowrap}
|
||||
.pop button:hover{background:var(--hover)}
|
||||
.pop button.sel{font-weight:600}
|
||||
.pop button.danger{color:#a32020}
|
||||
.pop button.danger{color:var(--danger)}
|
||||
.row-pop{top:40px;right:0;min-width:180px}
|
||||
.rel{position:relative}
|
||||
|
||||
@@ -73,7 +84,7 @@ svg{flex:none}
|
||||
.tr.th span{font:600 12px/1 var(--sans);color:var(--muted)}
|
||||
.tr.hoverable:hover{background:var(--hover)}
|
||||
.tr.clickable{cursor:pointer}
|
||||
.tr.child{background:#fbfcfd}
|
||||
.tr.child{background:var(--row-alt)}
|
||||
.tr.free{background:var(--head)}
|
||||
.tr.free span,.tr.free .mono{color:var(--faint)}
|
||||
.tr.last{border-bottom:0}
|
||||
@@ -95,13 +106,13 @@ svg{flex:none}
|
||||
.bar .pct{font:400 12px/1 var(--sans);color:var(--muted);white-space:nowrap}
|
||||
|
||||
/* badges */
|
||||
.badge{display:inline-flex;align-items:center;gap:4px;height:22px;padding:0 8px;border-radius:6px;font:500 12px/1 var(--sans);background:#eef1f5;color:#3c4859}
|
||||
.badge.green{background:#e3f4ea;color:#14683a}
|
||||
.badge.blue{background:#eaf0ff;color:#1e449e}
|
||||
.badge.red{background:#fbe6e6;color:#a32020}
|
||||
.badge.amber{background:#fdf0d5;color:#7c4a00}
|
||||
.badge.purple{background:#efe8fb;color:#5b2a9e}
|
||||
.more{margin-left:4px;display:inline-flex;align-items:center;height:18px;padding:0 5px;border-radius:4px;background:#eaf0ff;color:#1e449e;font:500 11px/1 var(--sans)}
|
||||
.badge{display:inline-flex;align-items:center;gap:4px;height:22px;padding:0 8px;border-radius:6px;font:500 12px/1 var(--sans);background:var(--neutral-bg);color:var(--badge-fg)}
|
||||
.badge.green{background:var(--ok-bg);color:var(--ok)}
|
||||
.badge.blue{background:var(--info-bg);color:var(--info)}
|
||||
.badge.red{background:var(--danger-bg);color:var(--danger)}
|
||||
.badge.amber{background:var(--warn-bg);color:var(--warn)}
|
||||
.badge.purple{background:var(--purple-bg);color:var(--purple)}
|
||||
.more{margin-left:4px;display:inline-flex;align-items:center;height:18px;padding:0 5px;border-radius:4px;background:var(--info-bg);color:var(--info);font:500 11px/1 var(--sans)}
|
||||
|
||||
/* overview */
|
||||
.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;margin-bottom:24px}
|
||||
@@ -122,8 +133,8 @@ svg{flex:none}
|
||||
.legend i{width:8px;height:8px;border-radius:2px;display:inline-block}
|
||||
|
||||
/* dialogs */
|
||||
.overlay{position:fixed;inset:0;background:rgba(24,33,47,.45);display:flex;align-items:flex-start;justify-content:center;padding-top:96px;overflow:auto;z-index:50}
|
||||
.dialog{box-sizing:content-box;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:0 16px 48px rgba(20,33,60,.25);flex:none;margin-bottom:40px}
|
||||
.overlay{position:fixed;inset:0;background:var(--overlay);display:flex;align-items:flex-start;justify-content:center;padding-top:96px;overflow:auto;z-index:50}
|
||||
.dialog{box-sizing:content-box;background:var(--card);border:1px solid var(--line);border-radius:12px;box-shadow:0 16px 48px var(--shadow-lg);flex:none;margin-bottom:40px}
|
||||
.dialog-head{display:flex;align-items:center;justify-content:space-between;padding:16px 16px 0 24px}
|
||||
.dialog-head h2{font:600 18px/1.2 var(--sans)}
|
||||
.dialog-note{padding:4px 24px 0;font:400 13px/1.4 var(--sans);color:var(--muted)}
|
||||
@@ -132,14 +143,14 @@ svg{flex:none}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
.field{display:flex;flex-direction:column;gap:6px;font:500 13px/1.3 var(--sans)}
|
||||
.field .opt{font-weight:400;color:var(--muted)}
|
||||
.input,.select,.textarea{width:100%;height:36px;margin:0;padding:0 12px;border:1px solid var(--line-strong);border-radius:8px;background:#fff;color:var(--text);font:400 14px/1 var(--sans)}
|
||||
.input,.select,.textarea{width:100%;height:36px;margin:0;padding:0 12px;border:1px solid var(--line-strong);border-radius:8px;background:var(--input-bg);color:var(--text);font:400 14px/1 var(--sans)}
|
||||
.input.mono,.textarea.mono{font-family:var(--mono)}
|
||||
.input.mono{font-size:14px}
|
||||
.select{appearance:none;font-weight:500;padding:0 34px 0 14px;background:#fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2318212f' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") no-repeat right 14px center;cursor:pointer}
|
||||
.select{appearance:none;font-weight:500;padding:0 34px 0 14px;background:var(--input-bg) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2318212f' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") no-repeat right 14px center;cursor:pointer}
|
||||
.textarea{height:56px;padding:10px 12px;line-height:1.4;resize:vertical}
|
||||
.input.bad,.select.bad,.textarea.bad{border-color:#c62828}
|
||||
.info{padding:10px 12px;border-radius:8px;background:#eaf0ff;color:#1e449e;font:400 13px/1.4 var(--sans)}
|
||||
.err-banner{padding:10px 12px;border-radius:8px;background:#fbe6e6;color:#a32020;font:400 13px/1.4 var(--sans)}
|
||||
.input.bad,.select.bad,.textarea.bad{border-color:var(--danger-strong)}
|
||||
.info{padding:10px 12px;border-radius:8px;background:var(--info-bg);color:var(--info);font:400 13px/1.4 var(--sans)}
|
||||
.err-banner{padding:10px 12px;border-radius:8px;background:var(--danger-bg);color:var(--danger);font:400 13px/1.4 var(--sans)}
|
||||
.check{display:flex;align-items:flex-start;gap:10px;font:500 14px/1.3 var(--sans)}
|
||||
.check input{width:16px;height:16px;margin:2px 0 0;accent-color:var(--primary)}
|
||||
.check small{display:block;font:400 12px/1.3 var(--sans);color:var(--muted)}
|
||||
@@ -152,14 +163,14 @@ svg{flex:none}
|
||||
|
||||
/* misc */
|
||||
#toast-root{position:fixed;bottom:16px;right:16px;pointer-events:none;z-index:100;display:flex;flex-direction:column;gap:8px}
|
||||
.toast{background:#18212f;color:#fff;padding:10px 14px;border-radius:8px;font:400 14px/1.3 var(--sans);box-shadow:0 8px 24px rgba(20,33,60,.25)}
|
||||
.toast.err{background:#a32020}
|
||||
.toast{background:var(--toast-bg);color:var(--toast-fg);padding:10px 14px;border-radius:8px;font:400 14px/1.3 var(--sans);box-shadow:0 8px 24px var(--shadow-lg)}
|
||||
.toast.err{background:var(--danger)}
|
||||
.login{max-width:380px;margin:120px auto 0;padding:32px}
|
||||
.login h1{font:600 22px/1.2 var(--sans);margin:20px 0 4px}
|
||||
.login .field{margin-top:16px}
|
||||
.login .btn{width:100%;margin-top:20px}
|
||||
|
||||
.input:focus,.select:focus,.textarea:focus,.search input:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 3px rgba(36,80,200,.15)}
|
||||
.input:focus,.select:focus,.textarea:focus,.search input:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 3px var(--focus-ring)}
|
||||
.btn:focus-visible,.icon-btn:focus-visible,.tab:focus-visible{outline:2px solid var(--primary);outline-offset:2px}
|
||||
|
||||
/* выпадающие списки в диалогах */
|
||||
@@ -171,22 +182,57 @@ button.select.ph{color:var(--faint)}
|
||||
.pop button.sel{background:var(--hover)}
|
||||
|
||||
/* журнал */
|
||||
.datebox{position:relative;display:inline-flex;align-items:center;gap:8px;height:36px;padding:0 12px;width:170px;border:1px solid var(--line-strong);border-radius:8px;background:#fff;font:400 14px/1 var(--sans);flex:none}
|
||||
.datebox{position:relative;display:inline-flex;align-items:center;gap:8px;height:36px;padding:0 12px;width:170px;border:1px solid var(--line-strong);border-radius:8px;background:var(--input-bg);font:400 14px/1 var(--sans);flex:none}
|
||||
.datebox input{flex:1;min-width:0;border:0;padding:0;background:transparent;color:var(--text);font:400 13px/1 var(--mono);outline:none}
|
||||
.datebox input::-webkit-calendar-picker-indicator{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer}
|
||||
.datebox .cal{display:flex;color:var(--muted);pointer-events:none}
|
||||
.datebox:focus-within{border-color:var(--primary);box-shadow:0 0 0 3px rgba(36,80,200,.15)}
|
||||
.datebox:focus-within{border-color:var(--primary);box-shadow:0 0 0 3px var(--focus-ring)}
|
||||
.kv{display:grid;grid-template-columns:130px 1fr;gap:12px;align-items:center;min-height:32px;font:400 14px/1.4 var(--sans)}
|
||||
.json{margin:0;padding:12px;border-radius:8px;background:var(--head);border:1px solid var(--line);font:400 13px/1.2 var(--mono);white-space:pre-wrap;overflow-wrap:anywhere}
|
||||
.statbox{padding:12px;border-radius:8px;background:var(--head);border:1px solid var(--line);font:400 13px/1.5 var(--sans);color:var(--muted)}
|
||||
.statbox b{color:var(--text);font-weight:600}
|
||||
.warn{padding:10px 12px;border-radius:8px;background:#fdf0d5;color:#7c4a00;font:400 13px/1.4 var(--sans)}
|
||||
.btn.danger:disabled{background:#f4f6f9;color:var(--faint);border-color:var(--line);cursor:not-allowed;opacity:1}
|
||||
.warn{padding:10px 12px;border-radius:8px;background:var(--warn-bg);color:var(--warn);font:400 13px/1.4 var(--sans)}
|
||||
.btn.danger:disabled{background:var(--bg);color:var(--faint);border-color:var(--line);cursor:not-allowed;opacity:1}
|
||||
.tr.clickable:focus-visible{outline:2px solid var(--primary);outline-offset:-2px}
|
||||
|
||||
.sel-cell{display:flex;align-items:center;justify-content:center;height:100%;cursor:pointer}
|
||||
.sel-cell input{width:16px;height:16px;margin:0;accent-color:var(--primary);cursor:pointer}
|
||||
.sel-cell input:disabled{cursor:not-allowed}
|
||||
.tr.selected,.tr.selected:hover{background:#eef3ff}
|
||||
.bulkbar{display:flex;align-items:center;gap:8px;height:48px;padding:0 16px;background:#f3f7ff;border-top:1px solid var(--line);font:400 14px/1 var(--sans);position:relative}
|
||||
.tr.selected,.tr.selected:hover{background:var(--selected-bg)}
|
||||
.bulkbar{display:flex;align-items:center;gap:8px;height:48px;padding:0 16px;background:var(--primary-soft);border-top:1px solid var(--line);font:400 14px/1 var(--sans);position:relative}
|
||||
.bulkbar .pop{top:40px}
|
||||
|
||||
/* изменение 038: тёмная тема — «авто» по prefers-color-scheme (если пользователь не выбрал явно) и принудительная через [data-theme] (ставит web/theme.js по localStorage["ipam.theme"]).
|
||||
Палитра продублирована в двух блоках намеренно — иначе пришлось бы дублировать вручную через var() с запасным значением, что менее читаемо. Контраст текста — не ниже WCAG AA. */
|
||||
@media (prefers-color-scheme: dark){
|
||||
:root:not([data-theme="light"]){
|
||||
--bg:#0f1419;--card:#182029;--line:#262f3a;--line-strong:#37414d;--text:#e6edf3;--muted:#9aa7b8;--faint:#6b7789;--primary:#6b9bff;--head:#131920;--hover:#1b2635;
|
||||
--input-bg:#101722;--primary-soft:#16233a;--selected-bg:#18243a;--row-alt:#141b24;--neutral-bg:#232c37;--badge-fg:#c7d0dc;
|
||||
--on-primary:#0b1220;--toast-bg:#080b10;--toast-fg:#fff;
|
||||
--overlay:rgba(4,8,14,.6);--shadow-sm:rgba(0,0,0,.5);--shadow-lg:rgba(0,0,0,.6);--focus-ring:rgba(107,155,255,.3);
|
||||
--danger:#ff8080;--danger-strong:#ff5252;--danger-bg:#3b1518;
|
||||
--warn:#f0b429;--warn-strong:#f2994a;--warn-bg:#3a2a08;
|
||||
--ok:#4ade80;--ok-bg:#123924;
|
||||
--info:#8fb3ff;--info-bg:#142a4d;
|
||||
--purple:#c7a6f7;--purple-bg:#2c1f45;
|
||||
--select-arrow:#e6edf3;
|
||||
color-scheme:dark}
|
||||
:root:not([data-theme="light"]) .select{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23e6edf3' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E")}
|
||||
}
|
||||
:root[data-theme="dark"]{
|
||||
--bg:#0f1419;--card:#182029;--line:#262f3a;--line-strong:#37414d;--text:#e6edf3;--muted:#9aa7b8;--faint:#6b7789;--primary:#6b9bff;--head:#131920;--hover:#1b2635;
|
||||
--input-bg:#101722;--primary-soft:#16233a;--selected-bg:#18243a;--row-alt:#141b24;--neutral-bg:#232c37;--badge-fg:#c7d0dc;
|
||||
--on-primary:#0b1220;--toast-bg:#080b10;--toast-fg:#fff;
|
||||
--overlay:rgba(4,8,14,.6);--shadow-sm:rgba(0,0,0,.5);--shadow-lg:rgba(0,0,0,.6);--focus-ring:rgba(107,155,255,.3);
|
||||
--danger:#ff8080;--danger-strong:#ff5252;--danger-bg:#3b1518;
|
||||
--warn:#f0b429;--warn-strong:#f2994a;--warn-bg:#3a2a08;
|
||||
--ok:#4ade80;--ok-bg:#123924;
|
||||
--info:#8fb3ff;--info-bg:#142a4d;
|
||||
--purple:#c7a6f7;--purple-bg:#2c1f45;
|
||||
--select-arrow:#e6edf3;
|
||||
color-scheme:dark}
|
||||
:root[data-theme="dark"] .select{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23e6edf3' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E")}
|
||||
:root[data-theme="light"]{color-scheme:light}
|
||||
|
||||
/* изменение 038: QR-код 2FA — намеренно остаётся тёмным на белом фоне независимо от темы (лучше сканируется), см. .qr-frame в app.js */
|
||||
.qr-frame{background:#fff;border-radius:8px;display:inline-flex;padding:12px}
|
||||
@@ -0,0 +1,11 @@
|
||||
// изменение 038: применяет сохранённый выбор темы до отрисовки CSS, чтобы избежать вспышки светлой темы.
|
||||
// Подключается синхронно в <head> до styles.css. Инлайн-скрипт запрещён CSP (default-src 'self'), поэтому — отдельный файл.
|
||||
// Нет значения в localStorage — тема остаётся «авто» (следует prefers-color-scheme через styles.css), атрибут не ставится.
|
||||
(function () {
|
||||
try {
|
||||
var t = localStorage.getItem("ipam.theme");
|
||||
if (t === "light" || t === "dark") document.documentElement.setAttribute("data-theme", t);
|
||||
} catch (e) {
|
||||
// localStorage недоступен (приватный режим и т.п.) — тема по умолчанию: авто по системе
|
||||
}
|
||||
})();
|
||||
Reference in new issue
Block a user