diff --git a/src/web/public/constants.js b/src/web/public/constants.js index c5c0491d..a459409c 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -3436,3 +3436,152 @@ if (typeof window !== 'undefined') { TERMINAL_MIN_ROWS, }; } + +// ═══════════════════════════════════════════════════════════════ +// Security notices +// ═══════════════════════════════════════════════════════════════ +// A security notice is a sticky warning toast about something outside Codeman that +// its users should act on (the first: OpenCode's Console breach, October 2026). The +// notices are DATA in the hand-maintained src/web/public/security-notices.json, never +// in whats-new.json (`npm run version-packages` rewrites that one every release), and +// they ship with a release. This is the pure half: which notices this browser shows, and +// on which surface. The wiring (fetches, the seen lists) is checkWhatsNew() in settings-ui.js, +// the cards are showSecurityNotice() / showUpdateNotice() in panels-ui.js. + +/** `YYYY-MM-DD`, and a real calendar day (no 2026-02-30). */ +const SECURITY_NOTICE_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; +/** An https URL with a plain host: no other scheme, no userinfo, no whitespace. */ +const SECURITY_NOTICE_URL_PATTERN = /^https:\/\/[A-Za-z0-9.-]+(?::\d{1,5})?(?:[/?#]\S*)?$/; + +/** + * The local-time bounds of one `YYYY-MM-DD` day, or null when the string is not one. + * Local time on purpose: a notice dated the 18th starts on the viewer's 18th. + */ +function securityNoticeDay(value) { + const match = typeof value === 'string' ? SECURITY_NOTICE_DATE_PATTERN.exec(value) : null; + if (!match) return null; + const year = Number(match[1]); + const month = Number(match[2]) - 1; + const day = Number(match[3]); + const start = new Date(year, month, day); + if (start.getFullYear() !== year || start.getMonth() !== month || start.getDate() !== day) return null; + const end = new Date(year, month, day + 1).getTime() - 1; + return { start: start.getTime(), end }; +} + +function securityNoticeText(value) { + return typeof value === 'string' && value.trim() ? value.trim() : ''; +} + +/** + * The notices to show, in file order, each as `{ id, title, message, label, url }` + * (`title` is '' when the entry has none). + * + * - A malformed entry is skipped, never repaired: no id, message or link label, a + * link that is not https, a date that is not a real `YYYY-MM-DD` day, a `title` + * that is present but not a non-blank string, or a `cli` that is not a registry + * id. A repeated id shows its first entry only. + * - Shown from the start of the `published` day to the END of the `expires` day. + * The expiry is what keeps an old notice from alarming a browser that first opens + * Codeman months later. + * - An id in `seen` is skipped (the user closed it, or followed its link). + * - A notice naming a `cli` is shown ONLY when `available[cli] === true`. Absent, + * unknown and false all mean "not installed" here, unlike isCliAvailable() in + * settings-ui.js (which offers a launch on a doubt): a warning about software you + * do not have is noise. A notice with no `cli` is for everyone. + * - `lang` 'zh-CN' takes the entry's `i18n['zh-CN']` title, message and label where + * they exist, each falling back to the English one on its own. + * + * The CLI id is data from the JSON, compared as a key; no code here names one. + * + * @param {Array} notices - `notices` from security-notices.json + * @param {{available?: Record, seen?: Iterable, now?: number, lang?: string}} [opts] + * `available` is window.__codemanCliAvailable, `now` epoch milliseconds (default Date.now()). + * @returns {Array<{id: string, title: string, message: string, label: string, url: string}>} + */ +function selectSecurityNotices(notices, opts = {}) { + if (!Array.isArray(notices)) return []; + const available = opts.available && typeof opts.available === 'object' ? opts.available : {}; + const seenSource = opts.seen; + const seen = new Set( + seenSource && typeof seenSource !== 'string' && typeof seenSource[Symbol.iterator] === 'function' + ? Array.from(seenSource, String) + : [] + ); + const now = typeof opts.now === 'number' && Number.isFinite(opts.now) ? opts.now : Date.now(); + const lang = opts.lang === 'zh-CN' ? 'zh-CN' : 'en'; + const taken = new Set(); + const shown = []; + for (const entry of notices) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; + const id = securityNoticeText(entry.id); + const title = securityNoticeText(entry.title); + const message = securityNoticeText(entry.message); + const link = entry.link && typeof entry.link === 'object' ? entry.link : null; + const label = securityNoticeText(link?.label); + const url = typeof link?.url === 'string' ? link.url.trim() : ''; + const published = securityNoticeDay(entry.published); + const expires = securityNoticeDay(entry.expires); + if (!id || !message || !label || !SECURITY_NOTICE_URL_PATTERN.test(url) || !published || !expires) continue; + if (entry.title !== undefined && !title) continue; + if (entry.cli !== undefined && (typeof entry.cli !== 'string' || !CLI_ID_PATTERN.test(entry.cli))) continue; + if (taken.has(id)) continue; + taken.add(id); + if (now < published.start || now > expires.end) continue; + if (seen.has(id)) continue; + if (entry.cli !== undefined && available[entry.cli] !== true) continue; + const localized = lang === 'en' ? null : entry.i18n?.[lang]; + shown.push({ + id, + title: securityNoticeText(localized?.title) || title, + message: securityNoticeText(localized?.message) || message, + label: securityNoticeText(localized?.label) || label, + url, + }); + } + return shown; +} + +/** + * Which surface carries each selected notice on this page load, decided together with + * the once-per-release update toast (checkWhatsNew in settings-ui.js) so a browser gets + * ONE notification, never a card and a toast for the same notice. + * + * - The update toast shows exactly as checkWhatsNew always decided: this browser saw an + * older version (`seenVersion` set and different), and `notes` are this version's with + * at least one highlight. Then every selected notice rides INSIDE it (`updateNotices`) + * and no standalone card is shown. + * - Otherwise (a fresh install, a reload on the same version, notes missing or empty) + * each notice gets its own card (`cards`). + * - `recordVersion`: store `runningVersion` as seen, as checkWhatsNew always did: on a + * fresh install (so it is not greeted with "what's new") and when the toast shows. + * Notes that are missing or empty record nothing, so the next load tries again. + * + * @param {{seenVersion?: string|null, runningVersion: string, notes?: object|null, notices?: Array}} state + * `notices` is the output of selectSecurityNotices(). + * @returns {{recordVersion: boolean, update: boolean, updateNotices: Array, cards: Array}} + */ +function planStartupNotices(state = {}) { + const seenVersion = typeof state.seenVersion === 'string' && state.seenVersion ? state.seenVersion : null; + const runningVersion = state.runningVersion; + const notices = Array.isArray(state.notices) ? state.notices : []; + const notes = state.notes; + const update = Boolean( + seenVersion && + seenVersion !== runningVersion && + notes && + notes.version === runningVersion && + Array.isArray(notes.highlights) && + notes.highlights.length > 0 + ); + return { + recordVersion: !seenVersion || update, + update, + updateNotices: update ? notices.slice() : [], + cards: update ? [] : notices.slice(), + }; +} + +if (typeof window !== 'undefined') { + window.CodemanSecurityNotices = { select: selectSecurityNotices, plan: planStartupNotices }; +} diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 42657a23..4dcef037 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -844,6 +844,9 @@ 'Settings saved': '设置已保存', // The once-per-release toast (settings-ui.js checkWhatsNew); its highlights stay English. "See what's new": '查看更新内容', + // The security-notice cards' own eyebrow (panels-ui.js showSecurityNotice/showUpdateNotice); + // each notice's title, message and link label carry their own zh-CN text and are skipped. + 'Security notice': '安全提示', 'Settings applied': '设置已应用', 'Save and keep Settings open': '保存并保持设置打开', 'Settings saved locally': '设置已保存到本机', diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 2d447dbc..630721cd 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -6249,16 +6249,7 @@ Object.assign(CodemanApp.prototype, { closeBtn.onclick = (e) => { e.stopPropagation(); dismiss(); }; toast.appendChild(closeBtn); - // Cache toast container reference - if (!this._toastContainer) { - this._toastContainer = document.querySelector('.toast-container'); - if (!this._toastContainer) { - this._toastContainer = document.createElement('div'); - this._toastContainer.className = 'toast-container'; - document.body.appendChild(this._toastContainer); - } - } - this._toastContainer.appendChild(toast); + this._toastStack().appendChild(toast); requestAnimationFrame(() => toast.classList.add('show')); @@ -6272,6 +6263,169 @@ Object.assign(CodemanApp.prototype, { return { dismiss, setMessage: (text) => { msgSpan.textContent = text; } }; }, + /** The fixed toast stack (`.toast-container`), created on first use and cached. */ + _toastStack() { + if (!this._toastContainer) { + this._toastContainer = document.querySelector('.toast-container'); + if (!this._toastContainer) { + this._toastContainer = document.createElement('div'); + this._toastContainer.className = 'toast-container'; + document.body.appendChild(this._toastContainer); + } + } + return this._toastContainer; + }, + + /** + * A security notice (security-notices.json) as its own card in the toast stack: shield, + * "Security notice" eyebrow, the notice's title and message, its link as the primary + * button, a quiet Dismiss and the ×. Same container as every toast, so it never fights + * the z-index stack. Sticky: only the user ends it. + * + * The notice's own text (title, message, link label) is already in the UI language, so it + * is set with textContent and carries `data-i18n-skip`; the card's own copy ("Security + * notice", "Dismiss", the × label) is application text and translates as usual. + * + * `opts.onClose(reason)` fires ONCE, and only on a user action: `'link'`, `'dismiss'` or + * `'close'`. Never on the handle's own `dismiss()`, which does not mean the user saw it. + * + * @param {{id: string, title: string, message: string, label: string, url: string}} notice + * @param {{onClose?: (reason: string) => void}} [opts] + */ + showSecurityNotice(notice, opts = {}) { + const card = this._noticeCard('security-notice', opts.onClose); + const main = this._noticeEl('div', 'security-notice-main'); + main.append(this._noticeEl('div', 'security-notice-eyebrow', 'Security notice')); + main.append(...this._noticeTextBlock(notice)); + const actions = this._noticeEl('div', 'security-notice-actions'); + actions.append( + this._noticeLinkButton(notice, () => card.close('link')), + this._noticeButton('security-notice-quiet', 'Dismiss', () => card.close('dismiss')) + ); + main.append(actions); + card.el.append(this._noticeIcon('shield', 'security-notice-icon'), main, card.closeButton); + card.mount(); + return { dismiss: () => card.close(null) }; + }, + + /** + * The once-per-release update toast when security notices ride INSIDE it + * (CodemanSecurityNotices.plan in constants.js decides that; with no notice the plain + * showToast toast is used, unchanged). Styled as the security card: the release text as + * before (translated by the same i18n.js pattern), one warning section per notice, then + * each notice's link button beside "See what's new". + * + * Sticky, since it carries a warning. `onNoticeSeen(id)` records a notice: the × records + * every notice in the card, a notice's link records that one and closes the card. "See + * what's new" opens the release notes and leaves the card up, because the warning in it + * has not been answered yet. + * + * @param {{text: string, releaseUrl: string, notices: Array, onNoticeSeen: (id: string) => void}} opts + */ + showUpdateNotice({ text, releaseUrl, notices, onNoticeSeen }) { + const seen = (id) => { try { onNoticeSeen?.(id); } catch (err) { console.error('[notice] onNoticeSeen failed:', err); } }; + const card = this._noticeCard('security-notice update-notice', (reason) => { + if (reason === 'close') for (const notice of notices) seen(notice.id); + }); + const main = this._noticeEl('div', 'security-notice-main'); + main.append(this._noticeEl('p', 'update-notice-text', text)); + const actions = this._noticeEl('div', 'security-notice-actions'); + for (const notice of notices) { + const section = this._noticeEl('section', 'security-notice-section'); + const head = this._noticeEl('div', 'security-notice-section-head'); + head.append(this._noticeIcon('shield'), this._noticeEl('span', 'security-notice-eyebrow', 'Security notice')); + section.append(head, ...this._noticeTextBlock(notice)); + main.append(section); + actions.append(this._noticeLinkButton(notice, () => { seen(notice.id); card.close('link'); })); + } + actions.append( + this._noticeButton('security-notice-secondary', "See what's new", () => window.open(releaseUrl, '_blank', 'noopener')) + ); + main.append(actions); + card.el.append(this._noticeIcon('update', 'security-notice-icon update-notice-icon'), main, card.closeButton); + card.mount(); + return { dismiss: () => card.close(null) }; + }, + + /** The shell both notice cards share: role=alert, the ×, mount and a once-only close. */ + _noticeCard(className, onUserClose) { + const el = this._noticeEl('div', className); + el.setAttribute('role', 'alert'); + el.setAttribute('aria-live', 'assertive'); + let closed = false; + const close = (reason) => { + if (closed) return; + closed = true; + el.classList.remove('show'); + setTimeout(() => el.remove(), 200); + if (reason && typeof onUserClose === 'function') { + try { onUserClose(reason); } catch (err) { console.error('[notice] onClose failed:', err); } + } + }; + const closeButton = this._noticeButton('security-notice-close', '×', () => close('close')); + closeButton.setAttribute('aria-label', 'Close'); + const mount = () => { + this._toastStack().appendChild(el); + requestAnimationFrame(() => el.classList.add('show')); + }; + return { el, closeButton, close, mount }; + }, + + /** The notice's own title (when it has one) and message: textContent, never translated. */ + _noticeTextBlock(notice) { + const parts = []; + if (notice.title) parts.push(this._noticeEl('div', 'security-notice-title', notice.title, true)); + parts.push(this._noticeEl('p', 'security-notice-body', notice.message, true)); + return parts; + }, + + _noticeLinkButton(notice, after) { + return this._noticeButton( + 'security-notice-primary', + notice.label, + () => { window.open(notice.url, '_blank', 'noopener'); after(); }, + true + ); + }, + + _noticeButton(className, label, onClick, untranslated = false) { + const btn = this._noticeEl('button', className, label, untranslated); + btn.type = 'button'; + btn.onclick = (e) => { e.stopPropagation(); onClick(); }; + return btn; + }, + + _noticeEl(tag, className, text, untranslated = false) { + const el = document.createElement(tag); + el.className = className; + if (text !== undefined) el.textContent = text; + if (untranslated) el.setAttribute('data-i18n-skip', ''); + return el; + }, + + /** Inline SVG icons (no asset): `shield` (with an exclamation) or `update` (arrow up). */ + _noticeIcon(kind, wrapperClass) { + const NS = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(NS, 'svg'); + for (const [k, v] of Object.entries({ + viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', + 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true', focusable: 'false', + })) svg.setAttribute(k, v); + const shapes = kind === 'shield' + ? [['path', { d: 'M12 3 4.5 6v5.2c0 4.5 3.1 8.4 7.5 9.8 4.4-1.4 7.5-5.3 7.5-9.8V6L12 3z' }], ['path', { d: 'M12 8.5v4.5' }], ['path', { d: 'M12 16.5h.01' }]] + : [['circle', { cx: '12', cy: '12', r: '9' }], ['path', { d: 'M12 16.5v-9' }], ['path', { d: 'm8.5 11 3.5-3.5 3.5 3.5' }]]; + for (const [tag, attrs] of shapes) { + const shape = document.createElementNS(NS, tag); + for (const [k, v] of Object.entries(attrs)) shape.setAttribute(k, v); + svg.appendChild(shape); + } + if (!wrapperClass) return svg; + const wrap = this._noticeEl('span', wrapperClass); + wrap.setAttribute('aria-hidden', 'true'); + wrap.appendChild(svg); + return wrap; + }, + /** * A prominent, screen-centred status banner — for the small set of messages that are * genuinely worth interrupting the eye for rather than living in the corner with every diff --git a/src/web/public/security-notices.json b/src/web/public/security-notices.json new file mode 100644 index 00000000..8b0e281b --- /dev/null +++ b/src/web/public/security-notices.json @@ -0,0 +1,21 @@ +{ + "notices": [ + { + "id": "opencode-console-breach-2026-10", + "severity": "warning", + "cli": "opencode", + "published": "2026-10-18", + "expires": "2026-12-31", + "title": "OpenCode Console breach: check your API keys", + "message": "Security notice for OpenCode users: on 17 October OpenCode reported unauthorized access to a legacy OpenCode Console system. Provider API keys some users had saved in a Console workspace (OpenAI, Anthropic and others) may have been exposed. OpenCode emailed the affected users: if you got that email, revoke and replace those keys and check their recent usage. The opencode CLI itself is not reported as affected.", + "link": { "label": "Read OpenCode's notice", "url": "https://x.com/opencode/status/2111519637049319818" }, + "i18n": { + "zh-CN": { + "title": "OpenCode Console 遭入侵:请检查你的 API 密钥", + "message": "OpenCode 用户安全提示:OpenCode 于 10 月 17 日报告,其旧版 OpenCode Console 系统遭到未授权访问。部分用户保存在 Console 工作区中的提供商 API 密钥(OpenAI、Anthropic 等)可能已泄露。OpenCode 已向受影响的用户发送邮件:如果你收到了该邮件,请吊销并更换这些密钥,并检查近期用量。目前没有报告称 opencode CLI 本身受到影响。", + "label": "查看 OpenCode 公告" + } + } + } + ] +} diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 7c8cb2fc..6ed29241 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -378,32 +378,108 @@ Object.assign(CodemanApp.prototype, { * The notes come from /whats-new.json, which `npm run version-packages` regenerates from * CHANGELOG.md for each release. A browser that has never seen any version just records the * current one, so a fresh install is not greeted with "what's new". + * + * The same pass places the security notices (/security-notices.json, see + * _loadSecurityNotices), so a load shows ONE notification per notice: + * CodemanSecurityNotices.plan() (constants.js) puts every selected notice INSIDE the update + * toast when that toast shows (showUpdateNotice), and gives each its own card otherwise, a + * fresh install or a reload on the same version (showSecurityNotice). With no notice + * selected the update toast is the plain one it always was. Storage blocked shows neither: + * nothing could remember a close. */ async checkWhatsNew(runningVersion) { if (this._whatsNewChecked) return; this._whatsNewChecked = true; const KEY = 'codeman-whats-new-seen'; let seen = null; - try { seen = localStorage.getItem(KEY); } catch { /* storage blocked: skip the announcement */ return; } - if (seen === runningVersion) return; + try { seen = localStorage.getItem(KEY); } catch { /* storage blocked: skip the announcement and the notices */ return; } const remember = () => { try { localStorage.setItem(KEY, runningVersion); } catch { /* ignore */ } }; - if (!seen) { remember(); return; } - try { - const res = await fetch('/whats-new.json', { cache: 'no-cache' }); - if (!res.ok) return; - const notes = await res.json(); - if (notes?.version !== runningVersion || !Array.isArray(notes.highlights) || !notes.highlights.length) return; + const [notes, notices] = await Promise.all([ + seen && seen !== runningVersion ? this._fetchWhatsNewNotes() : null, + this._loadSecurityNotices(), + ]); + const plan = window.CodemanSecurityNotices.plan({ seenVersion: seen, runningVersion, notes, notices }); + if (plan.recordVersion) remember(); + if (plan.update) { const shown = notes.highlights.slice(0, 3).join(' · '); const more = notes.highlights.length > 3 ? ` (+${notes.highlights.length - 3} more)` : ''; - remember(); - this.showToast(`Codeman updated to v${runningVersion}: ${shown}${more}`, 'info', { - duration: 20000, - action: { - label: "See what's new", - onClick: () => window.open(`https://github.com/Ark0N/Codeman/releases/tag/codeman%40${runningVersion}`, '_blank', 'noopener'), - }, + const text = `Codeman updated to v${runningVersion}: ${shown}${more}`; + const releaseUrl = `https://github.com/Ark0N/Codeman/releases/tag/codeman%40${runningVersion}`; + if (plan.updateNotices.length) { + this.showUpdateNotice({ + text, + releaseUrl, + notices: plan.updateNotices, + onNoticeSeen: (id) => this._rememberSecurityNotice(id), + }); + } else { + this.showToast(text, 'info', { + duration: 20000, + action: { + label: "See what's new", + onClick: () => window.open(releaseUrl, '_blank', 'noopener'), + }, + }); + } + } + for (const notice of plan.cards) { + this.showSecurityNotice(notice, { onClose: () => this._rememberSecurityNotice(notice.id) }); + } + }, + + /** This release's notes, or null (offline or malformed: try again next load). */ + async _fetchWhatsNewNotes() { + try { + const res = await fetch('/whats-new.json', { cache: 'no-cache' }); + return res.ok ? await res.json() : null; + } catch { + return null; + } + }, + + /** + * The security notices this browser should see now, from the HAND-maintained + * /security-notices.json (docs/releasing.md says how to add and retire one), selected by + * CodemanSecurityNotices.select() in constants.js: dates, the seen list, and for a notice + * naming a CLI, `window.__codemanCliAvailable[cli] === true` only (unknown means not + * installed). [] on any failure, and when storage is blocked (a close could not be + * remembered, so the card would return on every load). + */ + async _loadSecurityNotices() { + let seen; + try { seen = this._readSecurityNoticesSeen(); } catch { /* storage blocked: show nothing */ return []; } + try { + const res = await fetch(CodemanBase.url('/security-notices.json'), { cache: 'no-cache' }); + if (!res.ok) return []; + const data = await res.json(); + return window.CodemanSecurityNotices.select(data?.notices, { + available: window.__codemanCliAvailable, + seen, + now: Date.now(), + lang: window.CodemanI18n?.language || window.__codemanLanguage, }); - } catch { /* offline or malformed: try again next load */ } + } catch { /* offline or malformed: try again next load */ return []; } + }, + + /** The ids the user closed or followed (`codeman-security-notices-seen`). Throws when storage is blocked. */ + _readSecurityNoticesSeen() { + const raw = localStorage.getItem('codeman-security-notices-seen'); + let ids = []; + try { ids = raw ? JSON.parse(raw) : []; } catch { /* a corrupt value counts as nothing seen */ } + return Array.isArray(ids) ? ids.filter((id) => typeof id === 'string') : []; + }, + + /** + * Record a notice as seen. Only a user action calls this (a close, Dismiss, its link), never + * the card appearing, so one shown while nobody was looking comes back on the next load. + * Re-reads first: another tab may have recorded a notice since this one loaded. + */ + _rememberSecurityNotice(id) { + try { + const ids = this._readSecurityNoticesSeen(); + if (!ids.includes(id)) ids.push(id); + localStorage.setItem('codeman-security-notices-seen', JSON.stringify(ids)); + } catch { /* storage blocked since load: it shows again next load */ } }, openAppSettings() { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 15517d20..77985c48 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -9723,6 +9723,254 @@ kbd { .toast-warning { border-color: rgba(234, 179, 8, 0.4); } .toast-info { border-color: rgba(59, 130, 246, 0.4); } +/* Security notice card (security-notices.json; showSecurityNotice / showUpdateNotice in + panels-ui.js). Its own card in the toast stack, never the plain pill, and in the SAME + container, so it shares the toasts' position and z-index. Theme tokens only, so every skin + reads: the light skins' --yellow is already a dark amber that holds on white, and the + primary button's ink is --bg-card (dark ink on the dark skins' light amber, white ink on + the light skins' dark amber). The entrance animates opacity and transform only, and + nothing moves under prefers-reduced-motion. */ +.security-notice { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + column-gap: 0.75rem; + align-items: start; + box-sizing: border-box; + width: min(27rem, calc(100vw - 1rem)); + padding: 0.85rem 0.55rem 0.9rem 1.05rem; + background: + linear-gradient(180deg, color-mix(in srgb, var(--yellow) 9%, transparent), transparent 6rem), + var(--bg-card); + border: 1px solid color-mix(in srgb, var(--yellow) 32%, var(--border)); + border-radius: 10px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28), 0 2px 6px rgba(0, 0, 0, 0.16); + color: var(--text); + font-size: 0.8rem; + line-height: 1.45; + overflow: hidden; + pointer-events: auto; + opacity: 0; + transform: translateY(-6px) scale(0.985); + transition: opacity 0.22s ease, transform 0.22s cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.security-notice.show { + opacity: 1; + transform: none; +} + +/* The accent bar: amber running into red down the left edge. */ +.security-notice::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 4px; + background: linear-gradient(180deg, var(--yellow), var(--red)); +} + +.security-notice-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 50%; + color: var(--yellow); + background: color-mix(in srgb, var(--yellow) 15%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--yellow) 30%, transparent); +} + +.security-notice-icon svg { + width: 1.1rem; + height: 1.1rem; +} + +/* The update toast carrying a notice leads with the release, in the accent colour. */ +.security-notice-icon.update-notice-icon { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 14%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent); +} + +.security-notice-main { + display: flex; + flex-direction: column; + gap: 0.3rem; + min-width: 0; + padding-top: 0.1rem; +} + +.security-notice-eyebrow { + color: var(--yellow); + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.09em; + line-height: 1.3; + text-transform: uppercase; +} + +.security-notice-title { + color: var(--text); + font-size: 0.9rem; + font-weight: 700; + line-height: 1.3; + overflow-wrap: anywhere; +} + +.security-notice-body, +.update-notice-text { + margin: 0; + overflow-wrap: anywhere; +} + +.security-notice-body { + color: var(--text-dim); +} + +.update-notice-text { + color: var(--text); +} + +/* One warning section per notice inside the update toast. */ +.security-notice-section { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-top: 0.4rem; + padding: 0.6rem 0.7rem 0.65rem; + border: 1px solid color-mix(in srgb, var(--yellow) 26%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--yellow) 7%, transparent); +} + +.security-notice-section-head { + display: flex; + align-items: center; + gap: 0.4rem; + color: var(--yellow); +} + +.security-notice-section-head svg { + flex-shrink: 0; + width: 0.95rem; + height: 0.95rem; +} + +.security-notice-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.45rem 0.8rem; + margin-top: 0.5rem; +} + +/* The update toast's buttons share their row, and when it wraps (a phone, a long label) + each fills its own, so the two never sit at odd widths one under the other. */ +.update-notice .security-notice-actions { + column-gap: 0.6rem; +} + +.update-notice .security-notice-actions > button { + flex: 1 1 auto; +} + +.security-notice-primary, +.security-notice-secondary, +.security-notice-quiet { + font: inherit; + font-size: 0.78rem; + font-weight: 700; + line-height: 1.2; + cursor: pointer; +} + +.security-notice-primary { + padding: 0.45rem 0.85rem; + border: 1px solid transparent; + border-radius: var(--btn-radius); + background: var(--yellow); + color: var(--bg-card); +} + +.security-notice-primary:hover { + background: color-mix(in srgb, var(--yellow) 86%, var(--text)); +} + +.security-notice-secondary { + padding: 0.45rem 0.85rem; + border: 1px solid var(--border-light); + border-radius: var(--btn-radius); + background: transparent; + color: var(--text); +} + +.security-notice-secondary:hover { + background: var(--control-bg-hover); +} + +.security-notice-quiet { + padding: 0.45rem 0.15rem; + border: none; + background: none; + color: var(--text-dim); + font-weight: 600; +} + +.security-notice-quiet:hover { + color: var(--text); + text-decoration: underline; + text-underline-offset: 3px; +} + +.security-notice-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + margin-top: -0.3rem; + padding: 0; + border: none; + border-radius: 6px; + background: none; + color: var(--text-muted); + font-size: 1.15rem; + line-height: 1; + cursor: pointer; +} + +.security-notice-close:hover { + background: var(--control-bg-hover); + color: var(--text); +} + +.security-notice button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Phones: the full width minus the stack's 0.5rem gutters, and the notice's own button + takes the room left beside Dismiss (wrapping under it when the label is long). */ +@media (max-width: 599px) { + .security-notice { + width: calc(100vw - 1rem); + } + + .security-notice-primary { + flex: 1 1 auto; + } +} + +@media (prefers-reduced-motion: reduce) { + .security-notice { + transform: none; + transition: none; + } +} + /* Notification Bell Button */ .btn-icon-header.btn-notifications { position: relative;