auto-sync via WEVIA git_sync_all intent 2026-04-21T11:28:41+02:00
Some checks failed
WEVAL NonReg / nonreg (push) Has been cancelled

This commit is contained in:
opus
2026-04-21 11:28:41 +02:00
parent 1924285f23
commit c49928485f
7 changed files with 470 additions and 4 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

View File

@@ -0,0 +1,14 @@
{
"v136": "health-modal-drill-down",
"modal_exists": true,
"banner_clickable": true,
"hidden_initially": true,
"opens_on_click": true,
"fetches_broken_urls": true,
"content_preview": "<div style=\"color:#ef4444;font-weight:600;margin-bottom:6px\">\ud83d\udeab BROKEN/DOWN (20):</div><div style=\"padding:3px 8px;border-left:3px solid #ef4444;margin-bottom:4px;background:rgba(239,68,68,0.05)\"><span",
"escape_closes": true,
"reopen_works": true,
"click_outside_closes": true,
"js_errors": [],
"VERDICT": "OK"
}

167
api/kpi-unified.php Normal file
View File

@@ -0,0 +1,167 @@
<?php
// V118 KPI UNIFIED - Single source-of-truth endpoint for all dashboards
// Consolidates source-of-truth.json + nonreg-latest + token-health-cache + docker live
// Cache 60s for performance
// UX premium doctrine 60 - zero doublon zero divergence
header("Content-Type: application/json");
header("Access-Control-Allow-Origin: *");
header("Cache-Control: public, max-age=60");
$cache_file = "/tmp/kpi-unified-cache.json";
$cache_ttl = 60;
$force = !empty($_GET["force"]);
// Try cache first
if (!$force && is_readable($cache_file)) {
$age = time() - filemtime($cache_file);
if ($age < $cache_ttl) {
$cached = @json_decode(@file_get_contents($cache_file), true);
if (is_array($cached)) {
$cached["cache_hit"] = true;
$cached["cache_age_sec"] = $age;
echo json_encode($cached, JSON_PRETTY_PRINT);
exit;
}
}
}
// Build from sources (honest, no fake)
$sot = [];
if (is_readable("/var/www/html/api/source-of-truth.json")) {
$sot = @json_decode(@file_get_contents("/var/www/html/api/source-of-truth.json"), true) ?: [];
}
$nonreg = [];
if (is_readable("/var/www/html/api/nonreg-latest.json")) {
$nonreg = @json_decode(@file_get_contents("/var/www/html/api/nonreg-latest.json"), true) ?: [];
}
$tokens = [];
if (is_readable("/tmp/token-health-cache.json")) {
$tokens = @json_decode(@file_get_contents("/tmp/token-health-cache.json"), true) ?: [];
}
// Docker running count (live via shell)
$docker_count = 0;
$out = @shell_exec("docker ps 2>/dev/null | tail -n +2 | wc -l");
if ($out !== null) $docker_count = (int)trim($out);
if ($docker_count === 0 && isset($sot["docker_running"])) $docker_count = (int)$sot["docker_running"];
// V83 summary (via architecture_quality)
$v83 = ["kpis" => null, "ok" => null, "warn" => null, "fail" => null, "complete_pct" => null];
if (is_readable("/var/www/html/api/v83-business-kpi-latest.json")) {
$v83_raw = @json_decode(@file_get_contents("/var/www/html/api/v83-business-kpi-latest.json"), true);
if ($v83_raw && isset($v83_raw["summary"])) {
$v83 = [
"kpis" => $v83_raw["summary"]["total_kpis"] ?? null,
"ok" => $v83_raw["summary"]["ok"] ?? null,
"warn" => $v83_raw["summary"]["warn"] ?? null,
"fail" => $v83_raw["summary"]["fail"] ?? null,
"complete_pct" => $v83_raw["summary"]["data_completeness_pct"] ?? null
];
}
}
// Orphans (from V83 architecture_quality KPI)
$orphans_count = 0;
if (is_readable("/tmp/wevia-pages-registry-cache.json")) {
$reg = @json_decode(@file_get_contents("/tmp/wevia-pages-registry-cache.json"), true);
if ($reg && isset($reg["orphans"])) $orphans_count = (int)$reg["orphans"];
}
// Build unified schema (SINGLE SOURCE OF TRUTH)
$response = [
"ok" => true,
"version" => "v118-kpi-unified",
"doctrine" => "zero doublon single source-of-truth",
"ts" => date("c"),
"cache_hit" => false,
"cache_ttl_sec" => $cache_ttl,
"providers" => [
"total" => $sot["providers_count"] ?? ($sot["counts"]["providers"] ?? 0),
"ok" => $tokens["summary"]["live_ok"] ?? null,
"expired" => $tokens["summary"]["expired_ko"] ?? null,
"health_pct" => $tokens["summary"]["health_pct"] ?? null
],
"agents" => [
"active" => $sot["agents_count"] ?? 0,
"total_live" => $sot["counts"]["agents_total_live"] ?? ($sot["agents_total"] ?? 0)
],
"skills" => [
"count" => $sot["skills_count"] ?? 0,
"total" => $sot["skills_total"] ?? ($sot["counts"]["skills_total"] ?? 0)
],
"intents" => [
"count" => $sot["intents_count"] ?? ($sot["counts"]["intents"] ?? 0),
"total" => $sot["intents_total"] ?? 0
],
"docker" => [
"running" => $docker_count
],
"orphans" => [
"count" => $orphans_count,
"status" => ($orphans_count === 0) ? "ok" : "warn"
],
"nonreg" => [
"pass" => $nonreg["pass"] ?? 0,
"fail" => $nonreg["fail"] ?? 0,
"total" => ($nonreg["pass"] ?? 0) + ($nonreg["fail"] ?? 0),
"score" => $nonreg["score"] ?? 0,
"ts" => $nonreg["ts"] ?? null
],
"v83" => $v83,
"autonomy" => [
"score" => $sot["autonomy_score"] ?? 0,
"level" => $sot["autonomy_level"] ?? "UNKNOWN"
],
"qdrant" => [
"collections" => $sot["counts"]["qdrant_cols"] ?? 0,
"points" => $sot["counts"]["qdrant_points"] ?? 0
],
"dashboards" => [
"count" => $sot["dashboards_count"] ?? ($sot["counts"]["dashboards"] ?? 0)
],
"brains" => [
"count" => $sot["brains_count"] ?? ($sot["counts"]["brains"] ?? 0)
],
"doctrines" => [
"count" => $sot["doctrines_count"] ?? ($sot["counts"]["doctrines"] ?? 0)
],
"business" => [
"cash_collected_month_keur" => $sot["cash_collected_month_keur"] ?? 0,
"cash_collected_ytd_keur" => $sot["cash_collected_ytd_keur"] ?? 0,
"cash_target_month_keur" => $sot["cash_target_month_keur"] ?? 0,
"dso_days" => $sot["dso_days"] ?? 0
],
"ethica" => [
"total_hcps" => $sot["ethica_total"] ?? 0
],
"sources_used" => [
"source_of_truth_json" => !empty($sot),
"nonreg_latest" => !empty($nonreg),
"token_health_cache" => !empty($tokens),
"v83_latest" => ($v83["kpis"] !== null),
"docker_live" => ($docker_count > 0)
]
];
// Write cache
@file_put_contents($cache_file, json_encode($response, JSON_PRETTY_PRINT));
echo json_encode($response, JSON_PRETTY_PRINT);

View File

@@ -33,6 +33,8 @@ h1{font-size:1.6rem;font-weight:700;margin-bottom:4px}h1 span{color:#818cf8}
.secure{color:#475569;font-size:.75rem;margin-top:16px}
.back{color:#475569;font-size:.8rem;text-decoration:none;display:block;margin-top:12px}
.footer{color:#334155;font-size:.7rem;margin-top:16px}
.weval-logout-btn,[class*="logout"],#logout-btn,.session-badge,.user-badge{display:none!important;visibility:hidden!important}
</style>
</head>
<body>
@@ -63,10 +65,12 @@ var redirect=new URLSearchParams(window.location.search).get('r')||'/products/wo
var state=btoa(redirect);
var ssoUrl='https://auth.weval-consulting.com/application/o/authorize/?client_id=aB9IF9xQ8L9u7Ty1Eq63dMYFgy59O58fqzuNulwJ&response_type=code&redirect_uri=https%3A%2F%2Fweval-consulting.com%2Fapi%2Fauth-callback.php&scope=openid+profile+email&state='+encodeURIComponent(state);
document.getElementById('sso-link').href=ssoUrl;
if(!window.location.search.includes('manual=1')&&!window.location.search.includes('error=')){
//auto-redirect disabled — use manual SSO button or password login
}else{
document.getElementById('auto-redirect').style.display='none';
// v3 — auto-redirect DISABLED by default (Doctrine #2 : zero regression, clear UX)
// Hide spinner/message unless ?auto=1 is explicitly requested
document.getElementById('auto-redirect').style.display='none';
if(window.location.search.includes('auto=1')&&!window.location.search.includes('error=')){
document.getElementById('auto-redirect').style.display='block';
setTimeout(function(){window.location.href=ssoUrl;},300);
}
async function doLogin(e){
e.preventDefault();
@@ -84,6 +88,18 @@ async function doLogin(e){
btn.disabled=false;btn.textContent='Se connecter';
}
if(window.location.search.includes('error=')){document.getElementById('manual').classList.add('show');document.getElementById('auto-redirect').style.display='none';}
// UX premium : focus auto user field on manual open
(function(){
var t = document.querySelector('.toggle');
if(t) t.addEventListener('click', function(){
setTimeout(function(){ var u=document.getElementById('user'); if(u) u.focus(); }, 50);
});
// Auto-open manual if ?manual=1
if(window.location.search.includes('manual=1')){
document.getElementById('manual').classList.add('show');
setTimeout(function(){ var u=document.getElementById('user'); if(u) u.focus(); }, 100);
}
})();
</script>
<!-- === OPUS UNIVERSAL DRILL-DOWN v1 19avr — append-only, doctrine #14 === -->

View File

@@ -0,0 +1,152 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WEVAL — Connexion</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Segoe UI',system-ui,sans-serif;background:#0a0e1a;color:#e2e8f0;min-height:100vh;display:flex;align-items:center;justify-content:center}
.box{background:rgba(15,23,42,.9);border:1px solid rgba(99,102,241,.15);border-radius:20px;padding:48px 40px;width:420px;text-align:center;backdrop-filter:blur(20px)}
.logo{width:56px;height:56px;border-radius:50%;border:2px solid rgba(99,102,241,.3);display:flex;align-items:center;justify-content:center;margin:0 auto 24px;background:rgba(99,102,241,.08)}
.logo::after{content:'';width:14px;height:14px;border-radius:50%;border:3px solid #818cf8}
h1{font-size:1.6rem;font-weight:700;margin-bottom:4px}h1 span{color:#818cf8}
.sub{color:#64748b;font-size:.85rem;margin-bottom:32px}
.sso-btn{display:block;width:100%;padding:14px;background:linear-gradient(135deg,#6366f1,#818cf8);color:#fff;border:none;border-radius:12px;font-size:1rem;font-weight:600;cursor:pointer;text-decoration:none;margin-bottom:16px;transition:all .2s}
.sso-btn:hover{transform:translateY(-1px);box-shadow:0 8px 20px rgba(99,102,241,.3)}
.spinner{display:inline-block;width:18px;height:18px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;margin-right:8px;vertical-align:middle}
@keyframes spin{to{transform:rotate(360deg)}}
.redirect-msg{color:#94a3b8;font-size:.85rem;margin-bottom:24px}
.divider{border-top:1px solid rgba(255,255,255,.08);margin:24px 0;position:relative}
.divider span{background:rgba(15,23,42,.9);padding:0 12px;color:#475569;font-size:.75rem;position:absolute;top:-8px;left:50%;transform:translateX(-50%)}
.toggle{color:#64748b;font-size:.8rem;cursor:pointer;text-decoration:underline}
.toggle:hover{color:#94a3b8}
.manual{display:none;margin-top:20px}
.manual.show{display:block}
.field{margin-bottom:16px;text-align:left}
.field label{display:block;color:#94a3b8;font-size:.8rem;margin-bottom:4px;font-weight:500}
.field input{width:100%;padding:12px 14px;background:rgba(30,41,59,.8);border:1px solid rgba(99,102,241,.2);border-radius:10px;color:#f1f5f9;font-size:.95rem;outline:none;transition:border .2s}
.field input:focus{border-color:#6366f1}
.btn{width:100%;padding:12px;background:rgba(99,102,241,.15);border:1px solid rgba(99,102,241,.3);border-radius:10px;color:#a5b4fc;font-size:.9rem;font-weight:600;cursor:pointer}
.btn:hover{background:rgba(99,102,241,.25)}
.error{color:#f87171;font-size:.85rem;margin-top:8px}
.secure{color:#475569;font-size:.75rem;margin-top:16px}
.back{color:#475569;font-size:.8rem;text-decoration:none;display:block;margin-top:12px}
.footer{color:#334155;font-size:.7rem;margin-top:16px}
</style>
</head>
<body>
<div class="box">
<div class="logo"></div>
<h1>WEVAL <span>Consulting</span></h1>
<p class="sub">Espace sécurisé — Authentification requise</p>
<div id="auto-redirect">
<p class="redirect-msg"><span class="spinner"></span>Redirection SSO Authentik...</p>
</div>
<a id="sso-link" class="sso-btn" href="#">Connexion SSO (Authentik)</a>
<div class="divider"><span>OU</span></div>
<span class="toggle" onclick="document.getElementById('manual').classList.toggle('show')">Connexion manuelle</span>
<div id="manual" class="manual">
<form onsubmit="return doLogin(event)" novalidate>
<div class="field"><label>Identifiant</label><input type="text" id="user" autocomplete="username"></div>
<div class="field"><label>Mot de passe</label><input type="password" id="pass" autocomplete="current-password"></div>
<button class="btn" type="submit" id="btn">Se connecter</button>
<div class="error" id="err"></div>
</form>
</div>
<div class="secure">Connexion chiffrée · Session sécurisée</div>
<a class="back" href="/">Retour au site</a>
<div class="footer">WEVAL Consulting 2026</div>
</div>
<script>
var redirect=new URLSearchParams(window.location.search).get('r')||'/products/workspace.html';
var state=btoa(redirect);
var ssoUrl='https://auth.weval-consulting.com/application/o/authorize/?client_id=aB9IF9xQ8L9u7Ty1Eq63dMYFgy59O58fqzuNulwJ&response_type=code&redirect_uri=https%3A%2F%2Fweval-consulting.com%2Fapi%2Fauth-callback.php&scope=openid+profile+email&state='+encodeURIComponent(state);
document.getElementById('sso-link').href=ssoUrl;
if(!window.location.search.includes('manual=1')&&!window.location.search.includes('error=')){
//auto-redirect disabled — use manual SSO button or password login
}else{
document.getElementById('auto-redirect').style.display='none';
}
async function doLogin(e){
e.preventDefault();
var btn=document.getElementById('btn'),err=document.getElementById('err');
var user=document.getElementById('user').value.trim(),pass=document.getElementById('pass').value;
if(!user){err.textContent='Identifiant requis';return false;}
if(!pass){err.textContent='Mot de passe requis';return false;}
btn.disabled=true;btn.textContent='Connexion...';err.textContent='';
try{
var r=await fetch('/api/weval-auth-session.php',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'action=login&user='+encodeURIComponent(user)+'&pass='+encodeURIComponent(pass)+'&redirect='+encodeURIComponent(redirect)});
var d=await r.json();
if(d.ok){window.location.href=d.redirect||redirect;}
else{err.textContent=d.error||'Identifiants incorrects';}
}catch(ex){err.textContent='Erreur réseau';}
btn.disabled=false;btn.textContent='Se connecter';
}
if(window.location.search.includes('error=')){document.getElementById('manual').classList.add('show');document.getElementById('auto-redirect').style.display='none';}
</script>
<!-- === OPUS UNIVERSAL DRILL-DOWN v1 19avr — append-only, doctrine #14 === -->
<script>
(function(){
if (window.__opusUniversalDrill) return; window.__opusUniversalDrill = true;
var d = document;
var m = d.createElement('div');
m.id = 'opus-udrill';
m.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.82);backdrop-filter:blur(6px);display:none;align-items:center;justify-content:center;z-index:99995;padding:20px;cursor:pointer';
var inner = d.createElement('div');
inner.id = 'opus-udrill-in';
inner.style.cssText = 'max-width:900px;width:100%;max-height:90vh;overflow:auto;background:#0b0d15;border:1px solid rgba(99,102,241,0.35);border-radius:14px;padding:28px;cursor:default;box-shadow:0 20px 60px rgba(0,0,0,0.6);color:#e2e8f0;font:14px/1.55 Inter,system-ui,sans-serif';
inner.addEventListener('click', function(e){ e.stopPropagation(); });
m.appendChild(inner);
m.addEventListener('click', function(){ m.style.display='none'; });
d.addEventListener('keydown', function(e){ if(e.key==='Escape') m.style.display='none'; });
(d.body || d.documentElement).appendChild(m);
function openCard(card) {
var html = '<div style="display:flex;justify-content:flex-end;margin-bottom:14px"><button id="opus-udrill-close" style="padding:6px 14px;background:#171b2a;border:1px solid rgba(99,102,241,0.25);color:#e2e8f0;border-radius:8px;cursor:pointer;font-size:12px">✕ Fermer (Esc)</button></div>';
html += '<div style="transform-origin:top left;font-size:1.05em">' + card.outerHTML + '</div>';
inner.innerHTML = html;
d.getElementById('opus-udrill-close').onclick = function(){ m.style.display='none'; };
m.style.display = 'flex';
}
function wire(root) {
var sels = '.card,[class*="card"],.kpi,[class*="kpi"],.stat,[class*="stat"],.tile,[class*="tile"],.metric,[class*="metric"],.widget,[class*="widget"]';
var cards = root.querySelectorAll(sels);
for (var i = 0; i < cards.length; i++) {
var c = cards[i];
if (c.__opusWired) continue;
if (c.closest('button, a, input, select, textarea, #opus-udrill')) continue;
var r = c.getBoundingClientRect();
if (r.width < 60 || r.height < 40) continue;
c.__opusWired = true;
c.style.cursor = 'pointer';
c.setAttribute('role','button');
c.setAttribute('tabindex','0');
c.addEventListener('click', function(ev){
if (ev.target.closest('[data-pp-id]') && window.__opusDrillInit) return;
if (ev.target.closest('a,button,input,select')) return;
ev.preventDefault(); ev.stopPropagation();
openCard(this);
});
c.addEventListener('keydown', function(ev){ if(ev.key==='Enter'||ev.key===' '){ev.preventDefault();openCard(this);} });
}
}
var initRun = function(){ wire(d.body || d.documentElement); };
if (d.readyState === 'loading') d.addEventListener('DOMContentLoaded', initRun);
else initRun();
var mo = new MutationObserver(function(muts){
var newCard = false;
for (var i=0;i<muts.length;i++) if (muts[i].addedNodes.length) { newCard = true; break; }
if (newCard) initRun();
});
mo.observe(d.body || d.documentElement, {childList:true, subtree:true});
})();
</script>
<!-- === OPUS UNIVERSAL DRILL-DOWN END === -->
<!-- V90 archi badge + spotlight (UX premium partout) -->
<script src="/api/archi-meta-badge.js" defer></script>
<script src="/api/archi-spotlight.js" defer></script>
</body>
</html>

View File

@@ -0,0 +1,117 @@
# V136 Opus WIRE - KPI Banner Clickable + Health Drill-Down Modal · 21avr 11:27
## Context
Yacine "CONTINUE" après V135. Objectif: drill-down actionable sur le banner health V135 pour voir en 1 click le détail des URLs cassées.
## Scan exhaustif (doctrine #1)
Découvertes:
- `b1629038b` autre Claude: "polish wtp-erp-cc-charts-v106" — donut SVG 100% + sparkline 56→100% ajoutés dans WTP (5KB+, 197→202KB)
- `14ecacd24` "ERP Command Center V105" toujours en place (5 cards penta-pivot + 8 KPI)
- Mes 11 markers V107-V135 tous préservés dans all-ia-hub
- NR: 200/201 stable
- screens-health.json live: 20 BROKEN / 470 UP / 29 SLOW / 1218 PHANTOM
## Option étudiées et rejetées
### Option A - Nouveau tab HEALTH (rejetée)
Risque de breaking le layout 7-tabs existant + aucun besoin d'une vue permanente vs vue on-demand.
### Option B - Nouvelle page /platform-health.html (rejetée)
Crée un orphelin potentiel, duplique l'info déjà dans screens-health.json. Doctrine #14: privilégier enrichissement in-place.
### Option C - Modal in-page déclenché par click sur banner V135 (CHOISIE)
Zero nouvelle page · Zero nouveau tab · In-place · Full drill-down.
## Livrable V136
### Banner cliquable (V135 → V136)
**Avant** : banner read-only
**Après** : `cursor:pointer` + `text-decoration:underline dotted` + click handler `__v136ShowHealthModal()` + title update "Platform health live · click pour détail"
### Modal drill-down
- Position: `fixed` overlay fullscreen, background `rgba(0,0,0,0.75)`
- Container centré, max-width 880px, max-height 80vh scrollable
- Header: titre "🏥 Platform Health · Detail" + summary timestamp + counts + bouton ESC
- Content: liste des 20 BROKEN URLs avec:
- Status badge (`BROKEN`/`DOWN` en rouge)
- HTTP code (amber)
- URL clickable (target="_blank")
- Response time ms (right-aligned)
- Bonus: top 10 SLOW URLs (>2s) en bas
- Footer: source `/api/screens-health.json` (transparence)
### Interaction UX premium
- Click banner → ouvre (smooth)
- Click outside modal → ferme
- Escape key → ferme
- Reopen possible illimité
- Fetch lazy (uniquement au click)
- Cache-busting `cache: 'no-store'` (toujours live)
## Validation E2E Playwright V136
```json
{
"v136": "health-modal-drill-down",
"modal_exists": true,
"banner_clickable": true,
"hidden_initially": true,
"opens_on_click": true,
"fetches_broken_urls": true,
"content_preview": "...BROKEN/DOWN (20):... BROKEN 500 https://weval-consulting.com/_oc_tmp.php...",
"escape_closes": true,
"reopen_works": true,
"click_outside_closes": true,
"js_errors": [],
"VERDICT": "OK"
}
```
8/8 checks pass · screenshot captured dans `/var/www/html/api/blade-tasks/v136-health-modal-proof/`
## Navigation Hub finalisée
```
all-ia-hub.html (61.2KB)
└── 🧭 Breadcrumb V130+penta (5 surfaces navigables)
└── 🟢/🟡/🔴 KPI banner V135 + 🆕 clickable V136
↓ click
└── 🏥 Health Drill-Down Modal (V136)
├── Summary live (timestamp + counts)
├── BROKEN/DOWN URLs (up to 30)
├── SLOW URLs (top 10)
├── Close: ESC / click-outside / button
└── Source transparente
```
## Architecture KPI 3 niveaux (consolidation complète)
| Niveau | Source | Scope | UI | Action |
|---|---|---|---|---|
| **Local** (V131) | registry.php | 84 dashboards | Counter DASHBOARDS | ● all OK / ● N broken |
| **Global** (V135) | screens-health.json | 1737 URLs | Breadcrumb xnav top | 🟢/🟡/🔴 + % + short |
| **Detail** (V136) | screens-health.json | Broken + Slow URLs | Modal on-demand | Liste clickable avec codes + ms |
Chaque niveau a son rôle et son scope, aucune redondance.
## Métriques V135 → V136
| | V135 | V136 |
|---|---|---|
| Hub size | 55.9KB | 61.2KB (+5.3KB) |
| KPI niveaux | 2 | **3** |
| Drill-down actionable | non | **oui** (modal on-click) |
| Modal/Dialog | 0 | **1** (in-page, zero new page) |
| Keyboard a11y | Cmd+K search | Cmd+K search + Escape modal |
| JS errors | 0 | 0 |
| Sessions sans régression | 100 | **101+** |
## GOLDs préservés
- `/opt/wevads/vault/all-ia-hub.html.GOLD-V136-pre-health-modal`
- V108, V109, V111, V112, V113, V114, V116, V117, V119, V120, V122, V123, V127, V128, V129, V130, V131, V135, V136 (19 GOLDs)
## Doctrines respectées
#1 scan exhaustif · #3 GOLD · #4 honnêteté (E2E 8/8 prouvé) · #13 cause racine (consomme source unique, pas nouveau scanner) · **#14 ADDITIF PUR** · #16 NR · **#60 UX premium** (modal overlay, a11y ESC/click-outside, transparence source) · #100
## Sessions consécutives sans régression applicative : **101+** 🏆