/* Cloud IP Validator — admin dashboard design system. Self-contained, no framework: tokens + a small set of components (sidebar shell, panels, tables, pills, forms, buttons, alerts). Fonts are vendored separately — see vendor/fonts.css. Palette: cool signal-blue accent on a blue-grey neutral ground (was a warm teal/green), semantic status colors kept independent of the accent. Radius: a sharp 2/3/4/6px scale (was 6/10/16px + pill) — status badges are square tags, not pills. Typography: --font-display and --font-mono are both JetBrains Mono — one unified operations-console monospace register for headings/nav/labels/table heads *and* raw data (addresses, counters) alike — while --font-ui (IBM Plex Sans) stays on body copy and form microcopy for readability at length. Both a light and a dark palette are defined. The bare :root (no data-theme attribute) follows the OS/browser color-scheme; the sidebar toggle (see layout.html) stamps documentElement with data-theme="light"/"dark" to override that, storing the choice in localStorage so it survives page navigation. */ :root { --bg: #EEF1F5; --surface: #FFFFFF; --surface-alt: #E4E9F0; --border: #CBD3DE; --border-soft: #DCE2EA; --text: #12161F; --text-muted: #566073; --text-faint: #8992A3; --accent: #2F5FE0; --accent-strong: #1F45B0; --accent-soft: #E1E9FC; --accent-contrast: #FFFFFF; --success: #157F45; --success-soft: #DFF3E7; --warning: #9A6400; --warning-soft: #F8EDD4; --warning-border: #E8CE97; --danger: #B4291D; --danger-soft: #FADEDA; --danger-border: #E7B3AC; --info: #0E6FA8; --info-soft: #DDEEF8; --neutral: #5B6576; --neutral-soft: #E4E8EE; --cancel: #6D4FC4; --cancel-soft: #ECE7FA; --occupied: #0F7B72; --occupied-soft: #DCF3F0; --overlay: rgba(10, 14, 20, .32); --radius-xs: 2px; --radius-sm: 3px; --radius-md: 4px; --radius-lg: 6px; --shadow-sm: 0 1px 2px rgba(12, 18, 30, .06), 0 1px 1px rgba(12, 18, 30, .05); --shadow-md: 0 10px 28px rgba(12, 18, 30, .16); --font-ui: "IBM Plex Sans", -apple-system, "Segoe UI", sans-serif; --font-mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", Consolas, monospace; /* Same value as --font-mono: kept as a distinct token because it marks a different semantic role (console chrome — headings, nav, labels, table heads — versus --font-mono's "raw data" role), so the two can diverge later without a find-and-replace. */ --font-display: "JetBrains Mono", ui-monospace, "SFMono-Regular", Consolas, monospace; color-scheme: light; } @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --bg: #0A0D13; --surface: #10141C; --surface-alt: #161B25; --border: #262E3B; --border-soft: #1D2430; --text: #E7ECF5; --text-muted: #8D97AC; --text-faint: #626D82; --accent: #5C8CFF; --accent-strong: #82A6FF; --accent-soft: #182140; --accent-contrast: #071022; --success: #3DDC8A; --success-soft: #10261C; --warning: #F0B429; --warning-soft: #2B2210; --warning-border: #4A3B14; --danger: #FF6B5E; --danger-soft: #2C1613; --danger-border: #5A2620; --info: #5AC0FF; --info-soft: #10222E; --neutral: #A3ACBE; --neutral-soft: #1B212B; --cancel: #B79CFF; --cancel-soft: #221B3A; --occupied: #4FD8C9; --occupied-soft: #102B28; --overlay: rgba(0, 0, 0, .55); --shadow-sm: 0 1px 2px rgba(0, 0, 0, .4); --shadow-md: 0 16px 40px rgba(0, 0, 0, .5); color-scheme: dark; } } /* Explicit dark choice (toggle) wins even when the OS/browser is light. */ :root[data-theme="dark"] { --bg: #0A0D13; --surface: #10141C; --surface-alt: #161B25; --border: #262E3B; --border-soft: #1D2430; --text: #E7ECF5; --text-muted: #8D97AC; --text-faint: #626D82; --accent: #5C8CFF; --accent-strong: #82A6FF; --accent-soft: #182140; --accent-contrast: #071022; --success: #3DDC8A; --success-soft: #10261C; --warning: #F0B429; --warning-soft: #2B2210; --warning-border: #4A3B14; --danger: #FF6B5E; --danger-soft: #2C1613; --danger-border: #5A2620; --info: #5AC0FF; --info-soft: #10222E; --neutral: #A3ACBE; --neutral-soft: #1B212B; --cancel: #B79CFF; --cancel-soft: #221B3A; --occupied: #4FD8C9; --occupied-soft: #102B28; --overlay: rgba(0, 0, 0, .55); --shadow-sm: 0 1px 2px rgba(0, 0, 0, .4); --shadow-md: 0 16px 40px rgba(0, 0, 0, .5); color-scheme: dark; } * { box-sizing: border-box; } html, body { height: 100%; } body { margin: 0; background: var(--bg); color: var(--text); font-family: var(--font-ui); font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; } a { color: inherit; } ::selection { background: var(--accent-soft); color: var(--text); } .mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } /* faint technical grid texture on the page ground, behind .shell */ .bg-grid { position: fixed; inset: 0; z-index: 0; pointer-events: none; background-image: linear-gradient(var(--border-soft) 1px, transparent 1px), linear-gradient(90deg, var(--border-soft) 1px, transparent 1px); background-size: 48px 48px; opacity: .35; -webkit-mask-image: linear-gradient(to bottom, black, transparent 640px); mask-image: linear-gradient(to bottom, black, transparent 640px); } h1, h2, h3 { text-wrap: balance; } h1 { font-family: var(--font-display); font-size: 19px; font-weight: 700; letter-spacing: -.005em; margin: 0 0 4px; } h2.section-title { font-family: var(--font-display); font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; color: var(--text-muted); margin: 26px 0 10px; } section:first-of-type h2.section-title, main > h2.section-title:first-child { margin-top: 0; } /* ---------- shell layout ---------- */ .shell { position: relative; z-index: 1; display: grid; grid-template-columns: 224px 1fr; min-height: 100vh; } .sidebar { background: var(--surface); border-right: 1px solid var(--border); padding: 18px 12px; display: flex; flex-direction: column; gap: 20px; } .brand { display: flex; align-items: center; gap: 9px; padding: 0 6px 12px; border-bottom: 1px solid var(--border-soft); } .brand-chrome { display: flex; gap: 4px; } .brand-chrome i { width: 6px; height: 6px; border-radius: 50%; background: var(--border); } .brand-mark { width: 26px; height: 26px; border-radius: var(--radius-xs); background: var(--accent); color: var(--accent-contrast); display: grid; place-items: center; flex-shrink: 0; } .brand-mark svg { width: 15px; height: 15px; } .brand-name { font-family: var(--font-display); font-weight: 700; font-size: 13px; letter-spacing: -.005em; line-height: 1.2; } .brand-name small { display: block; font-weight: 400; font-size: 10.5px; color: var(--text-faint); letter-spacing: .02em; } nav.nav-groups { display: flex; flex-direction: column; gap: 1px; } .nav-link { display: flex; align-items: center; gap: 9px; padding: 7px 9px; border-radius: var(--radius-xs); border-left: 2px solid transparent; text-decoration: none; color: var(--text-muted); font-family: var(--font-display); font-weight: 600; font-size: 12.5px; transition: background .12s ease, color .12s ease, border-color .12s ease; } .nav-link svg { width: 16px; height: 16px; flex-shrink: 0; opacity: .8; } .nav-link:hover { background: var(--surface-alt); color: var(--text); } .nav-link.active { background: var(--accent-soft); color: var(--accent); border-left-color: var(--accent); } .nav-link.active svg { opacity: 1; } .nav-link:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } .sidebar-foot { margin-top: auto; padding: 9px 10px; border-radius: var(--radius-sm); background: var(--surface-alt); border: 1px solid var(--border-soft); font-family: var(--font-display); font-size: 11px; color: var(--text-muted); display: flex; align-items: center; justify-content: space-between; gap: 7px; } .sidebar-foot-status { display: flex; align-items: center; gap: 7px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .pulse-dot { width: 6px; height: 6px; border-radius: 1px; background: var(--success); box-shadow: 0 0 0 3px var(--success-soft); flex-shrink: 0; animation: pulse 2.2s ease-in-out infinite; } @media (prefers-reduced-motion: reduce) { .pulse-dot { animation: none; } } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } } .theme-toggle { display: grid; place-items: center; width: 22px; height: 22px; flex-shrink: 0; border-radius: var(--radius-xs); border: 1px solid var(--border); background: var(--surface); color: var(--text-muted); cursor: pointer; padding: 0; } .theme-toggle:hover { border-color: var(--accent); color: var(--accent); } .theme-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .theme-toggle svg { width: 13px; height: 13px; } .theme-toggle .icon-moon { display: none; } /* Icon reflects the *effective* theme (OS default, unless the toggle has stamped an explicit choice) — same three-state precedence as the color tokens above. */ @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .theme-toggle .icon-sun { display: none; } :root:not([data-theme="light"]) .theme-toggle .icon-moon { display: block; } } :root[data-theme="dark"] .theme-toggle .icon-sun { display: none; } :root[data-theme="dark"] .theme-toggle .icon-moon { display: block; } .main { padding: 22px 26px 60px; max-width: 1180px; margin-inline: auto; } .topbar { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 18px; flex-wrap: wrap; gap: 10px; } .topbar .sub { color: var(--text-muted); font-size: 12.5px; display: flex; align-items: center; gap: 6px; } /* ---------- stat cards ---------- */ .stat-grid { /* auto-fit instead of a fixed repeat(6, ...): the state histogram this drives is a variable-length map (whichever ip_queue states are actually present), so a fixed column count left visible empty background showing through the unused grid tracks whenever fewer than 6 cards exist. auto-fit collapses empty tracks and lets the real cards stretch to fill the row instead. */ display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; margin-bottom: 22px; } .stat-card { background: var(--surface); padding: 12px 14px; } .stat-card .value { font-family: var(--font-mono); font-size: 22px; font-weight: 700; display: block; font-variant-numeric: tabular-nums; } .stat-card .label { font-family: var(--font-display); font-size: 10.5px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: .06em; } .stat-card.accented .value { color: var(--accent); } .stat-card.warn .value { color: var(--warning); } .stat-card.bad .value { color: var(--danger); } /* ---------- panels ---------- */ .panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); overflow: hidden; } .panel + .panel { margin-top: 16px; } /* The htmx swap-target wrappers below (#X-table-wrap on the "list" pages) sit between the create-form panel above them and the panel they contain, so `.panel + .panel` never matches across that boundary — give the wrapper the same top margin directly instead. */ #sites-table-wrap, #targets-table-wrap, #checktypes-table-wrap, #validators-table-wrap, #ips-table-wrap { margin-top: 16px; } .panel-body { padding: 15px 16px; } .panel-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-bottom: 1px solid var(--border-soft); background: var(--surface-alt); } .panel-head h2 { font-size: 11.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); } /* ---------- forms ---------- */ .field-row { display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap; } .field { display: flex; flex-direction: column; gap: 5px; flex: 1 1 220px; } .field label { font-family: var(--font-display); font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); } .form-check { display: flex; align-items: center; gap: 6px; font-size: 13px; } input[type="text"], input[type="number"], input[type="search"], textarea, select { font-family: var(--font-ui); font-size: 13.5px; padding: 7px 10px; border-radius: var(--radius-xs); border: 1px solid var(--border); background: var(--bg); color: var(--text); resize: vertical; width: 100%; } input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } ::placeholder { color: var(--text-faint); } .btn { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-display); font-weight: 700; font-size: 12.5px; padding: 8px 13px; border-radius: var(--radius-xs); border: 1px solid transparent; cursor: pointer; transition: transform .08s ease, background .12s ease, border-color .12s ease; white-space: nowrap; line-height: 1.3; } .btn:active { transform: translateY(1px); } .btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .btn-primary { background: var(--accent); color: var(--accent-contrast); } .btn-primary:hover { background: var(--accent-strong); } .btn-ghost { background: transparent; border-color: var(--border); color: var(--text); } .btn-ghost:hover { border-color: var(--accent); color: var(--accent); } .btn-danger-ghost { background: transparent; border-color: var(--border); color: var(--danger); } .btn-danger-ghost:hover { background: var(--danger-soft); border-color: var(--danger); } .btn-sm { padding: 5px 9px; font-size: 11.5px; } .btn-block { width: 100%; justify-content: center; } /* ---------- tables ---------- */ table { width: 100%; border-collapse: collapse; } thead th { text-align: left; font-family: var(--font-display); font-size: 10px; text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); font-weight: 700; padding: 9px 16px; border-bottom: 1px solid var(--border); background: var(--surface-alt); } tbody td { padding: 9px 16px; border-bottom: 1px solid var(--border-soft); vertical-align: middle; } tbody tr:last-child td { border-bottom: none; } tbody tr:hover { background: var(--surface-alt); } td.addr { font-family: var(--font-mono); font-size: 13px; } td.num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; color: var(--text-muted); } .table-scroll { overflow-x: auto; } .actions { display: flex; gap: 6px; justify-content: flex-end; flex-wrap: wrap; } /* ---------- status tags (square, not pill) ---------- */ .pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px 3px 7px; border-radius: var(--radius-xs); font-family: var(--font-display); font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .02em; white-space: nowrap; } .pill::before { content: ""; width: 5px; height: 5px; border-radius: 1px; background: currentColor; flex-shrink: 0; } .pill-neutral { background: var(--neutral-soft); color: var(--neutral); } .pill-info { background: var(--info-soft); color: var(--info); } .pill-success { background: var(--success-soft); color: var(--success); } .pill-warning { background: var(--warning-soft); color: var(--warning); } .pill-danger { background: var(--danger-soft); color: var(--danger); } .pill-cancel { background: var(--cancel-soft); color: var(--cancel); } .pill-occupied { background: var(--occupied-soft); color: var(--occupied); } /* ---------- alerts ---------- */ .alert { display: flex; gap: 10px; align-items: flex-start; padding: 10px 14px; border-radius: var(--radius-md); font-size: 13px; font-weight: 500; border: 1px solid transparent; } .alert-warning { background: var(--warning-soft); color: var(--warning); border-color: var(--warning-border); } .alert-danger { background: var(--danger-soft); color: var(--danger); border-color: var(--danger-border); } .alert strong { color: var(--text); font-weight: 700; } #error-banner:empty { display: none; } #error-banner .alert { margin-bottom: 16px; } .breakdown { display: flex; gap: 16px; flex-wrap: wrap; margin: 2px 0 14px; font-size: 12.5px; color: var(--text-muted); } .breakdown b { color: var(--text); font-family: var(--font-mono); font-weight: 500; } .muted { color: var(--text-faint); font-size: 12px; } code.inline { font-family: var(--font-mono); background: var(--surface-alt); border: 1px solid var(--border-soft); padding: 1px 6px; border-radius: var(--radius-xs); font-size: 12px; } .section-note { padding: 14px 16px 4px; font-size: 12.5px; color: var(--text-muted); } .inline-form { display: flex; gap: 6px; align-items: center; } .inline-form input, .inline-form textarea { margin: 0; } /* ---------- responsive ---------- */ .nav-toggle { display: none; } .topbar-mobile { display: none; } @media (max-width: 900px) { .shell { grid-template-columns: 1fr; } .sidebar { position: fixed; inset: 0 30% 0 0; z-index: 40; transform: translateX(-100%); transition: transform .18s ease; box-shadow: var(--shadow-md); } #nav-toggle:checked ~ .shell .sidebar { transform: translateX(0); } #nav-toggle:checked ~ .shell::after { content: ""; position: fixed; inset: 0; z-index: 39; background: var(--overlay); } .topbar-mobile { display: flex; align-items: center; gap: 10px; padding: 13px 16px; border-bottom: 1px solid var(--border); background: var(--surface); position: sticky; top: 0; z-index: 30; } .topbar-mobile strong { font-family: var(--font-display); font-size: 13px; } label.burger { width: 32px; height: 32px; border-radius: var(--radius-xs); border: 1px solid var(--border); display: grid; place-items: center; cursor: pointer; flex-shrink: 0; } .main { padding: 16px 14px 50px; } } @media (max-width: 640px) { .field-row { flex-direction: column; align-items: stretch; } thead { display: none; } table, tbody, tr, td { display: block; width: 100%; } tbody tr { border-bottom: 1px solid var(--border-soft); padding: 9px 16px; } tbody td { border: none; padding: 3px 0; } tbody td[data-label]::before { content: attr(data-label); display: block; font-family: var(--font-display); font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: .05em; color: var(--text-faint); } .actions { justify-content: flex-start; margin-top: 6px; } } /* ---------- targets page: group summary + usage ---------- */ /* 4-card variant of .stat-grid for pages whose summary row isn't the dynamic ips_by_state histogram (which wants 6 columns). */ .stat-grid.cols-4 { grid-template-columns: repeat(4, 1fr); } @media (max-width: 900px) { .stat-grid.cols-4 { grid-template-columns: repeat(2, 1fr); } } /* Neutral data token for listing individual targets inline — distinct from .pill (which always carries a status meaning via its color + dot): a chip is just a value, not a state. */ .chip-list { display: flex; flex-wrap: wrap; gap: 5px; max-width: 460px; } .chip { font-family: var(--font-mono); font-size: 11.5px; padding: 2px 7px; border-radius: var(--radius-xs); background: var(--surface-alt); border: 1px solid var(--border-soft); color: var(--text-muted); white-space: nowrap; } /* Row group name + target count, stacked. */ .group-name { display: flex; flex-direction: column; gap: 2px; } .group-name .count { font-size: 11px; color: var(--text-faint); font-weight: 400; text-transform: none; letter-spacing: 0; font-family: var(--font-ui); } /* "Used by" cell — wraps its pills with a small gap, same idiom as chip-list, kept separate since these ARE status-colored .pill elements. */ .used-by { display: flex; flex-wrap: wrap; gap: 4px; } /* Inline edit row, toggled open by the "Изменить" button — collapses by default so the table stays scannable instead of showing N always-open textareas at once. */ tr.edit-row td { background: var(--surface-alt); padding: 14px 16px; } tr.edit-row textarea { flex: 1 1 auto; } /* Auto-growing textarea (new-group form + per-row edit forms above) — stretches with content instead of showing an internal scrollbar immediately; only caps out (and starts scrolling) past ~20 lines. Height is set by JS (see templates/targets.html), computed from the field's own line-height, so it stays correct if font-size ever changes. */ textarea.autosize { overflow-y: hidden; resize: none; }