This commit is contained in:
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
405
scripts/check_observability_visual_contract.js
Executable file
405
scripts/check_observability_visual_contract.js
Executable file
@@ -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);
|
||||
});
|
||||
20
scripts/check_observability_visual_contract.sh
Executable file
20
scripts/check_observability_visual_contract.sh
Executable file
@@ -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" "$@"
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,7 +440,7 @@
|
||||
<div class="biz-hero">
|
||||
<section class="biz-command">
|
||||
<span class="biz-kicker"><i class="fas fa-store"></i> Business Intelligence</span>
|
||||
<h1>商業 AI 戰果室</h1>
|
||||
<h1 class="biz-title">商業 AI 戰果室</h1>
|
||||
<p>
|
||||
這一頁不再只是資料列表,而是把價格建議、未跟進警示、閉環學習與競品監測收成一個商業決策控制台。
|
||||
先看 AI 是否真的推動結果,再往下追每一筆策略與市場訊號。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user