diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f55aaf6..cfb5de1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -42,7 +42,10 @@ "Bash(curl -sI http://100.114.205.53:12752/js/util.js)", "Bash(curl -sI http://100.114.205.53:12752/js/notifications.js)", "Bash(docker exec *)", - "Bash(curl -sI http://100.114.205.53:12752/api/notifications/alerts)" + "Bash(curl -sI http://100.114.205.53:12752/api/notifications/alerts)", + "Bash(git log *)", + "Bash(curl *)", + "Bash(docker inspect *)" ] } } diff --git a/.env.example b/.env.example index 8800802..35fcc87 100644 --- a/.env.example +++ b/.env.example @@ -185,6 +185,7 @@ TRANSCRIPT_DM_TO_CREATOR=false # DM the transcript file to the ticket # --- Settings site & internal API --- SETTINGS_PORT=12752 # Port for the settings web UI SETTINGS_ADMIN_PASSWORD= # Password to access the settings UI +SETTINGS_ADMIN_PASSWORD_2= # Optional second password with identical access (leave blank to disable) SETTINGS_DOMAIN=tickets.indifferentketchup.com # Domain for the settings site (update when domain changes) INTERNAL_API_PORT=12753 # Internal port for bot<->settings IPC (not exposed externally) INTERNAL_API_SECRET= # Shared secret between bot and settings site (generate a random string) diff --git a/CLAUDE.md b/CLAUDE.md index 946d614..c471248 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,11 +57,11 @@ Single Node process. Entry: `broccolini-discord.js`. 1. Module load: env validation, Discord `Client` created, `interactionCreate` / `messageCreate` listeners registered, `client.login(...)` called. 2. Public Express app (`app`) is defined at module scope with a **503 gate** — any `/api/*` request before `appReady` returns 503. 3. `client.once('ready')` (fires after Discord handshake): connects MongoDB, mounts bOSScord routes on `/api` (only if `BOSSCORD_API_KEY` set), calls `app.listen(CONFIG.PORT, CONFIG.HEALTHCHECK_HOST)`, sets `appReady = true`, then starts all background `setInterval`s. -4. The **internal** Express app (`internalApp`) listens separately on `127.0.0.1:INTERNAL_API_PORT` at module load, guarded by `INTERNAL_API_SECRET`. +4. The **internal** Express app (`internalApp`) listens separately on `0.0.0.0:INTERNAL_API_PORT` inside the bot container at module load, guarded by `INTERNAL_API_SECRET`. Not publicly exposed — reachable only from peers on the `broccoli-net` docker network (notably the settings-site container). ### Two HTTP surfaces - **Public (`app`)** — `GET /` healthcheck + `/api/*` (bOSScord consumer). CORS origin is `process.env.BOSSCORD_CLIENT_ORIGIN` (default `http://100.114.205.53:3081`). Rate-limited 60 req/min/IP. Auth: `Authorization: Bearer ${BOSSCORD_API_KEY}`. -- **Internal (`internalApp`)** — `127.0.0.1` only, `/internal/*`. Rate-limited 10 req/min. Auth: `x-internal-secret` header. `POST /config` enforces an explicit `ALLOWED_CONFIG_KEYS` allowlist; unknown keys return 400. `POST /restart` exits the process so the container supervisor restarts it. +- **Internal (`internalApp`)** — `broccoli-net` only (binds `0.0.0.0` inside the bot container; no host `ports:` publish), `/internal/*`. Rate-limited 10 req/min. Auth: `x-internal-secret` header. `POST /config` enforces an explicit `ALLOWED_CONFIG_KEYS` allowlist; unknown keys return 400. `POST /restart` exits the process so the container supervisor restarts it. `routes/internalApi.js` is required at module scope by `broccolini-discord.js` *before* the parent's `module.exports` populates — reaching back to the parent (e.g., `trackInterval`, `trackTimeout`, `clearGmailPollInterval`) must use a **lazy `require('../broccolini-discord')` inside the handler**, not a top-level destructure. diff --git a/broccolini-discord.js b/broccolini-discord.js index d4c25df..d7b96e1 100644 --- a/broccolini-discord.js +++ b/broccolini-discord.js @@ -342,8 +342,14 @@ internalApp.use('/internal', internalApi); let httpServer = null; let internalServer = null; if (CONFIG.INTERNAL_API_SECRET) { - internalServer = internalApp.listen(CONFIG.INTERNAL_API_PORT, '127.0.0.1', () => { - console.log(`[internalApi] listening on 127.0.0.1:${CONFIG.INTERNAL_API_PORT}`); + // Must bind all-interfaces inside the bot container: the settings-site is a + // separate container on broccoli-net and reaches this API over the docker + // bridge, not loopback. Not publicly exposed — docker-compose.yml has no + // `ports:` publish for INTERNAL_API_PORT, so ingress is limited to peers on + // broccoli-net, still gated by INTERNAL_API_SECRET and rate-limited. + // Do NOT flip this back to 127.0.0.1 — see commits d134f5f / 33b1f27. + internalServer = internalApp.listen(CONFIG.INTERNAL_API_PORT, '0.0.0.0', () => { + console.log(`[internalApi] listening on 0.0.0.0:${CONFIG.INTERNAL_API_PORT}`); }); } else { console.warn('[internalApi] INTERNAL_API_SECRET not set — internal API disabled.'); diff --git a/get-refresh-token.js b/get-refresh-token.js index 352e401..8dec974 100644 --- a/get-refresh-token.js +++ b/get-refresh-token.js @@ -1,20 +1,28 @@ /** * One-time script to generate a Gmail OAuth2 refresh token. - * Run once, copy the refresh token into .env, then delete this file. * * Usage: * node get-refresh-token.js * * Prerequisites: - * - GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET set in .env (or exported in shell) + * - GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET set in .env * - In Google Cloud Console → OAuth 2.0 Client → Authorized redirect URIs: * add http://localhost:3000/oauth2callback + * + * Flow: + * 1. Script prints an auth URL. Open it in any browser (same host or remote). + * 2a. Same-host browser: redirect to localhost:3000 is captured automatically. + * 2b. Remote browser (e.g. Windows, script running on Linux over SSH): + * the browser fails to load localhost:3000/oauth2callback?code=... — copy + * the full URL (or just the code= value) from the address bar and paste + * it into the terminal prompt. */ require('dotenv').config(); const { google } = require('googleapis'); const http = require('http'); const url = require('url'); +const readline = require('readline'); const CLIENT_ID = process.env.GOOGLE_CLIENT_ID; const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET; @@ -41,29 +49,37 @@ console.log('\n=== Gmail OAuth2 Token Generator ===\n'); console.log('1. Open this URL in your browser:\n'); console.log(authUrl); console.log('\n2. Authorize the app with your support Gmail account.'); -console.log('3. You will be redirected to localhost — this script will capture the token.\n'); +console.log('\n3a. If the browser runs on this host, the redirect is captured automatically.'); +console.log('3b. If the browser is on a different machine, it will fail to load'); +console.log(' http://localhost:3000/oauth2callback?code=... — copy the full URL'); +console.log(' (or just the code= value) from the address bar and paste it below.\n'); -// Temporary local server to capture the callback -const server = http.createServer(async (req, res) => { - const parsed = url.parse(req.url, true); - if (parsed.pathname !== '/oauth2callback') { - res.end('Not found'); - return; - } +let done = false; - const code = parsed.query.code; - if (!code) { - res.end('No code received.'); - server.close(); - return; +function extractCode(input) { + const trimmed = (input || '').trim(); + if (!trimmed) return null; + try { + // If the user pastes a full URL, pull the `code` query param. + // URL() treats localhost URLs fine; searchParams auto-decodes. + const parsed = new URL(trimmed); + return parsed.searchParams.get('code'); + } catch { + // Not a URL — assume the user pasted the raw code. + return trimmed; } +} + +async function finish(code, source, httpRespond) { + if (done) return; + done = true; try { const { tokens } = await oauth2Client.getToken(code); - res.end('
You can close this tab.
'); - server.close(); - - console.log('\n=== SUCCESS ===\n'); + if (httpRespond) { + httpRespond('You can close this tab.
'); + } + console.log(`\n=== SUCCESS (via ${source}) ===\n`); console.log('Add this to your .env:\n'); console.log(`REFRESH_TOKEN=${tokens.refresh_token}`); if (!tokens.refresh_token) { @@ -73,12 +89,51 @@ const server = http.createServer(async (req, res) => { console.log('\nAll tokens (for reference):'); console.log(JSON.stringify(tokens, null, 2)); } catch (err) { - res.end('Error exchanging code: ' + err.message); - server.close(); - console.error('Error:', err); + if (httpRespond) httpRespond('Error exchanging code: ' + err.message); + console.error('\nError exchanging code:', err.response?.data || err.message); + process.exitCode = 1; + } finally { + try { server.close(); } catch {} + try { rl.close(); } catch {} + } +} + +const server = http.createServer(async (req, res) => { + const parsed = url.parse(req.url, true); + if (parsed.pathname !== '/oauth2callback') { + res.end('Not found'); + return; + } + const code = parsed.query.code; + if (!code) { + res.end('No code received.'); + return; + } + await finish(code, 'browser redirect', (body) => res.end(body)); +}); + +server.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + console.log('Port 3000 is in use — auto-capture disabled. Use the paste prompt below.\n'); + } else { + console.error('HTTP server error:', err.message); } }); server.listen(3000, () => { - console.log('Waiting for OAuth callback on http://localhost:3000 ...\n'); + console.log('Waiting for OAuth callback on http://localhost:3000 (or paste below) ...\n'); +}); + +const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); +rl.question('Paste redirect URL or code here: ', async (answer) => { + if (done) return; + const code = extractCode(answer); + if (!code) { + console.error('Could not parse a code from that input.'); + process.exitCode = 1; + try { server.close(); } catch {} + rl.close(); + return; + } + await finish(code, 'pasted input'); }); diff --git a/routes/internalApi.js b/routes/internalApi.js index c3f0583..375d364 100644 --- a/routes/internalApi.js +++ b/routes/internalApi.js @@ -16,7 +16,9 @@ const { const router = express.Router(); -// Intentionally no trust-proxy: loopback-only; global rate-limit bucket. +// Intentionally no trust-proxy: reachable only from peers on broccoli-net +// (not publicly exposed). Rate limit keys off the raw connection IP, which +// inside the docker bridge is the peer container's address. const internalLimiter = rateLimit({ windowMs: 60 * 1000, max: 10, @@ -40,7 +42,7 @@ router.use((req, res, next) => { router.get('/config', (req, res) => { const map = readAllConfig(); const obj = {}; - const REDACTED = ['DISCORD_TOKEN', 'REFRESH_TOKEN', 'GOOGLE_CLIENT_SECRET', 'MONGODB_URI', 'INTERNAL_API_SECRET', 'SETTINGS_ADMIN_PASSWORD']; + const REDACTED = ['DISCORD_TOKEN', 'REFRESH_TOKEN', 'GOOGLE_CLIENT_SECRET', 'MONGODB_URI', 'INTERNAL_API_SECRET', 'SETTINGS_ADMIN_PASSWORD', 'SETTINGS_ADMIN_PASSWORD_2']; for (const [k, v] of map) { obj[k] = REDACTED.includes(k) ? '••••••••' : v; } diff --git a/settings-site/.env.example b/settings-site/.env.example index c7e03c4..704c059 100644 --- a/settings-site/.env.example +++ b/settings-site/.env.example @@ -1,5 +1,6 @@ SETTINGS_PORT=12752 SETTINGS_ADMIN_PASSWORD= +SETTINGS_ADMIN_PASSWORD_2= SETTINGS_DOMAIN=tickets.indifferentketchup.com INTERNAL_API_PORT=12753 INTERNAL_API_SECRET= diff --git a/settings-site/CLAUDE.md b/settings-site/CLAUDE.md index dbb50e5..1e85f42 100644 --- a/settings-site/CLAUDE.md +++ b/settings-site/CLAUDE.md @@ -24,7 +24,7 @@ The settings site is a thin HTTPS-oriented proxy in front of the bot's internal browser ──► settings server.js (:SETTINGS_PORT, default 12752) │ session auth (SETTINGS_ADMIN_PASSWORD) ▼ - bot internalApp (127.0.0.1:INTERNAL_API_PORT, default 12753) + bot internalApp (broccoli-net only, INTERNAL_API_PORT, default 12753) │ header auth (x-internal-secret = INTERNAL_API_SECRET) ▼ routes/internalApi.js in /opt/broccolini-bot @@ -48,6 +48,9 @@ browser ──► settings server.js (:SETTINGS_PORT, default 12752) Every response-shape change in the bot's `/internal/*` handlers (`routes/internalApi.js`) is a breaking change here. The bot also gates `POST /internal/config` on an `ALLOWED_CONFIG_KEYS` allowlist — **adding a new field to the UI requires adding the key to that Set in the bot first**, otherwise the save returns 400 for that key. +### Second admin password +`server.js` accepts an optional `SETTINGS_ADMIN_PASSWORD_2`. If set, the `/login` handler grants the same session for either password; no audit distinction between them. The primary `SETTINGS_ADMIN_PASSWORD` is still required at startup — only the second is optional. Both are redacted by the bot's `GET /internal/config` response. + ### Session cookie requires HTTPS `server.js:20-26` sets `cookie.secure: true`. Browsers will refuse to persist the session cookie over plain HTTP, so login silently fails when not behind an HTTPS reverse proxy (`SETTINGS_DOMAIN` is the deployed domain). If you're reproducing a login bug, check this first before debugging auth logic. The `session secret` falls back to `'fallback-secret-change-me'` when `INTERNAL_API_SECRET` is unset — don't rely on the fallback in any environment that matters. diff --git a/settings-site/public/css/main.css b/settings-site/public/css/main.css index a01551b..5a1e726 100644 --- a/settings-site/public/css/main.css +++ b/settings-site/public/css/main.css @@ -194,6 +194,7 @@ body::before { /* Sections */ .section { margin-bottom: 20px; } +.section.hidden { display: none; } .section-header { background: var(--surface); border: 1px solid var(--border); @@ -1023,4 +1024,49 @@ button.ss-chip { line-height: 1.4; } +/* Landing / home card grid */ +.landing-section { margin-bottom: 20px; } +.landing-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; +} +.landing-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 20px 22px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + text-decoration: none; + color: var(--text); + transition: border-color 150ms ease, background 150ms ease; +} +.landing-card:hover { + border-color: var(--border-strong); + background: var(--surface-2); +} +.landing-card-body { flex: 1; min-width: 0; } +.landing-card h3 { + font-family: var(--font-title); + font-size: 16px; + font-weight: 600; + color: var(--text); + margin-bottom: 4px; +} +.landing-card p { + font-family: var(--font-body); + font-size: 13px; + color: var(--text-muted); + line-height: 1.4; +} +.landing-card .chevron { + color: var(--primary); + font-size: 14px; + transition: transform 150ms ease; +} +.landing-card:hover .chevron { transform: translateX(3px); } + diff --git a/settings-site/public/css/main.css.bak-20260421 b/settings-site/public/css/main.css.bak-20260421 new file mode 100644 index 0000000..a01551b --- /dev/null +++ b/settings-site/public/css/main.css.bak-20260421 @@ -0,0 +1,1026 @@ +@import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;500;600;700&family=Sora:wght@400;500;600;700;800&display=swap'); + +* { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + /* Palette — "indifferent broccoli": deep near-black + chartreuse primary */ + --bg: #0D0F13; + --surface: #151920; + --surface-2: #1E242C; + --card: #151920; + --border: #262C35; + --border-strong: #3A4150; + + --primary: #C7E94D; + --primary-hover: #B5D83D; + --primary-dim: rgba(199, 233, 77, 0.12); + --primary-dim-2: rgba(199, 233, 77, 0.06); + + --secondary: #FFB84D; + --danger: #FF5A52; + --warning: #FFD66B; + --success: #7EE0A3; + + --text: #EFEEE8; + --text-muted: #a0a0a8; + --text-dim: #6B7280; + + --sidebar-width: 260px; + --topbar-height: 60px; + + --font-title: 'Sora', system-ui, -apple-system, sans-serif; + --font-body: 'Open Sans', system-ui, -apple-system, sans-serif; + --font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, monospace; +} + +body { + font-family: var(--font-body); + background: var(--bg); + color: var(--text); + display: flex; + min-height: 100vh; + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Ambient atmospheric glow — a single diffused lime pool, top-left */ +body::before { + content: ''; + position: fixed; + top: -200px; + left: -100px; + width: 640px; + height: 640px; + background: radial-gradient(circle, rgba(199, 233, 77, 0.08), transparent 60%); + filter: blur(60px); + pointer-events: none; + z-index: 0; +} + +/* Top bar */ +.topbar { + position: fixed; + top: 0; + left: var(--sidebar-width); + right: 0; + height: var(--topbar-height); + background: var(--surface); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 24px; + z-index: 100; + gap: 16px; +} +.topbar h1 { + font-family: var(--font-title); + font-size: 13px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--text); +} +.topbar h1::after { + content: ' (:|)'; + color: var(--primary); + margin-left: 2px; + letter-spacing: 0; + font-weight: 500; +} +.topbar .status { + display: flex; + align-items: center; + gap: 10px; + font-family: var(--font-title); + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.14em; +} +.topbar .status .dot { width: 8px; height: 8px; border-radius: 0; flex-shrink: 0; } +.topbar .status .dot.online { background: var(--primary); box-shadow: 0 0 14px var(--primary-dim); } +.topbar .status .dot.offline { background: var(--danger); } +.topbar .actions { display: flex; gap: 10px; align-items: center; } +.topbar .actions button { + background: transparent; + border: 1px solid var(--border-strong); + color: var(--text); + padding: 8px 16px; + font-family: var(--font-title); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.14em; + cursor: pointer; + transition: border-color 180ms ease, color 180ms ease, background 180ms ease; +} +.topbar .actions button:hover { + border-color: var(--primary); + color: var(--primary); + background: var(--primary-dim-2); +} + +/* Sidebar */ +.sidebar { + position: fixed; + top: 0; + left: 0; + width: var(--sidebar-width); + height: 100vh; + background: var(--surface); + border-right: 1px solid var(--border); + padding: 20px 0 24px; + overflow-y: auto; + z-index: 101; +} +.sidebar .logo { + padding: 8px 20px 24px; + font-family: var(--font-title); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--text); + line-height: 1.3; + position: relative; +} +.sidebar .logo::after { + content: '(:|)'; + display: block; + margin-top: 4px; + font-size: 11px; + letter-spacing: 0; + color: var(--primary); + font-weight: 500; +} +.sidebar a { + display: flex; + align-items: center; + padding: 11px 20px; + color: var(--text-muted); + text-decoration: none; + font-family: var(--font-title); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + border-left: 3px solid transparent; + transition: color 160ms ease, background 160ms ease, border-color 160ms ease; +} +.sidebar a:hover { + color: var(--text); + background: var(--primary-dim-2); +} +.sidebar a.active { + color: var(--primary); + border-left-color: var(--primary); + background: var(--primary-dim-2); +} + +/* Main content */ +.main { + margin-left: var(--sidebar-width); + margin-top: var(--topbar-height); + padding: 32px; + flex: 1; + padding-bottom: 140px; + position: relative; + z-index: 1; +} + +/* Sections */ +.section { margin-bottom: 20px; } +.section-header { + background: var(--surface); + border: 1px solid var(--border); + border-bottom: none; + padding: 18px 22px; + cursor: pointer; + display: flex; + align-items: center; + gap: 14px; + transition: background 180ms ease, border-color 180ms ease; +} +.section-header:hover { + background: var(--surface-2); + border-color: var(--border-strong); +} +.section-header h2 { + font-family: var(--font-title); + font-size: 14px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--text); + flex: 1; + display: flex; + align-items: center; + gap: 12px; +} +.section-header h2::before { + content: ''; + display: inline-block; + width: 6px; + height: 6px; + background: var(--primary); + flex-shrink: 0; +} +.section-header p { + font-family: var(--font-body); + font-size: 12px; + font-weight: 400; + color: var(--text-muted); + text-transform: none; + letter-spacing: 0; + line-height: 1.4; +} +.section-header .chevron { + transition: transform 200ms ease; + font-size: 12px; + color: var(--primary); +} +.section.collapsed .section-header { border-bottom: 1px solid var(--border); } +.section.collapsed .section-body { display: none; } +.section.collapsed .chevron { transform: rotate(-90deg); } +.section-body { + background: var(--surface); + border: 1px solid var(--border); + padding: 28px 24px; +} + +/* Field grid */ +.field-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 22px; +} +.field { display: flex; flex-direction: column; gap: 8px; } +.field.full-width { grid-column: 1 / -1; } +.field label { + font-family: var(--font-title); + font-size: 13px; + font-weight: 700; + color: var(--text-muted); + letter-spacing: 0; +} +.field input, +.field select, +.field textarea { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 2px; + padding: 11px 14px; + color: var(--text); + font-family: var(--font-body); + font-size: 14px; + outline: none; + transition: border-color 180ms ease, box-shadow 180ms ease; +} +.field input::placeholder, +.field textarea::placeholder { color: var(--text-dim); } +.field input:focus, +.field select:focus, +.field textarea:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--primary-dim-2); +} +.field textarea { + min-height: 88px; + resize: vertical; + line-height: 1.55; +} +.field input.changed, +.field select.changed, +.field textarea.changed { + border-color: var(--warning); + box-shadow: 0 0 0 3px rgba(255, 214, 107, 0.1); +} +.field .hint { + font-size: 12px; + color: var(--text-muted); + font-style: italic; + line-height: 1.4; +} + +/* Toggle switch */ +.toggle-wrap { display: flex; align-items: center; gap: 12px; } +.toggle-wrap > span { + font-family: var(--font-title); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--text-muted); +} +.toggle { position: relative; width: 44px; height: 22px; } +.toggle input { opacity: 0; width: 0; height: 0; } +.toggle .slider { + position: absolute; + inset: 0; + background: var(--surface-2); + border: 1px solid var(--border-strong); + cursor: pointer; + transition: background 180ms ease, border-color 180ms ease; +} +.toggle .slider::before { + content: ''; + position: absolute; + left: 3px; + top: 3px; + width: 14px; + height: 14px; + background: var(--text-muted); + transition: transform 200ms ease, background 180ms ease; +} +.toggle input:checked + .slider { + border-color: var(--primary); + background: var(--primary-dim); +} +.toggle input:checked + .slider::before { + transform: translateX(20px); + background: var(--primary); +} + +/* Color picker */ +.color-field { display: flex; align-items: center; gap: 12px; } +.color-field input[type="color"] { + width: 44px; + height: 44px; + border: 1px solid var(--border); + cursor: pointer; + background: var(--bg); + padding: 3px; + border-radius: 0; +} +.color-field span { + font-family: var(--font-body); + color: var(--text-muted); + font-size: 13px; +} + +/* Smart select */ +.smart-select { position: relative; } +.smart-select-display { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 2px; + padding: 11px 14px; + cursor: pointer; + display: flex; + align-items: center; + gap: 10px; + min-height: 44px; + transition: border-color 180ms ease; + font-size: 14px; +} +.smart-select-display:hover { border-color: var(--primary); } +.smart-select-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--surface); + border: 1px solid var(--border-strong); + margin-top: 4px; + z-index: 200; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55); + max-height: 320px; + overflow: hidden; + display: flex; + flex-direction: column; +} +.smart-select-dropdown.hidden { display: none; } +.ss-search { + background: var(--bg); + border: none; + border-bottom: 1px solid var(--border); + padding: 12px 14px; + color: var(--text); + font-family: var(--font-body); + font-size: 13px; + outline: none; +} +.ss-list { + overflow-y: auto; + max-height: 260px; + padding: 4px; +} +.ss-option { + padding: 10px 12px; + cursor: pointer; + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + transition: background 120ms ease, color 120ms ease; +} +.ss-option:hover { background: var(--primary-dim-2); color: var(--primary); } +.ss-option.selected { background: var(--primary-dim); color: var(--primary); } +.ss-option.ss-clear { color: var(--text-muted); font-style: italic; } +.ss-label { flex: 1; } +.ss-sub { font-size: 11px; color: var(--text-muted); } +.ss-id { font-size: 11px; color: var(--text-dim); font-family: var(--font-mono); } +.ss-placeholder { color: var(--text-dim); } +.ss-avatar { width: 20px; height: 20px; border-radius: 50%; } +.ss-dot { width: 10px; height: 10px; border-radius: 0; flex-shrink: 0; } +.ss-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; } +.ss-option.ss-chip { + display: inline-flex; + padding: 4px 8px; + margin: 2px; + border-radius: 12px; + font-size: 12px; + cursor: pointer; +} + +/* Save bar */ +.save-bar { + position: fixed; + bottom: 0; + left: var(--sidebar-width); + right: 0; + background: var(--surface); + border-top: 2px solid var(--primary); + padding: 16px 24px; + display: flex; + align-items: center; + justify-content: space-between; + transform: translateY(100%); + transition: transform 320ms cubic-bezier(0.4, 0, 0.2, 1); + z-index: 100; + box-shadow: 0 -16px 40px rgba(0, 0, 0, 0.35); + gap: 16px; +} +.save-bar.visible { transform: translateY(0); } +.save-bar > span { + font-family: var(--font-title); + font-size: 11px; + font-weight: 700; + color: var(--warning); + text-transform: uppercase; + letter-spacing: 0.16em; + display: inline-flex; + align-items: center; + gap: 10px; +} +.save-bar > span::before { + content: ''; + width: 7px; + height: 7px; + background: var(--warning); + animation: pulse 1.6s ease-in-out infinite; +} +@keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.3; transform: scale(0.8); } +} +.save-actions { display: flex; gap: 10px; flex-wrap: wrap; } +.save-actions button { + padding: 11px 22px; + font-family: var(--font-title); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + cursor: pointer; + border: 1px solid transparent; + border-radius: 0; + transition: all 180ms ease; +} +.save-actions button:first-child { + background: var(--primary); + color: var(--bg); + border-color: var(--primary); +} +.save-actions button:first-child:hover { + background: var(--primary-hover); + border-color: var(--primary-hover); +} +.save-actions button.secondary { + background: transparent; + color: var(--text); + border-color: var(--border-strong); +} +.save-actions button.secondary:hover { + border-color: var(--primary); + color: var(--primary); +} +.save-actions button.danger { + background: transparent; + color: var(--danger); + border-color: var(--danger); +} +.save-actions button.danger:hover { + background: var(--danger); + color: var(--bg); +} +.save-actions button:disabled { opacity: 0.4; cursor: not-allowed; } + +/* Toast */ +#toast-container { + position: fixed; + top: 76px; + right: 24px; + z-index: 300; + display: flex; + flex-direction: column; + gap: 8px; + pointer-events: none; +} +.toast { + padding: 12px 18px; + font-family: var(--font-title); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + border-left: 3px solid currentColor; + background: var(--surface); + animation: toast-in 260ms ease; + pointer-events: auto; + max-width: 420px; +} +.toast-success { color: var(--primary); background: rgba(199, 233, 77, 0.06); } +.toast-warning { color: var(--warning); background: rgba(255, 214, 107, 0.08); } +.toast-error { color: var(--danger); background: rgba(255, 90, 82, 0.08); } +@keyframes toast-in { + from { opacity: 0; transform: translateX(20px); } + to { opacity: 1; transform: translateX(0); } +} + +/* Modal */ +.modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.72); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 400; +} +.modal.hidden { display: none; } +.modal-card { + background: var(--surface); + border: 1px solid var(--border); + border-top: 3px solid var(--primary); + padding: 28px; + min-width: 360px; +} +.modal-card h3 { + margin-bottom: 20px; + font-family: var(--font-title); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; +} +.modal-card input { + width: 100%; + padding: 11px 14px; + background: var(--bg); + border: 1px solid var(--border); + color: var(--text); + font-family: var(--font-body); + font-size: 14px; + margin-bottom: 20px; + outline: none; + transition: border-color 180ms ease; +} +.modal-card input:focus { border-color: var(--primary); } +.modal-actions { display: flex; gap: 8px; justify-content: flex-end; } +.modal-actions button { + padding: 10px 20px; + font-family: var(--font-title); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + cursor: pointer; + border: 1px solid transparent; +} +.modal-actions button:first-child { + background: var(--primary); + color: var(--bg); + border-color: var(--primary); +} +.modal-actions button:first-child:hover { background: var(--primary-hover); } +.modal-actions button.secondary { + background: transparent; + color: var(--text); + border-color: var(--border-strong); +} +.modal-actions button.secondary:hover { border-color: var(--primary); color: var(--primary); } + +/* Loading */ +.loading { + position: fixed; + inset: 0; + background: var(--bg); + display: flex; + flex-direction: column; + gap: 20px; + align-items: center; + justify-content: center; + z-index: 500; +} +.loading.hidden { display: none; } +.loading::after { + content: 'LOADING (:|)'; + font-family: var(--font-title); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.32em; + color: var(--primary); +} +.spinner { + width: 40px; + height: 40px; + border: 2px solid var(--border); + border-top-color: var(--primary); + border-radius: 0; + animation: spin 0.9s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* Notifications section */ +#s-notifications .notif-tabs { + display: flex; + gap: 4px; + flex-wrap: wrap; + margin-bottom: 22px; + border-bottom: 1px solid var(--border); +} +#s-notifications .notif-tab-btn { + border: none; + background: transparent; + color: var(--text-muted); + font-family: var(--font-title); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.14em; + padding: 10px 16px; + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: color 160ms ease, border-color 160ms ease; +} +#s-notifications .notif-tab-btn:hover { color: var(--text); } +#s-notifications .notif-tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); } +#s-notifications .notif-panel.hidden { display: none; } +#s-notifications .notif-editor { + border: 1px solid var(--border); + padding: 20px; + margin-bottom: 16px; + background: var(--surface-2); +} +#s-notifications .notif-chips { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin: 14px 0; + min-height: 32px; +} +#s-notifications .notif-chip { + display: inline-flex; + align-items: center; + gap: 10px; + border: 1px solid var(--primary); + background: var(--primary-dim); + color: var(--primary); + padding: 5px 12px; + font-family: var(--font-title); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} +#s-notifications .notif-chip button { + border: none; + background: transparent; + color: currentColor; + cursor: pointer; + padding: 0; + line-height: 1; + font-size: 14px; + opacity: 0.6; +} +#s-notifications .notif-chip button:hover { opacity: 1; } +#s-notifications .notif-input-row { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} +#s-notifications .notif-input-row input { width: 220px; } +#s-notifications .notif-presets { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-top: 14px; +} +#s-notifications .notif-presets button, +#s-notifications .notif-add-btn { + padding: 8px 14px; + border: 1px solid var(--border-strong); + background: transparent; + color: var(--text-muted); + font-family: var(--font-title); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + cursor: pointer; + transition: border-color 160ms ease, color 160ms ease, background 160ms ease; +} +#s-notifications .notif-presets button:hover, +#s-notifications .notif-add-btn:hover { + border-color: var(--primary); + color: var(--primary); + background: var(--primary-dim-2); +} +#s-notifications .notif-trigger { margin-top: 16px; } +#s-notifications .notif-trigger summary { + cursor: pointer; + color: var(--text-muted); + font-family: var(--font-title); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + margin-bottom: 14px; + user-select: none; + list-style: none; + display: inline-flex; + align-items: center; + gap: 8px; +} +#s-notifications .notif-trigger summary::-webkit-details-marker { display: none; } +#s-notifications .notif-trigger summary::before { + content: '+'; + color: var(--primary); + font-weight: 700; + font-size: 14px; +} +#s-notifications .notif-trigger[open] summary::before { content: '−'; } +#s-notifications .notif-trigger[open] summary { color: var(--primary); } + +/* Phase 9 — notification enable toggles */ +#s-notifications .notif-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + padding-bottom: 14px; + margin-bottom: 14px; + border-bottom: 1px solid var(--border); +} +#s-notifications .notif-toggle-group { + display: flex; + align-items: center; + gap: 10px; +} +#s-notifications .notif-toggle-label { + font-family: var(--font-title); + font-size: 13px; + font-weight: 700; + color: var(--text); + letter-spacing: 0; +} +#s-notifications .notif-per-alert-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} +.notif-disabled { + opacity: 0.5; + pointer-events: none; + user-select: none; +} + +/* Logging hint link */ +.logging-hint { color: var(--text-muted); font-size: 13px; } +.logging-hint a { + color: var(--primary); + text-decoration: underline; + text-decoration-style: wavy; + text-decoration-thickness: 1.5px; + text-underline-offset: 4px; +} +.logging-hint a:hover { color: var(--primary-hover); } + +/* Legacy logout form wrapper (kept for compatibility) */ +.logout-form { display: inline; } + +/* Select element styling (native appearance override) */ +.field select { + appearance: none; + -webkit-appearance: none; + background-image: linear-gradient(45deg, transparent 50%, var(--primary) 50%), + linear-gradient(135deg, var(--primary) 50%, transparent 50%); + background-position: calc(100% - 18px) center, calc(100% - 13px) center; + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; + padding-right: 36px; +} + +/* ---------- Mobile navigation primitives ---------- */ +.menu-toggle { + display: none; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + padding: 0; + margin-right: 4px; + background: transparent; + border: 1px solid var(--border-strong); + border-radius: 0; + color: var(--text); + cursor: pointer; + transition: border-color 180ms ease, color 180ms ease, background 180ms ease; +} +.menu-toggle:hover { border-color: var(--primary); color: var(--primary); background: var(--primary-dim-2); } +.menu-toggle:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } +.menu-toggle-bars, +.menu-toggle-bars::before, +.menu-toggle-bars::after { + display: block; + width: 20px; + height: 2px; + background: currentColor; + position: relative; + transition: transform 200ms; +} +.menu-toggle-bars::before, +.menu-toggle-bars::after { content: ''; position: absolute; left: 0; } +.menu-toggle-bars::before { top: -6px; } +.menu-toggle-bars::after { top: 6px; } + +.sidebar-backdrop { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.72); + backdrop-filter: blur(3px); + -webkit-backdrop-filter: blur(3px); + opacity: 0; + pointer-events: none; + transition: opacity 200ms ease; + z-index: 150; +} + +/* ---------- Mobile breakpoint ---------- */ +@media (max-width: 900px) { + body.sidebar-open { overflow: hidden; } + + .sidebar { + transform: translateX(-100%); + transition: transform 260ms ease; + z-index: 151; + box-shadow: 4px 0 40px rgba(0, 0, 0, 0.7); + } + body.sidebar-open .sidebar { transform: translateX(0); } + + .sidebar-backdrop { display: block; } + body.sidebar-open .sidebar-backdrop { opacity: 1; pointer-events: auto; } + + .topbar { left: 0; padding: 0 14px; gap: 10px; height: 56px; } + .topbar h1 { font-size: 12px; flex: 1; min-width: 0; letter-spacing: 0.14em; } + .topbar .status { font-size: 10px; flex-shrink: 0; } + .topbar .actions button { min-height: 44px; padding: 10px 14px; font-size: 10px; } + .menu-toggle { display: inline-flex; } + + .main { + margin-left: 0; + margin-top: 56px; + padding: 20px 16px; + padding-bottom: 180px; + } + + .field-grid { grid-template-columns: 1fr; } + + .save-bar { + left: 0; + padding: 14px 16px calc(14px + env(safe-area-inset-bottom, 0px)); + flex-wrap: wrap; + gap: 10px; + } + .save-bar > span { width: 100%; } + .save-actions { width: 100%; display: flex; flex-wrap: wrap; gap: 8px; } + .save-actions button { flex: 1 1 140px; min-height: 44px; padding: 14px 16px; font-size: 11px; } + .save-actions button:first-child { flex-basis: 100%; } + + .sidebar a { padding: 14px 20px; min-height: 44px; font-size: 12px; } + .section-header { padding: 18px 20px; } + .smart-select-display { min-height: 44px; } + #s-notifications .notif-chip { padding: 8px 12px; } + #s-notifications .notif-chip button { min-width: 28px; min-height: 28px; font-size: 18px; } + #s-notifications .notif-tab-btn, + #s-notifications .notif-add-btn, + #s-notifications .notif-presets button { min-height: 40px; padding: 10px 14px; } + #s-notifications .notif-input-row input { flex: 1 1 auto; width: auto; min-width: 0; } + + .modal-card { width: calc(100vw - 32px); min-width: 0; max-width: 420px; } + + #toast-container { right: 12px; left: 12px; top: 64px; } + .toast { max-width: none; } +} + +/* ---------- Accessibility (Phase 6) ---------- */ + +/* Universal keyboard-focus indicator. Kept narrow: only shown when the + browser's focus heuristic says this is a keyboard user (never on click). + Pointer-driven focus still uses the component-specific hover/active/border + treatments defined above. */ +a:focus-visible, +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +[role="combobox"]:focus-visible, +[role="option"]:focus-visible, +[tabindex]:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +/* Smart-select option keyboard navigation. Same visual as :hover so + pointer and keyboard users see the same highlight on the active row. */ +.ss-option:focus { + background: var(--primary-dim-2); + color: var(--primary); +} +.ss-option[aria-selected="true"] { + background: var(--primary-dim); + color: var(--primary); +} +.ss-option:focus-visible { + outline: 2px solid var(--primary); + outline-offset: -2px; +} + +/* Combobox trigger shows an explicit focus ring in addition to the + border-color change, so keyboard users can see it against the dark bg. */ +.smart-select-display:focus-visible { + border-color: var(--primary); + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +/* Toast close button */ +.toast { + display: flex; + align-items: center; + gap: 10px; +} +.toast-message { flex: 1; } +.toast-close { + background: transparent; + border: none; + color: currentColor; + cursor: pointer; + padding: 2px 6px; + font-size: 16px; + line-height: 1; + opacity: 0.7; + font-family: inherit; +} +.toast-close:hover { opacity: 1; } + +/* Multi-select chip removal button (was a , now a