From a6e70ffd1a2e5b02f3f801cf413880c494684d36 Mon Sep 17 00:00:00 2001 From: ayurishchev Date: Sun, 27 Sep 2026 17:44:48 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=20038:=20?= =?UTF-8?q?=D1=82=D1=91=D0=BC=D0=BD=D0=B0=D1=8F=20=D1=82=D0=B5=D0=BC=D0=B0?= =?UTF-8?q?=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Все цвета — CSS-переменные; тёмная палитра по prefers-color-scheme (режим «авто») и принудительно через data-theme; светлая тема не изменилась. Переключатель — пиктограмма в шапке (авто / светлая / тёмная), выбор в localStorage; theme.js ставит тему до отрисовки CSS (без вспышки, без ослабления CSP). QR 2FA на белой подложке. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 + docs/changes/038-dark-theme/PLAN.md | 60 +++++++++++++ docs/changes/038-dark-theme/SUMMARY.md | 27 ++++++ web/app.js | 45 +++++++--- web/index.html | 3 + web/styles.css | 120 +++++++++++++++++-------- web/theme.js | 11 +++ 7 files changed, 221 insertions(+), 47 deletions(-) create mode 100644 docs/changes/038-dark-theme/PLAN.md create mode 100644 docs/changes/038-dark-theme/SUMMARY.md create mode 100644 web/theme.js diff --git a/README.md b/README.md index 684f19d..6b12a7d 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/changes/038-dark-theme/PLAN.md b/docs/changes/038-dark-theme/PLAN.md new file mode 100644 index 0000000..549c3da --- /dev/null +++ b/docs/changes/038-dark-theme/PLAN.md @@ -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` (новый), подключается в `` `web/index.html` обычным синхронным ` diff --git a/web/styles.css b/web/styles.css index 2108412..9ebc342 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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} diff --git a/web/theme.js b/web/theme.js new file mode 100644 index 0000000..5190db0 --- /dev/null +++ b/web/theme.js @@ -0,0 +1,11 @@ +// изменение 038: применяет сохранённый выбор темы до отрисовки CSS, чтобы избежать вспышки светлой темы. +// Подключается синхронно в до 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 недоступен (приватный режим и т.п.) — тема по умолчанию: авто по системе + } +})();