diff --git a/config.py b/config.py index 3aaef21..0e1e0de 100644 --- a/config.py +++ b/config.py @@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.115" +SYSTEM_VERSION = "V10.116" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/scripts/check_observability_suite.sh b/scripts/check_observability_suite.sh index 2fa2b34..e8e3646 100644 --- a/scripts/check_observability_suite.sh +++ b/scripts/check_observability_suite.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # AI observability QA suite. # -# Runs the static UI regression guard first, then the production page smoke -# check unless --skip-production is provided. +# Runs the static UI regression guard first, then production page smoke and +# rendered visual contract checks unless --skip-production is provided. set -euo pipefail @@ -30,7 +30,9 @@ Usage: scripts/check_observability_suite.sh [--base-url URL] [--skip-production] Runs: 1. python3 scripts/check_observability_ui.py - 2. python3 scripts/check_observability_pages.py --base-url URL + 2. python3 scripts/check_observability_deploy_gate.py --self-test + 3. python3 scripts/check_observability_pages.py --base-url URL + 4. scripts/check_observability_visual_contract.sh --base-url URL Options: --base-url URL Target site for the 10-page smoke check. @@ -50,18 +52,21 @@ cd "$PROJECT_ROOT" echo "========================================" echo "AI Observability QA Suite" echo "========================================" -echo "1/3 Static UI guard" +echo "1/4 Static UI guard" python3 scripts/check_observability_ui.py -echo "2/3 Deploy gate self-test" +echo "2/4 Deploy gate self-test" python3 scripts/check_observability_deploy_gate.py --self-test if [[ "$SKIP_PRODUCTION" -eq 1 ]]; then - echo "3/3 Production smoke skipped" + echo "3/4 Production smoke skipped" + echo "4/4 Rendered visual contract skipped" echo "AI Observability QA Suite: PASS" exit 0 fi -echo "3/3 Production 10-page smoke" +echo "3/4 Production 10-page smoke" python3 scripts/check_observability_pages.py --base-url "$BASE_URL" +echo "4/4 Rendered visual contract" +bash scripts/check_observability_visual_contract.sh --base-url "$BASE_URL" echo "AI Observability QA Suite: PASS" diff --git a/scripts/check_observability_visual_contract.js b/scripts/check_observability_visual_contract.js new file mode 100755 index 0000000..7fb9dea --- /dev/null +++ b/scripts/check_observability_visual_contract.js @@ -0,0 +1,405 @@ +#!/usr/bin/env node +/* + * Rendered visual contract for AI observability pages. + * + * This complements the static template guard by checking computed CSS in a + * browser: typography, warm-token surfaces, radius, hero backgrounds, and + * mobile density across the 10 observability pages. + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const Module = require('module'); + +const DEFAULT_BASE_URL = 'https://mo.wooo.work'; +const ROUTES = [ + '/observability/overview', + '/observability/agent_orchestration', + '/observability/business_intel', + '/observability/host_health', + '/observability/ai_calls', + '/observability/budget', + '/observability/promotion_review', + '/observability/rag_queries', + '/observability/quality_trend', + '/observability/ppt_audit_history', +]; + +const VIEWPORTS = [ + { name: 'desktop-1440', width: 1440, height: 950, maxHeroHeight: 430, maxTitleSize: 30 }, + { name: 'tablet-630', width: 630, height: 968, maxHeroHeight: 520, maxTitleSize: 30 }, + { name: 'mobile-390', width: 390, height: 844, maxHeroHeight: 560, maxTitleSize: 26 }, +]; + +const HERO_SELECTORS = [ + '.obs-hero', + '.agent-hero', + '.biz-command', + '.runtime-hero', + '.calls-hero', + '.gov-hero', + '.gate-hero', + '.rag-hero', + '.qa-hero', + '.quality-hero', + '.ppt-hero', +].join(','); + +const TITLE_SELECTORS = [ + '.obs-title', + '.agent-title', + '.biz-title', + '.runtime-title', + '.calls-title', + '.gov-title', + '.gate-title', + '.rag-title', + '.qa-title', + '.quality-title', + '.ppt-title', + '.container-fluid > h2:first-child', +].join(','); + +const SURFACE_SELECTORS = [ + HERO_SELECTORS, + '.obs-panel', + '.agent-panel', + '.biz-panel', + '.runtime-panel', + '.calls-panel', + '.gov-panel', + '.gate-panel', + '.rag-panel', + '.qa-panel', + '.quality-panel', + '.ppt-panel', + '.obs-signal', + '.agent-signal', + '.biz-signal', + '.runtime-signal', + '.calls-signal', + '.gov-signal', + '.gate-signal', + '.rag-signal', + '.qa-signal', + '.quality-signal', + '.ppt-signal', + '.biz-filter-card', + '.biz-strategy-card', + '.episode-card', + '.host-lane', + '.caller-card', + '.agent-card', + '.rec-card', + '.fix-card', + '.root-card', + '.strategy-card', +].join(','); + +function parseArgs(argv) { + const options = { baseUrl: DEFAULT_BASE_URL, routes: [], json: false, timeoutMs: 30000 }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--base-url') { + options.baseUrl = argv[++i]; + } else if (arg === '--route') { + options.routes.push(argv[++i]); + } else if (arg === '--routes') { + options.routes.push(...argv[++i].split(',').map((item) => item.trim()).filter(Boolean)); + } else if (arg === '--timeout') { + options.timeoutMs = parseInt(argv[++i], 10) * 1000; + } else if (arg === '--json') { + options.json = true; + } else if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + if (options.routes.length === 0) { + options.routes = ROUTES; + } + return options; +} + +function printHelp() { + console.log(`AI observability rendered visual contract + +Options: + --base-url URL Base URL, default ${DEFAULT_BASE_URL} + --route PATH Add one route, can be repeated + --routes A,B,C Add comma-separated routes + --timeout SEC Navigation timeout, default 30 + --json Print JSON results +`); +} + +function requirePlaywright() { + try { + return require('playwright'); + } catch (error) { + const candidates = [ + process.env.PLAYWRIGHT_NODE_MODULE_DIR, + process.env.NODE_PATH, + path.join(os.homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'), + ].filter(Boolean); + for (const candidate of candidates) { + const packageJson = path.join(candidate, 'playwright', 'package.json'); + if (fs.existsSync(packageJson)) { + return Module.createRequire(packageJson)('playwright'); + } + } + throw new Error('Cannot load playwright. Set NODE_PATH or PLAYWRIGHT_NODE_MODULE_DIR to a node_modules directory containing playwright.'); + } +} + +function findChromeExecutable() { + const candidates = [ + process.env.RESPONSIVE_CHROME_PATH, + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + ].filter(Boolean); + return candidates.find((candidate) => fs.existsSync(candidate)) || ''; +} + +function routeToUrl(baseUrl, route) { + return new URL(route, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString(); +} + +function px(value) { + return Number.parseFloat(String(value || '').replace('px', '')) || 0; +} + +function parseRgb(value) { + const match = String(value || '').match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (!match) return null; + return match.slice(1, 4).map((item) => parseInt(item, 10) / 255); +} + +function luminance(rgb) { + if (!rgb) return 0; + const values = rgb.map((channel) => ( + channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4 + )); + return 0.2126 * values[0] + 0.7152 * values[1] + 0.0722 * values[2]; +} + +function contrastRatio(fg, bg) { + const a = luminance(parseRgb(fg)); + const b = luminance(parseRgb(bg)); + const lighter = Math.max(a, b); + const darker = Math.min(a, b); + return (lighter + 0.05) / (darker + 0.05); +} + +async function collectMetrics(page) { + return page.evaluate(({ heroSelectors, titleSelectors, surfaceSelectors }) => { + const parseRgbValue = (value) => { + const match = String(value || '').match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?/); + if (!match) return null; + return { + rgb: match.slice(1, 4).map((item) => parseInt(item, 10) / 255), + alpha: match[4] === undefined ? 1 : Number.parseFloat(match[4]), + }; + }; + const effectiveBackground = (el) => { + let current = el; + while (current && current.nodeType === Node.ELEMENT_NODE) { + const color = getComputedStyle(current).backgroundColor; + const parsed = parseRgbValue(color); + if (parsed && parsed.alpha > 0.05) return color; + current = current.parentElement; + } + return 'rgb(250, 246, 236)'; + }; + const luminanceValue = (parsed) => { + if (!parsed) return 0; + const values = parsed.rgb.map((channel) => ( + channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4 + )); + return 0.2126 * values[0] + 0.7152 * values[1] + 0.0722 * values[2]; + }; + const contrast = (fg, bg) => { + const a = luminanceValue(parseRgbValue(fg)); + const b = luminanceValue(parseRgbValue(bg)); + const lighter = Math.max(a, b); + const darker = Math.min(a, b); + return (lighter + 0.05) / (darker + 0.05); + }; + const width = window.innerWidth; + const hero = document.querySelector(heroSelectors); + const title = document.querySelector(titleSelectors); + const badRadius = []; + const badBackground = []; + const badChipContrast = []; + + for (const el of document.querySelectorAll(`.momo-observability-mode :is(${surfaceSelectors})`)) { + const style = getComputedStyle(el); + const radius = parseFloat(style.borderTopLeftRadius) || 0; + if (radius > 8.5) { + badRadius.push({ + className: String(el.className || '').slice(0, 100), + radius, + text: String(el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 80), + }); + } + if ((el.matches(heroSelectors) || el.matches('.obs-panel,.agent-panel,.biz-panel,.runtime-panel,.calls-panel,.gov-panel,.gate-panel,.rag-panel,.qa-panel,.quality-panel,.ppt-panel')) && style.backgroundImage !== 'none') { + badBackground.push({ + className: String(el.className || '').slice(0, 100), + backgroundImage: style.backgroundImage.slice(0, 100), + }); + } + if (badRadius.length >= 8 && badBackground.length >= 8) break; + } + + for (const el of document.querySelectorAll('.momo-observability-mode :is(.badge,.obs-pill,[class$="-pill"],.biz-badge)')) { + const style = getComputedStyle(el); + const ratio = contrast(style.color, effectiveBackground(el)); + if (ratio < 3) { + badChipContrast.push({ + className: String(el.className || '').slice(0, 100), + text: String(el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60), + ratio, + }); + } + if (badChipContrast.length >= 8) break; + } + + const titleStyle = title ? getComputedStyle(title) : null; + const heroStyle = hero ? getComputedStyle(hero) : null; + return { + mode: !!document.querySelector('.momo-observability-mode'), + finalUrl: location.href, + rootOverflow: Math.max(0, document.documentElement.scrollWidth - width), + bodyOverflow: Math.max(0, document.body.scrollWidth - width), + scrollHeight: document.documentElement.scrollHeight, + hero: hero ? { + className: String(hero.className || ''), + height: Math.round(hero.getBoundingClientRect().height), + radius: heroStyle.borderTopLeftRadius, + backgroundImage: heroStyle.backgroundImage, + } : null, + title: title ? { + text: String(title.textContent || '').trim().slice(0, 80), + fontFamily: titleStyle.fontFamily, + fontSize: titleStyle.fontSize, + color: titleStyle.color, + letterSpacing: titleStyle.letterSpacing, + lineHeight: titleStyle.lineHeight, + } : null, + badRadius, + badBackground, + badChipContrast, + }; + }, { heroSelectors: HERO_SELECTORS, titleSelectors: TITLE_SELECTORS, surfaceSelectors: SURFACE_SELECTORS }); +} + +function issuesFor(metrics, viewport) { + const issues = []; + if (!metrics.mode) issues.push('missing momo-observability-mode'); + if (metrics.rootOverflow > 1 || metrics.bodyOverflow > 1) { + issues.push(`horizontal overflow root=${metrics.rootOverflow}px body=${metrics.bodyOverflow}px`); + } + if (!metrics.title) { + issues.push('missing title selector'); + } else { + if (!/Inter|Noto Sans TC/.test(metrics.title.fontFamily)) { + issues.push(`title font ${metrics.title.fontFamily}`); + } + if (px(metrics.title.fontSize) > viewport.maxTitleSize) { + issues.push(`title too large ${metrics.title.fontSize}`); + } + if (metrics.title.letterSpacing !== 'normal' && px(metrics.title.letterSpacing) < -0.1) { + issues.push(`title negative letter spacing ${metrics.title.letterSpacing}`); + } + if (metrics.title.color !== 'rgb(42, 37, 32)') { + issues.push(`title color ${metrics.title.color}`); + } + } + if (!metrics.hero) { + issues.push('missing hero selector'); + } else { + if (px(metrics.hero.radius) > 8.5) issues.push(`hero radius ${metrics.hero.radius}`); + if (metrics.hero.backgroundImage !== 'none') issues.push('hero background image is not none'); + if (metrics.hero.height > viewport.maxHeroHeight) { + issues.push(`hero too tall ${metrics.hero.height}px > ${viewport.maxHeroHeight}px`); + } + } + if (metrics.badRadius.length) issues.push(`surface radius offenders ${metrics.badRadius.length}`); + if (metrics.badBackground.length) issues.push(`surface background-image offenders ${metrics.badBackground.length}`); + if (metrics.badChipContrast.length) issues.push(`chip contrast offenders ${metrics.badChipContrast.length}`); + return issues; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const { chromium } = requirePlaywright(); + const launchOptions = { headless: true }; + const chromePath = findChromeExecutable(); + if (chromePath) launchOptions.executablePath = chromePath; + + const browser = await chromium.launch(launchOptions); + const results = []; + try { + for (const route of options.routes) { + for (const viewport of VIEWPORTS) { + const page = await browser.newPage({ + viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: 1, + }); + const url = routeToUrl(options.baseUrl, route); + let status = 0; + let error = ''; + let metrics = null; + try { + const response = await page.goto(url, { waitUntil: 'networkidle', timeout: options.timeoutMs }); + status = response ? response.status() : 0; + metrics = await collectMetrics(page); + if (new URL(metrics.finalUrl).pathname === '/login') { + error = 'redirected to /login'; + } else if (status >= 400) { + error = `HTTP ${status}`; + } + } catch (err) { + error = err.message || String(err); + } + const issues = metrics ? issuesFor(metrics, viewport) : []; + if (error) issues.unshift(error); + results.push({ route, viewport: viewport.name, status, passed: issues.length === 0, issues, metrics }); + await page.close(); + } + } + } finally { + await browser.close(); + } + + if (options.json) { + console.log(JSON.stringify(results, null, 2)); + } else { + for (const result of results) { + const status = result.passed ? 'PASS' : 'FAIL'; + const title = result.metrics?.title ? `${result.metrics.title.fontSize} ${result.metrics.title.color}` : 'n/a'; + const hero = result.metrics?.hero ? `${result.metrics.hero.height}px r=${result.metrics.hero.radius}` : 'n/a'; + console.log(`${status} ${result.viewport} ${result.route} title=${title} hero=${hero}${result.issues.length ? ` issues=${result.issues.join('; ')}` : ''}`); + if (!result.passed && result.metrics) { + for (const bucket of ['badRadius', 'badBackground', 'badChipContrast']) { + for (const offender of result.metrics[bucket] || []) { + console.log(` ${bucket}: ${JSON.stringify(offender)}`); + } + } + } + } + } + + if (results.some((result) => !result.passed)) { + process.exitCode = 1; + } +} + +main().catch((error) => { + console.error(error.message || error); + process.exit(1); +}); diff --git a/scripts/check_observability_visual_contract.sh b/scripts/check_observability_visual_contract.sh new file mode 100755 index 0000000..a5d9c3b --- /dev/null +++ b/scripts/check_observability_visual_contract.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUNDLED_NODE="$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node" +BUNDLED_NODE_MODULES="$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules" + +if [ -z "${NODE_BIN:-}" ] && [ -x "$BUNDLED_NODE" ]; then + NODE_BIN="$BUNDLED_NODE" +fi + +if [ -z "${NODE_BIN:-}" ]; then + NODE_BIN="node" +fi + +if [ -d "$BUNDLED_NODE_MODULES" ] && [ -z "${NODE_PATH:-}" ]; then + export NODE_PATH="$BUNDLED_NODE_MODULES" +fi + +exec "$NODE_BIN" "$PROJECT_ROOT/scripts/check_observability_visual_contract.js" "$@" diff --git a/scripts/quick_review.sh b/scripts/quick_review.sh index 686df3b..f9ff704 100755 --- a/scripts/quick_review.sh +++ b/scripts/quick_review.sh @@ -20,6 +20,7 @@ OBSERVABILITY_UI_GUARD="$PROJECT_ROOT/scripts/check_observability_ui.py" OBSERVABILITY_PAGE_SMOKE="$PROJECT_ROOT/scripts/check_observability_pages.py" OBSERVABILITY_QA_SUITE="$PROJECT_ROOT/scripts/check_observability_suite.sh" OBSERVABILITY_CSS_SYNC="$PROJECT_ROOT/scripts/sync_observability_css.py" +OBSERVABILITY_VISUAL_CONTRACT="$PROJECT_ROOT/scripts/check_observability_visual_contract.sh" RESPONSIVE_OVERFLOW_GUARD="$PROJECT_ROOT/scripts/check_responsive_overflow.sh" REVIEW_REPORT_HINT=0 @@ -88,6 +89,16 @@ run_observability_css_sync() { python3 "$OBSERVABILITY_CSS_SYNC" "$@" } +run_observability_visual_contract() { + if [ ! -f "$OBSERVABILITY_VISUAL_CONTRACT" ]; then + echo -e "${RED}❌ AI觀測台渲染後視覺契約不存在: $OBSERVABILITY_VISUAL_CONTRACT${NC}" + exit 1 + fi + + echo -e "${GREEN}🎨 開始 AI觀測台渲染後視覺契約檢查...${NC}" + bash "$OBSERVABILITY_VISUAL_CONTRACT" "$@" +} + run_responsive_overflow_guard() { if [ ! -f "$RESPONSIVE_OVERFLOW_GUARD" ]; then echo -e "${RED}❌ Responsive overflow guard 不存在: $RESPONSIVE_OVERFLOW_GUARD${NC}" @@ -126,6 +137,11 @@ if [ $# -gt 0 ]; then run_observability_css_sync --check exit $? ;; + --observability-visual) + shift + run_observability_visual_contract "$@" + exit $? + ;; --responsive-overflow) shift run_responsive_overflow_guard "$@" @@ -144,6 +160,8 @@ AI observability quick-review flags: Run production page/CSS smoke against the target URL. --observability-qa [--base-url URL] [--skip-production] Run the full QA suite. + --observability-visual [--base-url URL] [--timeout SEC] + Run rendered typography, surface, radius, contrast, and mobile density checks. --responsive-overflow [--base-url URL] [--route PATH ...] Run desktop/tablet/mobile body horizontal overflow checks for Flask routes. EOF @@ -165,8 +183,9 @@ if [ $# -eq 0 ]; then echo "8) AI觀測台完整 QA 套件" echo "9) 同步 AI觀測台 CSS static mirror" echo "10) 全頁 responsive overflow 檢查" + echo "11) AI觀測台渲染後視覺契約檢查" echo "" - read -p "請輸入選項 (1-10): " choice + read -p "請輸入選項 (1-11): " choice case $choice in 1) @@ -216,6 +235,9 @@ if [ $# -eq 0 ]; then 10) run_responsive_overflow_guard ;; + 11) + run_observability_visual_contract + ;; *) echo -e "${RED}❌ 無效選項${NC}" exit 1 diff --git a/static/css/observability-system.css b/static/css/observability-system.css index 57d49d5..daa97d4 100644 --- a/static/css/observability-system.css +++ b/static/css/observability-system.css @@ -1761,6 +1761,116 @@ } } +/* v3.7 typography tightening: no legacy tracking, poster-scale numerals, or wrapped action buttons. */ +.momo-observability-mode :is( + .obs-kicker, + .agent-kicker, + .biz-kicker, + .runtime-kicker, + .calls-kicker, + .gov-kicker, + .gate-kicker, + .rag-kicker, + .qa-kicker, + .quality-kicker, + .ppt-kicker, + [class$="-label"], + .obs-signal-label, + .obs-section-eyebrow, + .obs-route-code +) { + letter-spacing: 0 !important; + text-transform: none !important; +} + +.momo-observability-mode .biz-signal .value { + color: var(--obs-ink) !important; + font-family: var(--momo-font-mono, "JetBrains Mono", ui-monospace, monospace) !important; + font-size: var(--obs-value-size) !important; + font-weight: 800 !important; + letter-spacing: 0 !important; + line-height: 1.1 !important; +} + +.momo-observability-mode :is(.biz-signal .label, .biz-signal .note, .biz-filter-card label) { + color: var(--obs-muted) !important; + font-family: var(--momo-font-family, "Inter", "Noto Sans TC", system-ui, sans-serif) !important; + letter-spacing: 0 !important; + text-transform: none !important; +} + +.momo-observability-mode .biz-filter-card .btn { + min-width: 4.5rem; + white-space: nowrap; +} + +@media (max-width: 560px) { + .momo-observability-mode { + --obs-title-size: 1.38rem; + --obs-value-size: 1.12rem; + } + + .momo-observability-mode :is( + .obs-title, + .agent-title, + .biz-title, + .runtime-title, + .calls-title, + .gov-title, + .gate-title, + .rag-title, + .qa-title, + .quality-title, + .ppt-title + ) { + margin-top: 0.55rem !important; + margin-bottom: 0.25rem !important; + } + + .momo-observability-mode :is( + .obs-kicker, + .agent-kicker, + .biz-kicker, + .runtime-kicker, + .calls-kicker, + .gov-kicker, + .gate-kicker, + .rag-kicker, + .qa-kicker, + .quality-kicker, + .ppt-kicker + ) { + font-size: 0.74rem !important; + line-height: 1.25 !important; + padding: 0.24rem 0.42rem !important; + } + + .momo-observability-mode .biz-signal .value { + font-size: 1.12rem !important; + } + + .momo-observability-mode .biz-meta-row { + gap: 0.42rem !important; + margin-top: 0.7rem !important; + } + + .momo-observability-mode :is(.biz-meta-pill, .biz-badge, .badge, .obs-pill, [class$="-pill"]) { + font-size: 0.76rem !important; + min-height: 1.55rem; + padding: 0.22rem 0.42rem !important; + } + + .momo-observability-mode .biz-filter-card .d-flex { + align-items: stretch; + } + + .momo-observability-mode .biz-filter-card .btn { + min-width: 4rem; + padding-left: 0.65rem !important; + padding-right: 0.65rem !important; + } +} + /* v3.3 observability hardening: shared visual system, bounded data surfaces, mobile-safe widths. */ .momo-observability-mode { --obs-title-size: 1.8rem; @@ -2152,3 +2262,119 @@ )::after { display: none !important; } + +/* v3.6 mobile density: KPI groups stay readable without consuming the first viewport. */ +@media (max-width: 560px) { + .momo-observability-mode :is( + .obs-hero, + .agent-hero, + .biz-command, + .runtime-hero, + .calls-hero, + .gov-hero, + .gate-hero, + .rag-hero, + .qa-hero, + .quality-hero, + .ppt-hero + ) { + padding: 0.75rem !important; + } + + .momo-observability-mode :is( + .obs-lede, + .agent-subtitle, + .biz-command p, + .runtime-subtitle, + .calls-subtitle, + .gov-subtitle, + .gate-subtitle, + .rag-subtitle, + .qa-subtitle, + .quality-subtitle, + .ppt-subtitle + ) { + font-size: 0.88rem !important; + line-height: 1.5 !important; + } + + .momo-observability-mode :is( + .obs-command-strip, + .agent-command, + .biz-signal-grid, + .runtime-command, + .calls-command, + .gov-command, + .gate-command, + .rag-command, + .qa-command, + .quality-command, + .ppt-command, + [class$="-mini-grid"] + ) { + gap: 0.5rem !important; + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + margin-top: 0.75rem !important; + } + + .momo-observability-mode :is( + .obs-signal, + .agent-signal, + .biz-signal, + .runtime-signal, + .calls-signal, + .gov-signal, + .gate-signal, + .rag-signal, + .qa-signal, + .quality-signal, + .ppt-signal, + .gov-mini, + .gate-mini, + .runtime-mini, + .calls-mini, + .quality-mini, + .ppt-mini + ) { + min-height: 0 !important; + padding: 0.62rem !important; + } + + .momo-observability-mode :is( + .obs-signal-value, + .obs-value, + .agent-value, + .biz-value, + .runtime-value, + .calls-value, + .gov-value, + .gate-value, + .rag-value, + .qa-value, + .quality-value, + .ppt-value, + .kpi-value, + .gov-mini strong, + .gate-mini strong, + .runtime-mini strong, + .calls-mini strong, + .quality-mini strong, + .ppt-mini strong + ) { + font-size: 1.18rem !important; + line-height: 1.15 !important; + } + + .momo-observability-mode :is( + .agent-filter, + .calls-filter, + .gov-filter, + .quality-filter, + .qa-filter, + .biz-filter-card + ) { + gap: 0.4rem !important; + margin-top: 0.65rem !important; + padding: 0.55rem !important; + } +} diff --git a/templates/admin/business_intel.html b/templates/admin/business_intel.html index ca499a4..df13982 100644 --- a/templates/admin/business_intel.html +++ b/templates/admin/business_intel.html @@ -440,7 +440,7 @@
Business Intelligence -

商業 AI 戰果室

+

商業 AI 戰果室

這一頁不再只是資料列表,而是把價格建議、未跟進警示、閉環學習與競品監測收成一個商業決策控制台。 先看 AI 是否真的推動結果,再往下追每一筆策略與市場訊號。 diff --git a/web/static/css/observability-system.css b/web/static/css/observability-system.css index 57d49d5..daa97d4 100644 --- a/web/static/css/observability-system.css +++ b/web/static/css/observability-system.css @@ -1761,6 +1761,116 @@ } } +/* v3.7 typography tightening: no legacy tracking, poster-scale numerals, or wrapped action buttons. */ +.momo-observability-mode :is( + .obs-kicker, + .agent-kicker, + .biz-kicker, + .runtime-kicker, + .calls-kicker, + .gov-kicker, + .gate-kicker, + .rag-kicker, + .qa-kicker, + .quality-kicker, + .ppt-kicker, + [class$="-label"], + .obs-signal-label, + .obs-section-eyebrow, + .obs-route-code +) { + letter-spacing: 0 !important; + text-transform: none !important; +} + +.momo-observability-mode .biz-signal .value { + color: var(--obs-ink) !important; + font-family: var(--momo-font-mono, "JetBrains Mono", ui-monospace, monospace) !important; + font-size: var(--obs-value-size) !important; + font-weight: 800 !important; + letter-spacing: 0 !important; + line-height: 1.1 !important; +} + +.momo-observability-mode :is(.biz-signal .label, .biz-signal .note, .biz-filter-card label) { + color: var(--obs-muted) !important; + font-family: var(--momo-font-family, "Inter", "Noto Sans TC", system-ui, sans-serif) !important; + letter-spacing: 0 !important; + text-transform: none !important; +} + +.momo-observability-mode .biz-filter-card .btn { + min-width: 4.5rem; + white-space: nowrap; +} + +@media (max-width: 560px) { + .momo-observability-mode { + --obs-title-size: 1.38rem; + --obs-value-size: 1.12rem; + } + + .momo-observability-mode :is( + .obs-title, + .agent-title, + .biz-title, + .runtime-title, + .calls-title, + .gov-title, + .gate-title, + .rag-title, + .qa-title, + .quality-title, + .ppt-title + ) { + margin-top: 0.55rem !important; + margin-bottom: 0.25rem !important; + } + + .momo-observability-mode :is( + .obs-kicker, + .agent-kicker, + .biz-kicker, + .runtime-kicker, + .calls-kicker, + .gov-kicker, + .gate-kicker, + .rag-kicker, + .qa-kicker, + .quality-kicker, + .ppt-kicker + ) { + font-size: 0.74rem !important; + line-height: 1.25 !important; + padding: 0.24rem 0.42rem !important; + } + + .momo-observability-mode .biz-signal .value { + font-size: 1.12rem !important; + } + + .momo-observability-mode .biz-meta-row { + gap: 0.42rem !important; + margin-top: 0.7rem !important; + } + + .momo-observability-mode :is(.biz-meta-pill, .biz-badge, .badge, .obs-pill, [class$="-pill"]) { + font-size: 0.76rem !important; + min-height: 1.55rem; + padding: 0.22rem 0.42rem !important; + } + + .momo-observability-mode .biz-filter-card .d-flex { + align-items: stretch; + } + + .momo-observability-mode .biz-filter-card .btn { + min-width: 4rem; + padding-left: 0.65rem !important; + padding-right: 0.65rem !important; + } +} + /* v3.3 observability hardening: shared visual system, bounded data surfaces, mobile-safe widths. */ .momo-observability-mode { --obs-title-size: 1.8rem; @@ -2152,3 +2262,119 @@ )::after { display: none !important; } + +/* v3.6 mobile density: KPI groups stay readable without consuming the first viewport. */ +@media (max-width: 560px) { + .momo-observability-mode :is( + .obs-hero, + .agent-hero, + .biz-command, + .runtime-hero, + .calls-hero, + .gov-hero, + .gate-hero, + .rag-hero, + .qa-hero, + .quality-hero, + .ppt-hero + ) { + padding: 0.75rem !important; + } + + .momo-observability-mode :is( + .obs-lede, + .agent-subtitle, + .biz-command p, + .runtime-subtitle, + .calls-subtitle, + .gov-subtitle, + .gate-subtitle, + .rag-subtitle, + .qa-subtitle, + .quality-subtitle, + .ppt-subtitle + ) { + font-size: 0.88rem !important; + line-height: 1.5 !important; + } + + .momo-observability-mode :is( + .obs-command-strip, + .agent-command, + .biz-signal-grid, + .runtime-command, + .calls-command, + .gov-command, + .gate-command, + .rag-command, + .qa-command, + .quality-command, + .ppt-command, + [class$="-mini-grid"] + ) { + gap: 0.5rem !important; + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + margin-top: 0.75rem !important; + } + + .momo-observability-mode :is( + .obs-signal, + .agent-signal, + .biz-signal, + .runtime-signal, + .calls-signal, + .gov-signal, + .gate-signal, + .rag-signal, + .qa-signal, + .quality-signal, + .ppt-signal, + .gov-mini, + .gate-mini, + .runtime-mini, + .calls-mini, + .quality-mini, + .ppt-mini + ) { + min-height: 0 !important; + padding: 0.62rem !important; + } + + .momo-observability-mode :is( + .obs-signal-value, + .obs-value, + .agent-value, + .biz-value, + .runtime-value, + .calls-value, + .gov-value, + .gate-value, + .rag-value, + .qa-value, + .quality-value, + .ppt-value, + .kpi-value, + .gov-mini strong, + .gate-mini strong, + .runtime-mini strong, + .calls-mini strong, + .quality-mini strong, + .ppt-mini strong + ) { + font-size: 1.18rem !important; + line-height: 1.15 !important; + } + + .momo-observability-mode :is( + .agent-filter, + .calls-filter, + .gov-filter, + .quality-filter, + .qa-filter, + .biz-filter-card + ) { + gap: 0.4rem !important; + margin-top: 0.65rem !important; + padding: 0.55rem !important; + } +}