export const PANES_STYLE = ` .legend { display:flex; gap:16px; flex-wrap:wrap; align-items:center; margin:0 0 14px; font-size:12px; color:var(--muted); } .legend b { color:var(--ink); font-weight:600; } .swatch { display:inline-block; width:11px; height:11px; border-radius:3px; margin-right:6px; vertical-align:-1px; border:1px solid rgba(0,0,0,.12); } .blocks { display:grid; grid-template-columns:repeat(auto-fill,minmax(228px,1fr)); gap:14px; } .block { border:1px solid var(--line); border-radius:10px; padding:9px 11px 11px; background:var(--surface-2); } .block h3 { margin:0 0 7px; font:600 12px/1.3 ui-monospace,monospace; word-break:break-all; } .block h3 span { color:var(--muted); font-weight:400; } .cells { display:flex; flex-wrap:wrap; gap:3px; } .cell { width:13px; height:13px; border-radius:3px; cursor:pointer; border:1px solid transparent; } .cell:hover, .cell.sel { outline:2px solid var(--ink); outline-offset:1px; } .cell.s-both { background:var(--exact); } .cell.s-uncalled { background:var(--surface-3); border-color:var(--line); } .cell.s-feonly { background:var(--dead); } .cell.s-unpaired { background:repeating-linear-gradient(135deg,var(--surface-3) 0 3px,var(--surface) 3px 6px); border-color:var(--line); } .cell.open { box-shadow:inset 0 0 0 2px var(--surface); } .pick { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin:0 0 12px; font-size:12.5px; } .pick .hint { color:var(--muted); } .pick .spacer { flex:1; } svg.bip { width:100%; height:auto; display:block; } svg.bip text { font:11.5px ui-monospace,monospace; fill:var(--ink); } svg.bip text.head { font:600 11px ui-sans-serif,sans-serif; fill:var(--muted); letter-spacing:.08em; text-transform:uppercase; } svg.bip .node { fill:var(--surface-3); stroke:var(--line); } svg.bip .node.screen { fill:var(--inferred); stroke:none; } svg.bip .node.ep { fill:var(--exact); stroke:none; } svg.bip .node.ep.dead { fill:var(--dead); } svg.bip .edge { fill:none; stroke-width:1.2; opacity:.5; } svg.bip .edge.c-exact { stroke:var(--exact); } svg.bip .edge.c-inferred { stroke:var(--inferred); } svg.bip .edge.c-guess { stroke:var(--guess); stroke-dasharray:3 3; } svg.bip g.row { cursor:pointer; } svg.bip g.row:hover text { font-weight:700; } svg.bip.dim .edge { opacity:.08; } svg.bip.dim .edge.lit { opacity:.95; stroke-width:2; } svg.bip.dim text { opacity:.35; } svg.bip.dim g.lit text { opacity:1; font-weight:700; } svg.bip.dim rect.node { opacity:.25; } svg.bip.dim g.lit rect.node { opacity:1; } .wide { color:var(--guess); font-size:12.5px; margin:10px 0 0; } .toolrow { display:flex; gap:9px; align-items:center; flex-wrap:wrap; margin:0 0 10px; } .grid3 { display:grid; grid-template-columns:196px minmax(0,1fr) 336px; gap:14px; align-items:start; } @media (max-width:1400px) { .grid3 { grid-template-columns:196px minmax(0,1fr); } } @media (max-width:1000px) { .grid3 { grid-template-columns:1fr; } } .facets { border:1px solid var(--line); border-radius:12px; background:var(--surface); padding:11px 0 6px; position:sticky; top:14px; max-height:80vh; overflow:auto; } .facets .fhead { display:flex; align-items:center; padding:0 12px 8px; } .facets .fhead span { font:650 10px/1 ui-sans-serif,sans-serif; text-transform:uppercase; letter-spacing:.09em; color:var(--muted); } .facets .reset { margin-left:auto; font:11px inherit; color:var(--brand); background:none; border:0; cursor:pointer; padding:0; } .facets .fg { border-top:1px solid var(--line); padding:8px 0 6px; } .facets .fg > h5 { margin:0 0 4px; padding:0 12px; font:650 9.5px/1 ui-sans-serif,sans-serif; text-transform:uppercase; letter-spacing:.09em; color:var(--muted); } .facets label { display:flex; align-items:center; gap:7px; padding:4px 12px; font-size:12px; cursor:pointer; color:var(--ink-2); } .facets label:hover { background:var(--surface-2); } .facets label.on { color:var(--ink); font-weight:600; } .facets label input { accent-color:var(--brand); margin:0; flex:none; } .facets label .fn { margin-left:auto; font:11px ui-monospace,monospace; color:var(--muted); } .facets label .sw { width:8px; height:8px; border-radius:2px; flex:none; } .pager { display:flex; align-items:center; gap:5px; padding:9px 2px 4px; flex-wrap:wrap; } .pager button { min-width:28px; font:12px inherit; border:1px solid var(--line); border-radius:7px; background:var(--surface); color:var(--ink-2); padding:4px 7px; cursor:pointer; } .pager button:hover:not([disabled]) { background:var(--surface-3); color:var(--ink); } .pager button.on { background:var(--brand); border-color:var(--brand); color:#fff; font-weight:650; } .pager button[disabled] { opacity:.4; cursor:default; } .pager .gap { color:var(--muted); padding:0 2px; } .pager .of { margin-left:auto; font-size:11.5px; color:var(--muted); } .legendbar { display:flex; gap:16px; flex-wrap:wrap; align-items:center; margin:16px 0 0; padding:10px 13px; border:1px solid var(--line); border-radius:10px; background:var(--surface-2); font-size:11.5px; color:var(--muted); } .legendbar b { color:var(--ink); font-weight:600; } .cmp4 { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:14px; margin:0 0 16px; } @media (max-width:1150px) { .cmp4 { grid-template-columns:1fr; } } .barow { display:flex; align-items:baseline; gap:10px; padding:5px 0; border-bottom:1px dashed var(--line); } .barow:last-child { border-bottom:0; } .barow .lb { font-size:12px; color:var(--muted); min-width:76px; } .barow .lb .sw { width:8px; height:8px; border-radius:2px; display:inline-block; } .ba { display:flex; align-items:baseline; gap:7px; margin-left:auto; } .ba .b1 { font:600 13px ui-monospace,monospace; color:var(--muted); } .ba .b2 { font:650 16px ui-monospace,monospace; } .ba .ar { color:var(--muted); font-size:11px; } .ba .dl { font-size:11px; color:var(--muted); } .ba .dl.up { color:var(--exact); } .ba .dl.down { color:var(--dead); } .stk { display:flex; align-items:center; gap:8px; margin:0 0 7px; } .stk .tg { font-size:10.5px; color:var(--muted); min-width:32px; } .stk .sb { display:flex; flex:1; height:9px; border-radius:999px; overflow:hidden; background:var(--surface-3); } .stk .sb i { display:block; } .stk .tot { font:600 11px ui-monospace,monospace; color:var(--muted); } .bignum { display:flex; align-items:baseline; gap:9px; margin:2px 0 6px; } .bignum .b1 { font:600 19px ui-monospace,monospace; color:var(--muted); } .bignum .b2 { font:650 27px ui-sans-serif,sans-serif; letter-spacing:-.02em; } .bignum .b2.worse { color:var(--dead); } .bignum .ar { color:var(--muted); } .chg { font:650 9.5px/1 ui-sans-serif,sans-serif; text-transform:uppercase; letter-spacing:.07em; border-radius:999px; padding:4px 8px; border:1px solid var(--line); color:var(--muted); } .chg.ok { color:var(--exact); border-color:var(--exact); background:var(--tint-exact); } .chg.bad { color:var(--dead); border-color:var(--dead); background:var(--tint-dead); } .chg.warn { color:var(--guess); border-color:var(--guess); background:var(--tint-guess); } .ihead { display:flex; align-items:center; gap:8px; margin:0 0 9px; min-width:0; } .ihead h4 { margin:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .callout { border:1px solid var(--line); border-left-width:3px; border-radius:8px; padding:8px 10px; margin:0 0 11px; background:var(--surface-2); } .callout .ct { font-weight:650; font-size:12.5px; } .callout .cw { font-size:11.5px; color:var(--muted); margin-top:2px; line-height:1.5; } .callout.d-both { border-left-color:var(--exact); background:var(--tint-exact); } .callout.d-feonly { border-left-color:var(--dead); background:var(--tint-dead); } .callout.d-uncalled { border-left-color:var(--guess); background:var(--tint-guess); } .callout.d-unpaired { border-left-color:var(--line-2); } .btn.wide2 { width:100%; margin-top:12px; text-align:center; display:block; } .toolrow .search { display:flex; align-items:center; gap:7px; background:var(--surface); border:1px solid var(--line); border-radius:8px; padding:5px 9px; min-width:240px; } .toolrow .search input { border:0; outline:0; background:transparent; color:var(--ink); font:inherit; width:100%; } .facet { border:1px solid var(--line); border-radius:8px; background:var(--surface); color:var(--muted); font:inherit; font-size:12.5px; padding:5px 8px; } .grid2 { display:grid; grid-template-columns:1fr 348px; gap:14px; align-items:start; } @media (max-width:1150px) { .grid2 { grid-template-columns:1fr; } } /* cm:guard The chain needs FOUR readable columns โ€” squeezing it into the 348px inspector slot broke every label into one character per line, which is worse than not drawing it. */ .impgrid { display:grid; grid-template-columns:360px minmax(0,1fr); gap:14px; align-items:start; } @media (max-width:1250px) { .impgrid { grid-template-columns:1fr; } } table.rows { width:100%; border-collapse:collapse; font-size:12.5px; } table.rows th { text-align:left; font:600 10.5px/1 ui-sans-serif,sans-serif; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); padding:0 8px 7px; border-bottom:1px solid var(--line); white-space:nowrap; } table.rows td { padding:7px 8px; border-bottom:1px solid var(--line); vertical-align:top; } table.rows.dense { table-layout:fixed; } table.rows.dense td { padding:6px 8px; vertical-align:middle; } table.rows.dense th { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } table.rows.dense th.num, table.rows.dense td.num { text-align:right; font:600 12px ui-monospace,monospace; } table.rows.dense .clip { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } table.rows.dense .mono { font:12px/1.4 ui-monospace,monospace; } table.rows.dense .sub2 { font:10.5px/1.35 ui-monospace,monospace; } table.rows tbody tr { cursor:pointer; } table.rows tbody tr:hover { background:var(--surface-2); } table.rows tbody tr.on { background:var(--surface-3); box-shadow:inset 3px 0 0 var(--brand); } table.rows tbody tr.on .mono { color:var(--ink); font-weight:600; } .tblwrap { border:1px solid var(--line); border-radius:12px; background:var(--surface); padding:12px 12px 4px; overflow-x:auto; } .mono { font:12px/1.45 ui-monospace,monospace; word-break:break-all; } .sub2 { color:var(--muted); font:11px/1.4 ui-monospace,monospace; word-break:break-all; } .micro { display:flex; width:74px; height:7px; border-radius:999px; overflow:hidden; background:var(--surface-3); } .micro i { display:block; } .nowrap { white-space:nowrap; } .insp2 { border:1px solid var(--line); border-radius:12px; background:var(--surface); position:sticky; top:14px; } .insp2 .htabs { display:flex; gap:2px; border-bottom:1px solid var(--line); padding:8px 10px 0; flex-wrap:wrap; } .insp2 .htabs span { font-size:12px; padding:6px 9px; border-radius:8px 8px 0 0; cursor:pointer; color:var(--muted); } .insp2 .htabs span.on { background:var(--surface-3); color:var(--ink); font-weight:600; } .insp2 .body { padding:12px 14px 16px; max-height:70vh; overflow:auto; } .insp2 h4 { margin:0 0 3px; font:12.5px/1.4 ui-monospace,monospace; word-break:break-all; } .kv { display:grid; grid-template-columns:96px 1fr; gap:3px 10px; font-size:12px; margin:9px 0; } .kv span:first-child { color:var(--muted); } .chainwrap { overflow-x:auto; margin-top:6px; } svg.chaing { width:100%; min-width:760px; height:auto; display:block; } svg.chaing text { font:11px ui-monospace,monospace; fill:var(--ink); } svg.chaing text.sub { font:9.5px ui-monospace,monospace; fill:var(--muted); } svg.chaing text.head { font:650 9.5px ui-sans-serif,sans-serif; fill:var(--muted); letter-spacing:.09em; text-transform:uppercase; } svg.chaing g.cnode rect { fill:var(--surface-2); stroke:var(--line-2); stroke-width:1; } svg.chaing g.cnode.loose rect { stroke:var(--guess); stroke-dasharray:3 2; } svg.chaing .cedge { fill:none; stroke-width:1.3; opacity:.55; } svg.chaing .cedge.loose { stroke-dasharray:4 3; } svg.chaing .cedge.c-exact { stroke:var(--exact); } svg.chaing .cedge.c-inferred { stroke:var(--inferred); } svg.chaing .cedge.c-guess { stroke:var(--guess); } svg.chaing .arrowhead { fill:var(--muted); } svg.chaing g.cnode { cursor:default; } svg.chaing.dim .cedge { opacity:.07; } svg.chaing.dim .cedge.lit { opacity:.95; stroke-width:2; } svg.chaing.dim g.cnode { opacity:.25; } svg.chaing.dim g.cnode.lit { opacity:1; } svg.chaing.dim g.cnode.lit rect { stroke:var(--brand); } .chain { display:flex; flex-direction:column; gap:0; margin:8px 0 0; } .chain .step { display:flex; gap:8px; align-items:baseline; padding:6px 0; border-bottom:1px dashed var(--line); } .chain .role { font:600 9.5px/1 ui-sans-serif,sans-serif; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); border:1px solid var(--line); border-radius:5px; padding:3px 5px; min-width:66px; text-align:center; flex:none; } .chain .step.loose .role { color:var(--guess); border-color:var(--guess); } .cols { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; } @media (max-width:1000px) { .cols { grid-template-columns:1fr; } } .col h4 { margin:0 0 8px; font:600 10.5px/1 ui-sans-serif,sans-serif; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); } .node { border:1px solid var(--line); border-radius:9px; background:var(--surface-2); padding:7px 9px; margin:0 0 7px; } .node.loose { border-color:var(--guess); } .node .nm { font:12px/1.35 ui-monospace,monospace; word-break:break-all; } .badge { font-size:10px; padding:2px 6px; border-radius:999px; border:1px solid var(--line); color:var(--muted); } .badge.exact { color:var(--exact); border-color:var(--exact); } .badge.inferred { color:var(--inferred); border-color:var(--inferred); } .badge.guess { color:var(--guess); border-color:var(--guess); } .badge.high { color:var(--dead); border-color:var(--dead); } .badge.medium { color:var(--guess); border-color:var(--guess); } .cut { color:var(--guess); font-size:12.5px; margin:9px 0 0; } .group { border:1px solid var(--line); border-radius:10px; margin:0 0 10px; background:var(--surface); } .group > .gh { display:flex; gap:9px; align-items:baseline; padding:9px 12px; cursor:pointer; } .group > .gh b { font-size:13px; } .group > .gh .n { margin-left:auto; color:var(--muted); font:11.5px ui-monospace,monospace; } .group > .gb { display:none; padding:0 12px 10px; } .group.open > .gb { display:block; } .gb .item { padding:6px 0; border-top:1px solid var(--line); } .cmp { display:grid; grid-template-columns:repeat(auto-fit,minmax(210px,1fr)); gap:12px; margin:0 0 16px; } .cmp .box { border:1px solid var(--line); border-radius:11px; background:var(--surface); padding:12px 14px; } .cmp .box .k { font-size:10.5px; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); } .cmp .arrow { display:flex; align-items:baseline; gap:9px; font:650 21px/1.2 ui-sans-serif,sans-serif; margin:4px 0 0; } .cmp .arrow small { font:400 12px ui-sans-serif,sans-serif; color:var(--muted); } .headline { font:650 15px/1.4 ui-sans-serif,sans-serif; margin:0 0 12px; } .headline.bad { color:var(--dead); } .headline.good { color:var(--exact); } .pp { font:600 12px ui-monospace,monospace; } .pp.up { color:var(--exact); } .pp.down { color:var(--dead); } .empty2 { color:var(--muted); font-size:12.5px; padding:14px 4px; } `; export const PANES_HTML = ` `; // cm:guard No template literals and no backticks below โ€” this block is embedded inside a String.raw // literal, so one backtick closes it early and the page ships a syntax error. export const PANES_SCRIPT = String.raw` const el0 = (id) => (document.getElementById(id) || { textContent: 'null' }).textContent; const MAP = JSON.parse(document.getElementById('apimap').textContent); const ALERTS = JSON.parse(document.getElementById('alerts').textContent); const RELIABILITY = new Map(JSON.parse(document.getElementById('reliability').textContent) .map((r) => [r[0], { exact: r[1], inferred: r[2], guess: r[3] }])); const DIFF = JSON.parse(document.getElementById('diff').textContent); const SERIES = JSON.parse(el0('series')); const EPHIST = JSON.parse(el0('ephist')); // cm:why "scan 3/5" plus a date, not just a date: history holds only the scans that CHANGED // something, so "2d ago" alone hides that there were three scans in between that saw the same. function seenText(endpointId) { if (!EPHIST) return null; const total = EPHIST.scans.length; const index = EPHIST.first[endpointId]; if (index === undefined) return 'present in all ' + total + ' stored scans'; const at = EPHIST.scans[index] && EPHIST.scans[index].at; return 'first seen in scan ' + (index + 1) + '/' + total + (at ? ' (' + new Date(at).toLocaleString('en-US') + ')' : ''); } // cm:guard Kept for the panes that still cap instead of paginate โ€” unresolved groups by reason, and // a page boundary through a group would split one reason across two pages. const ROW_CAP = 400; const SCREEN_CAP = 20; const byId = (list) => new Map(list.map((x) => [x.id, x])); const endpoints = byId(MAP.endpoints); const screens = byId(MAP.screens); const callsByEndpoint = new Map(); const callsByScreen = new Map(); for (const c of MAP.calls) { if (!callsByEndpoint.has(c.endpointId)) callsByEndpoint.set(c.endpointId, []); callsByEndpoint.get(c.endpointId).push(c); if (!callsByScreen.has(c.screenId)) callsByScreen.set(c.screenId, []); callsByScreen.get(c.screenId).push(c); } // cm:guard Must stay BELOW callsByEndpoint: this block is inside a browser script, so tsc and eslint // see a string, not code โ€” reading the map above its own initialiser throws a TDZ ReferenceError at // the top of the script and takes every pane on the page down with it. const DECLARED = MAP.endpoints.filter((e) => e.source !== undefined).length; const UNDECLARED_CALLED = MAP.endpoints.filter((e) => e.source === undefined && (callsByEndpoint.get(e.id) || []).length > 0).length; const BE_PARTIAL = DECLARED > 0 && UNDECLARED_CALLED > DECLARED; const HAS_BE = DECLARED > 0 && !BE_PARTIAL; const HAS_FE = MAP.calls.length > 0; const fieldsByEndpoint = new Map(); for (const f of MAP.fields) { if (!fieldsByEndpoint.has(f.endpointId)) fieldsByEndpoint.set(f.endpointId, []); fieldsByEndpoint.get(f.endpointId).push(f); } const alertsByEndpoint = new Map(); for (const a of ALERTS) { if (!alertsByEndpoint.has(a.endpointId)) alertsByEndpoint.set(a.endpointId, []); alertsByEndpoint.get(a.endpointId).push(a); } // cm:edge contract -> packages/cli/src/workspace/summary.ts โ€” same four states as endpointState there. A one-sided // scan makes every endpoint look feOnly; painting that red invents a defect out of a missing half. function reconOf(e) { if (e.source === undefined) return HAS_BE ? 'feonly' : 'unpaired'; if ((callsByEndpoint.get(e.id) || []).length > 0) return 'both'; return HAS_FE ? 'uncalled' : 'unpaired'; } const RECON_LABEL = { both: 'both sides', uncalled: 'no screen calls it', feonly: 'API does not declare it', unpaired: 'not reconciled', }; const RECON_CLS = { both: 'd-both', uncalled: 'd-uncalled', feonly: 'd-feonly', unpaired: 'd-unpaired' }; // cm:why Each state gets a sentence, not just a colour: "feonly" is a finding a reader has to act on, // and the action depends on WHY apiflow says it โ€” which the colour cannot carry. const RECON_WHY = { both: 'The API declares this route and screens call it. Changing it changes both sides.', uncalled: 'The API declares this route but no screen in the scanned FE calls it. A dead-route candidate โ€” another client may still be calling it.', feonly: 'The FE calls this path but the BE does not declare it. Either the path is wrong, or the route lives in another service.', unpaired: 'Not reconciled because one side is missing. Scan the other side before concluding anything.', }; const screensFor = (endpointId) => [...new Set((callsByEndpoint.get(endpointId) || []).map((c) => c.screenId))]; const state = { section: 'overview', q: '', method: '', auth: '', recon: '', conf: '', endpoint: null, insp: 'overview', screen: null, impQ: '', alertKind: '', alertSev: '', unQ: '', scQ: '', cvQ: '', cvRecon: '', group: null, gEndpoint: null, page: 1, scPage: 1, alPage: 1, }; const el = (id) => document.getElementById(id); function h(tag, cls, text) { const n = document.createElement(tag); if (cls) n.className = cls; if (text !== undefined) n.textContent = text; return n; } const bestConf = (calls) => { const rank = { exact: 0, inferred: 1, guess: 2 }; return calls.reduce((acc, c) => (rank[c.confidence] < rank[acc] ? c.confidence : acc), calls.length ? 'guess' : undefined); }; function microBar(id) { const r = RELIABILITY.get(id); const wrap = h('div', 'micro'); if (!r) { wrap.title = 'no call traced yet'; return wrap; } const total = r.exact + r.inferred + r.guess; for (const k of ['exact', 'inferred', 'guess']) { if (!r[k]) continue; const seg = h('i', 'c-bg-' + k); seg.style.width = ((r[k] / total) * 100).toFixed(2) + '%'; wrap.appendChild(seg); } // cm:why Title carries the COUNT, not just the split: one call at exact is not stronger evidence // than twenty calls at 90% exact, and a bare percentage hides exactly that. wrap.title = total + ' calls ยท exact ' + r.exact + ' ยท inferred ' + r.inferred + ' ยท guess ' + r.guess; return wrap; } function fillFacet(id, label, options, value, onchange) { const sel = el(id); if (!sel) return; sel.textContent = ''; const all = document.createElement('option'); all.value = ''; all.textContent = label; sel.appendChild(all); for (const [v, text] of options) { const o = document.createElement('option'); o.value = v; o.textContent = text; sel.appendChild(o); } sel.value = value; sel.onchange = () => onchange(sel.value); } function visibleEndpoints() { const q = state.q.toLowerCase(); return MAP.endpoints.filter((e) => { if (q && !(e.method + ' ' + e.path + ' ' + (e.handler || '')).toLowerCase().includes(q)) return false; if (state.method && e.method !== state.method) return false; if (state.auth === 'yes' && e.auth !== true) return false; if (state.auth === 'no' && e.auth !== false) return false; if (state.auth === 'murky' && e.auth !== undefined) return false; if (state.recon && reconOf(e) !== state.recon) return false; if (state.conf) { const b = bestConf(callsByEndpoint.get(e.id) || []); if (b !== state.conf) return false; } return true; }); } const PAGE = 50; function kpiStrip(id) { const box = el(id); if (!box) return; box.textContent = ''; const d = DIFF; const rows = [ ['endpoint', MAP.endpoints.length, d && (d.endpoints.added.length - d.endpoints.removed.length), ''], ['screens', MAP.screens.length, d && (d.screens.after - d.screens.before), ''], ['calls', MAP.calls.length, d && (d.calls.after - d.calls.before), ''], ['field', MAP.fields.length, null, ''], ['unresolved', MAP.unresolved.length, d && (d.unresolved.after - d.unresolved.before), MAP.unresolved.length ? 'alarm' : ''], ['both sides', MAP.endpoints.filter((e) => reconOf(e) === 'both').length, null, ''], ]; const SERIES_KEY = { endpoint: 'endpoints', screens: 'screens', calls: 'calls', unresolved: 'unresolved' }; for (const [lab, val, delta, cls] of rows) { const card = h('div', 'k1 ' + cls); card.appendChild(h('div', 'lab', lab)); card.appendChild(h('div', 'val', val.toLocaleString('en-US'))); // cm:why A delta is shown only when two scans exist โ€” an unchanged "โ–ฒ 0" on a first scan reads // as "measured, no movement" when the truth is that nothing has been measured against yet. if (delta === null || delta === undefined || delta === 0) { card.appendChild(h('div', 'dlt', d ? 'โ€”' : 'no earlier scan to compare with')); } else { const up = lab === 'unresolved' ? delta < 0 : delta > 0; card.appendChild(h('div', 'dlt ' + (up ? 'up' : 'down'), (delta > 0 ? 'โ–ฒ ' : 'โ–ผ ') + Math.abs(delta).toLocaleString('en-US') + ' vs last scan')); } const line = SERIES && SERIES[SERIES_KEY[lab]]; if (line && line.length >= 3) card.appendChild(spark(line, cls === 'alarm')); box.appendChild(card); } } // cm:why Draws the series as-is with no y-axis and no baseline at zero: this is a shape, not a // measurement โ€” the number above it is the measurement, and a fake axis would invite reading values // off 40 pixels of svg. function spark(values, inverse) { const w = 78; const hgt = 16; const min = Math.min(...values); const max = Math.max(...values); const span = max - min || 1; const pts = values.map((v, i) => { const x = (i / (values.length - 1)) * (w - 2) + 1; const y = hgt - 1 - ((v - min) / span) * (hgt - 2); return x.toFixed(1) + ',' + y.toFixed(1); }).join(' '); const rising = values[values.length - 1] > values[0]; const good = inverse ? !rising : rising; const svg = svgEl('svg', { class: 'spark', width: w, height: hgt, viewBox: '0 0 ' + w + ' ' + hgt }); svg.appendChild(svgEl('polyline', { points: pts, fill: 'none', 'stroke-width': '1.4', 'stroke-linejoin': 'round', stroke: 'var(--' + (values[0] === values[values.length - 1] ? 'muted' : good ? 'exact' : 'dead') + ')', })); svg.setAttribute('aria-label', values.length + ' scans: ' + values.join(' โ†’ ')); const titleEl = svgEl('title', {}); titleEl.textContent = 'last ' + values.length + ' scans: ' + values.join(' โ†’ '); svg.appendChild(titleEl); return svg; } const FACET_GROUPS = [ { key: 'method', title: 'method', values: () => [...new Set(MAP.endpoints.map((e) => e.method))].sort() .map((m) => ({ v: m, label: m, test: (e) => e.method === m })) }, { key: 'auth', title: 'auth', values: () => [ { v: 'yes', label: 'has auth', test: (e) => e.auth === true }, { v: 'no', label: 'no auth', test: (e) => e.auth === false }, { v: 'murky', label: 'murky', test: (e) => e.auth === undefined }, ] }, { key: 'recon', title: 'reconciliation', values: () => Object.keys(RECON_LABEL) .map((k) => ({ v: k, label: RECON_LABEL[k], cls: RECON_CLS[k], test: (e) => reconOf(e) === k })) }, { key: 'conf', title: 'confidence of the calls', values: () => ['exact', 'inferred', 'guess'] .map((k) => ({ v: k, label: k, cls: 'c-bg-' + k, test: (e) => bestConf(callsByEndpoint.get(e.id) || []) === k })) }, ]; // cm:why Every value carries its own count, and the count is over the WHOLE map, not the filtered // rows: a facet whose number moves as you filter cannot tell you whether it is worth clicking. function renderFacets() { const box = el('f-groups'); box.textContent = ''; for (const group of FACET_GROUPS) { const wrap = h('div', 'fg'); const head = h('h5', null, group.title); wrap.appendChild(head); const all = h('label', state[group.key] === '' ? 'on' : null); const allInput = document.createElement('input'); allInput.type = 'radio'; allInput.name = 'f-' + group.key; allInput.checked = state[group.key] === ''; allInput.onchange = () => { state[group.key] = ''; state.page = 1; render(); }; all.append(allInput, h('span', null, 'all'), h('span', 'fn', String(MAP.endpoints.length))); wrap.appendChild(all); for (const value of group.values()) { const n = MAP.endpoints.filter(value.test).length; const row = h('label', state[group.key] === value.v ? 'on' : null); const input = document.createElement('input'); input.type = 'radio'; input.name = 'f-' + group.key; input.checked = state[group.key] === value.v; input.onchange = () => { state[group.key] = value.v; state.page = 1; render(); }; row.appendChild(input); if (value.cls) row.appendChild(h('span', 'sw ' + value.cls)); row.appendChild(h('span', null, value.label)); row.appendChild(h('span', 'fn', String(n))); wrap.appendChild(row); } box.appendChild(wrap); } el('f-reset').onclick = () => { state.method = ''; state.auth = ''; state.recon = ''; state.conf = ''; state.q = ''; const q = el('q'); if (q) q.value = ''; state.page = 1; render(); }; } // cm:why One pager for every long list: three panes each dumping 600+ rows was the reason the page // scrolled for 23 000 pixels, and three hand-rolled pagers would drift apart within a week. function renderPager(hostId, total, key, anchorId) { const box = el(hostId); if (!box) return; box.textContent = ''; const pages = Math.max(1, Math.ceil(total / PAGE)); if (state[key] > pages) state[key] = pages; const current = state[key]; const go = (n) => { state[key] = n; render(); const anchor = el(anchorId); if (anchor) anchor.scrollIntoView({ block: 'nearest' }); }; const btn = (label, n, on, disabled) => { const b = document.createElement('button'); b.textContent = label; if (on) b.className = 'on'; if (disabled) b.disabled = true; else b.onclick = () => go(n); return b; }; box.appendChild(btn('โ€น', current - 1, false, current === 1)); // cm:why Shows first, last and a window around the current page โ€” 22 numbered buttons is not // navigation, and dropping the last page hides how much is left. const want = new Set([1, pages, current, current - 1, current + 1]); let previous = 0; for (const n of [...want].filter((n) => n >= 1 && n <= pages).sort((a, b) => a - b)) { if (n - previous > 1) box.appendChild(h('span', 'gap', 'โ€ฆ')); box.appendChild(btn(String(n), n, n === current, false)); previous = n; } box.appendChild(btn('โ€บ', current + 1, false, current === pages)); box.appendChild(h('span', 'of', pages > 1 ? current + ' / ' + pages + ' trang' : '')); } function renderEndpoints() { kpiStrip('ep-kpis'); renderFacets(); const rows = visibleEndpoints(); el('ep-count').textContent = rows.length.toLocaleString('en-US') + ' / ' + MAP.endpoints.length.toLocaleString('en-US') + ' endpoint'; const from = (state.page - 1) * PAGE; const page = rows.slice(from, from + PAGE); // cm:why Selects the first visible row when the current selection is filtered away: an inspector // that says "click a row" next to a full table is a third of the pane spent on an instruction. if (state.endpoint === null || !rows.some((e) => e.id === state.endpoint)) { state.endpoint = page.length > 0 ? page[0].id : null; } el('ep-range').textContent = rows.length === 0 ? 'no row matches' : 'showing ' + (from + 1) + 'โ€“' + (from + page.length) + ' of ' + rows.length.toLocaleString('en-US'); const body = el('ep-rows'); body.textContent = ''; for (const e of page) { const tr = document.createElement('tr'); if (state.endpoint === e.id) tr.className = 'on'; const verb = h('td'); verb.appendChild(h('span', 'verb ' + e.method, e.method)); tr.appendChild(verb); // cm:why Path and handler share ONE cell on two lines: seven columns of real Laravel paths do not // fit 740px, and a path clipped to 20 characters is a row you cannot identify at all. // cm:guard title carries the full value on both lines, because both are clipped. const path = h('td'); const pathText = h('div', 'mono clip', e.path); pathText.title = e.path; path.appendChild(pathText); const where = e.handler ? e.handler + (e.source ? ' ยท ' + e.source.file + ':' + e.source.line : '') : e.source ? e.source.file + ':' + e.source.line : 'the BE does not declare this route'; const sub = h('div', 'sub2 clip', where); sub.title = where; path.appendChild(sub); tr.appendChild(path); tr.appendChild(h('td', 'nowrap', e.auth === true ? 'yes' : e.auth === false ? 'NO' : '?')); const st = h('td', 'nowrap'); st.appendChild(h('span', 'dot ' + RECON_CLS[reconOf(e)])); st.appendChild(document.createTextNode(' ' + RECON_LABEL[reconOf(e)])); tr.appendChild(st); tr.appendChild(h('td', 'num', String((callsByEndpoint.get(e.id) || []).length))); const rel = h('td'); rel.appendChild(microBar(e.id)); tr.appendChild(rel); tr.onclick = () => { state.endpoint = e.id; render(); }; body.appendChild(tr); } renderPager('ep-pager', rows.length, 'page', 'ep-rows'); renderInspector(); } const INSP_TABS = [['overview', 'Overview'], ['calls', 'Calls'], ['fields', 'Fields'], ['chain', 'Chain'], ['alerts', 'Alerts']]; function renderInspector() { const tabs = el('insp-tabs'); const body = el('insp-body'); tabs.textContent = ''; body.textContent = ''; const e = state.endpoint ? endpoints.get(state.endpoint) : null; if (!e) { body.appendChild(h('p', 'empty2', 'Click a row to see the detail.')); return; } for (const [id, label] of INSP_TABS) { const t = h('span', state.insp === id ? 'on' : '', label); t.onclick = () => { state.insp = id; renderInspector(); }; tabs.appendChild(t); } const calls = callsByEndpoint.get(e.id) || []; const title = h('div', 'ihead'); title.appendChild(h('span', 'verb ' + e.method, e.method)); const titlePath = h('h4', null, e.path); titlePath.title = e.path; title.appendChild(titlePath); body.appendChild(title); if (state.insp === 'overview') { const st = reconOf(e); const callout = h('div', 'callout ' + RECON_CLS[st]); callout.appendChild(h('div', 'ct', RECON_LABEL[st])); callout.appendChild(h('div', 'cw', RECON_WHY[st])); body.appendChild(callout); const kv = h('div', 'kv'); const add = (k, v) => { kv.appendChild(h('span', null, k)); kv.appendChild(h('span', 'mono', v)); }; add('reconciliation', RECON_LABEL[reconOf(e)]); add('auth', e.auth === true ? 'gated' : e.auth === false ? 'NO gate found' : 'gated but could not be classified'); add('handler', e.handler || 'โ€”'); add('declared at', e.source ? e.source.file + ':' + e.source.line : 'not found in the BE'); const seen = seenText(e.id); if (seen) add('history', seen); add('calls', String(calls.length)); add('confidence', calls.length ? bestConf(calls) + ' (best)' : 'โ€”'); body.appendChild(kv); const r = RELIABILITY.get(e.id); if (r) { body.appendChild(h('p', 'sub2', 'evidence spread: exact ' + r.exact + ' ยท inferred ' + r.inferred + ' ยท guess ' + r.guess)); body.appendChild(microBar(e.id)); } const mine = alertsByEndpoint.get(e.id) || []; for (const a of mine) { const p = h('p', 'sub2'); p.appendChild(h('span', 'badge ' + a.severity, a.kind)); p.appendChild(document.createTextNode(' ' + a.detail)); body.appendChild(p); } const go = h('button', 'btn primary wide2', calls.length ? 'See ' + calls.length + ' calls reaching ' + (screensFor(e.id).length) + ' screens โ†’' : 'Open the impact pane โ†’'); go.onclick = () => { state.section = 'impact'; location.hash = 'impact'; render(); }; body.appendChild(go); return; } if (state.insp === 'calls') { if (!calls.length) { body.appendChild(h('p', 'empty2', 'No call could be traced. That is not evidence nobody calls it โ€” see Unresolved.')); return; } for (const c of calls) { const row = h('div', 'gb'); const line = h('div'); line.appendChild(h('span', 'badge ' + c.confidence, c.confidence)); const s = screens.get(c.screenId); line.appendChild(document.createTextNode(' ' + (s && s.route ? s.route : (s ? s.label + ' (no route attached)' : '?')))); row.appendChild(line); row.appendChild(h('div', 'sub2', c.source.file + ':' + c.source.line + ' ยท qua ' + c.via)); body.appendChild(row); } return; } if (state.insp === 'fields') { const fs = fieldsByEndpoint.get(e.id) || []; if (!fs.length) { body.appendChild(h('p', 'empty2', 'No field seen yet. The code may use a TS type instead of reading fields at the call site, or apiflow probe has not been run.')); return; } for (const f of fs) { const row = h('div', 'gb'); row.appendChild(h('div', 'mono', f.path + (f.type ? ' : ' + f.type : ''))); const tag = []; if (f.declared) tag.push('code khai'); if (f.observed) tag.push('seen live by probe'); row.appendChild(h('div', 'sub2', f.kind + (tag.length ? ' ยท ' + tag.join(' ยท ') : ' ยท unclear'))); body.appendChild(row); } return; } if (state.insp === 'chain') { const withChain = calls.filter((c) => c.chain && c.chain.length); if (!withChain.length) { body.appendChild(h('p', 'empty2', 'No call has a chain that reaches a screen.')); return; } for (const c of withChain.slice(0, 8)) { const s = screens.get(c.screenId); body.appendChild(h('p', 'sub2', (s && s.route ? s.route : '?') + ' โ€” ' + c.confidence)); const box = h('div', 'chain'); for (const step of c.chain) { const st = h('div', 'step' + (step.precise ? '' : ' loose')); st.appendChild(h('span', 'role', step.role)); const t = h('div'); t.appendChild(h('div', 'mono', step.symbol)); t.appendChild(h('div', 'sub2', step.file + ':' + step.line)); st.appendChild(t); box.appendChild(st); } body.appendChild(box); } if (withChain.length > 8) body.appendChild(h('p', 'cut', (withChain.length - 8) + ' more chains โ€” open the Impact tab to see them all.')); return; } const mine = alertsByEndpoint.get(e.id) || []; if (!mine.length) { body.appendChild(h('p', 'empty2', 'No alert for this endpoint.')); return; } for (const a of mine) { const row = h('div', 'gb'); const t = h('div'); t.appendChild(h('span', 'badge ' + a.severity, a.severity)); t.appendChild(document.createTextNode(' ' + a.kind)); row.appendChild(t); row.appendChild(h('div', 'sub2', a.detail)); if (a.screens.length) row.appendChild(h('div', 'sub2', 'screens: ' + a.screens.join(' ยท '))); body.appendChild(row); } } `; export const PANES_SCRIPT_2 = String.raw` const ROLE_COLS = [['client', 'api client'], ['hook', 'hook / util'], ['component', 'component'], ['screen', 'screen']]; const COL_CAP = 14; // cm:why Draws the edges, not four independent lists: the columns alone tell you WHICH components // exist, never which screen each one leads to โ€” and that mapping is the whole answer this pane owes. function chainGraph(chains) { const byRole = new Map(ROLE_COLS.map(([role]) => [role, new Map()])); const key = (step) => step.role + '|' + step.file + '|' + step.symbol; for (const c of chains) { for (const step of c.chain) { const bucket = byRole.get(step.role); if (!bucket) continue; const k = key(step); if (!bucket.has(k) || (!bucket.get(k).precise && step.precise)) bucket.set(k, step); } } const shown = new Map(); const cut = new Map(); ROLE_COLS.forEach(([role]) => { const all = [...byRole.get(role).values()]; cut.set(role, Math.max(0, all.length - COL_CAP)); all.slice(0, COL_CAP).forEach((step) => shown.set(key(step), step)); }); const edges = new Map(); const rank = { exact: 0, inferred: 1, guess: 2 }; for (const c of chains) { const path = c.chain.filter((step) => shown.has(key(step))); for (let i = 0; i + 1 < path.length; i++) { const a = key(path[i]); const b = key(path[i + 1]); const id = a + '>' + b; const loose = path[i + 1].precise === false; const previous = edges.get(id); if (!previous || rank[c.confidence] < rank[previous.confidence]) { edges.set(id, { a, b, confidence: c.confidence, loose }); } } } const W = 1080; const colW = 236; const gap = (W - colW * 4) / 3; const xOf = (index) => index * (colW + gap); const top = 26; const rowH = 34; const tallest = Math.max(...ROLE_COLS.map(([role]) => Math.min(byRole.get(role).size, COL_CAP)), 1); const span = tallest * rowH; const height = top + span + 12; const pos = new Map(); ROLE_COLS.forEach(([role], index) => { const list = [...byRole.get(role).values()].slice(0, COL_CAP); list.forEach((step, i) => { pos.set(key(step), { x: xOf(index), y: top + ((i + 0.5) * span) / list.length, col: index }); }); }); const svg = svgEl('svg', { class: 'chaing', viewBox: '0 0 ' + W + ' ' + height, role: 'img' }); ROLE_COLS.forEach(([role, label], index) => { const t = svgEl('text', { x: xOf(index), y: 12, class: 'head' }); t.textContent = label + ' (' + byRole.get(role).size + ')'; svg.appendChild(t); }); const edgeEls = []; for (const edge of edges.values()) { const from = pos.get(edge.a); const to = pos.get(edge.b); if (!from || !to) continue; const x1 = from.x + colW; const x2 = to.x; const mid = (x1 + x2) / 2; const path = svgEl('path', { class: 'cedge c-' + edge.confidence + (edge.loose ? ' loose' : ''), d: 'M ' + x1 + ' ' + from.y + ' C ' + mid + ' ' + from.y + ', ' + mid + ' ' + to.y + ', ' + (x2 - 5) + ' ' + to.y, 'marker-end': 'url(#arrow)', }); path.dataset.a = edge.a; path.dataset.b = edge.b; svg.appendChild(path); edgeEls.push(path); } const groups = new Map(); for (const [k, step] of shown) { const at = pos.get(k); const g = svgEl('g', { class: 'cnode' + (step.precise ? '' : ' loose') }); g.appendChild(svgEl('rect', { x: at.x, y: at.y - 13, width: colW, height: 26, rx: 7 })); const name = svgEl('text', { x: at.x + 9, y: at.y - 1 }); name.textContent = clip(step.symbol, 26); const where = svgEl('text', { x: at.x + 9, y: at.y + 9, class: 'sub' }); where.textContent = clip(step.file.split('/').slice(-2).join('/') + ':' + step.line, 32); g.appendChild(name); g.appendChild(where); const tip = svgEl('title', {}); tip.textContent = step.symbol + '\n' + step.file + ':' + step.line + (step.precise ? '' : '\nthis step lost precision'); g.appendChild(tip); g.onmouseenter = () => { svg.classList.add('dim'); const lit = new Set([k]); // cm:why Lights the whole reachable branch in BOTH directions, not just adjacent nodes: the // question at a component is "which screen breaks", and that answer is two hops away. for (let pass = 0; pass < 4; pass++) { for (const e of edgeEls) { if (lit.has(e.dataset.a)) lit.add(e.dataset.b); if (lit.has(e.dataset.b)) lit.add(e.dataset.a); } } for (const e of edgeEls) e.classList.toggle('lit', lit.has(e.dataset.a) && lit.has(e.dataset.b)); for (const [nk, ng] of groups) ng.classList.toggle('lit', lit.has(nk)); }; g.onmouseleave = () => { svg.classList.remove('dim'); for (const e of edgeEls) e.classList.remove('lit'); for (const ng of groups.values()) ng.classList.remove('lit'); }; groups.set(k, g); svg.appendChild(g); } const defs = svgEl('defs', {}); const marker = svgEl('marker', { id: 'arrow', viewBox: '0 0 8 8', refX: '7', refY: '4', markerWidth: '5', markerHeight: '5', orient: 'auto-start-reverse', }); marker.appendChild(svgEl('path', { d: 'M 0 1 L 7 4 L 0 7 z', class: 'arrowhead' })); defs.appendChild(marker); svg.insertBefore(defs, svg.firstChild); const wrap = h('div', 'chainwrap'); wrap.appendChild(svg); const dropped = ROLE_COLS.filter(([role]) => cut.get(role) > 0) .map(([role, label]) => cut.get(role) + ' ' + label); if (dropped.length) { wrap.appendChild(h('p', 'cut', 'Not drawn: ' + dropped.join(' ยท ') + ' โ€” each column draws only its first ' + COL_CAP + ' boxes.')); } return wrap; } function renderImpact() { const pick = el('imp-pick'); const q = state.impQ.toLowerCase(); // cm:guard The selected endpoint is always in the list: capping the options silently at 300 left // the