openvibers/openvibe.report Commits

openvibe.report/design: every site's design health, read from its code (#44)

OP OpenVibers committed 13 days ago · 96f94c90ab · 12 files, +6138 −2

/design ranks every site most-to-fix first by a score from five rules (spinners, animation without reduced
motion, hand-set timings and curves, colours outside the theme), with a counted fold past fifteen and two-line
rows on a phone; /design/<site> shows the counts and the findings that cost most, each linked to its line at
the measured commit; /api/v1/design-health has the same. The rules move here from the orchestrator's
tools/design-lint (server/design-health/scan.js and its tests) so the measure is as open as the results;
scripts/design-health.js writes server/design-health/snapshot.json. hue-rotate no longer counts as a spinner.

Changes

ddiff --git a/public/css/app.css b/public/css/app.css
iindex 01a421a..3a31253 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -272,3 +272,50 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
.rp-you-next { border-top: 1px solid var(--border, #1f2d47); padding-top: 18px; }
.rp-you-next h2 { font-size: 1rem; }
@media (max-width: 560px) { .rp-person { align-items: flex-start; } }
++
++/* Design health (/design, /design/:site; server/views/design.js): a score on a bar, the table's columns, the counted
++ fold, the rules and the findings. Colours come from the theme (--success, --warning, --danger and their -text). */
++.dh-tr { grid-template-columns: minmax(0, 1.5fr) minmax(0, 1.4fr) repeat(5, minmax(0, .8fr)); }
++.dh-score { display: inline-flex; align-items: center; gap: 10px; width: 100%; min-width: 0; --dh-c: var(--success, var(--room-accent)); }
++.dh-score b { min-width: 2.4em; font-variant-numeric: tabular-nums; }
++.dh-score i { flex: 1 1 auto; min-width: 40px; height: 6px; border-radius: 999px; background: linear-gradient(90deg, var(--dh-c) var(--dh), color-mix(in srgb, var(--text-muted, currentColor) 22%, transparent) var(--dh)); }
++.dh-score.is-ok { --dh-c: var(--warning, var(--room-accent)); }
++.dh-score.is-low { --dh-c: var(--danger, var(--room-accent)); }
++.dh-n { font-variant-numeric: tabular-nums; }
++.dh-n.is-zero { color: var(--text-muted); }
++.dh-n.is-bad { color: var(--danger-text, var(--danger)); font-weight: 800; }
++.dh-fold { margin-top: 12px; }
++.dh-fold > summary { display: inline-flex; align-items: baseline; gap: 8px; padding: 8px 16px; border-radius: 999px; border: 1px solid var(--border); background: var(--bg-card); font-weight: 700; cursor: pointer; list-style: none; }
++.dh-fold > summary::-webkit-details-marker { display: none; }
++.dh-fold > summary span { color: var(--text-muted); font-size: .85rem; font-weight: 600; }
++.dh-fold > summary:hover { border-color: var(--room-accent); }
++.dh-fold[open] > summary { margin-bottom: 12px; }
++.dh-rules { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr)); gap: 12px; margin: 0 0 14px; }
++.dh-rules > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card); border: 1px solid var(--border); }
++.dh-rules dt { display: flex; justify-content: space-between; gap: 8px; font-weight: 800; }
++.dh-rules dt span { color: var(--text-muted); font-size: .78rem; font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; }
++.dh-rules dd { margin: 6px 0 0; color: var(--text-secondary); font-size: .92rem; line-height: 1.5; }
++.dh-lede { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; }
++.dh-lede .dh-score { width: min(280px, 100%); font-size: 1.4rem; }
++.dh-finds { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
++.dh-find { display: grid; grid-template-columns: 10.5em minmax(0, 1fr); align-items: baseline; gap: 4px 10px; padding: 10px 14px; border-radius: 12px; background: var(--bg-card); border: 1px solid var(--border); }
++.dh-rule { justify-self: start; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border); font-size: .72rem; font-weight: 800; letter-spacing: .03em; white-space: nowrap; }
++.dh-rule--spinner, .dh-rule--missing-reduced-motion { color: var(--danger-text, var(--danger)); border-color: color-mix(in srgb, var(--danger, currentColor) 45%, transparent); }
++.dh-where { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .85rem; overflow-wrap: anywhere; }
++.dh-text { grid-column: 2; display: block; overflow-x: auto; padding-top: 2px; color: var(--text-secondary); font-size: .8rem; white-space: pre; }
++.dh-none { display: none; }
++@media (max-width: 699px) {
++ /* A site in two lines: its name and score bar, then only what it has to fix, in words. */
++ .dh-tr { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 12px; }
++ .dh-tr::after { content: ''; order: 2; flex-basis: 100%; }
++ .dh-tr.rp-th { display: none; }
++ .dh-tr > .rp-site { order: 0; flex: 1 1 0; min-width: 0; }
++ .dh-tr > [data-l="Score"] { order: 1; flex: 0 0 auto; width: 132px; }
++ .dh-tr > [data-l]::before { content: none; }
++ .dh-tr > .dh-n, .dh-tr > .dh-none { order: 3; font-size: .82rem; color: var(--text-secondary); }
++ .dh-tr > .dh-n::after { content: ' ' attr(data-s); color: var(--text-muted); }
++ .dh-tr > .dh-n.is-zero { display: none; }
++ .dh-tr > .dh-none { display: inline; color: var(--text-muted); }
++ .dh-find { grid-template-columns: minmax(0, 1fr); }
++ .dh-text { grid-column: 1; }
++}
ddiff --git a/scripts/design-health.js b/scripts/design-health.js
nnew file mode 100644
iindex 0000000..5ebd7f0
---- /dev/null
++++ b/scripts/design-health.js
@@@ -0,0 +1,105 @@
++#!/usr/bin/env node
++'use strict';
++// Measures every OpenVibe site's design health and writes server/design-health/snapshot.json, the snapshot openvibe.report/design
++// shows (plan L45 step 6). The rules are server/design-health/scan.js: where a site hand-writes what the shared kit
++// owns (motion durations and curves, theme colours), animates without asking for reduced motion, or spins.
++//
++// It reads checkouts, so it runs where the repos are (the orchestrator's workstation), never on the server:
++//
++// node scripts/design-health.js every ~/openvibe/work/OpenVibe.* checkout, at its origin/main
++// node scripts/design-health.js --work <dir> another folder of checkouts
++// node scripts/design-health.js --repo Live --no-fetch just Live again; every other site keeps its last measure
++//
++// Each repo is read through a detached worktree of origin/main in a temp folder, removed again afterwards: no
++// checkout is touched and no branch moves. The snapshot keeps every count and the first findings of each site, so it
++// stays small; commit it with a PR to refresh the page.
++
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { execFileSync } = require('child_process');
++const { scanRepo, WEIGHTS, RULES } = require('../server/design-health/scan');
++
++const argv = process.argv.slice(2);
++const opt = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : null; };
++const WORK = opt('--work') || process.env.OV_WORK_DIR || path.join(os.homedir(), 'openvibe', 'work');
++const ONLY = argv.reduce((a, v, i) => (argv[i - 1] === '--repo' ? a.concat(v.toLowerCase()) : a), []);
++const FETCH = !argv.includes('--no-fetch');
++const OUT = path.join(__dirname, '..', 'server', 'design-health', 'snapshot.json');
++const KEEP = 25; // findings kept per site; the counts are always the whole picture
++
++const git = (dir, args, soft) => {
++ try { return execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); } catch (err) {
++ if (soft) return null;
++ throw new Error(`git ${args.join(' ')} in ${dir}: ${String(err.stderr || err.message).trim()}`);
++ }
++};
++// The repo's name on GitHub, so a clone and a linked worktree of it are one site.
++const nameOf = (dir) => { const url = git(dir, ['remote', 'get-url', 'origin'], true) || ''; const m = /([^/]+?)(\.git)?$/.exec(url); return m ? m[1] : path.basename(dir); };
++
++function repos() {
++ const seen = new Map();
++ for (const d of fs.readdirSync(WORK).sort()) {
++ if (!/^OpenVibe\./.test(d) && d !== 'OpenRestream') continue;
++ const dir = path.join(WORK, d);
++ if (!fs.existsSync(path.join(dir, '.git'))) continue;
++ const name = nameOf(dir);
++ if (!seen.has(name)) seen.set(name, dir);
++ }
++ return [...seen].filter(([name]) => !ONLY.length || ONLY.some((o) => name.toLowerCase().includes(o)));
++}
++
++function keep(findings) {
++ const weight = (f) => WEIGHTS[f.rule] || 0;
++ const ordered = findings.map((f, i) => ({ f, i })).sort((a, b) => weight(b.f) - weight(a.f) || a.i - b.i);
++ const perFile = new Map(), picked = [];
++ for (let round = 0; picked.length < KEEP && picked.length < ordered.length; round++) {
++ for (const x of ordered) {
++ if (picked.length >= KEEP) break;
++ if (x.taken || (perFile.get(x.f.file) || 0) !== round) continue;
++ x.taken = true; perFile.set(x.f.file, round + 1); picked.push(x);
++ }
++ }
++ return picked.sort((a, b) => weight(b.f) - weight(a.f) || a.i - b.i).map((x) => x.f);
++}
++
++function measure(name, dir, tmp) {
++ const wt = path.join(tmp, name.toLowerCase().replace(/[^a-z0-9]+/g, '-'));
++ let added = false;
++ try {
++ if (FETCH) git(dir, ['fetch', '--quiet', 'origin', '+refs/heads/main:refs/remotes/origin/main'], true);
++ const commit = git(dir, ['rev-parse', 'origin/main'], true);
++ if (!commit) return null;
++ git(dir, ['worktree', 'add', '--detach', wt, 'origin/main']);
++ added = true;
++ const r = scanRepo(wt);
++ return {
++ repo: name, commit, files_scanned: r.files_scanned, counts: r.counts, score: r.score, findings_total: r.findings.length,
++ // The findings kept matter most (by the rule's weight), spread over as many files as possible.
++ findings: keep(r.findings).map((f) => ({ rule: f.rule, file: f.file, line: f.line, value: f.value == null ? null : String(f.value).slice(0, 60), text: String(f.text || '').trim().slice(0, 160) })),
++ };
++ } finally {
++ if (added) { git(dir, ['worktree', 'remove', '--force', wt], true); git(dir, ['worktree', 'prune'], true); }
++ fs.rmSync(wt, { recursive: true, force: true });
++ }
++}
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'design-health-'));
++const sites = [];
++try {
++ for (const [name, dir] of repos()) {
++ const s = measure(name, dir, tmp);
++ if (s && s.files_scanned) sites.push(s); // a repo with no stylesheet where the rules read is not a site to score
++ process.stdout.write(`${name.padEnd(24)} ${s ? `${String(s.score).padStart(6)} ${s.findings_total} findings` : 'skipped (no origin/main)'}\n`);
++ }
++} finally { fs.rmSync(tmp, { recursive: true, force: true }); }
++// With --repo, only those sites are measured again: the others keep what the snapshot already has.
++if (ONLY.length) {
++ let before = [];
++ try { before = JSON.parse(fs.readFileSync(OUT, 'utf8')).sites || []; } catch { before = []; }
++ const fresh = new Set(sites.map((s) => s.repo));
++ sites.push(...before.filter((s) => !fresh.has(s.repo)));
++}
++sites.sort((a, b) => a.repo.localeCompare(b.repo));
++fs.writeFileSync(OUT, `${JSON.stringify({ generated_at: new Date().toISOString(), rules: RULES, weights: WEIGHTS, kept: KEEP, sites }, null, 1)}\n`);
++process.stdout.write(`\nwrote ${path.relative(process.cwd(), OUT)}: ${sites.length} sites\n`);
ddiff --git a/server/data/design-health.js b/server/data/design-health.js
nnew file mode 100644
iindex 0000000..005e9d5
---- /dev/null
++++ b/server/data/design-health.js
@@@ -0,0 +1,36 @@
++'use strict';
++/**
++ * Every site's design health (plan L45 step 6): the snapshot scripts/design-health.js writes to server/design-health/snapshot.json
++ * from each site's code at its main branch. Read once and kept; a refresh is a new snapshot in a PR.
++ *
++ * all() → { generated_at, rules, weights, kept, sites: [...] } (sites with a stylesheet to read only)
++ * site(slug) → one site by its short name ('live', 'tools', 'openrestream'), or null
++ * slugOf(repo) → 'OpenVibe.Live' → 'live'
++ */
++const fs = require('fs');
++const path = require('path');
++
++// DESIGN_HEALTH_FILE: another snapshot (the tests' fixture).
++const FILE = process.env.DESIGN_HEALTH_FILE || path.join(__dirname, '..', 'design-health', 'snapshot.json');
++const slugOf = (repo) => String(repo || '').replace(/^OpenVibe\./, '').toLowerCase();
++let cached = null;
++
++function all() {
++ if (cached) return cached;
++ let snap = null;
++ try { snap = JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch { snap = null; }
++ const sites = (snap && Array.isArray(snap.sites) ? snap.sites : [])
++ // A repo with no stylesheet where the rules read (a library, a service with no pages) is not a site to score.
++ .filter((s) => s && s.counts && Number(s.files_scanned) > 0)
++ .map((s) => ({ ...s, slug: slugOf(s.repo) }));
++ cached = { generated_at: snap ? snap.generated_at : null, rules: snap ? snap.rules : [], weights: snap ? snap.weights : {}, kept: snap ? snap.kept : 0, sites };
++ return cached;
++}
++
++function site(slug) {
++ const s = String(slug || '').toLowerCase();
++ if (!/^[a-z0-9-]{2,40}$/.test(s)) return null;
++ return all().sites.find((x) => x.slug === s) || null;
++}
++
++module.exports = { all, site, slugOf, _reset: () => { cached = null; } };
ddiff --git a/server/design-health/scan.js b/server/design-health/scan.js
nnew file mode 100644
iindex 0000000..7bbc667
---- /dev/null
++++ b/server/design-health/scan.js
@@@ -0,0 +1,492 @@
++'use strict';
++// The design-health rules (plan L45 step 6), served by openvibe.report/design. They began as the orchestrator's
++// tools/design-lint and live here now so the measure is as open as its results; scripts/design-health.js runs them.
++// Reads every CSS file and every inline <style> in HTML and JS under public/, server/views/ and
++// apps/*/public/, and reports where a site hand-writes what the shared kit already owns:
++//
++// literal_duration a transition/animation duration written as a literal (.25s, 300ms)
++// where the kit says var(--ov-dur-1..4)
++// literal_curve cubic-bezier(...) or an ease keyword where the kit says var(--ov-ease-*)
++// spinner @keyframes that rotate 360deg, and an animation that runs one forever
++// color_drift a hard-coded colour in a file that already uses the theme variables
++// missing_reduced_motion a file that animates and never asks prefers-reduced-motion
++//
++// node_modules, vendor and .min. files are skipped. The scanner never writes; it only reads.
++//
++// node scan.js <repo dir> JSON of the counts and findings on stdout
++// node scan.js <repo dir> --summary one line per rule
++//
++// The finding kinds are the same keys the report counts by, so a caller can group either way.
++
++const fs = require('fs');
++const path = require('path');
++
++// ── what the kit owns ─────────────────────────────────────────────────────────────────────────────────────────
++// the theme colour variables, from openvibe-shared/theme-loader.js THEME_KEYS
++const THEME_KEYS = ['--bg-primary', '--bg-secondary', '--bg-tertiary', '--bg-card', '--bg-hover', '--bg-input',
++ '--text-primary', '--text-secondary', '--text-muted', '--accent', '--accent-light', '--accent-dark',
++ '--border', '--border-light', '--live-red', '--success', '--warning', '--danger', '--info',
++ '--shadow', '--shadow-lg', '--success-text', '--warning-text', '--danger-text', '--info-text',
++ '--live-red-text', '--accent-strong', '--on-accent-strong', '--on-accent', '--accent-rgb',
++ '--accent-glow', '--color-scheme'];
++const THEME_SET = new Set(THEME_KEYS);
++
++// how much each rule costs a site its score: a spinner weighs most (nothing spins), then a file that
++// never asks prefers-reduced-motion, then a hand-written duration or curve, then a hand-written colour
++const WEIGHTS = {
++ spinner: 4,
++ missing_reduced_motion: 2,
++ literal_duration: 0.25,
++ literal_curve: 0.25,
++ color_drift: 0.05
++};
++
++const RULES = ['literal_duration', 'literal_curve', 'spinner', 'color_drift', 'missing_reduced_motion'];
++
++// the directories a site keeps its styles in
++const STYLE_ROOTS = ['public', 'server/views'];
++const APP_ROOT = 'apps';
++
++// the properties that carry a duration, and the ones that carry a curve
++const DURATION_PROPS = new Set(['transition', 'transition-duration', 'animation', 'animation-duration']);
++const CURVE_PROPS = new Set(['transition', 'transition-timing-function', 'animation', 'animation-timing-function']);
++
++const SKIP_DIRS = new Set(['node_modules', 'vendor', '.git']);
++const CSS_EXT = new Set(['.css']);
++const MARKUP_EXT = new Set(['.html', '.htm']);
++const SCRIPT_EXT = new Set(['.js', '.mjs', '.cjs']);
++
++// the keywords an animation shorthand can hold, so the name of the keyframes can be told from the rest
++const ANIM_KEYWORDS = new Set(['infinite', 'linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out', 'normal',
++ 'reverse', 'alternate', 'alternate-reverse', 'forwards', 'backwards', 'both', 'none', 'running', 'paused',
++ 'step-start', 'step-end']);
++
++// the colours a site may name, when it should be asking the theme
++const NAMED_COLOURS = new Set(['white', 'black', 'red', 'green', 'blue', 'yellow', 'orange', 'purple', 'pink',
++ 'gray', 'grey', 'silver', 'gold', 'navy', 'teal', 'maroon', 'olive', 'lime', 'aqua', 'fuchsia', 'cyan',
++ 'magenta', 'crimson', 'indigo', 'violet', 'salmon', 'tan', 'khaki', 'beige', 'ivory', 'lavender',
++ 'turquoise', 'plum', 'orchid', 'coral', 'tomato', 'chocolate', 'sienna', 'darkred', 'darkblue', 'darkgreen']);
++
++const TIME_RE = /(?<![\w.-])(\d*\.?\d+)(ms|s)\b/gi;
++const TIME_ONLY_RE = /^(?:\d*\.?\d+)(?:ms|s)$/i;
++const CURVE_FN_RE = /cubic-bezier\s*\(/gi;
++const EASE_RE = /(?<![\w-])(ease-in-out|ease-in|ease-out|ease)(?![\w-])/gi;
++const HEX_RE = /#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g;
++const FUNC_COLOUR_RE = /\b(?:rgba?|hsla?)\([^)]*\)/gi;
++// hue-rotate(360deg) cycles a colour, it does not turn anything: not a spinner (Live's eggRainbow, 2026-11-01).
++const SPIN_RE = /(?<!hue-)rotate\(\s*(?:360(?:\.0+)?deg|1turn|2turn|-360(?:\.0+)?deg)\s*\)/i;
++
++// ── helpers ────────────────────────────────────────────────────────────────────────────────────────────────
++// the fallback inside var(--name, fallback) is a stand-in, not a choice: look past it
++const VAR_WITH_FALLBACK_RE = /var\(\s*(--[\w-]+)\s*,(?:[^()]*|\([^()]*\))*\)/gi;
++function stripVarFallbacks(value) {
++ return value.replace(VAR_WITH_FALLBACK_RE, 'var($1)');
++}
++
++// the theme variables a value asks for
++function themeVarsIn(value) {
++ const found = new Set();
++ const re = /var\(\s*(--[\w-]+)/g;
++ let m;
++ while ((m = re.exec(value))) found.add(m[1]);
++ return found;
++}
++
++function isThemeVar(name) {
++ return THEME_SET.has(name);
++}
++
++// the hard-coded colours in a value
++function coloursIn(value) {
++ const out = [];
++ const text = stripVarFallbacks(value).replace(/url\([^)]*\)/gi, 'url()');
++ let m;
++ HEX_RE.lastIndex = 0;
++ while ((m = HEX_RE.exec(text))) out.push(m[0]);
++ FUNC_COLOUR_RE.lastIndex = 0;
++ while ((m = FUNC_COLOUR_RE.exec(text))) out.push(m[0]);
++ const re = /(?<![\w-#])([a-zA-Z]{3,20})(?![\w-])/g;
++ while ((m = re.exec(text))) {
++ const name = m[1].toLowerCase();
++ if (NAMED_COLOURS.has(name)) out.push(name);
++ }
++ return out;
++}
++
++// the duration literals in a value
++function durationsIn(value) {
++ const out = [];
++ const text = stripVarFallbacks(value);
++ TIME_RE.lastIndex = 0;
++ let m;
++ while ((m = TIME_RE.exec(text))) out.push(m[0]);
++ return out;
++}
++
++// the curves written out in a value
++function curvesIn(value) {
++ const out = [];
++ const text = stripVarFallbacks(value);
++ CURVE_FN_RE.lastIndex = 0;
++ let m;
++ while ((m = CURVE_FN_RE.exec(text))) out.push(m[0]);
++ EASE_RE.lastIndex = 0;
++ while ((m = EASE_RE.exec(text))) out.push(m[1]);
++ return out;
++}
++
++// the keyframes an animation shorthand names
++function animationName(value) {
++ const text = value.replace(/var\([^)]*\)/gi, ' ');
++ const tokens = text.split(/[\s,]+/).filter(Boolean);
++ for (const tok of tokens) {
++ const t = tok.toLowerCase();
++ if (ANIM_KEYWORDS.has(t)) continue;
++ if (TIME_ONLY_RE.test(t)) continue;
++ if (/^cubic-bezier\(/i.test(t) || /^steps\(/i.test(t)) continue;
++ return tok;
++ }
++ return null;
++}
++
++// ── the CSS reader ─────────────────────────────────────────────────────────────────────────────────────────
++// a small reader: enough to know which declaration sits on which line, which rule it belongs to
++// and which keyframes it is inside
++function parseCss(src, baseLine) {
++ const decls = [];
++ const keyframes = [];
++ let i = 0;
++ let line = baseLine;
++ let uid = 0;
++ const len = src.length;
++
++ function skipComment() {
++ const end = src.indexOf('*/', i + 2);
++ const stop = end < 0 ? len : end + 2;
++ for (let k = i; k < stop; k++) if (src[k] === '\n') line++;
++ i = stop;
++ }
++
++ function consumeString() {
++ const q = src[i];
++ let k = i + 1;
++ let out = q;
++ while (k < len) {
++ const ch = src[k];
++ if (ch === '\\') { out += src[k] + (src[k + 1] || ''); k += 2; continue; }
++ out += ch;
++ k++;
++ if (ch === q) break;
++ }
++ for (let x = i; x < k; x++) if (src[x] === '\n') line++;
++ i = k;
++ return out;
++ }
++
++ function flush(buf, bufLine, chain) {
++ const text = buf.trim();
++ if (!text) return;
++ const colon = text.indexOf(':');
++ if (colon < 0) return;
++ const prop = text.slice(0, colon).trim();
++ const value = text.slice(colon + 1).trim();
++ if (!prop || !value) return;
++ if (/[{}]/.test(prop)) return;
++ const owner = chain[chain.length - 1];
++ let kf = null;
++ for (const b of chain) if (b.kf) kf = b.kf;
++ const decl = { prop, value, line: bufLine, rule: owner, keyframes: kf };
++ decls.push(decl);
++ if (kf) kf.decls.push(decl);
++ }
++
++ function parseBlock(chain) {
++ let buf = '';
++ let bufLine = line;
++ for (;;) {
++ if (i >= len) break;
++ const c = src[i];
++ if (c === '/' && src[i + 1] === '*') { skipComment(); buf = ''; bufLine = line; continue; }
++ if (c === '"' || c === "'") { if (!buf.trim()) bufLine = line; buf += consumeString(); continue; }
++ if (c === '{') {
++ const prelude = buf.trim();
++ buf = '';
++ bufLine = line;
++ const child = { id: ++uid, type: 'rule', selector: prelude, at: null, keyframes: null, parent: chain[chain.length - 1] };
++ const am = /^@([\w-]+)/.exec(prelude);
++ if (am) {
++ child.type = 'at';
++ child.at = am[1].toLowerCase();
++ const km = /^@(?:-webkit-)?keyframes\s+("[^"]+"|'[^']+'|[^\s{]+)/i.exec(prelude);
++ if (child.at === 'keyframes' || child.at === '-webkit-keyframes') {
++ child.keyframes = km ? km[1].replace(/^["']|["']$/g, '') : null;
++ keyframes.push({ name: child.keyframes, line: bufLine, decls: [] });
++ child.kf = keyframes[keyframes.length - 1];
++ }
++ }
++ i++;
++ chain.push(child);
++ parseBlock(chain);
++ chain.pop();
++ buf = '';
++ bufLine = line;
++ continue;
++ }
++ if (c === '}') { i++; flush(buf, bufLine, chain); return; }
++ if (c === ';') { i++; flush(buf, bufLine, chain); buf = ''; bufLine = line; continue; }
++ if (c === '\n') { line++; buf += c; i++; continue; }
++ // the declaration starts at its first real character, not at the whitespace before it
++ if (!buf.trim()) bufLine = line;
++ buf += c;
++ i++;
++ }
++ flush(buf, bufLine, chain);
++ }
++
++ const root = { id: 0, type: 'root', selector: '', at: null, keyframes: null, parent: null };
++ parseBlock([root]);
++ return { decls, keyframes };
++}
++
++// ── the units a file is made of ─────────────────────────────────────────────────────────────────────────────
++// a CSS file is one unit; an HTML or JS file is one unit per inline <style>
++function unitsIn(file, text) {
++ const units = [];
++ const ext = path.extname(file).toLowerCase();
++ if (CSS_EXT.has(ext)) {
++ units.push({ kind: 'css', line: 1, text });
++ return units;
++ }
++ if (!MARKUP_EXT.has(ext) && !SCRIPT_EXT.has(ext)) return units;
++ const re = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
++ let m;
++ while ((m = re.exec(text))) {
++ const before = text.slice(0, m.index);
++ const line = 1 + (before.match(/\n/g) || []).length;
++ units.push({ kind: 'inline-style', line, text: m[1] });
++ }
++ return units;
++}
++
++// ── the walk ─────────────────────────────────────────────────────────────────────────────────────────────────
++function styleRoots(repoDir) {
++ const roots = [];
++ for (const rel of STYLE_ROOTS) {
++ const p = path.join(repoDir, rel);
++ if (fs.existsSync(p) && fs.statSync(p).isDirectory()) roots.push(p);
++ }
++ const apps = path.join(repoDir, APP_ROOT);
++ if (fs.existsSync(apps) && fs.statSync(apps).isDirectory()) {
++ for (const name of fs.readdirSync(apps)) {
++ const p = path.join(apps, name, 'public');
++ if (fs.existsSync(p) && fs.statSync(p).isDirectory()) roots.push(p);
++ }
++ }
++ return roots;
++}
++
++function walk(dir, out) {
++ let entries;
++ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return out; }
++ for (const e of entries) {
++ const p = path.join(dir, e.name);
++ if (e.isDirectory()) {
++ if (SKIP_DIRS.has(e.name)) continue;
++ walk(p, out);
++ } else if (e.isFile()) {
++ if (e.name.includes('.min.')) continue;
++ out.push(p);
++ }
++ }
++ return out;
++}
++
++// ── the lint ───────────────────────────────────────────────────────────────────────────────────────────────
++// scan one repo directory and return its counts, its findings and its score
++function scanRepo(repoDir) {
++ const root = path.resolve(repoDir);
++ const findings = [];
++ const files = [];
++ const roots = styleRoots(root);
++
++ for (const r of roots) {
++ for (const file of walk(r, [])) {
++ const ext = path.extname(file).toLowerCase();
++ if (!CSS_EXT.has(ext) && !MARKUP_EXT.has(ext) && !SCRIPT_EXT.has(ext)) continue;
++ let text;
++ try { text = fs.readFileSync(file, 'utf8'); } catch (e) { continue; }
++ files.push(file);
++ const rel = path.relative(root, file);
++ const units = unitsIn(file, text);
++ if (!units.length) continue;
++ findings.push(...lintFile(root, rel, text, units, file));
++ }
++ }
++
++ // the keyframes of a repo, so an animation can be traced back to the one it runs
++ const counts = { literal_duration: 0, literal_curve: 0, spinner: 0, color_drift: 0, missing_reduced_motion: 0, total: 0 };
++ const by_kind = {};
++ for (const f of findings) {
++ counts[f.rule] = (counts[f.rule] || 0) + 1;
++ by_kind[f.kind] = (by_kind[f.kind] || 0) + 1;
++ }
++ counts.total = findings.length;
++
++ findings.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
++ return { repo: path.basename(root), counts, by_kind: by_kind, score: scoreOf(counts), findings, files_scanned: files.length };
++}
++
++// the lint of one file
++function lintFile(root, rel, text, units, file) {
++ const out = [];
++ const lines = text.split('\n');
++ const lineText = (n) => (lines[n - 1] || '').trim().slice(0, 200);
++
++ const parsed = units.map((u) => Object.assign({}, u, parseCss(u.text, u.line)));
++ const allDecls = [];
++ const allKeyframes = [];
++ for (const p of parsed) { allDecls.push(...p.decls); allKeyframes.push(...p.keyframes); }
++
++ // what this file asks the theme for
++ const fileThemeVars = new Set();
++ for (const d of allDecls) for (const name of themeVarsIn(d.value)) fileThemeVars.add(name);
++ const fileUsesTheme = [...fileThemeVars].some(isThemeVar);
++ const fileHasReducedMotion = /prefers-reduced-motion/i.test(text);
++
++ // which rule uses a theme variable: a rule that does is themed, a hard-coded colour in it is drift
++ const ruleThemeVars = new Map();
++ for (const d of allDecls) {
++ if (!d.rule) continue;
++ if (!ruleThemeVars.has(d.rule.id)) ruleThemeVars.set(d.rule.id, new Set());
++ const set = ruleThemeVars.get(d.rule.id);
++ for (const name of themeVarsIn(d.value)) set.add(name);
++ }
++
++ // the keyframes that turn, anywhere in this file
++ const spinNames = new Set();
++ for (const kf of allKeyframes) {
++ const turns = kf.decls.some((d) => SPIN_RE.test(d.value));
++ if (turns && kf.name) spinNames.add(kf.name);
++ }
++
++ // the custom properties this file keeps for itself
++ const ownPalette = new Set();
++
++ // the animation declarations, grouped by the rule they sit in
++ const rules = new Map();
++ for (const d of allDecls) {
++ const id = d.rule ? d.rule.id : 0;
++ if (!rules.has(id)) rules.set(id, { rule: d.rule, decls: [] });
++ rules.get(id).decls.push(d);
++ }
++
++ for (const d of allDecls) {
++ const prop = d.prop.toLowerCase();
++ const value = d.value;
++ const line = d.line;
++ const at = { file: rel, line, text: lineText(line) };
++
++ // durations written out (the kit's own token definitions are the kit, not drift)
++ if (DURATION_PROPS.has(prop) && !/^--ov-/.test(prop)) {
++ for (const dur of durationsIn(value)) {
++ out.push(Object.assign({ rule: 'literal_duration', kind: 'duration', value: dur, detail: prop + ' uses ' + dur + ' where the kit says var(--ov-dur-1..4)' }, at));
++ }
++ }
++
++ // curves written out (the kit's own token definitions are the kit, not drift)
++ if (CURVE_PROPS.has(prop) && !/^--ov-/.test(prop)) {
++ for (const curve of curvesIn(value)) {
++ out.push(Object.assign({ rule: 'literal_curve', kind: 'curve', value: curve, detail: prop + ' uses ' + curve + ' where the kit says var(--ov-ease-out / -spring / -in-out)' }, at));
++ }
++ }
++
++ // hard-coded colours, in a file that already uses the theme
++ if (fileUsesTheme) {
++ const themed = d.rule && ruleThemeVars.get(d.rule.id) && [...ruleThemeVars.get(d.rule.id)].some(isThemeVar);
++ const colours = coloursIn(value);
++ if (colours.length) {
++ // a custom property is a palette: one finding for the property, not one per value
++ if (prop.startsWith('--')) {
++ if (!ownPalette.has(prop)) {
++ ownPalette.add(prop);
++ out.push(Object.assign({
++ rule: 'color_drift', kind: 'own_palette',
++ value: prop + ' ' + colours[0],
++ detail: 'the file uses theme variables and still keeps its own ' + prop
++ }, at));
++ }
++ } else {
++ for (const colour of colours) {
++ out.push(Object.assign({
++ rule: 'color_drift', kind: themed ? 'colour_in_themed_rule' : 'colour_in_themed_file',
++ value: colour,
++ detail: (themed ? 'the rule uses theme variables and still writes ' : 'the file uses theme variables and still writes ') + colour
++ }, at));
++ }
++ }
++ }
++ }
++ }
++
++ // spinners: the keyframes that turn, and the animations that run them forever
++ for (const kf of allKeyframes) {
++ if (!kf.name || !spinNames.has(kf.name)) continue;
++ out.push({ rule: 'spinner', kind: 'spinner_keyframes', file: rel, line: kf.line, text: lineText(kf.line), value: kf.name, detail: '@keyframes ' + kf.name + ' turns 360deg' });
++ }
++ for (const group of rules.values()) {
++ const hasInfinite = group.decls.some((d) => /^(-webkit-)?animation(-iteration-count)?$/.test(d.prop.toLowerCase()) && /\binfinite\b/i.test(d.value));
++ for (const d of group.decls) {
++ const prop = d.prop.toLowerCase();
++ if (prop !== 'animation' && prop !== '-webkit-animation' && prop !== 'animation-name') continue;
++ const name = prop === 'animation-name' ? d.value.trim() : animationName(d.value);
++ if (!name || !spinNames.has(name)) continue;
++ const infinite = /\binfinite\b/i.test(d.value) || (prop === 'animation-name' && hasInfinite);
++ if (!infinite) continue;
++ out.push({ rule: 'spinner', kind: 'spinner_usage', file: rel, line: d.line, text: lineText(d.line), value: name, detail: 'animation runs ' + name + ' forever' });
++ }
++ }
++
++ // a file that animates and never asks prefers-reduced-motion
++ if (!fileHasReducedMotion) {
++ const first = allDecls.find((d) => /^(-webkit-)?animation(-name)?$/.test(d.prop.toLowerCase()) && !/^none$/i.test(d.value.trim()));
++ if (first) {
++ out.push({ rule: 'missing_reduced_motion', kind: 'missing_reduced_motion', file: rel, line: first.line, text: lineText(first.line), value: first.prop, detail: 'the file animates and never asks prefers-reduced-motion' });
++ }
++ }
++
++ return out;
++}
++
++// ── the score ───────────────────────────────────────────────────────────────────────────────────────────────
++// 100 minus what the findings cost, never below 0
++function scoreOf(counts) {
++ let score = 100;
++ for (const rule of RULES) score -= (counts[rule] || 0) * (WEIGHTS[rule] || 0);
++ return Math.max(0, Math.min(100, Math.round(score * 100) / 100));
++}
++
++// ── the command line ───────────────────────────────────────────────────────────────────────────────────────
++function main(argv) {
++ const args = argv.filter((a) => !a.startsWith('--'));
++ const summary = argv.includes('--summary');
++ if (!args.length) {
++ process.stderr.write('usage: node scan.js <repo dir> [--summary]\n');
++ process.exit(2);
++ }
++ const dir = args[0];
++ if (!fs.existsSync(dir)) {
++ process.stderr.write('scan: no such directory: ' + dir + '\n');
++ process.exit(2);
++ }
++ const result = scanRepo(dir);
++ if (summary) {
++ const parts = RULES.map((r) => r + '=' + result.counts[r]);
++ process.stdout.write(result.repo + ' score=' + result.score + ' files=' + result.files_scanned + ' ' + parts.join(' ') + '\n');
++ } else {
++ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
++ }
++}
++
++module.exports = { scanRepo, scoreOf, lintFile, parseCss, THEME_KEYS, WEIGHTS, RULES };
++
++if (require.main === module) main(process.argv.slice(2));
ddiff --git a/server/design-health/snapshot.json b/server/design-health/snapshot.json
nnew file mode 100644
iindex 0000000..d785536
---- /dev/null
++++ b/server/design-health/snapshot.json
@@@ -0,0 +1,4984 @@
++{
++ "generated_at": "2026-11-01T11:14:41.112Z",
++ "rules": [
++ "literal_duration",
++ "literal_curve",
++ "spinner",
++ "color_drift",
++ "missing_reduced_motion"
++ ],
++ "weights": {
++ "spinner": 4,
++ "missing_reduced_motion": 2,
++ "literal_duration": 0.25,
++ "literal_curve": 0.25,
++ "color_drift": 0.05
++ },
++ "kept": 25,
++ "sites": [
++ {
++ "repo": "OpenVibe.Actor",
++ "commit": "ac296db14b66fafbff94a0e3a82e33fbda52f013",
++ "files_scanned": 12,
++ "counts": {
++ "literal_duration": 27,
++ "literal_curve": 12,
++ "spinner": 0,
++ "color_drift": 18,
++ "missing_reduced_motion": 0,
++ "total": 57
++ },
++ "score": 89.35,
++ "findings_total": 57,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/actor.css",
++ "line": 68,
++ "value": ".15s",
++ "text": ".mode span { display: flex; flex-direction: column; gap: 2px; height: 100%; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); b"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/actor.css",
++ "line": 68,
++ "value": ".15s",
++ "text": ".mode span { display: flex; flex-direction: column; gap: 2px; height: 100%; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); b"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/actor.css",
++ "line": 111,
++ "value": ".25s",
++ "text": ".tl-item { display: grid; grid-template-columns: 52px 1fr; gap: 10px; font-size: .9rem; position: relative; animation: step-in .25s ease-out; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/actor.css",
++ "line": 111,
++ "value": "ease-out",
++ "text": ".tl-item { display: grid; grid-template-columns: 52px 1fr; gap: 10px; font-size: .9rem; position: relative; animation: step-in .25s ease-out; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/actor.css",
++ "line": 135,
++ "value": "1.2s",
++ "text": ".dot-pulse { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: var(--accent, #3b82f6); animation: pulse 1.2s ease-in-out infinit"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/actor.css",
++ "line": 135,
++ "value": "ease-in-out",
++ "text": ".dot-pulse { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: var(--accent, #3b82f6); animation: pulse 1.2s ease-in-out infinit"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/chat.css",
++ "line": 17,
++ "value": ".26s",
++ "text": "overflow-y: auto; overscroll-behavior: contain; transform: translateX(-104%); transition: transform .26s cubic-bezier(.2, .8, .2, 1); box-shadow: 0 0 40px rgba("
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/chat.css",
++ "line": 17,
++ "value": "cubic-bezier(",
++ "text": "overflow-y: auto; overscroll-behavior: contain; transform: translateX(-104%); transition: transform .26s cubic-bezier(.2, .8, .2, 1); box-shadow: 0 0 40px rgba("
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/chat.css",
++ "line": 37,
++ "value": "1.6s",
++ "text": ".cs-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--accent, #f97316); animation: ct-breathe 1.6s ease-in-out infinite; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/chat.css",
++ "line": 37,
++ "value": "ease-in-out",
++ "text": ".cs-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--accent, #f97316); animation: ct-breathe 1.6s ease-in-out infinite; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/chat.css",
++ "line": 60,
++ "value": ".32s",
++ "text": ".ct-turn.is-new .ct-bubble { animation: ct-rise .32s cubic-bezier(.2, .8, .2, 1) both; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/chat.css",
++ "line": 60,
++ "value": "cubic-bezier(",
++ "text": ".ct-turn.is-new .ct-bubble { animation: ct-rise .32s cubic-bezier(.2, .8, .2, 1) both; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/chat.css",
++ "line": 64,
++ "value": "1.6s",
++ "text": ".ct-turn[data-open] .ct-avatar { animation: ct-breathe 1.6s ease-in-out infinite; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/chat.css",
++ "line": 64,
++ "value": "ease-in-out",
++ "text": ".ct-turn[data-open] .ct-avatar { animation: ct-breathe 1.6s ease-in-out infinite; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/chat.css",
++ "line": 73,
++ "value": ".2s",
++ "text": ".cw-chev { flex: none; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; transition: "
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/chat.css",
++ "line": 76,
++ "value": "2.2s",
++ "text": "-webkit-background-clip: text; background-clip: text; color: transparent; animation: ct-shimmer 2.2s linear infinite; font-weight: 550; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/task.css",
++ "line": 8,
++ "value": ".25s",
++ "text": ".ps { position: relative; display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 12px; padding: 5px 0; color: var(--text-secondary, #96a7c2); line-heig"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/task.css",
++ "line": 8,
++ "value": "ease-out",
++ "text": ".ps { position: relative; display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 12px; padding: 5px 0; color: var(--text-secondary, #96a7c2); line-heig"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/task.css",
++ "line": 16,
++ "value": "1.4s",
++ "text": ".ps.is-active .ps-mark { border-color: var(--accent, #3b82f6); animation: ps-pulse 1.4s ease-out infinite; } /* the step on now: a ring that pulses out, never"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/task.css",
++ "line": 16,
++ "value": "ease-out",
++ "text": ".ps.is-active .ps-mark { border-color: var(--accent, #3b82f6); animation: ps-pulse 1.4s ease-out infinite; } /* the step on now: a ring that pulses out, never"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/task.css",
++ "line": 28,
++ "value": ".15s",
++ "text": "color: var(--accent-light, #60a5fa); background: color-mix(in srgb, var(--accent, #3b82f6) 15%, transparent); border: 1px solid color-mix(in srgb, var(--accent,"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/task.css",
++ "line": 51,
++ "value": ".3s",
++ "text": ".ev-item { padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); scroll-margin-top: 80px; t"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/task.css",
++ "line": 51,
++ "value": ".3s",
++ "text": ".ev-item { padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); scroll-margin-top: 80px; t"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/task.css",
++ "line": 91,
++ "value": ".15s",
++ "text": ".follow-form { display: flex; align-items: flex-end; gap: 8px; padding: 6px 6px 6px 14px; border-radius: 16px; border: 1px solid var(--border-light, #2c3d5c); b"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/task.css",
++ "line": 91,
++ "value": ".15s",
++ "text": ".follow-form { display: flex; align-items: flex-end; gap: 8px; padding: 6px 6px 6px 14px; border-radius: 16px; border: 1px solid var(--border-light, #2c3d5c); b"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Bench",
++ "commit": "d18fd58d82f16de3284255acba49b8ea0999b01c",
++ "files_scanned": 8,
++ "counts": {
++ "literal_duration": 2,
++ "literal_curve": 2,
++ "spinner": 0,
++ "color_drift": 5,
++ "missing_reduced_motion": 0,
++ "total": 9
++ },
++ "score": 98.75,
++ "findings_total": 9,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/bench.css",
++ "line": 189,
++ "value": ".18s",
++ "text": ".br-card-link { height: 100%; display: grid; align-content: start; gap: 8px; padding: 16px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1p"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/bench.css",
++ "line": 189,
++ "value": ".18s",
++ "text": ".br-card-link { height: 100%; display: grid; align-content: start; gap: 8px; padding: 16px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1p"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/bench.css",
++ "line": 189,
++ "value": "ease",
++ "text": ".br-card-link { height: 100%; display: grid; align-content: start; gap: 8px; padding: 16px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1p"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/bench.css",
++ "line": 189,
++ "value": "ease",
++ "text": ".br-card-link { height: 100%; display: grid; align-content: start; gap: 8px; padding: 16px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1p"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/bench.css",
++ "line": 617,
++ "value": "#f59e0b",
++ "text": ".br-contam { padding: 12px 14px; border-radius: 14px; background: color-mix(in srgb, #f59e0b 10%, var(--bg-card)); border: 1px solid color-mix(in srgb, #f59e0b "
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/bench.css",
++ "line": 617,
++ "value": "#f59e0b",
++ "text": ".br-contam { padding: 12px 14px; border-radius: 14px; background: color-mix(in srgb, #f59e0b 10%, var(--bg-card)); border: 1px solid color-mix(in srgb, #f59e0b "
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/bench.css",
++ "line": 619,
++ "value": "#fde68a",
++ "text": ".br-contam-chip { display: inline-block; margin-left: 8px; padding: 1px 7px; border-radius: 999px; font-size: .68rem; font-weight: 800; letter-spacing: .02em; c"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/bench.css",
++ "line": 619,
++ "value": "#f59e0b",
++ "text": ".br-contam-chip { display: inline-block; margin-left: 8px; padding: 1px 7px; border-radius: 999px; font-size: .68rem; font-weight: 800; letter-spacing: .02em; c"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/bench.css",
++ "line": 619,
++ "value": "#f59e0b",
++ "text": ".br-contam-chip { display: inline-block; margin-left: 8px; padding: 1px 7px; border-radius: 999px; font-size: .68rem; font-weight: 800; letter-spacing: .02em; c"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Blog",
++ "commit": "f754247ce16df1a62cb517e4e420d3f2243fbaeb",
++ "files_scanned": 1,
++ "counts": {
++ "literal_duration": 1,
++ "literal_curve": 0,
++ "spinner": 0,
++ "color_drift": 2,
++ "missing_reduced_motion": 0,
++ "total": 3
++ },
++ "score": 99.65,
++ "findings_total": 3,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/blog.css",
++ "line": 221,
++ "value": ".15s",
++ "text": ".toc-fold > summary::after, .series-more > summary::after { content: ''; margin-left: auto; width: 8px; height: 8px; border-right: 2px solid currentColor; borde"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/blog.css",
++ "line": 60,
++ "value": "rgba(34,197,94,.25)",
++ "text": ".ov-diff ins { background: rgba(34,197,94,.25); text-decoration: none; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/blog.css",
++ "line": 61,
++ "value": "rgba(239,68,68,.25)",
++ "text": ".ov-diff del { background: rgba(239,68,68,.25); }"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Bot",
++ "commit": "ffe8f47f7901d934173a7620a21fbea9b7ab5ae4",
++ "files_scanned": 13,
++ "counts": {
++ "literal_duration": 12,
++ "literal_curve": 5,
++ "spinner": 0,
++ "color_drift": 100,
++ "missing_reduced_motion": 0,
++ "total": 117
++ },
++ "score": 90.75,
++ "findings_total": 117,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/directory.css",
++ "line": 31,
++ "value": ".15s",
++ "text": ".dir-card { background: var(--bg-secondary, #101828); border: 1px solid var(--border, #1f2d47); border-radius: 14px; display: flex; flex-direction: column; tran"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/directory.css",
++ "line": 115,
++ "value": "1.4s",
++ "text": ".sd-status[data-state=\"waiting\"] .sd-dot, .sd-status[data-state=\"installing\"] .sd-dot { background: #f59e0b; animation: sd-pulse 1.4s ease-in-out infinite; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/directory.css",
++ "line": 115,
++ "value": "ease-in-out",
++ "text": ".sd-status[data-state=\"waiting\"] .sd-dot, .sd-status[data-state=\"installing\"] .sd-dot { background: #f59e0b; animation: sd-pulse 1.4s ease-in-out infinite; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/home.css",
++ "line": 27,
++ "value": ".15s",
++ "text": ".hm-stats a { display: grid; gap: 2px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, rgba(255, 255, 255, .08)); background: var(--bg-"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/home.css",
++ "line": 34,
++ "value": ".15s",
++ "text": ".hm-card { display: flex; flex-direction: column; min-width: 0; border-radius: 16px; border: 1px solid var(--border, rgba(255, 255, 255, .08)); background: var("
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/panel.css",
++ "line": 29,
++ "value": "0.12s",
++ "text": "touch-action: manipulation; -webkit-tap-highlight-color: transparent; transition: background 0.12s, border-color 0.12s, transform 0.06s;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/panel.css",
++ "line": 29,
++ "value": "0.12s",
++ "text": "touch-action: manipulation; -webkit-tap-highlight-color: transparent; transition: background 0.12s, border-color 0.12s, transform 0.06s;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/panel.css",
++ "line": 29,
++ "value": "0.06s",
++ "text": "touch-action: manipulation; -webkit-tap-highlight-color: transparent; transition: background 0.12s, border-color 0.12s, transform 0.06s;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/panel.css",
++ "line": 71,
++ "value": "1.2s",
++ "text": ".estop-banner[data-latched=\"true\"] .estop-dot { background: #fff; box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.25); animation: pulse 1.2s ease-in-out infinite; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/panel.css",
++ "line": 71,
++ "value": "ease-in-out",
++ "text": ".estop-banner[data-latched=\"true\"] .estop-dot { background: #fff; box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.25); animation: pulse 1.2s ease-in-out infinite; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/panel.css",
++ "line": 83,
++ "value": "1s",
++ "text": ".online[data-state=\"connecting\"] .dot { animation: pulse 1s ease-in-out infinite; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/panel.css",
++ "line": 83,
++ "value": "ease-in-out",
++ "text": ".online[data-state=\"connecting\"] .dot { animation: pulse 1s ease-in-out infinite; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/panel.css",
++ "line": 123,
++ "value": "0.12s",
++ "text": "box-shadow: 0 6px 14px rgba(0, 0, 0, 0.55); transition: transform 0.12s ease-out;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/directory.css",
++ "line": 43,
++ "value": "#a78bfa",
++ "text": ".dir-browser { color: #a78bfa; } .dir-phone { color: #f472b6; } .dir-onboard { color: #22c55e; } .dir-bridge { color: #f59e0b; } .dir-server { color: #38bdf8; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/directory.css",
++ "line": 43,
++ "value": "#f472b6",
++ "text": ".dir-browser { color: #a78bfa; } .dir-phone { color: #f472b6; } .dir-onboard { color: #22c55e; } .dir-bridge { color: #f59e0b; } .dir-server { color: #38bdf8; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/directory.css",
++ "line": 43,
++ "value": "#22c55e",
++ "text": ".dir-browser { color: #a78bfa; } .dir-phone { color: #f472b6; } .dir-onboard { color: #22c55e; } .dir-bridge { color: #f59e0b; } .dir-server { color: #38bdf8; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/directory.css",
++ "line": 43,
++ "value": "#f59e0b",
++ "text": ".dir-browser { color: #a78bfa; } .dir-phone { color: #f472b6; } .dir-onboard { color: #22c55e; } .dir-bridge { color: #f59e0b; } .dir-server { color: #38bdf8; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/docs.css",
++ "line": 37,
++ "value": "rgba(239, 68, 68, .45)",
++ "text": ".docs-main .error { border: 1px solid rgba(239, 68, 68, .45); background: rgba(239, 68, 68, .08); border-radius: 10px; padding: 12px 14px; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/docs.css",
++ "line": 37,
++ "value": "rgba(239, 68, 68, .08)",
++ "text": ".docs-main .error { border: 1px solid rgba(239, 68, 68, .45); background: rgba(239, 68, 68, .08); border-radius: 10px; padding: 12px 14px; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/docs.css",
++ "line": 38,
++ "value": "#22c55e",
++ "text": ".docs-main [role=\"status\"] strong { color: #22c55e; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/home.css",
++ "line": 13,
++ "value": "#0b1220",
++ "text": ".hm-term { border-radius: 16px; background: #0b1220; color: #e6edf7; border: 1px solid rgba(148, 163, 184, .25); box-shadow: var(--shadow-lg, 0 18px 50px rgba(0"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/home.css",
++ "line": 13,
++ "value": "#e6edf7",
++ "text": ".hm-term { border-radius: 16px; background: #0b1220; color: #e6edf7; border: 1px solid rgba(148, 163, 184, .25); box-shadow: var(--shadow-lg, 0 18px 50px rgba(0"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/home.css",
++ "line": 13,
++ "value": "rgba(148, 163, 184, .25)",
++ "text": ".hm-term { border-radius: 16px; background: #0b1220; color: #e6edf7; border: 1px solid rgba(148, 163, 184, .25); box-shadow: var(--shadow-lg, 0 18px 50px rgba(0"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/home.css",
++ "line": 14,
++ "value": "rgba(148, 163, 184, .1)",
++ "text": ".hm-term-top { display: flex; align-items: center; gap: 6px; margin: 0; padding: 10px 14px; background: rgba(148, 163, 184, .1); font-size: 12.5px; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/home.css",
++ "line": 15,
++ "value": "#f87171",
++ "text": ".hm-term-top span { width: 10px; height: 10px; border-radius: 50%; background: #f87171; }"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Cam",
++ "commit": "0ad257ee0decb99121d649162f58e21cf6891c2d",
++ "files_scanned": 3,
++ "counts": {
++ "literal_duration": 0,
++ "literal_curve": 0,
++ "spinner": 0,
++ "color_drift": 27,
++ "missing_reduced_motion": 0,
++ "total": 27
++ },
++ "score": 98.65,
++ "findings_total": 27,
++ "findings": [
++ {
++ "rule": "color_drift",
++ "file": "public/css/cam.css",
++ "line": 65,
++ "value": "#05070c",
++ "text": ".cm-stage { position: relative; overflow: hidden; aspect-ratio: 4 / 3; border-radius: 22px; background: #05070c; box-shadow: inset 0 0 0 1px rgba(255, 255, 255,"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/cam.css",
++ "line": 65,
++ "value": "rgba(255, 255, 255, .06)",
++ "text": ".cm-stage { position: relative; overflow: hidden; aspect-ratio: 4 / 3; border-radius: 22px; background: #05070c; box-shadow: inset 0 0 0 1px rgba(255, 255, 255,"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/cam.css",
++ "line": 65,
++ "value": "rgba(0, 0, 0, .8)",
++ "text": ".cm-stage { position: relative; overflow: hidden; aspect-ratio: 4 / 3; border-radius: 22px; background: #05070c; box-shadow: inset 0 0 0 1px rgba(255, 255, 255,"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/cam.css",
++ "line": 72,
++ "value": "rgba(255, 255, 255, .55)",
++ "text": "color: rgba(255, 255, 255, .55); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/cam.css",
++ "line": 74,
++ "value": "rgba(255, 255, 255, .35)",
++ "text": "background: linear-gradient(90deg, transparent calc(33.333% - .5px), rgba(255, 255, 255, .35) calc(33.333% - .5px) calc(33.333% + .5px), transparent calc(33.333"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/cam.css",
++ "line": 74,
++ "value": "rgba(255, 255, 255, .35)",
++ "text": "background: linear-gradient(90deg, transparent calc(33.333% - .5px), rgba(255, 255, 255, .35) calc(33.333% - .5px) calc(33.333% + .5px), transparent calc(33.333"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/cam.css",
++ "line": 74,
++ "value": "rgba(255, 255, 255, .35)",
++ "text": "background: linear-gradient(90deg, transparent calc(33.333% - .5px), rgba(255, 255, 255, .35) calc(33.333% - .5px) calc(33.333% + .5px), transparent calc(33.333"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/cam.css",
++ "line": 74,
++ "value": "rgba(255, 255, 255, .35)",
++ "text": "background: linear-gradient(90deg, transparent calc(33.333% - .5px), rgba(255, 255, 255, .35) calc(33.333% - .5px) calc(33.333% + .5px), transparent calc(33.333"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/cam.css",
++ "line": 129,
++ "value": "rgba(0, 0, 0, .7)",
++ "text": ".cm-live-frame { position: relative; overflow: hidden; border-radius: 14px; aspect-ratio: 4 / 3; box-shadow: 0 24px 50px -30px rgba(0, 0, 0, .7); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/cam.css",
++ "line": 131,
++ "value": "#fff",
++ "text": ".cm-live-badge { position: absolute; top: 10px; left: 10px; padding: 3px 8px; border-radius: 6px; background: var(--ovk-live, #ef4444); color: #fff;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/cam.css",
++ "line": 134,
++ "value": "rgba(10, 12, 18, .66)",
++ "text": "background: rgba(10, 12, 18, .66); color: #fff; box-shadow: 0 0 0 2px rgba(255, 255, 255, .7);"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/cam.css",
++ "line": 134,
++ "value": "#fff",
++ "text": "background: rgba(10, 12, 18, .66); color: #fff; box-shadow: 0 0 0 2px rgba(255, 255, 255, .7);"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/cam.css",
++ "line": 134,
++ "value": "rgba(255, 255, 255, .7)",
++ "text": "background: rgba(10, 12, 18, .66); color: #fff; box-shadow: 0 0 0 2px rgba(255, 255, 255, .7);"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/cam.css",
++ "line": 137,
++ "value": "#fff",
++ "text": ".cm-live-flash { position: absolute; inset: 0; background: #fff; opacity: 0;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/cam.css",
++ "line": 147,
++ "value": "rgba(0, 0, 0, .6)",
++ "text": "background: var(--ovk-panel); border: 1px solid var(--ovk-line); box-shadow: 0 24px 50px -30px rgba(0, 0, 0, .6); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/cam.css",
++ "line": 164,
++ "value": "rgba(0, 0, 0, .7)",
++ "text": ".cm-f-main { position: relative; overflow: hidden; border-radius: 14px; aspect-ratio: 4 / 3; box-shadow: 0 24px 50px -30px rgba(0, 0, 0, .7); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/cam.css",
++ "line": 165,
++ "value": "rgba(10, 12, 18, .7)",
++ "text": ".cm-f-saved { position: absolute; left: 10px; bottom: 10px; padding: 5px 10px; border-radius: 999px; background: rgba(10, 12, 18, .7); color: #fff;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/cam.css",
++ "line": 165,
++ "value": "#fff",
++ "text": ".cm-f-saved { position: absolute; left: 10px; bottom: 10px; padding: 5px 10px; border-radius: 999px; background: rgba(10, 12, 18, .7); color: #fff;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/cam.css",
++ "line": 180,
++ "value": "rgba(0, 0, 0, .75)",
++ "text": "box-shadow: 0 40px 80px -40px rgba(0, 0, 0, .75), 0 0 0 1px color-mix(in srgb, var(--room-accent) 30%, transparent); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/cam.css",
++ "line": 182,
++ "value": "rgba(255, 255, 255, .9)",
++ "text": ".cm-vf-c { position: absolute; width: 28px; height: 28px; border: 0 solid rgba(255, 255, 255, .9); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/cam.css",
++ "line": 187,
++ "value": "#ffd27a",
++ "text": ".cm-vf-focus { position: absolute; left: 60%; top: 42%; width: 64px; height: 64px; margin: -32px 0 0 -32px; border: 2px solid #ffd27a; border-radius: 10px;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/cam.css",
++ "line": 191,
++ "value": "rgba(10, 12, 18, .6)",
++ "text": "background: rgba(10, 12, 18, .6); color: #fff; font-size: 12px; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/cam.css",
++ "line": 191,
++ "value": "#fff",
++ "text": "background: rgba(10, 12, 18, .6); color: #fff; font-size: 12px; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/cam.css",
++ "line": 192,
++ "value": "#ffd27a",
++ "text": ".cm-vf-top b { color: #ffd27a; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/cam.css",
++ "line": 193,
++ "value": "#fff",
++ "text": ".cm-vf-shutter { position: absolute; left: 50%; bottom: 18px; width: 54px; height: 54px; margin-left: -27px; border-radius: 50%; background: #fff;"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Casino",
++ "commit": "fbca80eca82d056b389826b85233b7278c2c635d",
++ "files_scanned": 6,
++ "counts": {
++ "literal_duration": 0,
++ "literal_curve": 0,
++ "spinner": 0,
++ "color_drift": 0,
++ "missing_reduced_motion": 0,
++ "total": 0
++ },
++ "score": 100,
++ "findings_total": 0,
++ "findings": []
++ },
++ {
++ "repo": "OpenVibe.Chat",
++ "commit": "20eb7b56ec9b8261c365056fef38627b9c4f0453",
++ "files_scanned": 16,
++ "counts": {
++ "literal_duration": 29,
++ "literal_curve": 7,
++ "spinner": 0,
++ "color_drift": 65,
++ "missing_reduced_motion": 0,
++ "total": 101
++ },
++ "score": 87.75,
++ "findings_total": 101,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/web/chat.css",
++ "line": 123,
++ "value": ".25s",
++ "text": "@media (prefers-reduced-motion: no-preference) { .oc-ring:not(.oc-ring-gone) { animation: oc-ring-in .25s ease-out; } @keyframes oc-ring-in { from { transform: "
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/web/chat.css",
++ "line": 123,
++ "value": "ease-out",
++ "text": "@media (prefers-reduced-motion: no-preference) { .oc-ring:not(.oc-ring-gone) { animation: oc-ring-in .25s ease-out; } @keyframes oc-ring-in { from { transform: "
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/web/chat.css",
++ "line": 151,
++ "value": ".15s",
++ "text": ".oc-srv-icon { position: relative; display: grid; place-items: center; width: 44px; height: 44px; flex: 0 0 auto; border-radius: 50%; background: hsl(var(--oc-h"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/web/chat.css",
++ "line": 151,
++ "value": ".15s",
++ "text": ".oc-srv-icon { position: relative; display: grid; place-items: center; width: 44px; height: 44px; flex: 0 0 auto; border-radius: 50%; background: hsl(var(--oc-h"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/web/chat.css",
++ "line": 151,
++ "value": "ease",
++ "text": ".oc-srv-icon { position: relative; display: grid; place-items: center; width: 44px; height: 44px; flex: 0 0 auto; border-radius: 50%; background: hsl(var(--oc-h"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/web/chat.css",
++ "line": 151,
++ "value": "ease",
++ "text": ".oc-srv-icon { position: relative; display: grid; place-items: center; width: 44px; height: 44px; flex: 0 0 auto; border-radius: 50%; background: hsl(var(--oc-h"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/web/chat.css",
++ "line": 356,
++ "value": "1.6s",
++ "text": ".oc-task-running .oc-task-state { background: var(--oc-accent); color: var(--oc-on-accent); animation: oc-work 1.6s ease-in-out infinite; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/web/embed.css",
++ "line": 37,
++ "value": "1.2s",
++ "text": ".em-dots { display: inline-flex; gap: 4px; } .em-dots i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: .4; animation: emDot 1"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/web/feed-view.css",
++ "line": 9,
++ "value": "2s",
++ "text": ".fv-live .fv-dot { background: var(--fv-ok); box-shadow: 0 0 0 0 color-mix(in srgb, var(--fv-ok) 60%, transparent); animation: fvPulse 2s infinite; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/web/feed-view.css",
++ "line": 15,
++ "value": ".15s",
++ "text": ".fv-chev { color: var(--fv-muted); transition: transform .15s; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/web/vibe.css",
++ "line": 28,
++ "value": ".15s",
++ "text": "transition: background .15s, border-color .15s;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/web/vibe.css",
++ "line": 28,
++ "value": ".15s",
++ "text": "transition: background .15s, border-color .15s;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/web/vibe.css",
++ "line": 42,
++ "value": ".15s",
++ "text": ".vb-how summary::before { content: \"▸\"; display: inline-block; margin-right: 6px; transition: transform .15s; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/web/vibe.css",
++ "line": 140,
++ "value": "1.2s",
++ "text": ".vb-dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--oc-muted); opacity: .35; animation: vbDot 1.2s infinite ease-in-out; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/web/vibe.css",
++ "line": 140,
++ "value": "ease-in-out",
++ "text": ".vb-dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--oc-muted); opacity: .35; animation: vbDot 1.2s infinite ease-in-out; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/web/vibe.css",
++ "line": 178,
++ "value": ".15s",
++ "text": "transition: background .15s, border-color .15s, transform .15s;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/web/vibe.css",
++ "line": 178,
++ "value": ".15s",
++ "text": "transition: background .15s, border-color .15s, transform .15s;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/web/widget.js",
++ "line": 49,
++ "value": ".15s",
++ "text": "'background:var(--c);color:#fff;box-shadow:0 10px 30px rgba(0,0,0,.28);display:grid;place-items:center;transition:transform .15s}' +"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/web/widget.js",
++ "line": 55,
++ "value": ".16s",
++ "text": "'box-shadow:0 24px 70px rgba(0,0,0,.35);background:#0a0f1c;opacity:0;transform:translateY(10px);pointer-events:none;transition:opacity .16s,transform .2s}' +"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/web/widget.js",
++ "line": 55,
++ "value": ".2s",
++ "text": "'box-shadow:0 24px 70px rgba(0,0,0,.35);background:#0a0f1c;opacity:0;transform:translateY(10px);pointer-events:none;transition:opacity .16s,transform .2s}' +"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/web/embed.css",
++ "line": 3,
++ "value": "--bg #ffffff",
++ "text": ":root { --bg: #ffffff; --panel: #f4f6fa; --text: #101828; --muted: #5b6577; --line: #e3e7ef; --mine: #eef2fb; color-scheme: light; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/web/embed.css",
++ "line": 3,
++ "value": "--panel #f4f6fa",
++ "text": ":root { --bg: #ffffff; --panel: #f4f6fa; --text: #101828; --muted: #5b6577; --line: #e3e7ef; --mine: #eef2fb; color-scheme: light; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/web/embed.css",
++ "line": 3,
++ "value": "--text #101828",
++ "text": ":root { --bg: #ffffff; --panel: #f4f6fa; --text: #101828; --muted: #5b6577; --line: #e3e7ef; --mine: #eef2fb; color-scheme: light; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/web/embed.css",
++ "line": 3,
++ "value": "--muted #5b6577",
++ "text": ":root { --bg: #ffffff; --panel: #f4f6fa; --text: #101828; --muted: #5b6577; --line: #e3e7ef; --mine: #eef2fb; color-scheme: light; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/web/embed.css",
++ "line": 3,
++ "value": "--line #e3e7ef",
++ "text": ":root { --bg: #ffffff; --panel: #f4f6fa; --text: #101828; --muted: #5b6577; --line: #e3e7ef; --mine: #eef2fb; color-scheme: light; }"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Codes",
++ "commit": "302722579e101e3e1dfe468ae875cdb85e4bd781",
++ "files_scanned": 8,
++ "counts": {
++ "literal_duration": 13,
++ "literal_curve": 6,
++ "spinner": 0,
++ "color_drift": 7,
++ "missing_reduced_motion": 0,
++ "total": 26
++ },
++ "score": 94.9,
++ "findings_total": 26,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/c4.css",
++ "line": 51,
++ "value": ".24s",
++ "text": ".c4-progress-content{grid-template-rows:0fr;opacity:0;overflow:hidden;transition:grid-template-rows .24s ease,opacity .24s ease}.c4-progress-content.is-open,.c4"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/c4.css",
++ "line": 51,
++ "value": ".24s",
++ "text": ".c4-progress-content{grid-template-rows:0fr;opacity:0;overflow:hidden;transition:grid-template-rows .24s ease,opacity .24s ease}.c4-progress-content.is-open,.c4"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/c4.css",
++ "line": 51,
++ "value": "ease",
++ "text": ".c4-progress-content{grid-template-rows:0fr;opacity:0;overflow:hidden;transition:grid-template-rows .24s ease,opacity .24s ease}.c4-progress-content.is-open,.c4"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/c4.css",
++ "line": 51,
++ "value": "ease",
++ "text": ".c4-progress-content{grid-template-rows:0fr;opacity:0;overflow:hidden;transition:grid-template-rows .24s ease,opacity .24s ease}.c4-progress-content.is-open,.c4"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/chat.css",
++ "line": 24,
++ "value": "1.6s",
++ "text": ".ch-dot.is-busy{background:var(--ch-accent);animation:ch-breathe 1.6s ease-in-out infinite}"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/chat.css",
++ "line": 24,
++ "value": "ease-in-out",
++ "text": ".ch-dot.is-busy{background:var(--ch-accent);animation:ch-breathe 1.6s ease-in-out infinite}"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/chat.css",
++ "line": 69,
++ "value": ".2s",
++ "text": ".ch-caret{width:14px;height:14px;transition:rotate .2s}"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/chat.css",
++ "line": 77,
++ "value": ".35s",
++ "text": ".ch-step.is-new{animation:ch-in .35s ease-out}"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/chat.css",
++ "line": 77,
++ "value": "ease-out",
++ "text": ".ch-step.is-new{animation:ch-in .35s ease-out}"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/chat.css",
++ "line": 83,
++ "value": "1s",
++ "text": ".ch-dots i{width:6px;height:6px;border-radius:50%;background:var(--ch-accent);animation:ch-hop 1s ease-in-out infinite}"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/chat.css",
++ "line": 83,
++ "value": "ease-in-out",
++ "text": ".ch-dots i{width:6px;height:6px;border-radius:50%;background:var(--ch-accent);animation:ch-hop 1s ease-in-out infinite}"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/chat.css",
++ "line": 150,
++ "value": ".2s",
++ "text": ".ch-send{display:inline-grid;place-items:center;width:36px;height:36px;flex:none;border:0;border-radius:50%;background:var(--ch-accent);color:var(--ch-on-accent"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/chat.css",
++ "line": 150,
++ "value": ".15s",
++ "text": ".ch-send{display:inline-grid;place-items:center;width:36px;height:36px;flex:none;border:0;border-radius:50%;background:var(--ch-accent);color:var(--ch-on-accent"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/chat.css",
++ "line": 172,
++ "value": ".15s",
++ "text": ".ch-mode{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr);align-content:start;gap:2px 11px;padding:12px 14px;border:1px solid var(--ch-li"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/chat.css",
++ "line": 172,
++ "value": ".15s",
++ "text": ".ch-mode{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr);align-content:start;gap:2px 11px;padding:12px 14px;border:1px solid var(--ch-li"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/chat.css",
++ "line": 212,
++ "value": ".15s",
++ "text": ".ch-idea{display:inline-flex;align-items:center;gap:8px;padding:8px 14px 8px 11px;border-radius:14px;border:1px solid var(--ch-line);background:var(--ch-card);c"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/chat.css",
++ "line": 212,
++ "value": ".15s",
++ "text": ".ch-idea{display:inline-flex;align-items:center;gap:8px;padding:8px 14px 8px 11px;border-radius:14px;border:1px solid var(--ch-line);background:var(--ch-card);c"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/chat.css",
++ "line": 248,
++ "value": ".25s",
++ "text": ".ch-side{position:fixed;z-index:10060;top:0;right:0;bottom:0;width:min(340px,88vw);translate:100% 0;transition:translate .25s ease;box-shadow:-12px 0 32px rgba("
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/chat.css",
++ "line": 248,
++ "value": "ease",
++ "text": ".ch-side{position:fixed;z-index:10060;top:0;right:0;bottom:0;width:min(340px,88vw);translate:100% 0;transition:translate .25s ease;box-shadow:-12px 0 32px rgba("
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/chat.css",
++ "line": 132,
++ "value": "rgba(0,0,0,.14)",
++ "text": ".ch-composer{position:relative;margin:0 max(16px,calc((100% - var(--ch-col)) / 2)) 14px;padding:10px 10px 8px 16px;border:1px solid var(--ch-line);border-radius"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/chat.css",
++ "line": 207,
++ "value": "#000",
++ "text": ".ch-spark-view{flex:1;min-width:0;overflow:hidden;-webkit-mask-image:linear-gradient(90deg,transparent,#000 5%,#000 95%,transparent);mask-image:linear-gradient("
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/chat.css",
++ "line": 207,
++ "value": "#000",
++ "text": ".ch-spark-view{flex:1;min-width:0;overflow:hidden;-webkit-mask-image:linear-gradient(90deg,transparent,#000 5%,#000 95%,transparent);mask-image:linear-gradient("
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/chat.css",
++ "line": 207,
++ "value": "#000",
++ "text": ".ch-spark-view{flex:1;min-width:0;overflow:hidden;-webkit-mask-image:linear-gradient(90deg,transparent,#000 5%,#000 95%,transparent);mask-image:linear-gradient("
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/chat.css",
++ "line": 207,
++ "value": "#000",
++ "text": ".ch-spark-view{flex:1;min-width:0;overflow:hidden;-webkit-mask-image:linear-gradient(90deg,transparent,#000 5%,#000 95%,transparent);mask-image:linear-gradient("
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/chat.css",
++ "line": 248,
++ "value": "rgba(0,0,0,.25)",
++ "text": ".ch-side{position:fixed;z-index:10060;top:0;right:0;bottom:0;width:min(340px,88vw);translate:100% 0;transition:translate .25s ease;box-shadow:-12px 0 32px rgba("
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Community",
++ "commit": "0962b43798e79204632f3c74ee82cfe67056f04f",
++ "files_scanned": 3,
++ "counts": {
++ "literal_duration": 7,
++ "literal_curve": 0,
++ "spinner": 0,
++ "color_drift": 30,
++ "missing_reduced_motion": 0,
++ "total": 37
++ },
++ "score": 96.75,
++ "findings_total": 37,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/community.css",
++ "line": 31,
++ "value": ".15s",
++ "text": ".btn { display: inline-flex; align-items: center; gap: 7px; padding: 9px 15px; border-radius: 9px; border: 1px solid var(--border-light); background: var(--bg-c"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/community.css",
++ "line": 31,
++ "value": ".15s",
++ "text": ".btn { display: inline-flex; align-items: center; gap: 7px; padding: 9px 15px; border-radius: 9px; border: 1px solid var(--border-light); background: var(--bg-c"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/community.css",
++ "line": 31,
++ "value": ".1s",
++ "text": ".btn { display: inline-flex; align-items: center; gap: 7px; padding: 9px 15px; border-radius: 9px; border: 1px solid var(--border-light); background: var(--bg-c"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/community.css",
++ "line": 58,
++ "value": ".15s",
++ "text": ".card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; display: flex; flex-direction: column; tran"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/community.css",
++ "line": 58,
++ "value": ".15s",
++ "text": ".card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; display: flex; flex-direction: column; tran"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/community.css",
++ "line": 58,
++ "value": ".15s",
++ "text": ".card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; display: flex; flex-direction: column; tran"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/community.css",
++ "line": 113,
++ "value": ".2s",
++ "text": ".shot.nsfw img { filter: blur(20px); transition: filter .2s; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/community.css",
++ "line": 5,
++ "value": "--bg-primary #0a0f1c",
++ "text": "--bg-primary: #0a0f1c; --bg-secondary: #101828; --bg-tertiary: #172238; --bg-card: #131c2e; --bg-hover: #1c2a44; --bg-input: #0d1424;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/community.css",
++ "line": 5,
++ "value": "--bg-secondary #101828",
++ "text": "--bg-primary: #0a0f1c; --bg-secondary: #101828; --bg-tertiary: #172238; --bg-card: #131c2e; --bg-hover: #1c2a44; --bg-input: #0d1424;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/community.css",
++ "line": 5,
++ "value": "--bg-tertiary #172238",
++ "text": "--bg-primary: #0a0f1c; --bg-secondary: #101828; --bg-tertiary: #172238; --bg-card: #131c2e; --bg-hover: #1c2a44; --bg-input: #0d1424;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/community.css",
++ "line": 5,
++ "value": "--bg-card #131c2e",
++ "text": "--bg-primary: #0a0f1c; --bg-secondary: #101828; --bg-tertiary: #172238; --bg-card: #131c2e; --bg-hover: #1c2a44; --bg-input: #0d1424;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/community.css",
++ "line": 5,
++ "value": "--bg-hover #1c2a44",
++ "text": "--bg-primary: #0a0f1c; --bg-secondary: #101828; --bg-tertiary: #172238; --bg-card: #131c2e; --bg-hover: #1c2a44; --bg-input: #0d1424;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/community.css",
++ "line": 5,
++ "value": "--bg-input #0d1424",
++ "text": "--bg-primary: #0a0f1c; --bg-secondary: #101828; --bg-tertiary: #172238; --bg-card: #131c2e; --bg-hover: #1c2a44; --bg-input: #0d1424;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/community.css",
++ "line": 6,
++ "value": "--text-primary #e6edf7",
++ "text": "--text-primary: #e6edf7; --text-secondary: #96a7c2; --text-muted: #7386a3;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/community.css",
++ "line": 6,
++ "value": "--text-secondary #96a7c2",
++ "text": "--text-primary: #e6edf7; --text-secondary: #96a7c2; --text-muted: #7386a3;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/community.css",
++ "line": 6,
++ "value": "--text-muted #7386a3",
++ "text": "--text-primary: #e6edf7; --text-secondary: #96a7c2; --text-muted: #7386a3;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/community.css",
++ "line": 7,
++ "value": "--accent #3b82f6",
++ "text": "--accent: #3b82f6; --accent-light: #60a5fa; --accent-dark: #1d4ed8; --accent-rgb: 59,130,246; --on-accent: #ffffff;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/community.css",
++ "line": 7,
++ "value": "--accent-light #60a5fa",
++ "text": "--accent: #3b82f6; --accent-light: #60a5fa; --accent-dark: #1d4ed8; --accent-rgb: 59,130,246; --on-accent: #ffffff;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/community.css",
++ "line": 7,
++ "value": "--accent-dark #1d4ed8",
++ "text": "--accent: #3b82f6; --accent-light: #60a5fa; --accent-dark: #1d4ed8; --accent-rgb: 59,130,246; --on-accent: #ffffff;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/community.css",
++ "line": 7,
++ "value": "--on-accent #ffffff",
++ "text": "--accent: #3b82f6; --accent-light: #60a5fa; --accent-dark: #1d4ed8; --accent-rgb: 59,130,246; --on-accent: #ffffff;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/community.css",
++ "line": 8,
++ "value": "--border #1f2d47",
++ "text": "--border: #1f2d47; --border-light: #2c3d5c;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/community.css",
++ "line": 8,
++ "value": "--border-light #2c3d5c",
++ "text": "--border: #1f2d47; --border-light: #2c3d5c;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/community.css",
++ "line": 9,
++ "value": "--success #22c55e",
++ "text": "--success: #22c55e; --warning: #f59e0b; --danger: #ef4444; --info: #38bdf8;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/community.css",
++ "line": 9,
++ "value": "--warning #f59e0b",
++ "text": "--success: #22c55e; --warning: #f59e0b; --danger: #ef4444; --info: #38bdf8;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/community.css",
++ "line": 9,
++ "value": "--danger #ef4444",
++ "text": "--success: #22c55e; --warning: #f59e0b; --danger: #ef4444; --info: #38bdf8;"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Coupons",
++ "commit": "60c410db42097b4e8e6352e6ffac0b146d08afa7",
++ "files_scanned": 1,
++ "counts": {
++ "literal_duration": 1,
++ "literal_curve": 0,
++ "spinner": 0,
++ "color_drift": 0,
++ "missing_reduced_motion": 0,
++ "total": 1
++ },
++ "score": 99.75,
++ "findings_total": 1,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/coupons.css",
++ "line": 75,
++ "value": ".15s",
++ "text": ".cp-stats a { display: flex; flex-direction: column; gap: 2px; height: 100%; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); "
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Deals",
++ "commit": "73e9cac7b3a1f88e05184013267ddd3bec0eff37",
++ "files_scanned": 2,
++ "counts": {
++ "literal_duration": 4,
++ "literal_curve": 0,
++ "spinner": 0,
++ "color_drift": 2,
++ "missing_reduced_motion": 0,
++ "total": 6
++ },
++ "score": 98.9,
++ "findings_total": 6,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/deals.css",
++ "line": 51,
++ "value": ".15s",
++ "text": ".deal-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 18px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, "
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/deals.css",
++ "line": 51,
++ "value": ".15s",
++ "text": ".deal-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 18px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, "
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/deals.css",
++ "line": 139,
++ "value": ".15s",
++ "text": ".hm-stats a { display: flex; flex-direction: column; gap: 2px; height: 100%; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); "
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/deals.css",
++ "line": 151,
++ "value": ".15s",
++ "text": ".hm-card { display: flex; flex-direction: column; gap: 8px; min-width: 0; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); bac"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/deal-cards.css",
++ "line": 27,
++ "value": "#f5b301",
++ "text": ".dl-band--pick { color: #f5b301; background: color-mix(in srgb, #f5b301 16%, transparent); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/deal-cards.css",
++ "line": 27,
++ "value": "#f5b301",
++ "text": ".dl-band--pick { color: #f5b301; background: color-mix(in srgb, #f5b301 16%, transparent); }"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Design",
++ "commit": "b40d0aa2ab3265977319cc8295e01d83228cfb8e",
++ "files_scanned": 41,
++ "counts": {
++ "literal_duration": 143,
++ "literal_curve": 59,
++ "spinner": 0,
++ "color_drift": 235,
++ "missing_reduced_motion": 0,
++ "total": 437
++ },
++ "score": 37.75,
++ "findings_total": 437,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/brand.css",
++ "line": 34,
++ "value": ".18s",
++ "text": ".br-chip{min-height:40px;padding:0 15px;border-radius:999px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);font:inherit"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/brand.css",
++ "line": 34,
++ "value": ".18s",
++ "text": ".br-chip{min-height:40px;padding:0 15px;border-radius:999px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);font:inherit"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/brand.css",
++ "line": 34,
++ "value": ".18s",
++ "text": ".br-chip{min-height:40px;padding:0 15px;border-radius:999px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);font:inherit"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/design.css",
++ "line": 26,
++ "value": ".15s",
++ "text": ".ds-swatch { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px; width: 100%; min-height: 48px; padding: 8px 10px; border-radiu"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/design.css",
++ "line": 26,
++ "value": ".15s",
++ "text": ".ds-swatch { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px; width: 100%; min-height: 48px; padding: 8px 10px; border-radiu"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/design.css",
++ "line": 60,
++ "value": ".2s",
++ "text": ".ds-previews { display: grid; gap: 12px; grid-template-columns: minmax(0, 1fr); transition: filter .2s; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/icon-motion.css",
++ "line": 14,
++ "value": "cubic-bezier(",
++ "text": "animation: ovm-draw var(--ovm-draw-time, .9s) cubic-bezier(.65, 0, .35, 1) forwards;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/icon-motion.css",
++ "line": 21,
++ "value": ".32s",
++ "text": ".ovm-pop { transition: transform .32s cubic-bezier(.34, 1.56, .64, 1); transform-origin: 50% 50%; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/icon-motion.css",
++ "line": 21,
++ "value": "cubic-bezier(",
++ "text": ".ovm-pop { transition: transform .32s cubic-bezier(.34, 1.56, .64, 1); transform-origin: 50% 50%; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/icons.css",
++ "line": 31,
++ "value": ".15s",
++ "text": ".ic-tile{text-decoration:none;display:grid;justify-items:center;align-content:center;gap:10px;min-height:104px;padding:14px 6px 10px;border-radius:14px;border:1"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/icons.css",
++ "line": 31,
++ "value": ".15s",
++ "text": ".ic-tile{text-decoration:none;display:grid;justify-items:center;align-content:center;gap:10px;min-height:104px;padding:14px 6px 10px;border-radius:14px;border:1"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/icons.css",
++ "line": 31,
++ "value": ".15s",
++ "text": ".ic-tile{text-decoration:none;display:grid;justify-items:center;align-content:center;gap:10px;min-height:104px;padding:14px 6px 10px;border-radius:14px;border:1"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/kit-pages.css",
++ "line": 38,
++ "value": ".15s",
++ "text": "transition: border-color .15s, box-shadow .15s; scroll-margin-top: 72px; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/kit-pages.css",
++ "line": 38,
++ "value": ".15s",
++ "text": "transition: border-color .15s, box-shadow .15s; scroll-margin-top: 72px; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/marks.css",
++ "line": 25,
++ "value": ".6s",
++ "text": ".mx-lean-bar i{position:absolute;inset:0 auto 0 0;width:var(--l,50%);background:#4f7cff;border-right:2px solid var(--mx-card);transition:width .6s cubic-bezier("
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/marks.css",
++ "line": 25,
++ "value": "cubic-bezier(",
++ "text": ".mx-lean-bar i{position:absolute;inset:0 auto 0 0;width:var(--l,50%);background:#4f7cff;border-right:2px solid var(--mx-card);transition:width .6s cubic-bezier("
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/marks.css",
++ "line": 35,
++ "value": ".5s",
++ "text": ".mx-top.is-new{animation:mxTopIn .5s cubic-bezier(.2,.9,.25,1.2) both}"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/motion.css",
++ "line": 71,
++ "value": "1.1s",
++ "text": ".mo-race.is-run i{left:100%;transition:left 1.1s var(--mo-ease,ease)}"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/patterns.css",
++ "line": 6,
++ "value": "180ms",
++ "text": ".pgroup{padding:18px}.pcards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.pcard{position:relative;display:grid;gap:6px;min-height:94px;pa"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/patterns.css",
++ "line": 6,
++ "value": "180ms",
++ "text": ".pgroup{padding:18px}.pcards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.pcard{position:relative;display:grid;gap:6px;min-height:94px;pa"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/mark-studies.html",
++ "line": 49,
++ "value": "1.1s",
++ "text": ".busy{animation:ringBreathe 1.1s ease-in-out infinite alternate;transform-origin:50px 50px;transform-box:view-box}"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/mark-studies.html",
++ "line": 49,
++ "value": "ease-in-out",
++ "text": ".busy{animation:ringBreathe 1.1s ease-in-out infinite alternate;transform-origin:50px 50px;transform-box:view-box}"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/loading.css",
++ "line": 31,
++ "value": "#22c55e",
++ "text": ".ld-foot output.is-zero { color: #22c55e; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/loading.css",
++ "line": 34,
++ "value": "hsl(var(--h)",
++ "text": ".ld-thumb, .ld-tile, .ld-avatar, .ld-dot { display: block; background: linear-gradient(135deg, hsl(var(--h, 220) 70% 55%), hsl(calc(var(--h, 220) + 40) 70% 40%)"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/motion.css",
++ "line": 3,
++ "value": "--mo-o #4f7cff",
++ "text": ".mo{--mo-o:#4f7cff;--mo-v:#8b5cf6;--mo-face:'OV Brand',Nunito,Inter,-apple-system,BlinkMacSystemFont,sans-serif;max-width:1120px;margin:0 auto;padding:clamp(18p"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Domains",
++ "commit": "7b292c87b16648b74b9808f7f9be21c6968a8b61",
++ "files_scanned": 4,
++ "counts": {
++ "literal_duration": 6,
++ "literal_curve": 1,
++ "spinner": 0,
++ "color_drift": 0,
++ "missing_reduced_motion": 0,
++ "total": 7
++ },
++ "score": 98.25,
++ "findings_total": 7,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/domains.css",
++ "line": 37,
++ "value": ".18s",
++ "text": ".dm-tile { position: relative; display: grid; gap: 4px; min-height: 132px; padding: 16px; border-radius: 16px; background: linear-gradient(160deg, color-mix(in "
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/domains.css",
++ "line": 37,
++ "value": ".18s",
++ "text": ".dm-tile { position: relative; display: grid; gap: 4px; min-height: 132px; padding: 16px; border-radius: 16px; background: linear-gradient(160deg, color-mix(in "
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/domains.css",
++ "line": 124,
++ "value": ".9s",
++ "text": "html.ov-motion [data-ov-reveal] .dm-bar span { transform: scaleY(.02); transform-origin: bottom; transition: transform .9s cubic-bezier(.2, .8, .2, 1) .15s; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/domains.css",
++ "line": 124,
++ "value": ".15s",
++ "text": "html.ov-motion [data-ov-reveal] .dm-bar span { transform: scaleY(.02); transform-origin: bottom; transition: transform .9s cubic-bezier(.2, .8, .2, 1) .15s; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/domains.css",
++ "line": 124,
++ "value": "cubic-bezier(",
++ "text": "html.ov-motion [data-ov-reveal] .dm-bar span { transform: scaleY(.02); transform-origin: bottom; transition: transform .9s cubic-bezier(.2, .8, .2, 1) .15s; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/domains.css",
++ "line": 171,
++ "value": ".15s",
++ "text": "background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); transition: border-color .15s, transform .15s; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/domains.css",
++ "line": 171,
++ "value": ".15s",
++ "text": "background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); transition: border-color .15s, transform .15s; }"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Exchange",
++ "commit": "0921f08c9df602814dbb42eaa3786f334fb1bc48",
++ "files_scanned": 3,
++ "counts": {
++ "literal_duration": 5,
++ "literal_curve": 2,
++ "spinner": 0,
++ "color_drift": 2,
++ "missing_reduced_motion": 0,
++ "total": 9
++ },
++ "score": 98.15,
++ "findings_total": 9,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/exchange.css",
++ "line": 70,
++ "value": "2s",
++ "text": "html.ov-motion .ex-motif-arrows path { stroke-dasharray: 500; animation: ex-loop 2s cubic-bezier(.2,.7,.2,1) both; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/exchange.css",
++ "line": 70,
++ "value": "cubic-bezier(",
++ "text": "html.ov-motion .ex-motif-arrows path { stroke-dasharray: 500; animation: ex-loop 2s cubic-bezier(.2,.7,.2,1) both; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/exchange.css",
++ "line": 77,
++ "value": ".5s",
++ "text": "html.ov-motion .ex-motif-passport { animation: ex-stamp .5s cubic-bezier(.2,.9,.3,1.4) 1.2s both; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/exchange.css",
++ "line": 77,
++ "value": "1.2s",
++ "text": "html.ov-motion .ex-motif-passport { animation: ex-stamp .5s cubic-bezier(.2,.9,.3,1.4) 1.2s both; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/exchange.css",
++ "line": 77,
++ "value": "cubic-bezier(",
++ "text": "html.ov-motion .ex-motif-passport { animation: ex-stamp .5s cubic-bezier(.2,.9,.3,1.4) 1.2s both; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/exchange.css",
++ "line": 92,
++ "value": ".15s",
++ "text": ".ex-listing-link { display: grid; gap: 8px; height: 100%; padding: 16px; border-radius: 16px; text-decoration: none; color: var(--text-primary, #e6edf7) !import"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/exchange.css",
++ "line": 92,
++ "value": ".15s",
++ "text": ".ex-listing-link { display: grid; gap: 8px; height: 100%; padding: 16px; border-radius: 16px; text-decoration: none; color: var(--text-primary, #e6edf7) !import"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/exchange.css",
++ "line": 72,
++ "value": "rgba(0, 0, 0, .22)",
++ "text": ".ex-motif-item { position: absolute; padding: 9px 14px; border-radius: 12px; font-size: .86rem; font-weight: 800; background: var(--bg-card, #0f1729); border: 1"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/exchange.css",
++ "line": 74,
++ "value": "rgba(0, 0, 0, .28)",
++ "text": ".ex-motif-passport { position: absolute; left: 50%; bottom: 8%; translate: -50% 0; display: grid; justify-items: center; padding: 10px 18px; border-radius: 14px"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Food",
++ "commit": "5c31fd571cb36619fda2714a0cb0b784093560bc",
++ "files_scanned": 10,
++ "counts": {
++ "literal_duration": 4,
++ "literal_curve": 4,
++ "spinner": 0,
++ "color_drift": 8,
++ "missing_reduced_motion": 0,
++ "total": 16
++ },
++ "score": 97.6,
++ "findings_total": 16,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/delivery.css",
++ "line": 7,
++ "value": ".15s",
++ "text": ".dv-app { display: flex; flex-direction: column; gap: 6px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/delivery.css",
++ "line": 7,
++ "value": ".15s",
++ "text": ".dv-app { display: flex; flex-direction: column; gap: 6px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/delivery.css",
++ "line": 7,
++ "value": "ease",
++ "text": ".dv-app { display: flex; flex-direction: column; gap: 6px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/delivery.css",
++ "line": 7,
++ "value": "ease",
++ "text": ".dv-app { display: flex; flex-direction: column; gap: 6px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/home.css",
++ "line": 13,
++ "value": ".15s",
++ "text": ".home-cards a { display: flex; flex-direction: column; gap: 4px; height: 100%; box-sizing: border-box; padding: 12px 14px; border-radius: 12px; border: 1px soli"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/home.css",
++ "line": 13,
++ "value": ".15s",
++ "text": ".home-cards a { display: flex; flex-direction: column; gap: 4px; height: 100%; box-sizing: border-box; padding: 12px 14px; border-radius: 12px; border: 1px soli"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/home.css",
++ "line": 13,
++ "value": "ease",
++ "text": ".home-cards a { display: flex; flex-direction: column; gap: 4px; height: 100%; box-sizing: border-box; padding: 12px 14px; border-radius: 12px; border: 1px soli"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/home.css",
++ "line": 13,
++ "value": "ease",
++ "text": ".home-cards a { display: flex; flex-direction: column; gap: 4px; height: 100%; box-sizing: border-box; padding: 12px 14px; border-radius: 12px; border: 1px soli"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/app.css",
++ "line": 67,
++ "value": "rgba(0, 0, 0, .7)",
++ "text": ".food-search-panel { flex-direction: column; align-items: stretch; flex-wrap: nowrap; gap: 14px; margin: 0; padding: 22px; border-radius: 18px; border: 1px soli"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/delivery.css",
++ "line": 19,
++ "value": "#22c55e",
++ "text": ".dv-budget { color: color-mix(in srgb, #22c55e 75%, var(--text-primary, #e6edf7)); border-color: rgba(74, 222, 128, .45); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/delivery.css",
++ "line": 19,
++ "value": "rgba(74, 222, 128, .45)",
++ "text": ".dv-budget { color: color-mix(in srgb, #22c55e 75%, var(--text-primary, #e6edf7)); border-color: rgba(74, 222, 128, .45); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/delivery.css",
++ "line": 20,
++ "value": "#3b82f6",
++ "text": ".dv-mid { color: color-mix(in srgb, #3b82f6 75%, var(--text-primary, #e6edf7)); border-color: rgba(96, 165, 250, .45); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/delivery.css",
++ "line": 20,
++ "value": "rgba(96, 165, 250, .45)",
++ "text": ".dv-mid { color: color-mix(in srgb, #3b82f6 75%, var(--text-primary, #e6edf7)); border-color: rgba(96, 165, 250, .45); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/delivery.css",
++ "line": 21,
++ "value": "#d946ef",
++ "text": ".dv-premium { color: color-mix(in srgb, #d946ef 75%, var(--text-primary, #e6edf7)); border-color: rgba(240, 171, 252, .45); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/delivery.css",
++ "line": 21,
++ "value": "rgba(240, 171, 252, .45)",
++ "text": ".dv-premium { color: color-mix(in srgb, #d946ef 75%, var(--text-primary, #e6edf7)); border-color: rgba(240, 171, 252, .45); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/pantry.css",
++ "line": 6,
++ "value": "#000",
++ "text": "#bc-video { display: block; width: 100%; max-width: 420px; margin-top: 10px; border-radius: 12px; background: #000; }"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Fun",
++ "commit": "a287ae83a37868dd4a5d472a154eb74ee4e6ec90",
++ "files_scanned": 4,
++ "counts": {
++ "literal_duration": 7,
++ "literal_curve": 2,
++ "spinner": 0,
++ "color_drift": 4,
++ "missing_reduced_motion": 0,
++ "total": 13
++ },
++ "score": 97.55,
++ "findings_total": 13,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/fun.css",
++ "line": 67,
++ "value": ".5s",
++ "text": "html.ov-motion .fu-map-motif .fu-pin { animation: fu-pop .5s cubic-bezier(.2,.9,.3,1.5) both; } .fu-pin-2 { animation-delay: .2s !important; } .fu-pin-3 { anima"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/fun.css",
++ "line": 67,
++ "value": "cubic-bezier(",
++ "text": "html.ov-motion .fu-map-motif .fu-pin { animation: fu-pop .5s cubic-bezier(.2,.9,.3,1.5) both; } .fu-pin-2 { animation-delay: .2s !important; } .fu-pin-3 { anima"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/fun.css",
++ "line": 74,
++ "value": ".8s",
++ "text": "html.ov-motion .fu-flip-card { animation: fu-flip .8s cubic-bezier(.2,.7,.2,1) .9s both; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/fun.css",
++ "line": 74,
++ "value": ".9s",
++ "text": "html.ov-motion .fu-flip-card { animation: fu-flip .8s cubic-bezier(.2,.7,.2,1) .9s both; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/fun.css",
++ "line": 74,
++ "value": "cubic-bezier(",
++ "text": "html.ov-motion .fu-flip-card { animation: fu-flip .8s cubic-bezier(.2,.7,.2,1) .9s both; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/fun.css",
++ "line": 87,
++ "value": ".15s",
++ "text": ".fu-quest-link, .fu-plan-link { transition: border-color .15s, transform .15s; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/fun.css",
++ "line": 87,
++ "value": ".15s",
++ "text": ".fu-quest-link, .fu-plan-link { transition: border-color .15s, transform .15s; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/fun.css",
++ "line": 91,
++ "value": ".15s",
++ "text": ".fu-plan-card { gap: 6px; align-content: start; height: 100%; padding: 15px 16px; border-radius: 16px; background: var(--bg-card, #0f1729); border: 1px solid va"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/fun.css",
++ "line": 91,
++ "value": ".15s",
++ "text": ".fu-plan-card { gap: 6px; align-content: start; height: 100%; padding: 15px 16px; border-radius: 16px; background: var(--bg-card, #0f1729); border: 1px solid va"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/fun.css",
++ "line": 65,
++ "value": "rgba(0, 0, 0, .25)",
++ "text": ".fu-pin { position: absolute; translate: -50% -100%; padding: 5px 10px; border-radius: 10px 10px 10px 2px; font-size: .78rem; font-weight: 800; color: var(--roo"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/fun.css",
++ "line": 70,
++ "value": "rgba(0, 0, 0, .3)",
++ "text": ".fu-flip-card { position: absolute; left: 50%; bottom: 8%; translate: -50% 0; display: grid; gap: 2px; width: min(70%, 260px); padding: 10px 14px; border-radius"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/fun.css",
++ "line": 114,
++ "value": "#000",
++ "text": ".fu-overlay { display: grid; gap: 4px; max-width: 420px; padding: 14px 16px; border-radius: 16px; background: color-mix(in srgb, #000 70%, transparent); color: "
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/fun.css",
++ "line": 114,
++ "value": "#fff",
++ "text": ".fu-overlay { display: grid; gap: 4px; max-width: 420px; padding: 14px 16px; border-radius: 16px; background: color-mix(in srgb, #000 70%, transparent); color: "
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Games",
++ "commit": "3b8910cd2dfab9415c56f680b298097aa57bfea1",
++ "files_scanned": 3,
++ "counts": {
++ "literal_duration": 0,
++ "literal_curve": 0,
++ "spinner": 0,
++ "color_drift": 0,
++ "missing_reduced_motion": 0,
++ "total": 0
++ },
++ "score": 100,
++ "findings_total": 0,
++ "findings": []
++ },
++ {
++ "repo": "OpenVibe.Help",
++ "commit": "bf94ad1b939d27448c9b037fab8df1f250d0fc3d",
++ "files_scanned": 1,
++ "counts": {
++ "literal_duration": 2,
++ "literal_curve": 0,
++ "spinner": 0,
++ "color_drift": 1,
++ "missing_reduced_motion": 0,
++ "total": 3
++ },
++ "score": 99.45,
++ "findings_total": 3,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/app.css",
++ "line": 86,
++ "value": ".15s",
++ "text": ".site-card { display: flex; flex-direction: column; gap: 4px; height: 100%; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); b"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/app.css",
++ "line": 86,
++ "value": ".15s",
++ "text": ".site-card { display: flex; flex-direction: column; gap: 4px; height: 100%; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); b"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/app.css",
++ "line": 58,
++ "value": "#10151f",
++ "text": "mark { background: var(--warning, #f59e0b); color: #10151f; border-radius: 3px; }"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Host",
++ "commit": "6ee9b8afccbe1353c4d94808d57caaa69304de57",
++ "files_scanned": 3,
++ "counts": {
++ "literal_duration": 2,
++ "literal_curve": 2,
++ "spinner": 0,
++ "color_drift": 13,
++ "missing_reduced_motion": 0,
++ "total": 17
++ },
++ "score": 98.35,
++ "findings_total": 17,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/catalog.css",
++ "line": 225,
++ "value": ".15s",
++ "text": ".hh-cards a { display: flex; flex-direction: column; gap: 4px; height: 100%; box-sizing: border-box; padding: 12px 14px; border-radius: 12px; border: 1px solid "
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/catalog.css",
++ "line": 225,
++ "value": ".15s",
++ "text": ".hh-cards a { display: flex; flex-direction: column; gap: 4px; height: 100%; box-sizing: border-box; padding: 12px 14px; border-radius: 12px; border: 1px solid "
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/catalog.css",
++ "line": 225,
++ "value": "ease",
++ "text": ".hh-cards a { display: flex; flex-direction: column; gap: 4px; height: 100%; box-sizing: border-box; padding: 12px 14px; border-radius: 12px; border: 1px solid "
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/catalog.css",
++ "line": 225,
++ "value": "ease",
++ "text": ".hh-cards a { display: flex; flex-direction: column; gap: 4px; height: 100%; box-sizing: border-box; padding: 12px 14px; border-radius: 12px; border: 1px solid "
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/catalog.css",
++ "line": 26,
++ "value": "rgba(255,255,255,.02)",
++ "text": ".hc-table thead th { font-size: 12px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; color: var(--hs-muted); background: rgba(255,255,255,."
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/catalog.css",
++ "line": 29,
++ "value": "rgba(96,165,250,.05)",
++ "text": ".hc-table tbody tr:hover > * { background: rgba(96,165,250,.05); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/console.css",
++ "line": 13,
++ "value": "#f87171",
++ "text": ".cs-bad { color: #f87171; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/console.css",
++ "line": 23,
++ "value": "#4ade80",
++ "text": ".cs-save a { color: #4ade80; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/console.css",
++ "line": 26,
++ "value": "#4ade80",
++ "text": ".cs-status.is-ok { color: #4ade80; } .cs-status.is-off { color: var(--text-secondary, #9fb0c8); } .cs-status.is-busy { color: #fbbf24; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/console.css",
++ "line": 26,
++ "value": "#fbbf24",
++ "text": ".cs-status.is-ok { color: #4ade80; } .cs-status.is-off { color: var(--text-secondary, #9fb0c8); } .cs-status.is-busy { color: #fbbf24; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/host.css",
++ "line": 3,
++ "value": "--hs-line rgba(255,255,255,.12)",
++ "text": "--hs-card: var(--bg-secondary, #111a2c); --hs-line: rgba(255,255,255,.12); --hs-accent: var(--accent, #60a5fa);"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/host.css",
++ "line": 4,
++ "value": "--hs-ok #34d399",
++ "text": "--hs-ok: #34d399; --hs-warn: #fbbf24; --hs-bad: #f87171; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/host.css",
++ "line": 4,
++ "value": "--hs-warn #fbbf24",
++ "text": "--hs-ok: #34d399; --hs-warn: #fbbf24; --hs-bad: #f87171; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/host.css",
++ "line": 4,
++ "value": "--hs-bad #f87171",
++ "text": "--hs-ok: #34d399; --hs-warn: #fbbf24; --hs-bad: #f87171; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/host.css",
++ "line": 15,
++ "value": "rgba(96,165,250,.08)",
++ "text": "tr.active td { background: rgba(96,165,250,.08); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/host.css",
++ "line": 25,
++ "value": "#0a0f1c",
++ "text": "button, .button { font: inherit; padding: .45rem .9rem; border-radius: 6px; border: 0; background: var(--hs-accent); color: #0a0f1c; cursor: pointer; text-decor"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/host.css",
++ "line": 27,
++ "value": "#0a0f1c",
++ "text": "button.danger, .danger { background: var(--hs-bad); color: #0a0f1c; } button.link.danger { background: none; color: var(--hs-bad); }"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Inventory",
++ "commit": "bb0f57d6ef10f3a9832cc524d4a82d57102963d3",
++ "files_scanned": 1,
++ "counts": {
++ "literal_duration": 0,
++ "literal_curve": 0,
++ "spinner": 0,
++ "color_drift": 9,
++ "missing_reduced_motion": 0,
++ "total": 9
++ },
++ "score": 99.55,
++ "findings_total": 9,
++ "findings": [
++ {
++ "rule": "color_drift",
++ "file": "public/css/app.css",
++ "line": 51,
++ "value": "--rar #94a3b8",
++ "text": ".r-common { --rar: #94a3b8; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/app.css",
++ "line": 91,
++ "value": "#fbbf24",
++ "text": ".inv-hero-panel { padding: 20px; border-radius: 18px; border: 1px solid var(--border, #1f2d47); background: linear-gradient(160deg, color-mix(in srgb, #fbbf24 8"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/app.css",
++ "line": 91,
++ "value": "rgba(0, 0, 0, .7)",
++ "text": ".inv-hero-panel { padding: 20px; border-radius: 18px; border: 1px solid var(--border, #1f2d47); background: linear-gradient(160deg, color-mix(in srgb, #fbbf24 8"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/app.css",
++ "line": 119,
++ "value": "#22c55e",
++ "text": ".ws-published { color: #22c55e; border-color: color-mix(in srgb, #22c55e 50%, transparent); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/app.css",
++ "line": 119,
++ "value": "#22c55e",
++ "text": ".ws-published { color: #22c55e; border-color: color-mix(in srgb, #22c55e 50%, transparent); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/app.css",
++ "line": 120,
++ "value": "#f59e0b",
++ "text": ".ws-in_review { color: #f59e0b; border-color: color-mix(in srgb, #f59e0b 50%, transparent); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/app.css",
++ "line": 120,
++ "value": "#f59e0b",
++ "text": ".ws-in_review { color: #f59e0b; border-color: color-mix(in srgb, #f59e0b 50%, transparent); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/app.css",
++ "line": 121,
++ "value": "#ef4444",
++ "text": ".ws-draft { color: #ef4444; border-color: color-mix(in srgb, #ef4444 50%, transparent); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/app.css",
++ "line": 121,
++ "value": "#ef4444",
++ "text": ".ws-draft { color: #ef4444; border-color: color-mix(in srgb, #ef4444 50%, transparent); }"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Live",
++ "commit": "b90121e8c8dfbaa41ecf7caa652ea84983d19a2e",
++ "files_scanned": 119,
++ "counts": {
++ "literal_duration": 931,
++ "literal_curve": 448,
++ "spinner": 0,
++ "color_drift": 1652,
++ "missing_reduced_motion": 0,
++ "total": 3031
++ },
++ "score": 0,
++ "findings_total": 3031,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/banned.html",
++ "line": 17,
++ "value": "6s",
++ "text": "background: radial-gradient(120% 90% at 50% 115%, #4a0b0b 0%, #1a0708 45%, #0b0a0d 75%); animation: breathe 6s ease-in-out infinite; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/broadcast.css",
++ "line": 33,
++ "value": "0.2s",
++ "text": "transition: color 0.2s ease;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/chat-shared.css",
++ "line": 17,
++ "value": "0.12s",
++ "text": "animation: ctxFadeIn 0.12s ease-out;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/context-cards.css",
++ "line": 9,
++ "value": "0.35s",
++ "text": ".cc-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 0.35s ease; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/cosmetics.css",
++ "line": 17,
++ "value": "3s",
++ "text": "animation: nameFxRainbow 3s linear infinite;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/features/broadcast.css",
++ "line": 73,
++ "value": "0.15s",
++ "text": "transition: background 0.15s;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/features/channel.css",
++ "line": 14,
++ "value": "0.15s",
++ "text": "transition: border-color 0.15s ease, transform 0.15s ease;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/features/chatpage.css",
++ "line": 15,
++ "value": "0.15s",
++ "text": "font-size: 0.82rem; color: var(--text-secondary); transition: background 0.15s;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/features/content.css",
++ "line": 141,
++ "value": ".32s",
++ "text": "animation: cfIn .32s var(--ease-standard, ease) both;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/features/dashboard.css",
++ "line": 6,
++ "value": ".3s",
++ "text": "#dash-powerchat-card { transition: box-shadow .3s ease, border-color .3s ease; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/features/docs.css",
++ "line": 14,
++ "value": "0.15s",
++ "text": ".docs-tab-arrow { flex: 0 0 auto; display: none; align-items: center; justify-content: center; width: 30px; background: none; border: none; color: var(--text-mu"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/features/featured.css",
++ "line": 10,
++ "value": "320ms",
++ "text": "animation: hfIn 320ms cubic-bezier(.2, .9, .3, 1);"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/features/home.css",
++ "line": 31,
++ "value": "3.5s",
++ "text": ".hero-egg-icon { font-size: 1.3rem; filter: drop-shadow(0 2px 6px rgba(0,0,0,0.4)); animation: eggWobble 3.5s ease-in-out infinite; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/features/rundown.css",
++ "line": 238,
++ "value": ".18s",
++ "text": ".rd-more > summary::before { content: ''; width: 8px; height: 8px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: trans"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/features/stats.css",
++ "line": 6,
++ "value": "ease-out",
++ "text": "animation: snFade var(--dur-fast, 160ms) ease-out;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/features/viewer-places.css",
++ "line": 8,
++ "value": "0.16s",
++ "text": "animation: ovp-in 0.16s ease-out;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/home-fx.css",
++ "line": 10,
++ "value": "0.4s",
++ "text": "pointer-events: none; z-index: 0; opacity: 0; transition: opacity 0.4s;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/home-tour.css",
++ "line": 32,
++ "value": "1.1s",
++ "text": ".tour-wire { fill: none; stroke-width: 1.6; stroke-linecap: round; stroke: color-mix(in srgb, var(--text-primary) 22%, transparent); stroke-dasharray: 5 7; anim"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/i18n-star.css",
++ "line": 15,
++ "value": "0.35s",
++ "text": "animation: i18nSlideIn 0.35s ease both;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/media-player.css",
++ "line": 82,
++ "value": "1.4s",
++ "text": "animation: mp-pulse 1.4s ease-out infinite; /* a ring that pulses out: nothing spins */"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/messenger.css",
++ "line": 24,
++ "value": "0.15s",
++ "text": "transition: transform 0.15s, background 0.15s;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/ov-guide.css",
++ "line": 7,
++ "value": ".25s",
++ "text": ".ovg-scrim { position: absolute; inset: 0; background: rgba(4,5,10,0.72); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); animation: ovgFade .25"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/style.css",
++ "line": 180,
++ "value": "0.4s",
++ "text": "transition: background 0.4s ease, border-color 0.4s ease, box-shadow 0.4s ease, backdrop-filter 0.4s ease;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/index.html",
++ "line": 78,
++ "value": "1.5s",
++ "text": ".ovsk::after,.ovsk-bar::after{content:'';position:absolute;inset:0;background:linear-gradient(100deg,transparent 20%,color-mix(in srgb,var(--text-primary,#f1f4f"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/kiosk.html",
++ "line": 123,
++ "value": ".5s",
++ "text": "min-height:1.5em;transition:opacity .5s"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Media",
++ "commit": "d4dc27191555fab54bd0503266e3e50cee2c6673",
++ "files_scanned": 2,
++ "counts": {
++ "literal_duration": 0,
++ "literal_curve": 0,
++ "spinner": 0,
++ "color_drift": 0,
++ "missing_reduced_motion": 0,
++ "total": 0
++ },
++ "score": 100,
++ "findings_total": 0,
++ "findings": []
++ },
++ {
++ "repo": "OpenVibe.MediaHub",
++ "commit": "7a2bef57872feaf52d005d562a8bdcbf53951d51",
++ "files_scanned": 9,
++ "counts": {
++ "literal_duration": 13,
++ "literal_curve": 7,
++ "spinner": 0,
++ "color_drift": 46,
++ "missing_reduced_motion": 0,
++ "total": 66
++ },
++ "score": 92.7,
++ "findings_total": 66,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/pics.css",
++ "line": 36,
++ "value": ".15s",
++ "text": ".ovp-drop { position: relative; margin: 6px 0 0; border: 2px dashed color-mix(in srgb, var(--ovp-pink) 55%, var(--ovp-line-strong)); border-radius: 20px; backgr"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/pics.css",
++ "line": 36,
++ "value": ".15s",
++ "text": ".ovp-drop { position: relative; margin: 6px 0 0; border: 2px dashed color-mix(in srgb, var(--ovp-pink) 55%, var(--ovp-line-strong)); border-radius: 20px; backgr"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/pics.css",
++ "line": 36,
++ "value": "ease",
++ "text": ".ovp-drop { position: relative; margin: 6px 0 0; border: 2px dashed color-mix(in srgb, var(--ovp-pink) 55%, var(--ovp-line-strong)); border-radius: 20px; backgr"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/pics.css",
++ "line": 36,
++ "value": "ease",
++ "text": ".ovp-drop { position: relative; margin: 6px 0 0; border: 2px dashed color-mix(in srgb, var(--ovp-pink) 55%, var(--ovp-line-strong)); border-radius: 20px; backgr"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/pics.css",
++ "line": 60,
++ "value": ".15s",
++ "text": ".ovp-progress span { display: block; height: 100%; width: 0; background: var(--ovp-pink); transition: width .15s linear; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/pics.css",
++ "line": 78,
++ "value": ".2s",
++ "text": ".ovp-tile img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .2s ease; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/pics.css",
++ "line": 78,
++ "value": "ease",
++ "text": ".ovp-tile img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .2s ease; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/video.css",
++ "line": 78,
++ "value": ".25s",
++ "text": ".ovv-card-media img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .25s ease; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/video.css",
++ "line": 78,
++ "value": "ease",
++ "text": ".ovv-card-media img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .25s ease; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/video.css",
++ "line": 116,
++ "value": ".45s",
++ "text": ".ovv-player { display: block; width: 100%; aspect-ratio: var(--ovv-ar, 16 / 9); max-height: calc(100vh - 150px); max-height: calc(100dvh - 150px); background: #"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/video.css",
++ "line": 116,
++ "value": "cubic-bezier(",
++ "text": ".ovv-player { display: block; width: 100%; aspect-ratio: var(--ovv-ar, 16 / 9); max-height: calc(100vh - 150px); max-height: calc(100dvh - 150px); background: #"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/video.css",
++ "line": 531,
++ "value": ".15s",
++ "text": ".ovv-pip-bar { position: absolute; inset: 0 0 auto 0; display: flex; align-items: center; gap: 4px; padding: 4px 6px; background: linear-gradient(rgba(0, 0, 0, "
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/video.css",
++ "line": 547,
++ "value": "0.18s",
++ "text": ".ovv-owner[open] { animation: ovv-owner-in 0.18s ease-out; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/video.css",
++ "line": 547,
++ "value": "ease-out",
++ "text": ".ovv-owner[open] { animation: ovv-owner-in 0.18s ease-out; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/video.css",
++ "line": 556,
++ "value": ".15s",
++ "text": "border: 1px solid var(--ovv-line-strong); background: color-mix(in srgb, var(--ovv-card) 70%, transparent); transition: border-color .15s, background .15s;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/video.css",
++ "line": 556,
++ "value": ".15s",
++ "text": "border: 1px solid var(--ovv-line-strong); background: color-mix(in srgb, var(--ovv-card) 70%, transparent); transition: border-color .15s, background .15s;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/video.css",
++ "line": 582,
++ "value": ".15s",
++ "text": ".ovv-stat { display: flex; flex-direction: column; gap: 2px; height: 100%; padding: 11px 14px; border: 1px solid var(--ovv-line); border-radius: 14px; backgroun"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/video.css",
++ "line": 582,
++ "value": ".15s",
++ "text": ".ovv-stat { display: flex; flex-direction: column; gap: 2px; height: 100%; padding: 11px 14px; border: 1px solid var(--ovv-line); border-radius: 14px; backgroun"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/video.css",
++ "line": 590,
++ "value": "1.6s",
++ "text": "html.ov-motion .ovv-stat.is-live .ovv-dot { animation: ovv-pulse 1.6s ease-out infinite; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/video.css",
++ "line": 590,
++ "value": "ease-out",
++ "text": "html.ov-motion .ovv-stat.is-live .ovv-dot { animation: ovv-pulse 1.6s ease-out infinite; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/pics.css",
++ "line": 6,
++ "value": "--ovp-pink #f472b6",
++ "text": "--ovp-pink: #f472b6;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/pics.css",
++ "line": 7,
++ "value": "--ovp-pink-soft #f9a8d4",
++ "text": "--ovp-pink-soft: #f9a8d4;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/pics.css",
++ "line": 8,
++ "value": "--ovp-on-pink #1f0a17",
++ "text": "--ovp-on-pink: #1f0a17;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/pics.css",
++ "line": 32,
++ "value": "#c084fc",
++ "text": ".ovp-accent { background: linear-gradient(92deg, var(--ovp-pink), #c084fc 70%); -webkit-background-clip: text; background-clip: text; color: transparent; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/pics.css",
++ "line": 81,
++ "value": "rgba(0, 0, 0, .7)",
++ "text": ".ovp-flag { position: absolute; left: 8px; top: 8px; padding: 2px 8px; border-radius: 999px; background: rgba(0, 0, 0, .7); color: #fff; font-size: .72rem; font"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Meme",
++ "commit": "c227e892e228707fc51b80852b3ae83680c22613",
++ "files_scanned": 3,
++ "counts": {
++ "literal_duration": 5,
++ "literal_curve": 3,
++ "spinner": 0,
++ "color_drift": 17,
++ "missing_reduced_motion": 0,
++ "total": 25
++ },
++ "score": 97.15,
++ "findings_total": 25,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/meme.css",
++ "line": 47,
++ "value": "1.6s",
++ "text": "html.ov-motion .mm-tree-lines path { animation: mm-grow 1.6s cubic-bezier(.2,.7,.2,1) both; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/meme.css",
++ "line": 47,
++ "value": "cubic-bezier(",
++ "text": "html.ov-motion .mm-tree-lines path { animation: mm-grow 1.6s cubic-bezier(.2,.7,.2,1) both; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/meme.css",
++ "line": 59,
++ "value": ".7s",
++ "text": "html.ov-motion .mm-sticker { animation: mm-fan .7s cubic-bezier(.2,.9,.3,1.3) both; animation-delay: calc(.9s + var(--d, 0s)); }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/meme.css",
++ "line": 59,
++ "value": "cubic-bezier(",
++ "text": "html.ov-motion .mm-sticker { animation: mm-fan .7s cubic-bezier(.2,.9,.3,1.3) both; animation-delay: calc(.9s + var(--d, 0s)); }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/meme.css",
++ "line": 66,
++ "value": ".15s",
++ "text": "background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); transition: border-color .15s, transform .15s; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/meme.css",
++ "line": 66,
++ "value": ".15s",
++ "text": "background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); transition: border-color .15s, transform .15s; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/meme.css",
++ "line": 144,
++ "value": ".9s",
++ "text": "html.ov-motion .mm-demo-bars i { animation: mm-bar .9s cubic-bezier(.2,.7,.2,1) both; animation-delay: calc(var(--h) * 30ms); transform-origin: bottom; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/meme.css",
++ "line": 144,
++ "value": "cubic-bezier(",
++ "text": "html.ov-motion .mm-demo-bars i { animation: mm-bar .9s cubic-bezier(.2,.7,.2,1) both; animation-delay: calc(var(--h) * 30ms); transform-origin: bottom; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/meme.css",
++ "line": 51,
++ "value": "rgba(0, 0, 0, .22)",
++ "text": "background: var(--bg-card, #0f1729); border: 1px solid color-mix(in srgb, var(--room-accent) 45%, var(--border, #1f2d47)); box-shadow: 0 8px 22px rgba(0, 0, 0, "
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/meme.css",
++ "line": 56,
++ "value": "black",
++ "text": ".mm-sticker { position: absolute; padding: 4px 10px; border-radius: 8px; font: 900 .78rem/1 Impact, 'Arial Black', sans-serif; letter-spacing: .04em; text-trans"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/meme.css",
++ "line": 56,
++ "value": "#fff",
++ "text": ".mm-sticker { position: absolute; padding: 4px 10px; border-radius: 8px; font: 900 .78rem/1 Impact, 'Arial Black', sans-serif; letter-spacing: .04em; text-trans"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/meme.css",
++ "line": 57,
++ "value": "#000",
++ "text": "background: color-mix(in srgb, var(--room-accent) 70%, #000); rotate: -8deg; box-shadow: 0 6px 14px rgba(0, 0, 0, .25); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/meme.css",
++ "line": 57,
++ "value": "rgba(0, 0, 0, .25)",
++ "text": "background: color-mix(in srgb, var(--room-accent) 70%, #000); rotate: -8deg; box-shadow: 0 6px 14px rgba(0, 0, 0, .25); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/meme.css",
++ "line": 130,
++ "value": "#111",
++ "text": ".mm-caption-card { position: relative; margin: 0; overflow: hidden; border-radius: 16px; background: #111; container-type: inline-size; box-shadow: 0 14px 34px "
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/meme.css",
++ "line": 130,
++ "value": "rgba(0, 0, 0, .3)",
++ "text": ".mm-caption-card { position: relative; margin: 0; overflow: hidden; border-radius: 16px; background: #111; container-type: inline-size; box-shadow: 0 14px 34px "
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/meme.css",
++ "line": 133,
++ "value": "#fff",
++ "text": ".mm-caption-card figcaption span { display: block; text-align: center; color: #fff; text-transform: uppercase; overflow-wrap: anywhere;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/meme.css",
++ "line": 134,
++ "value": "black",
++ "text": "font: 900 clamp(1rem, 7.2cqw, 3.2rem)/1.05 Impact, Haettenschweiler, 'Arial Narrow Bold', 'Arial Black', sans-serif; letter-spacing: .01em;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/meme.css",
++ "line": 135,
++ "value": "#000",
++ "text": "text-shadow: 2px 2px 0 #000, -2px 2px 0 #000, 2px -2px 0 #000, -2px -2px 0 #000, 0 2px 0 #000, 2px 0 0 #000, 0 -2px 0 #000, -2px 0 0 #000; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/meme.css",
++ "line": 135,
++ "value": "#000",
++ "text": "text-shadow: 2px 2px 0 #000, -2px 2px 0 #000, 2px -2px 0 #000, -2px -2px 0 #000, 0 2px 0 #000, 2px 0 0 #000, 0 -2px 0 #000, -2px 0 0 #000; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/meme.css",
++ "line": 135,
++ "value": "#000",
++ "text": "text-shadow: 2px 2px 0 #000, -2px 2px 0 #000, 2px -2px 0 #000, -2px -2px 0 #000, 0 2px 0 #000, 2px 0 0 #000, 0 -2px 0 #000, -2px 0 0 #000; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/meme.css",
++ "line": 135,
++ "value": "#000",
++ "text": "text-shadow: 2px 2px 0 #000, -2px 2px 0 #000, 2px -2px 0 #000, -2px -2px 0 #000, 0 2px 0 #000, 2px 0 0 #000, 0 -2px 0 #000, -2px 0 0 #000; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/meme.css",
++ "line": 135,
++ "value": "#000",
++ "text": "text-shadow: 2px 2px 0 #000, -2px 2px 0 #000, 2px -2px 0 #000, -2px -2px 0 #000, 0 2px 0 #000, 2px 0 0 #000, 0 -2px 0 #000, -2px 0 0 #000; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/meme.css",
++ "line": 135,
++ "value": "#000",
++ "text": "text-shadow: 2px 2px 0 #000, -2px 2px 0 #000, 2px -2px 0 #000, -2px -2px 0 #000, 0 2px 0 #000, 2px 0 0 #000, 0 -2px 0 #000, -2px 0 0 #000; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/meme.css",
++ "line": 135,
++ "value": "#000",
++ "text": "text-shadow: 2px 2px 0 #000, -2px 2px 0 #000, 2px -2px 0 #000, -2px -2px 0 #000, 0 2px 0 #000, 2px 0 0 #000, 0 -2px 0 #000, -2px 0 0 #000; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/meme.css",
++ "line": 135,
++ "value": "#000",
++ "text": "text-shadow: 2px 2px 0 #000, -2px 2px 0 #000, 2px -2px 0 #000, -2px -2px 0 #000, 0 2px 0 #000, 2px 0 0 #000, 0 -2px 0 #000, -2px 0 0 #000; }"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Network",
++ "commit": "390bcc1ca2aa5fcd0e391244bb27a6edebd8fecf",
++ "files_scanned": 31,
++ "counts": {
++ "literal_duration": 89,
++ "literal_curve": 31,
++ "spinner": 0,
++ "color_drift": 212,
++ "missing_reduced_motion": 2,
++ "total": 334
++ },
++ "score": 55.4,
++ "findings_total": 334,
++ "findings": [
++ {
++ "rule": "missing_reduced_motion",
++ "file": "public/css/admin.css",
++ "line": 43,
++ "value": "animation",
++ "text": ".section{display:none;animation:sec-in .2s ease}"
++ },
++ {
++ "rule": "missing_reduced_motion",
++ "file": "public/css/login.css",
++ "line": 17,
++ "value": "animation",
++ "text": "animation:orb-drift 20s ease-in-out infinite alternate;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/admin.css",
++ "line": 30,
++ "value": ".15s",
++ "text": "background:transparent;color:var(--text-muted);cursor:pointer;transition:all .15s;white-space:nowrap;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/admin.css",
++ "line": 36,
++ "value": ".15s",
++ "text": "background:var(--bg-secondary);color:var(--text-muted);cursor:pointer;transition:all .15s;opacity:0;pointer-events:none;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/admin.css",
++ "line": 43,
++ "value": ".2s",
++ "text": ".section{display:none;animation:sec-in .2s ease}"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/admin.css",
++ "line": 43,
++ "value": "ease",
++ "text": ".section{display:none;animation:sec-in .2s ease}"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/login.css",
++ "line": 17,
++ "value": "20s",
++ "text": "animation:orb-drift 20s ease-in-out infinite alternate;"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/login.css",
++ "line": 17,
++ "value": "ease-in-out",
++ "text": "animation:orb-drift 20s ease-in-out infinite alternate;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/login.css",
++ "line": 49,
++ "value": "30s",
++ "text": "animation:grid-scroll 30s linear infinite;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/my.css",
++ "line": 38,
++ "value": ".15s",
++ "text": "background:transparent;color:var(--text-muted);cursor:pointer;transition:all .15s;white-space:nowrap;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/my.css",
++ "line": 65,
++ "value": ".2s",
++ "text": ".section{display:none;animation:sec-in .2s ease}"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/my.css",
++ "line": 65,
++ "value": "ease",
++ "text": ".section{display:none;animation:sec-in .2s ease}"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/my.css",
++ "line": 82,
++ "value": ".18s",
++ "text": "transition:border-color .18s ease,box-shadow .18s ease,background-color .18s ease;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/index.html",
++ "line": 65,
++ "value": "24s",
++ "text": ".bg-orbs .orb{position:absolute;border-radius:50%;filter:blur(110px);opacity:.14;animation:orb-drift 24s ease-in-out infinite alternate}"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/index.html",
++ "line": 65,
++ "value": "ease-in-out",
++ "text": ".bg-orbs .orb{position:absolute;border-radius:50%;filter:blur(110px);opacity:.14;animation:orb-drift 24s ease-in-out infinite alternate}"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/index.html",
++ "line": 88,
++ "value": ".15s",
++ "text": "transition:transform .15s, box-shadow .15s, background .15s, border-color .15s;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/index.html",
++ "line": 88,
++ "value": ".15s",
++ "text": "transition:transform .15s, box-shadow .15s, background .15s, border-color .15s;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/sso-fanout.html",
++ "line": 35,
++ "value": ".4s",
++ "text": ".card{width:100%;max-width:440px;background:var(--bg-card);border:1px solid var(--border);border-radius:18px;padding:30px 28px;position:relative;overflow:hidden"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/sso-fanout.html",
++ "line": 35,
++ "value": "cubic-bezier(",
++ "text": ".card{width:100%;max-width:440px;background:var(--bg-card);border:1px solid var(--border);border-radius:18px;padding:30px 28px;position:relative;overflow:hidden"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/sso-fanout.html",
++ "line": 42,
++ "value": ".45s",
++ "text": ".bar i{position:absolute;inset:0 auto 0 0;width:var(--w,0%);background:linear-gradient(90deg,var(--accent),var(--accent-light));transition:width .45s cubic-bezi"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/sso-fanout.html",
++ "line": 42,
++ "value": "cubic-bezier(",
++ "text": ".bar i{position:absolute;inset:0 auto 0 0;width:var(--w,0%);background:linear-gradient(90deg,var(--accent),var(--accent-light));transition:width .45s cubic-bezi"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/verify-email.html",
++ "line": 38,
++ "value": ".5s",
++ "text": ".card{width:100%;max-width:480px;background:var(--bg-card);border:1px solid var(--border);border-radius:18px;padding:36px 32px;text-align:center;position:relati"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/verify-email.html",
++ "line": 38,
++ "value": "cubic-bezier(",
++ "text": ".card{width:100%;max-width:480px;background:var(--bg-card);border:1px solid var(--border);border-radius:18px;padding:36px 32px;text-align:center;position:relati"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/verify-email.html",
++ "line": 39,
++ "value": ".6s",
++ "text": ".card::before{content:\"\";position:absolute;inset:-40% -20% auto;height:70%;background:radial-gradient(60% 60% at 50% 40%,rgba(var(--accent-rgb),.22),transparent"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/verify-email.html",
++ "line": 45,
++ "value": "1.4s",
++ "text": ".ring.spin{border-color:var(--accent);animation:ring-pulse 1.4s ease-out infinite}"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.News",
++ "commit": "5c1200cc31425da6aef045cff5cea7bd6c56fca7",
++ "files_scanned": 2,
++ "counts": {
++ "literal_duration": 1,
++ "literal_curve": 1,
++ "spinner": 0,
++ "color_drift": 15,
++ "missing_reduced_motion": 0,
++ "total": 17
++ },
++ "score": 98.75,
++ "findings_total": 17,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/briefs.css",
++ "line": 14,
++ "value": "1.6s",
++ "text": ".live-dot { display: inline-block; width: .55em; height: .55em; border-radius: 50%; background: #ef4444; vertical-align: middle; animation: live-pulse 1.6s ease"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/briefs.css",
++ "line": 14,
++ "value": "ease-in-out",
++ "text": ".live-dot { display: inline-block; width: .55em; height: .55em; border-radius: 50%; background: #ef4444; vertical-align: middle; animation: live-pulse 1.6s ease"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/briefs.css",
++ "line": 2,
++ "value": "#a78bfa",
++ "text": ".badge-ai { background: color-mix(in srgb, #a78bfa 18%, transparent); color: color-mix(in srgb, #8b5cf6 70%, var(--text-primary, #e6edf7)); border: 1px solid co"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/briefs.css",
++ "line": 2,
++ "value": "#8b5cf6",
++ "text": ".badge-ai { background: color-mix(in srgb, #a78bfa 18%, transparent); color: color-mix(in srgb, #8b5cf6 70%, var(--text-primary, #e6edf7)); border: 1px solid co"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/briefs.css",
++ "line": 2,
++ "value": "#a78bfa",
++ "text": ".badge-ai { background: color-mix(in srgb, #a78bfa 18%, transparent); color: color-mix(in srgb, #8b5cf6 70%, var(--text-primary, #e6edf7)); border: 1px solid co"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/briefs.css",
++ "line": 3,
++ "value": "#ef4444",
++ "text": ".badge-breaking { background: color-mix(in srgb, #ef4444 16%, transparent); color: color-mix(in srgb, #ef4444 75%, var(--text-primary, #e6edf7)); border: 1px so"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/briefs.css",
++ "line": 3,
++ "value": "#ef4444",
++ "text": ".badge-breaking { background: color-mix(in srgb, #ef4444 16%, transparent); color: color-mix(in srgb, #ef4444 75%, var(--text-primary, #e6edf7)); border: 1px so"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/briefs.css",
++ "line": 3,
++ "value": "#ef4444",
++ "text": ".badge-breaking { background: color-mix(in srgb, #ef4444 16%, transparent); color: color-mix(in srgb, #ef4444 75%, var(--text-primary, #e6edf7)); border: 1px so"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/briefs.css",
++ "line": 14,
++ "value": "#ef4444",
++ "text": ".live-dot { display: inline-block; width: .55em; height: .55em; border-radius: 50%; background: #ef4444; vertical-align: middle; animation: live-pulse 1.6s ease"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/briefs.css",
++ "line": 17,
++ "value": "#ef4444",
++ "text": ".wire-cluster.is-breaking { border-left: 3px solid #ef4444; padding-left: 10px; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/news.css",
++ "line": 64,
++ "value": "rgba(34,197,94,.25)",
++ "text": ".ov-diff ins { background: rgba(34,197,94,.25); text-decoration: none; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/news.css",
++ "line": 65,
++ "value": "rgba(239,68,68,.25)",
++ "text": ".ov-diff del { background: rgba(239,68,68,.25); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/news.css",
++ "line": 90,
++ "value": "#ef4444",
++ "text": ".live-dot { display: inline-block; width: .55em; height: .55em; border-radius: 50%; background: #ef4444; vertical-align: middle; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/news.css",
++ "line": 91,
++ "value": "#a78bfa",
++ "text": ".badge-ai { background: color-mix(in srgb, #a78bfa 18%, transparent); color: color-mix(in srgb, #8b5cf6 70%, var(--text-primary, #e6edf7)); border: 1px solid co"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/news.css",
++ "line": 91,
++ "value": "#8b5cf6",
++ "text": ".badge-ai { background: color-mix(in srgb, #a78bfa 18%, transparent); color: color-mix(in srgb, #8b5cf6 70%, var(--text-primary, #e6edf7)); border: 1px solid co"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/news.css",
++ "line": 91,
++ "value": "#a78bfa",
++ "text": ".badge-ai { background: color-mix(in srgb, #a78bfa 18%, transparent); color: color-mix(in srgb, #8b5cf6 70%, var(--text-primary, #e6edf7)); border: 1px solid co"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/news.css",
++ "line": 99,
++ "value": "#8b5cf6",
++ "text": ".nh-kicker .badge-ai { color: color-mix(in srgb, #8b5cf6 55%, var(--text-primary, #e6edf7)); } /* 4.49:1 at 70% on a card */"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Play",
++ "commit": "d43308f86605aa099d43b012d60c7fe069e0aa9d",
++ "files_scanned": 1,
++ "counts": {
++ "literal_duration": 0,
++ "literal_curve": 0,
++ "spinner": 0,
++ "color_drift": 0,
++ "missing_reduced_motion": 0,
++ "total": 0
++ },
++ "score": 100,
++ "findings_total": 0,
++ "findings": []
++ },
++ {
++ "repo": "OpenVibe.Quest",
++ "commit": "3c8533e78a10a5d17c97ee4ba631a412865bf93e",
++ "files_scanned": 1,
++ "counts": {
++ "literal_duration": 2,
++ "literal_curve": 0,
++ "spinner": 0,
++ "color_drift": 8,
++ "missing_reduced_motion": 0,
++ "total": 10
++ },
++ "score": 99.1,
++ "findings_total": 10,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/app.css",
++ "line": 61,
++ "value": ".15s",
++ "text": ".quest { display: grid; grid-template-columns: 56px minmax(0, 1fr); gap: 14px; padding: 16px; border-radius: 16px; border: 1px solid var(--border, #1f2d47); bac"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/app.css",
++ "line": 61,
++ "value": ".15s",
++ "text": ".quest { display: grid; grid-template-columns: 56px minmax(0, 1fr); gap: 14px; padding: 16px; border-radius: 16px; border: 1px solid var(--border, #1f2d47); bac"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/app.css",
++ "line": 63,
++ "value": "hsl(var(--h)",
++ "text": ".medal { width: 56px; height: 56px; border-radius: 50%; display: grid; place-items: center; font-weight: 800; font-size: 1rem; letter-spacing: .02em; color: hsl"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/app.css",
++ "line": 63,
++ "value": "hsl(var(--h)",
++ "text": ".medal { width: 56px; height: 56px; border-radius: 50%; display: grid; place-items: center; font-weight: 800; font-size: 1rem; letter-spacing: .02em; color: hsl"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/app.css",
++ "line": 63,
++ "value": "hsl(var(--h)",
++ "text": ".medal { width: 56px; height: 56px; border-radius: 50%; display: grid; place-items: center; font-weight: 800; font-size: 1rem; letter-spacing: .02em; color: hsl"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/app.css",
++ "line": 63,
++ "value": "hsl(var(--h)",
++ "text": ".medal { width: 56px; height: 56px; border-radius: 50%; display: grid; place-items: center; font-weight: 800; font-size: 1rem; letter-spacing: .02em; color: hsl"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/app.css",
++ "line": 63,
++ "value": "hsl(var(--h)",
++ "text": ".medal { width: 56px; height: 56px; border-radius: 50%; display: grid; place-items: center; font-weight: 800; font-size: 1rem; letter-spacing: .02em; color: hsl"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/app.css",
++ "line": 64,
++ "value": "hsl(var(--h)",
++ "text": ".medal.earned { box-shadow: inset 0 0 0 3px hsl(var(--h) 80% 62%), 0 0 0 3px hsl(var(--h) 80% 62% / .25), 0 8px 20px -8px hsl(var(--h) 80% 50%); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/app.css",
++ "line": 64,
++ "value": "hsl(var(--h)",
++ "text": ".medal.earned { box-shadow: inset 0 0 0 3px hsl(var(--h) 80% 62%), 0 0 0 3px hsl(var(--h) 80% 62% / .25), 0 8px 20px -8px hsl(var(--h) 80% 50%); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/app.css",
++ "line": 64,
++ "value": "hsl(var(--h)",
++ "text": ".medal.earned { box-shadow: inset 0 0 0 3px hsl(var(--h) 80% 62%), 0 0 0 3px hsl(var(--h) 80% 62% / .25), 0 8px 20px -8px hsl(var(--h) 80% 50%); }"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Rent",
++ "commit": "de271ae4d15931793564c520eb4aa6c46dc0d9c3",
++ "files_scanned": 7,
++ "counts": {
++ "literal_duration": 4,
++ "literal_curve": 2,
++ "spinner": 0,
++ "color_drift": 3,
++ "missing_reduced_motion": 0,
++ "total": 9
++ },
++ "score": 98.35,
++ "findings_total": 9,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/app.css",
++ "line": 44,
++ "value": ".15s",
++ "text": ".listing { --k: 212; display: grid; grid-template-columns: 52px minmax(0, 1fr) auto; gap: 14px; align-items: center; padding: 14px 16px; border-radius: 14px; bo"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/app.css",
++ "line": 44,
++ "value": ".15s",
++ "text": ".listing { --k: 212; display: grid; grid-template-columns: 52px minmax(0, 1fr) auto; gap: 14px; align-items: center; padding: 14px 16px; border-radius: 14px; bo"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/everywhere.css",
++ "line": 7,
++ "value": ".15s",
++ "text": ".ew-site { display: flex; flex-direction: column; gap: 6px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(-"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/everywhere.css",
++ "line": 7,
++ "value": ".15s",
++ "text": ".ew-site { display: flex; flex-direction: column; gap: 6px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(-"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/everywhere.css",
++ "line": 7,
++ "value": "ease",
++ "text": ".ew-site { display: flex; flex-direction: column; gap: 6px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(-"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/everywhere.css",
++ "line": 7,
++ "value": "ease",
++ "text": ".ew-site { display: flex; flex-direction: column; gap: 6px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(-"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/app.css",
++ "line": 47,
++ "value": "hsl(var(--k)",
++ "text": ".listing-mark { width: 52px; height: 52px; border-radius: 13px; display: grid; place-items: center; font-size: .72rem; font-weight: 800; letter-spacing: .04em; "
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/app.css",
++ "line": 47,
++ "value": "hsl(var(--k)",
++ "text": ".listing-mark { width: 52px; height: 52px; border-radius: 13px; display: grid; place-items: center; font-size: .72rem; font-weight: 800; letter-spacing: .04em; "
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/app.css",
++ "line": 47,
++ "value": "hsl(var(--k)",
++ "text": ".listing-mark { width: 52px; height: 52px; border-radius: 13px; display: grid; place-items: center; font-size: .72rem; font-weight: 800; letter-spacing: .04em; "
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Report",
++ "commit": "dce6c1f9cb0ca4fed8f27e9f4336ff1b579c94f4",
++ "files_scanned": 4,
++ "counts": {
++ "literal_duration": 2,
++ "literal_curve": 0,
++ "spinner": 0,
++ "color_drift": 0,
++ "missing_reduced_motion": 0,
++ "total": 2
++ },
++ "score": 99.5,
++ "findings_total": 2,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/app.css",
++ "line": 124,
++ "value": ".18s",
++ "text": ".rp-topic { display: flex; flex-direction: column; gap: 6px; height: 100%; padding: 14px 16px; border-radius: 16px; background: var(--bg-card, #0f1729); border:"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/app.css",
++ "line": 124,
++ "value": ".18s",
++ "text": ".rp-topic { display: flex; flex-direction: column; gap: 6px; height: 100%; padding: 14px 16px; border-radius: 16px; background: var(--bg-card, #0f1729); border:"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Reviews",
++ "commit": "2732d267d97e85bb1c4b858ce4e7199ff13f31d3",
++ "files_scanned": 3,
++ "counts": {
++ "literal_duration": 0,
++ "literal_curve": 0,
++ "spinner": 0,
++ "color_drift": 9,
++ "missing_reduced_motion": 0,
++ "total": 9
++ },
++ "score": 99.55,
++ "findings_total": 9,
++ "findings": [
++ {
++ "rule": "color_drift",
++ "file": "public/css/reviews.css",
++ "line": 10,
++ "value": "--rv-red #f87171",
++ "text": "--rv-red: #f87171;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/reviews.css",
++ "line": 11,
++ "value": "--rv-ok #34d399",
++ "text": "--rv-ok: #34d399;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/reviews.css",
++ "line": 12,
++ "value": "--rv-warn #fbbf24",
++ "text": "--rv-warn: #fbbf24;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/reviews.css",
++ "line": 35,
++ "value": "#a78bfa",
++ "text": ".rv-notice.rv-ai { border-color: #a78bfa; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/reviews.css",
++ "line": 36,
++ "value": "#0a0f1c",
++ "text": ".rv-button, button { display: inline-block; background: var(--rv-accent); color: #0a0f1c; border: 0; border-radius: 6px; padding: 6px 14px; font: inherit; font-"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/reviews.css",
++ "line": 54,
++ "value": "rgba(52, 211, 153, .22)",
++ "text": ".ov-diff ins { background: rgba(52, 211, 153, .22); text-decoration: underline; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/reviews.css",
++ "line": 55,
++ "value": "rgba(248, 113, 113, .22)",
++ "text": ".ov-diff del { background: rgba(248, 113, 113, .22); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/reviews.css",
++ "line": 109,
++ "value": "#f5b301",
++ "text": ".rv-stars { color: #f5b301; letter-spacing: 1px; white-space: nowrap; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/reviews.css",
++ "line": 144,
++ "value": "#dc2626",
++ "text": ".rv-critical { background: color-mix(in srgb, #dc2626 16%, transparent); color: inherit; }"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Services",
++ "commit": "248e48f1dc87f5d04e67d7944ac976c4b68ef345",
++ "files_scanned": 3,
++ "counts": {
++ "literal_duration": 0,
++ "literal_curve": 0,
++ "spinner": 0,
++ "color_drift": 0,
++ "missing_reduced_motion": 0,
++ "total": 0
++ },
++ "score": 100,
++ "findings_total": 0,
++ "findings": []
++ },
++ {
++ "repo": "OpenVibe.Shoes",
++ "commit": "ccc1f8a02f8dd49e44a1ba0122a0eadab59c0b05",
++ "files_scanned": 2,
++ "counts": {
++ "literal_duration": 0,
++ "literal_curve": 0,
++ "spinner": 0,
++ "color_drift": 3,
++ "missing_reduced_motion": 0,
++ "total": 3
++ },
++ "score": 99.85,
++ "findings_total": 3,
++ "findings": [
++ {
++ "rule": "color_drift",
++ "file": "public/css/shoes.css",
++ "line": 22,
++ "value": "#fff",
++ "text": ".sh-btn { display: inline-flex; align-items: center; gap: 7px; min-height: 40px; padding: 8px 14px; border: 1px solid var(--room-accent, var(--accent)); border-"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/shoes.css",
++ "line": 98,
++ "value": "rgba(0, 0, 0, .7)",
++ "text": "box-shadow: 0 14px 30px -18px rgba(0, 0, 0, .7); color: var(--ovk-text, #e8ecf3); font-variant-numeric: tabular-nums;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/shoes.css",
++ "line": 105,
++ "value": "rgba(0, 0, 0, .7)",
++ "text": ".sh-mt.is-best { border-color: var(--room-accent); box-shadow: 0 0 0 1px var(--room-accent), 0 14px 30px -18px rgba(0, 0, 0, .7); }"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Sources",
++ "commit": "ce8f4619ef9e5af96bd8e964419c2efe37f95c56",
++ "files_scanned": 1,
++ "counts": {
++ "literal_duration": 0,
++ "literal_curve": 0,
++ "spinner": 0,
++ "color_drift": 0,
++ "missing_reduced_motion": 0,
++ "total": 0
++ },
++ "score": 100,
++ "findings_total": 0,
++ "findings": []
++ },
++ {
++ "repo": "OpenVibe.Space",
++ "commit": "d9becca7da9443a04f2828cf4aa1599b90ab6188",
++ "files_scanned": 5,
++ "counts": {
++ "literal_duration": 3,
++ "literal_curve": 0,
++ "spinner": 0,
++ "color_drift": 29,
++ "missing_reduced_motion": 0,
++ "total": 32
++ },
++ "score": 97.8,
++ "findings_total": 32,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/hosting.css",
++ "line": 129,
++ "value": ".15s",
++ "text": ".hd-tile { display: flex; gap: 14px; align-items: flex-start; padding: 18px; border: 1px solid var(--hd-line); border-radius: 16px; background: var(--hd-card); "
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/spaces.css",
++ "line": 39,
++ "value": ".15s",
++ "text": ".sp-card { display: flex; flex-direction: column; border: 1px solid var(--border, #2c3d5c); border-radius: 16px; background: var(--bg-secondary, #101828); overf"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/spaces.css",
++ "line": 39,
++ "value": ".15s",
++ "text": ".sp-card { display: flex; flex-direction: column; border: 1px solid var(--border, #2c3d5c); border-radius: 16px; background: var(--bg-secondary, #101828); overf"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/hosting.css",
++ "line": 44,
++ "value": "#fff",
++ "text": ".hd-badge--accent { color: color-mix(in srgb, var(--accent-light, #60a5fa) 65%, #fff); border-color: color-mix(in srgb, var(--hd-accent) 50%, transparent); back"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/hosting.css",
++ "line": 111,
++ "value": "#fff",
++ "text": ".hd-table a { color: color-mix(in srgb, var(--accent-light, #60a5fa) 70%, #fff); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/spaces.css",
++ "line": 20,
++ "value": "#ef4444",
++ "text": ".sp-flash.is-bad { border-color: #ef4444; color: #fecaca; background: color-mix(in srgb, #ef4444 12%, transparent); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/spaces.css",
++ "line": 20,
++ "value": "#fecaca",
++ "text": ".sp-flash.is-bad { border-color: #ef4444; color: #fecaca; background: color-mix(in srgb, #ef4444 12%, transparent); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/spaces.css",
++ "line": 20,
++ "value": "#ef4444",
++ "text": ".sp-flash.is-bad { border-color: #ef4444; color: #fecaca; background: color-mix(in srgb, #ef4444 12%, transparent); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/spaces.css",
++ "line": 34,
++ "value": "#f87171",
++ "text": ".sp-btn-danger { background: transparent; color: #f87171; border-color: color-mix(in srgb, #f87171 50%, transparent); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/spaces.css",
++ "line": 34,
++ "value": "#f87171",
++ "text": ".sp-btn-danger { background: transparent; color: #f87171; border-color: color-mix(in srgb, #f87171 50%, transparent); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/spaces.css",
++ "line": 46,
++ "value": "#4ade80",
++ "text": ".sp-card-badge.is-live { color: #4ade80; border-color: color-mix(in srgb, #4ade80 45%, transparent); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/spaces.css",
++ "line": 46,
++ "value": "#4ade80",
++ "text": ".sp-card-badge.is-live { color: #4ade80; border-color: color-mix(in srgb, #4ade80 45%, transparent); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/spaces.css",
++ "line": 59,
++ "value": "#fff",
++ "text": ".sp-template-icon { width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; font-size: 18px; color: #fff; background: var(--tpl); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/spaces.css",
++ "line": 85,
++ "value": "#fff",
++ "text": ".sp-frame { margin: 20px 0 8px; border: 1px solid var(--border, #2c3d5c); border-radius: 16px; overflow: hidden; background: #fff; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/spaces.css",
++ "line": 114,
++ "value": "#f87171",
++ "text": ".sp-ed-tab .sp-ed-x:hover { opacity: 1; color: #f87171; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/spaces.css",
++ "line": 121,
++ "value": "#f472b6",
++ "text": ".sp-ed-code .cm-s-default .cm-tag { color: #f472b6; } .sp-ed-code .cm-s-default .cm-attribute { color: #fbbf24; } .sp-ed-code .cm-s-default .cm-string, .sp-ed-c"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/spaces.css",
++ "line": 121,
++ "value": "#fbbf24",
++ "text": ".sp-ed-code .cm-s-default .cm-tag { color: #f472b6; } .sp-ed-code .cm-s-default .cm-attribute { color: #fbbf24; } .sp-ed-code .cm-s-default .cm-string, .sp-ed-c"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/spaces.css",
++ "line": 121,
++ "value": "#4ade80",
++ "text": ".sp-ed-code .cm-s-default .cm-tag { color: #f472b6; } .sp-ed-code .cm-s-default .cm-attribute { color: #fbbf24; } .sp-ed-code .cm-s-default .cm-string, .sp-ed-c"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/spaces.css",
++ "line": 122,
++ "value": "#c084fc",
++ "text": ".sp-ed-code .cm-s-default .cm-keyword { color: #c084fc; } .sp-ed-code .cm-s-default .cm-comment { color: #7b8aa3; font-style: italic; } .sp-ed-code .cm-s-defaul"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/spaces.css",
++ "line": 122,
++ "value": "#7b8aa3",
++ "text": ".sp-ed-code .cm-s-default .cm-keyword { color: #c084fc; } .sp-ed-code .cm-s-default .cm-comment { color: #7b8aa3; font-style: italic; } .sp-ed-code .cm-s-defaul"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/spaces.css",
++ "line": 122,
++ "value": "#fb923c",
++ "text": ".sp-ed-code .cm-s-default .cm-keyword { color: #c084fc; } .sp-ed-code .cm-s-default .cm-comment { color: #7b8aa3; font-style: italic; } .sp-ed-code .cm-s-defaul"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/spaces.css",
++ "line": 123,
++ "value": "#60a5fa",
++ "text": ".sp-ed-code .cm-s-default .cm-def, .sp-ed-code .cm-s-default .cm-property { color: #60a5fa; } .sp-ed-code .cm-s-default .cm-variable { color: var(--text-primary"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/spaces.css",
++ "line": 124,
++ "value": "#93c5fd",
++ "text": ".sp-ed-code .cm-s-default .cm-variable-2, .sp-ed-code .cm-s-default .cm-variable-3, .sp-ed-code .cm-s-default .cm-type { color: #93c5fd; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/spaces.css",
++ "line": 125,
++ "value": "#fcd34d",
++ "text": ".sp-ed-code .cm-s-default .cm-atom, .sp-ed-code .cm-s-default .cm-qualifier, .sp-ed-code .cm-s-default .cm-builtin { color: #fcd34d; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/spaces.css",
++ "line": 126,
++ "value": "#a5b4c8",
++ "text": ".sp-ed-code .cm-s-default .cm-meta, .sp-ed-code .cm-s-default .cm-bracket, .sp-ed-code .cm-s-default .cm-operator { color: #a5b4c8; }"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Surf",
++ "commit": "e605c50166437344f97e78283a39d33bce37636e",
++ "files_scanned": 4,
++ "counts": {
++ "literal_duration": 9,
++ "literal_curve": 5,
++ "spinner": 0,
++ "color_drift": 23,
++ "missing_reduced_motion": 0,
++ "total": 37
++ },
++ "score": 95.35,
++ "findings_total": 37,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/surf.css",
++ "line": 45,
++ "value": "1.1s",
++ "text": "html.ov-motion .sf-run-speed i { animation: sf-speed 1.1s linear infinite; animation-delay: calc(var(--i) * -.12s); }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/surf.css",
++ "line": 55,
++ "value": ".15s",
++ "text": ".sf-tile a { display: grid; gap: 8px; height: 100%; padding: 18px; border-radius: 20px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/surf.css",
++ "line": 55,
++ "value": ".15s",
++ "text": ".sf-tile a { display: grid; gap: 8px; height: 100%; padding: 18px; border-radius: 20px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/surf.css",
++ "line": 78,
++ "value": "1.4s",
++ "text": ".sf-demo-steps li.is-now::before { border-color: var(--room-accent); animation: sf-pulse 1.4s ease-out infinite; } /* the step on now pulses out, never spins "
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/surf.css",
++ "line": 78,
++ "value": "ease-out",
++ "text": ".sf-demo-steps li.is-now::before { border-color: var(--room-accent); animation: sf-pulse 1.4s ease-out infinite; } /* the step on now pulses out, never spins "
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/surf.css",
++ "line": 89,
++ "value": ".15s",
++ "text": ".sf-map-link { display: grid; grid-template-columns: 84px minmax(0, 1fr); gap: 14px; align-items: center; padding: 12px; border-radius: 18px; border: 1px solid "
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/surf.css",
++ "line": 129,
++ "value": ".28s",
++ "text": ".sf-tv-screen.is-changing { animation: sf-change .28s ease; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/surf.css",
++ "line": 129,
++ "value": "ease",
++ "text": ".sf-tv-screen.is-changing { animation: sf-change .28s ease; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/surf.css",
++ "line": 160,
++ "value": "2.4s",
++ "text": "html.ov-motion .sf-party-dot { animation: sf-party-pulse 2.4s ease-in-out infinite; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/surf.css",
++ "line": 160,
++ "value": "ease-in-out",
++ "text": "html.ov-motion .sf-party-dot { animation: sf-party-pulse 2.4s ease-in-out infinite; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/surf.css",
++ "line": 209,
++ "value": "1.6s",
++ "text": "html.ov-motion .sf-party-playing .sf-party-dot { animation: sf-pulse 1.6s ease-out infinite; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/surf.css",
++ "line": 209,
++ "value": "ease-out",
++ "text": "html.ov-motion .sf-party-playing .sf-party-dot { animation: sf-pulse 1.6s ease-out infinite; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/surf.css",
++ "line": 227,
++ "value": "1.6s",
++ "text": ".sf-desk-page[data-sf-desk-open=\"1\"] .sf-desk-state::before { background: #22c55e; animation: sf-desk-pulse 1.6s ease-out infinite; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/surf.css",
++ "line": 227,
++ "value": "ease-out",
++ "text": ".sf-desk-page[data-sf-desk-open=\"1\"] .sf-desk-state::before { background: #22c55e; animation: sf-desk-pulse 1.6s ease-out infinite; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/surf.css",
++ "line": 47,
++ "value": "rgba(0, 0, 0, .55)",
++ "text": ".sf-run-hud { position: absolute; left: 50%; bottom: 10%; translate: -50% 0; display: grid; justify-items: center; gap: 2px; padding: 8px 16px; border-radius: 1"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/surf.css",
++ "line": 47,
++ "value": "#fff",
++ "text": ".sf-run-hud { position: absolute; left: 50%; bottom: 10%; translate: -50% 0; display: grid; justify-items: center; gap: 2px; padding: 8px 16px; border-radius: 1"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/surf.css",
++ "line": 66,
++ "value": "#0b0f17",
++ "text": ".sf-demo-run { display: grid; gap: 10px; padding: 16px; border-radius: 18px; background: #0b0f17; color: #e9f2ff; border: 1px solid color-mix(in srgb, var(--roo"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/surf.css",
++ "line": 66,
++ "value": "#e9f2ff",
++ "text": ".sf-demo-run { display: grid; gap: 10px; padding: 16px; border-radius: 18px; background: #0b0f17; color: #e9f2ff; border: 1px solid color-mix(in srgb, var(--roo"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/surf.css",
++ "line": 69,
++ "value": "#4ade80",
++ "text": ".sf-demo-hud .is-good { color: #4ade80; font-variant-numeric: tabular-nums; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/surf.css",
++ "line": 70,
++ "value": "rgba(255, 255, 255, .12)",
++ "text": ".sf-demo-bar { height: 6px; border-radius: 6px; background: rgba(255, 255, 255, .12); overflow: hidden; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/surf.css",
++ "line": 71,
++ "value": "#00bef6",
++ "text": ".sf-demo-bar i { display: block; height: 100%; background: #00bef6; border-radius: inherit; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/surf.css",
++ "line": 82,
++ "value": "#fff",
++ "text": ".sf-demo-screen { position: relative; display: flex; align-items: flex-end; gap: 8px; aspect-ratio: 16 / 6; max-height: 210px; padding: 12px; border-radius: 12p"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/surf.css",
++ "line": 83,
++ "value": "#1e293b",
++ "text": "background: radial-gradient(120% 120% at 70% 30%, color-mix(in srgb, var(--room-accent) 45%, transparent), transparent 60%), linear-gradient(160deg, #1e293b, #0"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/surf.css",
++ "line": 83,
++ "value": "#0b1020",
++ "text": "background: radial-gradient(120% 120% at 70% 30%, color-mix(in srgb, var(--room-accent) 45%, transparent), transparent 60%), linear-gradient(160deg, #1e293b, #0"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/surf.css",
++ "line": 85,
++ "value": "#fff",
++ "text": ".sf-pill-live { padding: 1px 7px; border-radius: 5px; background: var(--live-red, #e11d48); color: #fff; font-size: .68rem; font-weight: 800; }"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Tips",
++ "commit": "eb708f169ed98840d56f248f07f4e5da998c875c",
++ "files_scanned": 4,
++ "counts": {
++ "literal_duration": 3,
++ "literal_curve": 0,
++ "spinner": 0,
++ "color_drift": 9,
++ "missing_reduced_motion": 0,
++ "total": 12
++ },
++ "score": 98.8,
++ "findings_total": 12,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/overlay.css",
++ "line": 3,
++ "value": ".35s",
++ "text": "#alert { position: fixed; left: 50%; top: 12%; transform: translateX(-50%); width: min(90vw, 760px); text-align: center; opacity: 0; transition: opacity .35s, t"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/overlay.css",
++ "line": 3,
++ "value": ".35s",
++ "text": "#alert { position: fixed; left: 50%; top: 12%; transform: translateX(-50%); width: min(90vw, 760px); text-align: center; opacity: 0; transition: opacity .35s, t"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/overlay.css",
++ "line": 14,
++ "value": ".6s",
++ "text": ".g .bar span { display: block; height: 100%; background: #3b82f6; transition: width .6s; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/tips.css",
++ "line": 10,
++ "value": "--tips-bad #f87171",
++ "text": "--tips-bad: #f87171;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/tips.css",
++ "line": 11,
++ "value": "--tips-good #34d399",
++ "text": "--tips-good: #34d399;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/tips.css",
++ "line": 40,
++ "value": "rgba(59, 130, 246, .12)",
++ "text": ".notice { background: rgba(59, 130, 246, .12); border: 1px solid rgba(59, 130, 246, .4); border-radius: 8px; padding: 10px 14px; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/tips.css",
++ "line": 40,
++ "value": "rgba(59, 130, 246, .4)",
++ "text": ".notice { background: rgba(59, 130, 246, .12); border: 1px solid rgba(59, 130, 246, .4); border-radius: 8px; padding: 10px 14px; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/tips.css",
++ "line": 41,
++ "value": "rgba(248, 113, 113, .12)",
++ "text": ".notice.error { background: rgba(248, 113, 113, .12); border-color: rgba(248, 113, 113, .5); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/tips.css",
++ "line": 41,
++ "value": "rgba(248, 113, 113, .5)",
++ "text": ".notice.error { background: rgba(248, 113, 113, .12); border-color: rgba(248, 113, 113, .5); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/tips.css",
++ "line": 112,
++ "value": "#b91c1c",
++ "text": ".tp-live { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: .72rem; font-weight: 800; letter-spacing: .04em; text-transform: uppercase;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/tips.css",
++ "line": 112,
++ "value": "#000",
++ "text": ".tp-live { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: .72rem; font-weight: 800; letter-spacing: .04em; text-transform: uppercase;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/tips.css",
++ "line": 112,
++ "value": "#fff",
++ "text": ".tp-live { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: .72rem; font-weight: 800; letter-spacing: .04em; text-transform: uppercase;"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Today",
++ "commit": "2d77c36fbc70f6aeae7c59645822d5bd48631df6",
++ "files_scanned": 3,
++ "counts": {
++ "literal_duration": 5,
++ "literal_curve": 3,
++ "spinner": 0,
++ "color_drift": 9,
++ "missing_reduced_motion": 0,
++ "total": 17
++ },
++ "score": 97.55,
++ "findings_total": 17,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/today.css",
++ "line": 37,
++ "value": "1.4s",
++ "text": ".td-pill.is-live::before { content: \"\"; width: 6px; height: 6px; border-radius: 50%; background: #fff; animation: td-pulse 1.4s ease-in-out infinite; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/today.css",
++ "line": 37,
++ "value": "ease-in-out",
++ "text": ".td-pill.is-live::before { content: \"\"; width: 6px; height: 6px; border-radius: 50%; background: #fff; animation: td-pulse 1.4s ease-in-out infinite; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/today.css",
++ "line": 48,
++ "value": ".15s",
++ "text": ".td-ev-link { display: flex; align-items: center; gap: 14px; padding: 10px 14px 10px 10px; border-radius: 16px; border: 1px solid var(--border, #1f2d47); backgr"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/today.css",
++ "line": 48,
++ "value": ".15s",
++ "text": ".td-ev-link { display: flex; align-items: center; gap: 14px; padding: 10px 14px 10px 10px; border-radius: 16px; border: 1px solid var(--border, #1f2d47); backgr"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/today.css",
++ "line": 73,
++ "value": ".6s",
++ "text": "html.ov-motion .td-ring-dot { animation: td-dot-in .6s cubic-bezier(.2, .8, .2, 1) both; animation-delay: calc(var(--i) * 90ms + 200ms); }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/today.css",
++ "line": 73,
++ "value": "cubic-bezier(",
++ "text": "html.ov-motion .td-ring-dot { animation: td-dot-in .6s cubic-bezier(.2, .8, .2, 1) both; animation-delay: calc(var(--i) * 90ms + 200ms); }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/today.css",
++ "line": 95,
++ "value": "4.5s",
++ "text": "html.ov-motion .td-demo-toast { animation: td-toast 4.5s ease-in-out infinite; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/today.css",
++ "line": 95,
++ "value": "ease-in-out",
++ "text": "html.ov-motion .td-demo-toast { animation: td-toast 4.5s ease-in-out infinite; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/today.css",
++ "line": 36,
++ "value": "#fff",
++ "text": ".td-pill.is-live { background: var(--live-red, #e11d48); color: #fff; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/today.css",
++ "line": 37,
++ "value": "#fff",
++ "text": ".td-pill.is-live::before { content: \"\"; width: 6px; height: 6px; border-radius: 50%; background: #fff; animation: td-pulse 1.4s ease-in-out infinite; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/today.css",
++ "line": 92,
++ "value": "rgba(0, 0, 0, .3)",
++ "text": ".td-demo-toast { display: flex; align-items: center; gap: 12px; margin-left: 10%; padding: 12px 14px; border-radius: 16px; background: var(--bg-elevated, var(--"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/today.css",
++ "line": 206,
++ "value": "#f87171",
++ "text": ".td-remind-out.is-bad { color: #f87171; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/today.css",
++ "line": 252,
++ "value": "--k #22c55e",
++ "text": ".td-wi.is-band { --k: color-mix(in srgb, var(--room-accent) 55%, #22c55e); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/today.css",
++ "line": 306,
++ "value": "#22c55e",
++ "text": ".td-remind-ok { display: grid; place-items: center; width: 52px; height: 52px; margin-bottom: 10px; border-radius: 16px; background: color-mix(in srgb, #22c55e "
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/today.css",
++ "line": 306,
++ "value": "#22c55e",
++ "text": ".td-remind-ok { display: grid; place-items: center; width: 52px; height: 52px; margin-bottom: 10px; border-radius: 16px; background: color-mix(in srgb, #22c55e "
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/today.css",
++ "line": 308,
++ "value": "#ef4444",
++ "text": ".td-notice--bad { padding: 12px 14px; border-radius: 12px; border: 1px solid color-mix(in srgb, #ef4444 50%, var(--border, #1f2d47)); background: color-mix(in s"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/today.css",
++ "line": 308,
++ "value": "#ef4444",
++ "text": ".td-notice--bad { padding: 12px 14px; border-radius: 12px; border: 1px solid color-mix(in srgb, #ef4444 50%, var(--border, #1f2d47)); background: color-mix(in s"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Tools",
++ "commit": "1f1ac7afc05d127533887403dd4dbfb6430c3e64",
++ "files_scanned": 69,
++ "counts": {
++ "literal_duration": 157,
++ "literal_curve": 44,
++ "spinner": 0,
++ "color_drift": 399,
++ "missing_reduced_motion": 11,
++ "total": 611
++ },
++ "score": 7.8,
++ "findings_total": 611,
++ "findings": [
++ {
++ "rule": "missing_reduced_motion",
++ "file": "apps/audio/public/css/style.css",
++ "line": 213,
++ "value": "animation",
++ "text": ".option-group { animation: fadeIn .25s ease; }"
++ },
++ {
++ "rule": "missing_reduced_motion",
++ "file": "apps/docs/public/css/style.css",
++ "line": 342,
++ "value": "animation",
++ "text": "animation: spin 1.4s ease-out infinite;"
++ },
++ {
++ "rule": "missing_reduced_motion",
++ "file": "apps/food/public/css/style.css",
++ "line": 212,
++ "value": "animation",
++ "text": ".spinner { width:24px; height:24px; border: 3px solid var(--green); --spin-c: var(--green); border-radius:50%; animation: spin 1.4s ease-out infinite; margin:0 "
++ },
++ {
++ "rule": "missing_reduced_motion",
++ "file": "apps/gateway/public/audio.html",
++ "line": 67,
++ "value": "animation",
++ "text": ".showcase-tool { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px; border-radius: 16px; background: var(--bg-c"
++ },
++ {
++ "rule": "missing_reduced_motion",
++ "file": "apps/gateway/public/dev.html",
++ "line": 35,
++ "value": "animation",
++ "text": ".bg-glow::before,.bg-glow::after{content:'';position:absolute;border-radius:50%;filter:blur(120px);opacity:.35;animation:gd 20s ease-in-out infinite alternate}"
++ },
++ {
++ "rule": "missing_reduced_motion",
++ "file": "apps/gateway/public/img.html",
++ "line": 67,
++ "value": "animation",
++ "text": ".showcase-tool { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px; border-radius: 16px; background: var(--bg-c"
++ },
++ {
++ "rule": "missing_reduced_motion",
++ "file": "apps/gateway/public/net.html",
++ "line": 74,
++ "value": "animation",
++ "text": "animation: glow-drift 20s ease-in-out infinite alternate;"
++ },
++ {
++ "rule": "missing_reduced_motion",
++ "file": "apps/img/public/css/style.css",
++ "line": 337,
++ "value": "animation",
++ "text": "animation: spin 1.4s ease-out infinite;"
++ },
++ {
++ "rule": "missing_reduced_motion",
++ "file": "apps/maps/public/css/style.css",
++ "line": 48,
++ "value": "animation",
++ "text": ".splash-content { text-align: center; animation: fadeIn 0.5s ease; }"
++ },
++ {
++ "rule": "missing_reduced_motion",
++ "file": "apps/text/public/css/shared.css",
++ "line": 305,
++ "value": "animation",
++ "text": "animation: livePulse 2s ease-in-out infinite;"
++ },
++ {
++ "rule": "missing_reduced_motion",
++ "file": "apps/yt/public/css/style.css",
++ "line": 172,
++ "value": "animation",
++ "text": "animation: spin 1.4s ease-out infinite;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "apps/gateway/public/codes.html",
++ "line": 56,
++ "value": ".15s",
++ "text": ".cx-tabs a{display:inline-flex;align-items:center;gap:6px;flex:none;padding:7px 12px;border-radius:9px;color:var(--c-tx2);text-decoration:none;font-size:.86rem;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "apps/text/public/badge.html",
++ "line": 38,
++ "value": ".2s",
++ "text": ".shape-pill { padding: .4rem .9rem; border-radius: 20px; border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-secondary); font-siz"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "apps/text/public/ice.html",
++ "line": 44,
++ "value": "0.2s",
++ "text": "transition: border-color 0.2s, transform 0.2s;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "apps/text/public/peso.html",
++ "line": 68,
++ "value": "0.3s",
++ "text": "transition: transform 0.3s, background 0.2s;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "apps/text/public/slang.html",
++ "line": 46,
++ "value": "0.2s",
++ "text": "font-weight: 600; transition: all 0.2s; font-family: 'Inter', sans-serif;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "apps/text/public/spanish.html",
++ "line": 34,
++ "value": "0.2s",
++ "text": "transition: border-color 0.2s;"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "apps/text/public/thumbnail.html",
++ "line": 40,
++ "value": ".2s",
++ "text": ".upload-btn { width: 100%; padding: .75rem; border-radius: 12px; border: 2px dashed var(--border); background: var(--bg-tertiary); color: var(--text-secondary);"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "apps/text/public/title.html",
++ "line": 36,
++ "value": ".2s",
++ "text": ".preset-btn { padding: .5rem .25rem; font-size: .7rem; border-radius: 10px; cursor: pointer; border: 2px solid var(--border); background: var(--bg-tertiary); co"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "apps/text/public/watermark.html",
++ "line": 29,
++ "value": ".2s",
++ "text": ".drop-zone { border: 2px dashed var(--border); border-radius: var(--radius); padding: 2rem; text-align: center; cursor: pointer; transition: all .2s; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "apps/text/public/wordmark.html",
++ "line": 25,
++ "value": ".2s",
++ "text": ".wm-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; cursor: pointer; transition: all .2s; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "apps/text/public/compare.html",
++ "line": 28,
++ "value": "rgba(34,197,94,0.15)",
++ "text": ".diff-add { background: rgba(34,197,94,0.15); color: #4ade80; text-decoration: none; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "apps/text/public/json.html",
++ "line": 29,
++ "value": "#93c5fd",
++ "text": ".json-key { color: #93c5fd; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "apps/text/public/transparent.html",
++ "line": 25,
++ "value": "#141414",
++ "text": ".canvas-wrap { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 2rem; text-align: center; margin: 2rem 0; bac"
++ },
++ {
++ "rule": "color_drift",
++ "file": "apps/text/public/unicode.html",
++ "line": 30,
++ "value": "rgba(255, 0, 0, 0.1)",
++ "text": ".char-invisible { background: rgba(255, 0, 0, 0.1); }"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Tours",
++ "commit": "c04d2bc4e070188f55ede2a11c367c7c0003e7c1",
++ "files_scanned": 4,
++ "counts": {
++ "literal_duration": 0,
++ "literal_curve": 0,
++ "spinner": 0,
++ "color_drift": 13,
++ "missing_reduced_motion": 0,
++ "total": 13
++ },
++ "score": 99.35,
++ "findings_total": 13,
++ "findings": [
++ {
++ "rule": "color_drift",
++ "file": "public/css/tours.css",
++ "line": 32,
++ "value": "#0a0f1c",
++ "text": ".tr-pin-n { fill: #0a0f1c; font-size: 9px; font-weight: 800; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/tours.css",
++ "line": 33,
++ "value": "rgba(0,0,0,.4)",
++ "text": ".tr-walk { filter: drop-shadow(0 2px 8px rgba(0,0,0,.4)); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/tours.css",
++ "line": 50,
++ "value": "#b44444",
++ "text": ".tr-note-error { border-color: #b44444; color: #ffb4b4; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/tours.css",
++ "line": 50,
++ "value": "#ffb4b4",
++ "text": ".tr-note-error { border-color: #b44444; color: #ffb4b4; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/tours.css",
++ "line": 51,
++ "value": "#8a6a1f",
++ "text": ".tr-note-warn { border-color: #8a6a1f; color: #ffd98a; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/tours.css",
++ "line": 51,
++ "value": "#ffd98a",
++ "text": ".tr-note-warn { border-color: #8a6a1f; color: #ffd98a; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/tours.css",
++ "line": 81,
++ "value": "#ef4444",
++ "text": ".tr-live-card img, .tr-live-noimg { width: 100%; aspect-ratio: 16/9; object-fit: cover; border-radius: 10px; background: var(--bg-primary, #0a0f1c); display: gr"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/tours.css",
++ "line": 82,
++ "value": "#ef4444",
++ "text": ".tr-live-live { position: absolute; top: 16px; left: 16px; padding: 2px 8px; border-radius: 999px; background: #ef4444; color: #fff; font-size: .7rem; font-weig"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/tours.css",
++ "line": 82,
++ "value": "#fff",
++ "text": ".tr-live-live { position: absolute; top: 16px; left: 16px; padding: 2px 8px; border-radius: 999px; background: #ef4444; color: #fff; font-size: .7rem; font-weig"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/tours.css",
++ "line": 83,
++ "value": "#ef4444",
++ "text": ".tr-live-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: #ef4444; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/tours.css",
++ "line": 93,
++ "value": "#0a0f1c",
++ "text": ".tr-map-pin { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; background: var(--room-accent, #F98942); color: #0a0f1c; font-w"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/tours.css",
++ "line": 93,
++ "value": "#0a0f1c",
++ "text": ".tr-map-pin { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; background: var(--room-accent, #F98942); color: #0a0f1c; font-w"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/tours.css",
++ "line": 96,
++ "value": "#0a0f1c",
++ "text": ".tr-stop-num { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; background: var(--room-accent, #F98942); color: #0a0f1c; font-"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Trade",
++ "commit": "48bcb670dd00d6206e35ae6e81a9263cd0710f64",
++ "files_scanned": 1,
++ "counts": {
++ "literal_duration": 1,
++ "literal_curve": 0,
++ "spinner": 0,
++ "color_drift": 1,
++ "missing_reduced_motion": 0,
++ "total": 2
++ },
++ "score": 99.7,
++ "findings_total": 2,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/trade.css",
++ "line": 78,
++ "value": ".15s",
++ "text": ".tr-stats a { display: flex; flex-direction: column; gap: 2px; height: 100%; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); "
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/trade.css",
++ "line": 30,
++ "value": "rgba(245, 158, 11, .07)",
++ "text": "tr.is-stale td, tr.is-stale th { background: rgba(245, 158, 11, .07); }"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.VIP",
++ "commit": "daab64a5fbe7c4706bcd82f073b5d20cc3f48a34",
++ "files_scanned": 2,
++ "counts": {
++ "literal_duration": 1,
++ "literal_curve": 0,
++ "spinner": 0,
++ "color_drift": 8,
++ "missing_reduced_motion": 0,
++ "total": 9
++ },
++ "score": 99.35,
++ "findings_total": 9,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/vip.css",
++ "line": 55,
++ "value": ".15s",
++ "text": ".creator-list a { display: flex; align-items: center; gap: 12px; height: 100%; background: var(--vip-card); border: 1px solid var(--vip-border); border-radius: "
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/vip.css",
++ "line": 13,
++ "value": "--vip-bad #f87171",
++ "text": "--vip-bad: #f87171;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/vip.css",
++ "line": 14,
++ "value": "--vip-good #34d399",
++ "text": "--vip-good: #34d399;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/vip.css",
++ "line": 49,
++ "value": "rgba(59, 130, 246, .12)",
++ "text": ".notice { background: rgba(59, 130, 246, .12); border: 1px solid rgba(59, 130, 246, .4); border-radius: 8px; padding: 10px 14px; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/vip.css",
++ "line": 49,
++ "value": "rgba(59, 130, 246, .4)",
++ "text": ".notice { background: rgba(59, 130, 246, .12); border: 1px solid rgba(59, 130, 246, .4); border-radius: 8px; padding: 10px 14px; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/vip.css",
++ "line": 50,
++ "value": "rgba(248, 113, 113, .12)",
++ "text": ".notice-error { background: rgba(248, 113, 113, .12); border-color: rgba(248, 113, 113, .5); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/vip.css",
++ "line": 50,
++ "value": "rgba(248, 113, 113, .5)",
++ "text": ".notice-error { background: rgba(248, 113, 113, .12); border-color: rgba(248, 113, 113, .5); }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/vip.css",
++ "line": 63,
++ "value": "rgba(255, 255, 255, .06)",
++ "text": "code { background: rgba(255, 255, 255, .06); padding: 1px 5px; border-radius: 4px; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/vip.css",
++ "line": 79,
++ "value": "#1a0505",
++ "text": ".vip-live-pill { flex: none; display: inline-block; border-radius: 999px; padding: 0 8px; font-size: .75rem; font-weight: 700; letter-spacing: .04em; text-trans"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Vision",
++ "commit": "45060caa486acaad2b9bd97dc1fac2cc4786e2c1",
++ "files_scanned": 4,
++ "counts": {
++ "literal_duration": 11,
++ "literal_curve": 5,
++ "spinner": 0,
++ "color_drift": 7,
++ "missing_reduced_motion": 0,
++ "total": 23
++ },
++ "score": 95.65,
++ "findings_total": 23,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/vision.css",
++ "line": 61,
++ "value": "1.8s",
++ "text": "html.ov-motion .vi-race-img path { stroke-dasharray: 420; animation: vi-draw 1.8s cubic-bezier(.2,.7,.2,1) both; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/vision.css",
++ "line": 61,
++ "value": "cubic-bezier(",
++ "text": "html.ov-motion .vi-race-img path { stroke-dasharray: 420; animation: vi-draw 1.8s cubic-bezier(.2,.7,.2,1) both; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/vision.css",
++ "line": 68,
++ "value": ".6s",
++ "text": "html.ov-motion .vi-race-box { animation: vi-pop .6s cubic-bezier(.2,.9,.3,1.2) both; } html.ov-motion .vi-race-b { animation-delay: .35s; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/vision.css",
++ "line": 68,
++ "value": "cubic-bezier(",
++ "text": "html.ov-motion .vi-race-box { animation: vi-pop .6s cubic-bezier(.2,.9,.3,1.2) both; } html.ov-motion .vi-race-b { animation-delay: .35s; }"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/vision.css",
++ "line": 108,
++ "value": ".15s",
++ "text": ".vi-task { display: grid; gap: 6px; height: 100%; padding: 16px 18px; border-radius: 18px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, "
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/vision.css",
++ "line": 108,
++ "value": ".15s",
++ "text": ".vi-task { display: grid; gap: 6px; height: 100%; padding: 16px 18px; border-radius: 18px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, "
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/vision.css",
++ "line": 122,
++ "value": ".2s",
++ "text": ".vi-demo-switch::after { content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: var(--text-muted, #738"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/vision.css",
++ "line": 129,
++ "value": ".2s",
++ "text": ".vi-check > input[role=switch] { appearance: none; -webkit-appearance: none; position: relative; flex: none; width: 44px; height: 26px; margin: 0; border-radius"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/vision.css",
++ "line": 130,
++ "value": ".2s",
++ "text": ".vi-check > input[role=switch]::after { content: ''; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: var(--t"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/vision.css",
++ "line": 130,
++ "value": ".2s",
++ "text": ".vi-check > input[role=switch]::after { content: ''; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: var(--t"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/vision.css",
++ "line": 144,
++ "value": ".15s",
++ "text": ".vi-drop { position: relative; display: grid; justify-items: center; align-content: center; gap: 6px; min-height: 170px; padding: 24px 20px; border: 2px dashed "
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/vision.css",
++ "line": 144,
++ "value": ".15s",
++ "text": ".vi-drop { position: relative; display: grid; justify-items: center; align-content: center; gap: 6px; min-height: 170px; padding: 24px 20px; border: 2px dashed "
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/vision.css",
++ "line": 144,
++ "value": "ease",
++ "text": ".vi-drop { position: relative; display: grid; justify-items: center; align-content: center; gap: 6px; min-height: 170px; padding: 24px 20px; border: 2px dashed "
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/vision.css",
++ "line": 144,
++ "value": "ease",
++ "text": ".vi-drop { position: relative; display: grid; justify-items: center; align-content: center; gap: 6px; min-height: 170px; padding: 24px 20px; border: 2px dashed "
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/vision.css",
++ "line": 258,
++ "value": ".15s",
++ "text": ".vi-options summary::after, .vi-disclose summary::after, .vi-api summary::after { content: ''; flex: none; width: 8px; height: 8px; margin-left: auto; border-ri"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/vision.css",
++ "line": 258,
++ "value": "ease",
++ "text": ".vi-options summary::after, .vi-disclose summary::after, .vi-api summary::after { content: ''; flex: none; width: 8px; height: 8px; margin-left: auto; border-ri"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/vision.css",
++ "line": 73,
++ "value": "#fbfaf6",
++ "text": ".vi-demo-receipt { display: grid; gap: 4px; width: min(100%, 300px); margin: 0 auto; padding: 18px 20px; border-radius: 6px; background: #fbfaf6; color: #222; f"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/vision.css",
++ "line": 73,
++ "value": "#222",
++ "text": ".vi-demo-receipt { display: grid; gap: 4px; width: min(100%, 300px); margin: 0 auto; padding: 18px 20px; border-radius: 6px; background: #fbfaf6; color: #222; f"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/vision.css",
++ "line": 73,
++ "value": "rgba(0, 0, 0, .25)",
++ "text": ".vi-demo-receipt { display: grid; gap: 4px; width: min(100%, 300px); margin: 0 auto; padding: 18px 20px; border-radius: 6px; background: #fbfaf6; color: #222; f"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/vision.css",
++ "line": 74,
++ "value": "#999",
++ "text": ".vi-demo-receipt strong { margin-top: 6px; padding-top: 6px; border-top: 1px dashed #999; font-size: 1.05rem; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/vision.css",
++ "line": 75,
++ "value": "#6b6b6b",
++ "text": ".vi-demo-receipt small { color: #6b6b6b; font-size: .74rem; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/vision.css",
++ "line": 76,
++ "value": "#fff",
++ "text": ".vi-demo-kicker { justify-self: start; padding: 2px 8px; border-radius: 6px; font: 800 .68rem/1.4 Inter, sans-serif; letter-spacing: .08em; color: #fff; backgro"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/vision.css",
++ "line": 76,
++ "value": "#6b4ab0",
++ "text": ".vi-demo-kicker { justify-self: start; padding: 2px 8px; border-radius: 6px; font: 800 .68rem/1.4 Inter, sans-serif; letter-spacing: .08em; color: #fff; backgro"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Watch",
++ "commit": "eb5557e97c5075c757946d2c60a5551541b13f11",
++ "files_scanned": 2,
++ "counts": {
++ "literal_duration": 1,
++ "literal_curve": 0,
++ "spinner": 0,
++ "color_drift": 0,
++ "missing_reduced_motion": 0,
++ "total": 1
++ },
++ "score": 99.75,
++ "findings_total": 1,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/home.css",
++ "line": 13,
++ "value": ".15s",
++ "text": ".hm-card { display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-width: 0; padding: 14px 16px; border-radius: 16px; border: 1px "
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Wiki",
++ "commit": "ee24a8beabde07168ad0f8cf36f2b6fd8ab2df78",
++ "files_scanned": 2,
++ "counts": {
++ "literal_duration": 6,
++ "literal_curve": 3,
++ "spinner": 0,
++ "color_drift": 7,
++ "missing_reduced_motion": 1,
++ "total": 17
++ },
++ "score": 95.4,
++ "findings_total": 17,
++ "findings": [
++ {
++ "rule": "missing_reduced_motion",
++ "file": "public/css/vibe.css",
++ "line": 31,
++ "value": "animation",
++ "text": ".wk-vp-steps .wk-vp-step-now { border-color: var(--wk-accent); color: var(--wk-text); background: color-mix(in srgb, var(--wk-accent) 12%, transparent); animati"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/vibe.css",
++ "line": 14,
++ "value": ".15s",
++ "text": ".wk-vibe-sizes span { display: block; height: 100%; padding: 10px 12px; border: 1px solid var(--wk-line); border-radius: 12px; background: var(--wk-panel); tran"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/vibe.css",
++ "line": 14,
++ "value": ".15s",
++ "text": ".wk-vibe-sizes span { display: block; height: 100%; padding: 10px 12px; border: 1px solid var(--wk-line); border-radius: 12px; background: var(--wk-panel); tran"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/vibe.css",
++ "line": 31,
++ "value": "1.6s",
++ "text": ".wk-vp-steps .wk-vp-step-now { border-color: var(--wk-accent); color: var(--wk-text); background: color-mix(in srgb, var(--wk-accent) 12%, transparent); animati"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/vibe.css",
++ "line": 31,
++ "value": "ease-in-out",
++ "text": ".wk-vp-steps .wk-vp-step-now { border-color: var(--wk-accent); color: var(--wk-text); background: color-mix(in srgb, var(--wk-accent) 12%, transparent); animati"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/vibe.css",
++ "line": 36,
++ "value": ".2s",
++ "text": ".wk-vp-pages li { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px; border: 1px solid var(--wk-line); border-radius: 12px; background: var(--"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/vibe.css",
++ "line": 42,
++ "value": "1s",
++ "text": ".wk-vp-pages .wk-vp-writing small::before { content: \"\"; display: inline-block; width: 8px; height: 8px; margin-right: 6px; border-radius: 50%; background: var("
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/vibe.css",
++ "line": 42,
++ "value": "ease-in-out",
++ "text": ".wk-vp-pages .wk-vp-writing small::before { content: \"\"; display: inline-block; width: 8px; height: 8px; margin-right: 6px; border-radius: 50%; background: var("
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/vibe.css",
++ "line": 45,
++ "value": "1.2s",
++ "text": ".wk-vp-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--wk-accent); animation: wk-pulse 1.2s ease-in-out infinite; }"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/vibe.css",
++ "line": 45,
++ "value": "ease-in-out",
++ "text": ".wk-vp-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--wk-accent); animation: wk-pulse 1.2s ease-in-out infinite; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/vibe.css",
++ "line": 18,
++ "value": "#f472b6",
++ "text": ".wk-vibe h1 span { background: linear-gradient(90deg, var(--wk-accent), color-mix(in srgb, var(--wk-accent) 40%, #f472b6)); -webkit-background-clip: text; backg"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/wiki.css",
++ "line": 10,
++ "value": "--wk-red #f87171",
++ "text": "--wk-red: #f87171;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/wiki.css",
++ "line": 11,
++ "value": "--wk-ok #34d399",
++ "text": "--wk-ok: #34d399;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/wiki.css",
++ "line": 12,
++ "value": "--wk-warn #fbbf24",
++ "text": "--wk-warn: #fbbf24;"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/wiki.css",
++ "line": 35,
++ "value": "#a78bfa",
++ "text": ".wk-notice.wk-ai { border-color: #a78bfa; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/wiki.css",
++ "line": 56,
++ "value": "rgba(52, 211, 153, .22)",
++ "text": ".ov-diff ins { background: rgba(52, 211, 153, .22); text-decoration: underline; }"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/wiki.css",
++ "line": 57,
++ "value": "rgba(248, 113, 113, .22)",
++ "text": ".ov-diff del { background: rgba(248, 113, 113, .22); }"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Work",
++ "commit": "44cba11abad694b8bd895aa2275675760d51f3e8",
++ "files_scanned": 3,
++ "counts": {
++ "literal_duration": 4,
++ "literal_curve": 2,
++ "spinner": 0,
++ "color_drift": 3,
++ "missing_reduced_motion": 0,
++ "total": 9
++ },
++ "score": 98.35,
++ "findings_total": 9,
++ "findings": [
++ {
++ "rule": "literal_duration",
++ "file": "public/css/app.css",
++ "line": 62,
++ "value": ".15s",
++ "text": ".job-item { display: grid; grid-template-columns: 44px minmax(0, 1fr); gap: 14px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/app.css",
++ "line": 62,
++ "value": ".15s",
++ "text": ".job-item { display: grid; grid-template-columns: 44px minmax(0, 1fr); gap: 14px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/home.css",
++ "line": 11,
++ "value": ".15s",
++ "text": ".home-cards a { display: flex; flex-direction: column; gap: 3px; height: 100%; box-sizing: border-box; padding: 12px 14px; border-radius: 12px; border: 1px soli"
++ },
++ {
++ "rule": "literal_duration",
++ "file": "public/css/home.css",
++ "line": 11,
++ "value": ".15s",
++ "text": ".home-cards a { display: flex; flex-direction: column; gap: 3px; height: 100%; box-sizing: border-box; padding: 12px 14px; border-radius: 12px; border: 1px soli"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/home.css",
++ "line": 11,
++ "value": "ease",
++ "text": ".home-cards a { display: flex; flex-direction: column; gap: 3px; height: 100%; box-sizing: border-box; padding: 12px 14px; border-radius: 12px; border: 1px soli"
++ },
++ {
++ "rule": "literal_curve",
++ "file": "public/css/home.css",
++ "line": 11,
++ "value": "ease",
++ "text": ".home-cards a { display: flex; flex-direction: column; gap: 3px; height: 100%; box-sizing: border-box; padding: 12px 14px; border-radius: 12px; border: 1px soli"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/app.css",
++ "line": 64,
++ "value": "hsl(var(--h)",
++ "text": ".job-logo { width: 44px; height: 44px; border-radius: 12px; display: grid; place-items: center; font-weight: 800; font-size: 1.1rem; color: hsl(var(--h) 80% 78%"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/app.css",
++ "line": 64,
++ "value": "hsl(var(--h)",
++ "text": ".job-logo { width: 44px; height: 44px; border-radius: 12px; display: grid; place-items: center; font-weight: 800; font-size: 1.1rem; color: hsl(var(--h) 80% 78%"
++ },
++ {
++ "rule": "color_drift",
++ "file": "public/css/app.css",
++ "line": 64,
++ "value": "hsl(var(--h)",
++ "text": ".job-logo { width: 44px; height: 44px; border-radius: 12px; display: grid; place-items: center; font-weight: 800; font-size: 1.1rem; color: hsl(var(--h) 80% 78%"
++ }
++ ]
++ },
++ {
++ "repo": "OpenVibe.Zone",
++ "commit": "6ac0483c6338c55ecc8bfe355a5263d276c0e9a2",
++ "files_scanned": 1,
++ "counts": {
++ "literal_duration": 0,
++ "literal_curve": 0,
++ "spinner": 0,
++ "color_drift": 0,
++ "missing_reduced_motion": 0,
++ "total": 0
++ },
++ "score": 100,
++ "findings_total": 0,
++ "findings": []
++ }
++ ]
++}
ddiff --git a/server/http/api.js b/server/http/api.js
iindex f4da000..ef990cc 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -9,6 +9,7 @@
* GET /api/v1/rundown/t/:slug
* GET /api/v1/after-show/:streamId
* GET /api/v1/c/:username
++ * GET /api/v1/design-health, /api/v1/design-health/:site every site's design health (a snapshot, server/design-health/)
*
* A bad parameter is checked here, before the source is asked, and is a 404 problem+json, exactly like a source
* that answered nothing. Every /api/v1 answer is CORS-open and public for two minutes; the app mounts the 120/min
@@@ -24,6 +25,7 @@ const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('.
const { createServiceAuth, CAPS } = require('../auth');
const { GROUPS: AI_USAGE_GROUPS, PERSON_GROUPS } = require('../analytics/ai-usage');
const GROUPS = Object.keys(AI_USAGE_GROUPS);
++const designHealth = require('../data/design-health');
const CACHE = 'public, max-age=120';
@@@ -69,6 +71,15 @@ function createApi({ network, live, analytics = null, aiUsage = null, config = {
return res.json(stats);
});
++ // Every site's design health (plan L45 step 6): the snapshot scripts/design-health.js wrote from each site's code.
++ r.get('/design-health', (req, res) => res.json(designHealth.all()));
++ r.get('/design-health/:site', (req, res) => {
++ const site = designHealth.site(req.params.site);
++ if (!site) return notFound(req, res, 'report.design_site_not_found', 'No site by that name in the design health snapshot.');
++ const { generated_at, rules, weights, kept } = designHealth.all();
++ return res.json({ generated_at, rules, weights, kept, site });
++ });
++
r.get('/rundown', async (req, res) => {
const rundown = await live.rundown();
if (!rundown) return notFound(req, res, 'report.rundown_not_found', 'OpenVibe.Live did not answer with The Rundown.');
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 7416f64..4d98ee2 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -4,7 +4,7 @@
* Crawl artifacts for openvibe.report, built with openvibe-shared/seo:
*
* GET /robots.txt the public pages are crawlable; the API is not
-- * GET /sitemap.xml /, /stats, /rundown and every /rundown/t/<slug> from the Rundown's topics
++ * GET /sitemap.xml /, /stats, /design, /rundown and every /rundown/t/<slug> from the Rundown's topics
* GET /llms.txt a markdown map of the site and its machine-readable answers
*
* The sitemap reads topics through the same cached client as the pages, so a crawler is no heavier than a reader
@@@ -53,6 +53,7 @@ function createDiscoveryRoutes({ config, live, bench }) {
const pages = [
{ loc: abs('/'), changefreq: 'daily', priority: 1.0 },
{ loc: abs('/stats'), changefreq: 'daily', priority: 0.9 },
++ { loc: abs('/design'), changefreq: 'weekly', priority: 0.6 },
{ loc: abs('/rundown'), changefreq: 'daily', priority: 0.9 },
{ loc: abs('/ai'), changefreq: 'daily', priority: 0.9 },
...kinds.filter((k) => k && bench.isKindId(k.id)).map((k) => ({
@@@ -76,11 +77,13 @@ function createDiscoveryRoutes({ config, live, bench }) {
{ title: 'Start here', links: [
{ title: 'OpenVibe.Report', url: abs('/'), note: 'the Report room' },
{ title: 'Open numbers', url: abs('/stats'), note: 'page views, accounts and traffic for every OpenVibe site' },
++ { title: 'Design health', url: abs('/design'), note: 'how closely each site follows the shared design kit, read from its code; per site at /design/<site>' },
{ title: 'The Rundown', url: abs('/rundown'), note: 'what streamers talked about, topic by topic' },
{ title: 'AI boards', url: abs('/ai'), note: 'Bench ratings, quality and cost across rooms' },
] },
{ title: 'Machine-readable', links: [
{ title: 'Open stats (JSON)', url: abs('/api/v1/stats?days=7'), note: '?days=7, 30 or 90' },
++ { title: 'Design health (JSON)', url: abs('/api/v1/design-health'), note: 'every site; /api/v1/design-health/<site> for one' },
{ title: 'The Rundown (JSON)', url: abs('/api/v1/rundown') },
{ title: 'Topics (JSON)', url: abs('/api/v1/rundown/topics') },
{ title: 'Sitemap', url: abs('/sitemap.xml') },
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex abeff9a..67d4b77 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -22,6 +22,8 @@ const express = require('express');
const views = require('../views');
const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('../data/source');
const { twinPath } = require('../agent-pages');
++const designViews = require('../views/design');
++const designHealth = require('../data/design-health');
const CACHE = 'public, max-age=120';
@@@ -92,6 +94,14 @@ function createPageRoutes(ctx) {
r.get('/developers', (req, res) => send(req, res, publicPages.developers().html));
++ // Every site's design health (plan L45 step 6), from the snapshot (server/data/design-health.js).
++ r.get('/design', (req, res) => send(req, res, designViews.designHealth(designHealth.all())));
++ r.get('/design/:site', (req, res) => {
++ const site = designHealth.site(req.params.site);
++ if (!site) return notFound(req, res);
++ return send(req, res, designViews.designSite(designHealth.all(), site));
++ });
++
r.get('/stats', async (req, res) => {
const page = await publicPages.stats(normaliseDays(req.query.days));
if (!page) return notFound(req, res);
ddiff --git a/server/views/design.js b/server/views/design.js
nnew file mode 100644
iindex 0000000..bd6fda4
---- /dev/null
++++ b/server/views/design.js
@@@ -0,0 +1,102 @@
++'use strict';
++/**
++ * openvibe.report/design: how closely every OpenVibe site follows the shared design kit (plan L45 step 6), read from
++ * each site's code (server/design-health/scan.js, snapshot in server/design-health/snapshot.json).
++ *
++ * designHealth(snapshot) /design: the network at a glance, every site most-to-fix first, and how it is scored
++ * designSite(snapshot, site) /design/:site: one site's counts and where, each finding linked to its line on GitHub
++ */
++const kit = require('openvibe-shared/kit');
++const { layout } = require('./index');
++
++const esc = kit.esc;
++const exact = (v) => (v == null ? '—' : Number(v).toLocaleString('en-US'));
++const day = (s) => { const t = Date.parse(s || ''); return Number.isFinite(t) ? new Date(t).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }) : ''; };
++const IN_VIEW = 15; // sites before the counted fold
++
++// The rules in the order they matter, in words a reader knows.
++const RULES = [
++ ['spinner', 'Spinners', 'A keyframe that turns a whole circle, or an animation that runs one forever. Nothing on OpenVibe spins: a loading state breathes, bobs or fills.'],
++ ['missing_reduced_motion', 'No reduced motion', 'A stylesheet that animates and never asks for prefers-reduced-motion, so a person who asked for less movement still gets it.'],
++ ['literal_duration', 'Hand-set timings', 'A transition or animation time written by hand (.25s, 300ms) where the kit says var(--ov-dur-1…4), so a look\'s motion and Calm cannot change it.'],
++ ['literal_curve', 'Hand-set curves', 'A cubic-bezier() or an ease keyword where the kit says var(--ov-ease-out), var(--ov-ease-spring) or var(--ov-ease-in-out).'],
++ ['color_drift', 'Colours outside the theme', 'A colour written by hand in a file that already uses the theme\'s variables: it stays put when a person changes their look.'],
++];
++const LABEL = Object.fromEntries(RULES.map(([k, l]) => [k, l]));
++const SHORT = { spinner: 'Spins', missing_reduced_motion: 'No reduced motion', literal_duration: 'Timing', literal_curve: 'Curve', color_drift: 'Colour' };
++// A count in words, for a phone's one-line summary of a site ("931 timings 448 curves").
++const WORDS = { spinner: 'spinning', missing_reduced_motion: 'without reduced motion', literal_duration: 'timings', literal_curve: 'curves', color_drift: 'colours' };
++
++const band = (s) => (s >= 90 ? 'is-good' : s >= 60 ? 'is-ok' : 'is-low');
++/** The score as a number on a bar: 0 to 100, coloured by band, with its words for a screen reader. */
++const scoreBar = (s) => `<span class="dh-score ${band(Number(s))}" style="--dh:${Math.max(0, Math.min(100, Number(s) || 0))}%"><b>${esc(Number(s).toFixed(Number.isInteger(Number(s)) ? 0 : 1))}</b><i aria-hidden="true"></i></span>`;
++const repoUrl = (s) => `https://github.com/OpenVibers/${encodeURIComponent(s.repo)}`;
++const median = (xs) => { const v = xs.filter((x) => Number.isFinite(x)).sort((a, b) => a - b); if (!v.length) return null; const m = v.length >> 1; return v.length % 2 ? v[m] : (v[m - 1] + v[m]) / 2; };
++
++function siteRow(s) {
++ const c = s.counts || {};
++ return `<div class="rp-tr dh-tr" role="row"><span role="cell" class="rp-site"><a href="/design/${esc(s.slug)}">${esc(s.repo)}</a><small>${exact(s.files_scanned)} ${Number(s.files_scanned) === 1 ? 'file' : 'files'}</small></span>`
++ + `<span role="cell" data-l="Score">${scoreBar(s.score)}</span>`
++ + RULES.map(([k]) => `<span role="cell" data-l="${esc(LABEL[k])}" data-s="${esc(WORDS[k])}" class="dh-n${c[k] ? '' : ' is-zero'}${k === 'spinner' && c[k] ? ' is-bad' : ''}">${exact(c[k] || 0)}</span>`).join('')
++ + (Number(c.total) ? '' : '<span role="cell" class="dh-none">Nothing to fix</span>')
++ + '</div>';
++}
++const tableHead = `<div class="rp-tr rp-th dh-tr" role="row"><span role="columnheader">Site</span><span role="columnheader">Score</span>${RULES.map(([k]) => `<span role="columnheader">${esc(LABEL[k])}</span>`).join('')}</div>`;
++
++function designHealth(snap) {
++ const sites = (snap.sites || []).slice().sort((a, b) => a.score - b.score || a.repo.localeCompare(b.repo));
++ const sum = (k) => sites.reduce((n, s) => n + (Number(s.counts && s.counts[k]) || 0), 0);
++ const spins = sum('spinner'), still = sum('missing_reduced_motion');
++ const clean = sites.filter((s) => Number(s.score) >= 100).length;
++ const head = sites.slice(0, IN_VIEW), rest = sites.slice(IN_VIEW);
++ const W = snap.weights || {};
++ const body = `<header class="rp-page-h"><p class="rp-kicker">Open numbers</p><h1>Design health of every site</h1>
++<p class="rp-lede">Read from each site's own code: motion through the kit's timings and curves, colours from the theme, a pause for people who ask for less movement, and nothing that spins. 100 is a site that follows the kit everywhere it was read.${snap.generated_at ? ` Measured ${esc(day(snap.generated_at))}, at each site's main branch.` : ''}</p></header>
++<section class="rp-tiles" aria-label="Across the network">
++<div class="rp-tile"><span class="rp-tile-l">Sites measured</span><b class="rp-tile-v">${exact(sites.length)}</b><span class="rp-tile-s">${exact(clean)} at 100</span></div>
++<div class="rp-tile"><span class="rp-tile-l">Median score</span><b class="rp-tile-v">${median(sites.map((s) => Number(s.score))) == null ? '—' : esc(String(Math.round(median(sites.map((s) => Number(s.score))) * 10) / 10))}</b><span class="rp-tile-s">of 100</span></div>
++<div class="rp-tile"><span class="rp-tile-l">Spinners</span><b class="rp-tile-v">${spins ? exact(spins) : 'None'}</b><span class="rp-tile-s">${spins ? 'still to replace' : 'nothing spins'}</span></div>
++<div class="rp-tile"><span class="rp-tile-l">Files without reduced motion</span><b class="rp-tile-v">${still ? exact(still) : 'None'}</b><span class="rp-tile-s">${still ? 'animate for everyone' : 'every animation can stop'}</span></div>
++</section>
++<section aria-labelledby="dh-sites-h"><h2 id="dh-sites-h">Every site</h2><p class="rp-fine">Most to fix first. A site's page shows where, line by line.</p>
++${sites.length ? `<div class="rp-table dh-table" role="table" aria-label="Design health, most to fix first">${tableHead}${head.map(siteRow).join('')}</div>
++${rest.length ? `<details class="dh-fold"><summary>All ${exact(sites.length)} sites <span>${exact(rest.length)} more</span></summary><div class="rp-table dh-table" role="table" aria-label="Design health, the rest">${tableHead}${rest.map(siteRow).join('')}</div></details>` : ''}`
++ : '<p class="rp-fine">No measurement yet.</p>'}
++</section>
++<section aria-labelledby="dh-how-h"><h2 id="dh-how-h">How it is measured</h2>
++<dl class="dh-rules">${RULES.map(([k, l, what]) => `<div><dt>${esc(l)} <span>−${esc(String(W[k] != null ? W[k] : ''))} each</span></dt><dd>${esc(what)}</dd></div>`).join('')}</dl>
++<p class="rp-fine">A site's score is 100 minus what its findings cost, never below 0. The rules read every stylesheet, and every &lt;style&gt; in a page or script, under a site's <code>public/</code> and <code>server/views/</code>; a site with none there is left out. The kit's own tokens and the fallback inside <code>var(--name, fallback)</code> never count. The rules are open: <a href="https://github.com/OpenVibers/OpenVibe.Report/blob/main/server/design-health/scan.js"><code>server/design-health/scan.js</code></a>, run by <code>scripts/design-health.js</code>. The same numbers: <a href="/api/v1/design-health"><code>/api/v1/design-health</code></a>.</p></section>`;
++ return layout({
++ title: 'Design health of every site',
++ description: 'How closely each OpenVibe site follows the shared design kit: motion timings and curves, theme colours, reduced motion and nothing that spins, read from its code.',
++ body, active: '/stats', path: '/design',
++ });
++}
++
++function designSite(snap, s) {
++ const c = s.counts || {};
++ const commit = String(s.commit || '');
++ const finds = (s.findings || []).map((f) => {
++ const where = `${f.file}${f.line ? `:${f.line}` : ''}`;
++ const href = commit ? `${repoUrl(s)}/blob/${encodeURIComponent(commit)}/${String(f.file).split('/').map(encodeURIComponent).join('/')}${f.line ? `#L${Number(f.line)}` : ''}` : '';
++ return `<li class="dh-find"><span class="dh-rule dh-rule--${esc(String(f.rule).replace(/_/g, '-'))}">${esc(SHORT[f.rule] || f.rule)}</span>`
++ + `${href ? `<a class="dh-where" href="${esc(href)}">${esc(where)}</a>` : `<span class="dh-where">${esc(where)}</span>`}`
++ + `${f.text ? `<code class="dh-text">${esc(f.text)}</code>` : ''}</li>`;
++ }).join('');
++ const total = Number(s.findings_total) || 0;
++ const body = `<header class="rp-page-h"><p class="rp-kicker"><a href="/design">Design health</a></p><h1>${esc(s.repo)}</h1>
++<p class="rp-lede dh-lede">${scoreBar(s.score)} <span>${exact(s.files_scanned)} ${Number(s.files_scanned) === 1 ? 'file' : 'files'} read${commit ? ` at <a href="${esc(`${repoUrl(s)}/tree/${encodeURIComponent(commit)}`)}"><code>${esc(commit.slice(0, 7))}</code></a>` : ''}${snap.generated_at ? `, ${esc(day(snap.generated_at))}` : ''}.</span></p></header>
++<section class="rp-tiles" aria-label="Findings by rule">${RULES.map(([k, l]) => `<div class="rp-tile"><span class="rp-tile-l">${esc(l)}</span><b class="rp-tile-v">${exact(c[k] || 0)}</b></div>`).join('')}</section>
++<section aria-labelledby="dh-where-h"><h2 id="dh-where-h">Where</h2>
++${total ? `<p class="rp-fine">${total > (s.findings || []).length ? `${exact((s.findings || []).length)} of ${exact(total)}: the ones that cost most, from as many files as possible.` : `All ${exact(total)}.`} Each opens its line on GitHub.</p><ol class="dh-finds">${finds}</ol>`
++ : '<p class="rp-fine">Nothing to fix: this site follows the kit everywhere the rules read.</p>'}
++</section>
++<p class="rp-fine"><a href="/design">Every site</a> · <a href="/design#dh-how-h">How it is measured</a> · <a href="/api/v1/design-health/${esc(s.slug)}"><code>/api/v1/design-health/${esc(s.slug)}</code></a></p>`;
++ return layout({
++ title: `${s.repo}: design health`,
++ description: `How closely ${s.repo} follows the shared design kit: score ${s.score} of 100, with every finding by rule and line.`,
++ body, active: '/stats', path: `/design/${s.slug}`,
++ });
++}
++
++module.exports = { designHealth, designSite, scoreBar };
ddiff --git a/server/views/index.js b/server/views/index.js
iindex bb18e2f..3f33b9f 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -280,7 +280,7 @@ ${sites ? `<section aria-labelledby="rp-sites-h"><h2 id="rp-sites-h">Every site<
<div class="rp-table" role="table" aria-label="Every site"><div class="rp-tr rp-th" role="row"><span role="columnheader">Site</span><span role="columnheader">Page views</span><span role="columnheader">Visitors</span><span role="columnheader">Requests, 24 h</span><span role="columnheader">95% answered in</span></div>${sites}</div></section>` : ''}
${L ? `<section aria-labelledby="rp-live-h"><h2 id="rp-live-h">OpenVibe.Live</h2><div class="rp-minis">${[['Streamers', L.streamers], ['Streams', L.streams], ['Hours watched', L.hours_watched], ['Chat messages', L.messages], ['Recordings', L.vods], ['Clips', L.clips], ['AI moments', L.ai_moments]].filter(([, v]) => n(v)).map(([l, v]) => `<div><b>${fmt(v)}</b><span>${esc(l)}</span></div>`).join('')}</div></section>` : ''}
${A && A.daily && A.daily.length > 1 ? `<section aria-labelledby="rp-acc-h"><h2 id="rp-acc-h">New accounts a day</h2>${bars(A.daily, 'New accounts a day', { h: 110 })}</section>` : ''}
--<section aria-labelledby="rp-how-h"><h2 id="rp-how-h">How these are counted</h2><p class="rp-fine">${esc(d.note || '')} The same numbers: <a href="/api/v1/stats?days=${esc(days)}"><code>/api/v1/stats</code></a>.</p></section>`;
++<section aria-labelledby="rp-how-h"><h2 id="rp-how-h">How these are counted</h2><p class="rp-fine">${esc(d.note || '')} The same numbers: <a href="/api/v1/stats?days=${esc(days)}"><code>/api/v1/stats</code></a>. How closely each site follows the design kit, read from its code: <a href="/design">Design health</a>.</p></section>`;
return layout({ title: `Every site's numbers, ${days} days`, description: 'Page views, people, streams and how fast each OpenVibe service answers, open to everyone.', body, active: '/stats', path: `/stats${Number(days) === 30 ? '' : `?days=${Number(days)}`}`, agent: true });
}
ddiff --git a/test/design-health.test.js b/test/design-health.test.js
nnew file mode 100644
iindex 0000000..46d80ac
---- /dev/null
++++ b/test/design-health.test.js
@@@ -0,0 +1,91 @@
++'use strict';
++/**
++ * openvibe.report/design (plan L45 step 6): every site's design health from the snapshot, most to fix first, with a
++ * counted fold past fifteen; a site's page with its counts and each finding linked to its line at the measured commit;
++ * the same as JSON; an unknown site is a 404; a repo with nothing to read is left out; every value from the snapshot
++ * is escaped. The real snapshot (server/design-health/snapshot.json) must parse and hold sites.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++
++const FIXTURE = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'dh-')), 'design-health.json');
++const counts = (o) => ({ literal_duration: 0, literal_curve: 0, spinner: 0, color_drift: 0, missing_reduced_motion: 0, total: 0, ...o });
++const sites = [
++ { repo: 'OpenVibe.Live', commit: 'abcdef1234567890', files_scanned: 119, score: 12.5, findings_total: 40, counts: counts({ spinner: 1, literal_duration: 30, total: 31 }),
++ findings: [{ rule: 'spinner', file: 'public/css/style.css', line: 489, value: 'spin', text: '@keyframes spin{to{transform:rotate(360deg)}} <script>alert(1)</script>' }] },
++ { repo: 'OpenVibe.Zone', commit: '1111111222222', files_scanned: 1, score: 100, findings_total: 0, counts: counts({}), findings: [] },
++ { repo: 'OpenVibe.SDK', commit: '333', files_scanned: 0, score: 100, findings_total: 0, counts: counts({}), findings: [] },
++];
++for (let i = 0; i < 16; i++) sites.push({ repo: `OpenVibe.Site${i}`, commit: 'c0ffee', files_scanned: 3, score: 90 + i / 2, findings_total: 2, counts: counts({ literal_curve: 2, total: 2 }), findings: [] });
++fs.writeFileSync(FIXTURE, JSON.stringify({ generated_at: '2026-11-01T11:00:00.000Z', rules: ['literal_duration', 'literal_curve', 'spinner', 'color_drift', 'missing_reduced_motion'], weights: { spinner: 4, missing_reduced_motion: 2, literal_duration: 0.25, literal_curve: 0.25, color_drift: 0.05 }, kept: 25, sites }));
++process.env.DESIGN_HEALTH_FILE = FIXTURE;
++
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('/design: every site with something to read, most to fix first, fifteen in view and the rest in a counted fold', async () => {
++ const r = await t.get('/design');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=120');
++ assert.ok(r.text.includes('<h1>Design health of every site</h1>'));
++ assert.ok(r.text.includes('<link rel="canonical" href="https://openvibe.report/design">'));
++ assert.ok(!r.text.includes('OpenVibe.SDK'), 'a repo with no stylesheet is not scored');
++ const first = r.text.indexOf('href="/design/live"'), zone = r.text.indexOf('href="/design/zone"');
++ assert.ok(first > 0 && zone > first, 'the lowest score comes first');
++ assert.match(r.text, /<summary>All 18 sites <span>3 more<\/span><\/summary>/);
++ assert.ok(r.text.includes('<b class="rp-tile-v">1</b><span class="rp-tile-s">still to replace</span>'), 'the spinners are counted');
++ assert.ok(r.text.includes('class="dh-score is-low" style="--dh:12.5%"'), 'the score is a bar in its band');
++ assert.ok(r.text.includes('server/design-health/scan.js'), 'the rules are linked');
++ });
++
++ await check('/design/:site: the counts, and each finding opens its line at the measured commit, escaped', async () => {
++ const r = await t.get('/design/live');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('<h1>OpenVibe.Live</h1>'));
++ assert.ok(r.text.includes('href="https://github.com/OpenVibers/OpenVibe.Live/blob/abcdef1234567890/public/css/style.css#L489"'));
++ assert.ok(r.text.includes('1 of 40: the ones that cost most'));
++ assert.ok(!r.text.includes('<script>alert(1)</script>'), 'a finding\'s text is escaped');
++ assert.ok(r.text.includes('&lt;script&gt;alert(1)&lt;/script&gt;'));
++ const clean = await t.get('/design/zone');
++ assert.ok(clean.text.includes('Nothing to fix'));
++ });
++
++ await check('an unknown or malformed site is a 404 page and a 404 problem', async () => {
++ assert.strictEqual((await t.get('/design/nope')).status, 404);
++ assert.strictEqual((await t.get('/design/sdk')).status, 404, 'left out of the snapshot\'s sites');
++ assert.strictEqual((await t.get('/design/%3Cx%3E')).status, 404);
++ const api = await t.get('/api/v1/design-health/nope');
++ assert.strictEqual(api.status, 404);
++ assert.match(api.headers.get('content-type') || '', /problem\+json/);
++ });
++
++ await check('the same as JSON, CORS-open', async () => {
++ const r = await t.get('/api/v1/design-health');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');
++ const j = r.json();
++ assert.strictEqual(j.sites.length, 18);
++ assert.strictEqual(j.weights.spinner, 4);
++ const one = (await t.get('/api/v1/design-health/live')).json();
++ assert.strictEqual(one.site.repo, 'OpenVibe.Live');
++ assert.strictEqual(one.site.findings[0].line, 489);
++ });
++
++ await check('the snapshot parses and holds sites with counts', async () => {
++ const real = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'server', 'design-health', 'snapshot.json'), 'utf8'));
++ assert.ok(Array.isArray(real.sites) && real.sites.length >= 20, `${(real.sites || []).length} sites`);
++ for (const s of real.sites) {
++ assert.ok(s.repo && s.counts && Number.isFinite(Number(s.score)), s.repo);
++ assert.ok(Number(s.files_scanned) > 0, `${s.repo}: nothing to read, so not in the snapshot`);
++ }
++ });
++ } finally {
++ await t.close();
++ fs.rmSync(path.dirname(FIXTURE), { recursive: true, force: true });
++ }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/design-scan.test.js b/test/design-scan.test.js
nnew file mode 100644
iindex 0000000..56338d5
---- /dev/null
++++ b/test/design-scan.test.js
@@@ -0,0 +1,255 @@
++'use strict';
++// design-lint/test.js — the lint's own tests: plain node and assert, over small fixtures.
++//
++// node test.js
++//
++// Each rule has to find what it should, and a clean fixture has to stay clean.
++
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++
++const { scanRepo, scoreOf } = require('../server/design-health/scan.js');
++
++// ── the fixtures ─────────────────────────────────────────────────────────────────────────────────────────────
++// a repo where every style already goes through the kit
++const CLEAN_CSS = [
++ '/* a site that already uses the kit */',
++ '.card {',
++ ' background: var(--bg-primary);',
++ ' color: var(--text-primary);',
++ ' border: 1px solid var(--border);',
++ ' transition: transform var(--ov-dur-2) var(--ov-ease-out);',
++ '}',
++ '.card:hover { transform: translateY(-2px); }',
++ '.fade { animation: fade var(--ov-dur-3) var(--ov-ease-in-out) both; }',
++ '@keyframes fade { from { opacity: 0; } to { opacity: 1; } }',
++ '@media (prefers-reduced-motion: reduce) {',
++ ' .card, .fade { transition: none; animation: none; }',
++ '}',
++ ''
++].join('\n');
++
++// a repo that writes its own timing, its own curves, its own spinner and its own colours
++const DIRTY_CSS = [
++ '/* a site that hand-writes what the kit owns */',
++ '.card {',
++ ' background: var(--bg-primary);',
++ ' transition: transform .25s ease;',
++ '}',
++ '.panel { animation: fade 300ms cubic-bezier(.4, 0, .2, 1) both; }',
++ '.slow { transition-duration: 1.4s; }',
++ '.badge { color: #ff006e; background: rgb(0, 255, 65); }',
++ '@keyframes spin { to { transform: rotate(360deg); } }',
++ '.spinner { animation: spin 1s linear infinite; }',
++ ''
++].join('\n');
++
++// an inline <style> in HTML
++const DIRTY_HTML = [
++ '<!doctype html>',
++ '<html><head>',
++ '<style>',
++ ' .hero { color: #ff006e; background: var(--bg-primary); transition: opacity .2s ease-in-out; }',
++ ' .hero2 { animation: pulse 800ms linear infinite; }',
++ ' @keyframes pulse { to { transform: rotate(360deg); } }',
++ '</style>',
++ '</head><body><div class="hero">hi</div></body></html>',
++ ''
++].join('\n');
++
++const CLEAN_HTML = [
++ '<!doctype html>',
++ '<html><head>',
++ '<style>',
++ ' .hero { color: var(--text-primary); transition: opacity var(--ov-dur-1) var(--ov-ease-out); }',
++ '</style>',
++ '</head><body><div class="hero">hi</div></body></html>',
++ ''
++].join('\n');
++
++// ── the harness ──────────────────────────────────────────────────────────────────────────────────────────────
++const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'design-lint-test-'));
++
++function makeRepo(files) {
++ const dir = fs.mkdtempSync(path.join(TMP, 'repo-'));
++ for (const rel of Object.keys(files)) {
++ const full = path.join(dir, rel);
++ fs.mkdirSync(path.dirname(full), { recursive: true });
++ fs.writeFileSync(full, files[rel]);
++ }
++ return dir;
++}
++
++const results = [];
++function test(name, fn) {
++ try {
++ fn();
++ results.push({ name, ok: true });
++ } catch (err) {
++ results.push({ name, ok: false, err });
++ }
++}
++
++const rulesOf = (res, rule) => res.findings.filter((f) => f.rule === rule);
++const countOf = (res, rule) => res.counts[rule] || 0;
++
++// ── the tests ─────────────────────────────────────────────────────────────────────────────────────────────────
++const clean = scanRepo(makeRepo({ 'public/css/site.css': CLEAN_CSS, 'public/index.html': CLEAN_HTML }));
++
++test('a clean file has no findings at all', () => {
++ assert.deepStrictEqual(clean.counts, { literal_duration: 0, literal_curve: 0, spinner: 0, color_drift: 0, missing_reduced_motion: 0, total: 0 });
++ assert.deepStrictEqual(clean.findings, []);
++});
++
++test('a clean file scores 100', () => {
++ assert.strictEqual(clean.score, 100);
++});
++
++test('the kit tokens are never read as drift', () => {
++ assert.strictEqual(countOf(clean, 'literal_duration'), 0);
++ assert.strictEqual(countOf(clean, 'literal_curve'), 0);
++});
++
++const dirty = scanRepo(makeRepo({ 'public/css/site.css': DIRTY_CSS }));
++
++test('a literal duration is found, with its file and line', () => {
++ const found = rulesOf(dirty, 'literal_duration');
++ assert.ok(found.length >= 3, 'expected at least three literal durations, got ' + found.length);
++ assert.ok(found.some((f) => f.value === '.25s' && f.file === 'public/css/site.css'));
++ assert.ok(found.some((f) => f.value === '300ms'));
++ assert.ok(found.some((f) => f.value === '1.4s'));
++ assert.ok(found.every((f) => f.line > 0));
++ assert.ok(found.every((f) => f.file === 'public/css/site.css'));
++});
++
++test('a literal curve is found, whether a cubic-bezier or an ease keyword', () => {
++ const found = rulesOf(dirty, 'literal_curve');
++ assert.ok(found.some((f) => /cubic-bezier/i.test(f.value)), 'expected a cubic-bezier curve');
++ assert.ok(found.some((f) => /^ease/i.test(f.value)), 'expected an ease keyword');
++});
++
++test('a spinner is found: the keyframes that turn, and the animation that runs them forever', () => {
++ const found = rulesOf(dirty, 'spinner');
++ assert.ok(found.some((f) => f.kind === 'spinner_keyframes' && f.value === 'spin'), 'expected the spin keyframes');
++ assert.ok(found.some((f) => f.kind === 'spinner_usage' && f.value === 'spin'), 'expected the infinite animation');
++});
++
++test('a hard-coded colour is found in a file that already uses the theme', () => {
++ const found = rulesOf(dirty, 'color_drift');
++ assert.ok(found.some((f) => f.value === '#ff006e'), 'expected the hex colour');
++ assert.ok(found.some((f) => /rgb/i.test(f.value)), 'expected the rgb colour');
++});
++
++test('a file that animates without prefers-reduced-motion is found', () => {
++ const found = rulesOf(dirty, 'missing_reduced_motion');
++ assert.strictEqual(found.length, 1);
++ assert.strictEqual(found[0].file, 'public/css/site.css');
++});
++
++test('an inline <style> in HTML is linted like a CSS file', () => {
++ const html = scanRepo(makeRepo({ 'public/index.html': DIRTY_HTML }));
++ assert.ok(countOf(html, 'literal_duration') >= 1, 'expected a literal duration in the inline style');
++ assert.ok(countOf(html, 'literal_curve') >= 1, 'expected a literal curve in the inline style');
++ assert.ok(countOf(html, 'spinner') >= 1, 'expected a spinner in the inline style');
++ assert.ok(countOf(html, 'color_drift') >= 1, 'expected colour drift in the inline style');
++ assert.strictEqual(countOf(html, 'missing_reduced_motion'), 1);
++});
++
++test('an inline <style> that uses the kit is clean', () => {
++ const html = scanRepo(makeRepo({ 'public/index.html': CLEAN_HTML }));
++ assert.strictEqual(html.counts.total, 0, JSON.stringify(html.findings));
++});
++
++test('the spinner is not reported without infinite', () => {
++ const repo = makeRepo({ 'public/css/site.css': '@keyframes spin { to { transform: rotate(360deg); } }\n.a { animation: spin 1s linear; }\n' });
++ const res = scanRepo(repo);
++ assert.strictEqual(countOf(res, 'spinner'), 1, 'only the keyframes, not the finite animation');
++ assert.strictEqual(rulesOf(res, 'spinner')[0].kind, 'spinner_keyframes');
++});
++
++test('a keyframes that does not turn is not a spinner', () => {
++ const repo = makeRepo({ 'public/css/site.css': '@keyframes fade { from { opacity: 0; } to { opacity: 1; } }\n.a { animation: fade 1s linear infinite; }\n' });
++ const res = scanRepo(repo);
++ assert.strictEqual(countOf(res, 'spinner'), 0);
++});
++
++test('a var() fallback is not read as a hard-coded colour', () => {
++ const repo = makeRepo({ 'public/css/site.css': '.a { color: var(--accent, #ff006e); background: var(--bg-primary); }\n' });
++ const res = scanRepo(repo);
++ assert.strictEqual(countOf(res, 'color_drift'), 0, JSON.stringify(rulesOf(res, 'color_drift')));
++});
++
++test('a file with no theme variables is not judged for colour drift', () => {
++ const repo = makeRepo({ 'public/css/site.css': '.a { color: #ff006e; background: #000; }\n' });
++ const res = scanRepo(repo);
++ assert.strictEqual(countOf(res, 'color_drift'), 0);
++});
++
++test('the score is 100 minus the weighted counts, clamped at 0', () => {
++ assert.strictEqual(scoreOf({ spinner: 0, missing_reduced_motion: 0, literal_duration: 0, literal_curve: 0, color_drift: 0 }), 100);
++ const one = scoreOf({ spinner: 1, missing_reduced_motion: 0, literal_duration: 0, literal_curve: 0, color_drift: 0 });
++ assert.ok(one < 100 && one > 90, 'a spinner costs the most, got ' + one);
++ const many = scoreOf({ spinner: 100, missing_reduced_motion: 0, literal_duration: 0, literal_curve: 0, color_drift: 0 });
++ assert.strictEqual(many, 0, 'the score never goes below 0');
++ const over = scoreOf({ spinner: 0, missing_reduced_motion: 0, literal_duration: 0, literal_curve: 0, color_drift: 100000 });
++ assert.strictEqual(over, 0, 'a pile of colour drift cannot push the score below 0 either');
++});
++
++test('the weights are in order: spinner, then reduced motion, then duration and curve, then colour', () => {
++ const cost = (rule) => 100 - scoreOf({ spinner: 0, missing_reduced_motion: 0, literal_duration: 0, literal_curve: 0, color_drift: 0, [rule]: 1 });
++ assert.ok(cost('spinner') > cost('missing_reduced_motion'), 'a spinner weighs more than a missing reduced motion');
++ assert.ok(cost('missing_reduced_motion') > cost('literal_duration'), 'a missing reduced motion weighs more than a literal duration');
++ assert.strictEqual(cost('literal_duration'), cost('literal_curve'), 'a duration and a curve weigh the same');
++ assert.ok(cost('literal_curve') > cost('color_drift'), 'a literal curve weighs more than a hard-coded colour');
++});
++
++test('the score falls as the findings pile up', () => {
++ const full = scoreOf({ spinner: 0, missing_reduced_motion: 0, literal_duration: 0, literal_curve: 0, color_drift: 0 });
++ const some = scoreOf({ spinner: 0, missing_reduced_motion: 1, literal_duration: 2, literal_curve: 2, color_drift: 0 });
++ assert.ok(some < full);
++});
++
++test('node_modules, vendor and .min. files are skipped', () => {
++ const repo = makeRepo({
++ 'public/css/site.css': CLEAN_CSS,
++ 'public/node_modules/pkg/x.css': DIRTY_CSS,
++ 'public/vendor/x.css': DIRTY_CSS,
++ 'public/css/site.min.css': DIRTY_CSS
++ });
++ const res = scanRepo(repo);
++ assert.strictEqual(res.counts.total, 0, JSON.stringify(res.findings));
++});
++
++test('the findings are sorted by file and line', () => {
++ const repo = makeRepo({ 'public/css/b.css': DIRTY_CSS, 'public/css/a.css': DIRTY_CSS });
++ const res = scanRepo(repo);
++ for (let i = 1; i < res.findings.length; i++) {
++ const prev = res.findings[i - 1];
++ const cur = res.findings[i];
++ assert.ok(prev.file < cur.file || (prev.file === cur.file && prev.line <= cur.line), 'not sorted at ' + i);
++ }
++});
++
++test('an empty repo is clean', () => {
++ const res = scanRepo(makeRepo({ 'README.md': '# nothing here' }));
++ assert.strictEqual(res.counts.total, 0);
++ assert.strictEqual(res.score, 100);
++});
++
++// ── the report ───────────────────────────────────────────────────────────────────────────────────────────────
++let failed = 0;
++for (const r of results) {
++ if (r.ok) {
++ console.log(' ok ' + r.name);
++ } else {
++ failed++;
++ console.log(' FAIL ' + r.name);
++ console.log(' ' + (r.err && r.err.message ? String(r.err.message).split('\n').join('\n ') : r.err));
++ }
++}
++console.log('');
++console.log((results.length - failed) + '/' + results.length + ' passed');
++try { fs.rmSync(TMP, { recursive: true, force: true }); } catch (e) { /* the temp dir is the OS's problem now */ }
++process.exit(failed ? 1 : 0);