@progen-labs/spectra 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ :root{--font-ui:-apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif;--font-mono:ui-monospace, "SF Mono", Menlo, Consolas, monospace;--radius:14px;--radius-sm:9px;--btn-radius:9px;--blur:none;--on-accent:#fff;--label-case:none;--label-tracking:0}:root[data-theme=glass]{--radius:22px;--radius-sm:12px;--btn-radius:999px;--blur:blur(28px) saturate(180%);--font-ui:-apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", system-ui, sans-serif}:root[data-theme=glass][data-mode=light]{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--bg:radial-gradient(900px 600px at 8% -8%, #b9dcff 0%, transparent 62%), radial-gradient(800px 560px at 96% 4%, #ffd3ea 0%, transparent 58%), radial-gradient(1000px 700px at 62% 112%, #d6cfff 0%, transparent 62%), #eaeff7;--panel:#ffffff80;--panel-solid:#f4f7fc;--panel-border:#ffffffbf;--panel-edge:inset 0 1px 0 #ffffffe6, inset 0 -1px 0 #7887aa14;--shadow:0 1px 2px #283c6e0f, 0 12px 36px #283c6e1f;--shadow-pop:0 4px 12px #283c6e1f, 0 24px 60px #283c6e38;--surface:#f6f8fc;--surface-2:#ffffff9e;--control:#ffffffb3;--control-hover:#fffffff2;--border:#46557d29;--text:#0e1220;--text-2:#444c5e;--text-muted:#6d7588;--grid:#46557d1a;--accent:#0a67d1;--accent-strong:#0a6fdb;--accent-soft:#0a6fdb1f;--accent-glow:0 6px 18px #0a6fdb59;--heat:#f2602f;--cat-1:#0a6fdb;--cat-2:#f2602f;--cat-3:#1fa971;--cat-4:#eba000;--cat-5:#e5679b;--cat-6:#5b54d6;--cat-other:#a3a9b8;--good:#1a7f37;--warn:#a35607;--poor:#c9302c;--code-string:#17754f;--code-keyword:#5b54d6;--code-number:#a35607}:root[data-theme=glass][data-mode=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;--bg:radial-gradient(900px 600px at 6% -10%, #2463eb80 0%, transparent 62%), radial-gradient(800px 560px at 98% 0%, #a838c861 0%, transparent 58%), radial-gradient(1000px 700px at 60% 115%, #1496aa4d 0%, transparent 62%), #0a0c13;--panel:#22263685;--panel-solid:#161924;--panel-border:#ffffff24;--panel-edge:inset 0 1px 0 #ffffff2e, inset 0 -1px 0 #ffffff08;--shadow:0 1px 2px #0000004d, 0 16px 44px #00000061;--shadow-pop:0 4px 14px #0006, 0 28px 70px #0000008c;--surface:#12141d;--surface-2:#ffffff0f;--control:#ffffff17;--control-hover:#ffffff29;--border:#ffffff21;--text:#f4f6fb;--text-2:#b8bfd0;--text-muted:#8990a3;--grid:#ffffff12;--accent:#4da3ff;--accent-strong:#0a6fdb;--accent-soft:#4da3ff2e;--accent-glow:0 6px 22px #0a6fdb80;--heat:#ff7a4d;--cat-1:#4da3ff;--cat-2:#ff7a4d;--cat-3:#30c98a;--cat-4:#f0b429;--cat-5:#f06ea0;--cat-6:#9a93f5;--cat-other:#6c7386;--good:#3fcf6b;--warn:#f0b04a;--poor:#ff6b6b;--code-string:#3fd49b;--code-keyword:#a29bf7;--code-number:#f0b04a}:root[data-theme=space]{--radius:12px;--radius-sm:8px;--btn-radius:8px;--blur:blur(10px);--label-case:uppercase;--label-tracking:.08em;--font-ui:"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif}:root[data-theme=space][data-mode=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;--bg:radial-gradient(1px 1px at 22px 34px, #ffffffe6, transparent), radial-gradient(1px 1px at 96px 128px, #fff9, transparent), radial-gradient(1.5px 1.5px at 168px 62px, #a0c8ffe6, transparent), radial-gradient(1px 1px at 58px 176px, #ffffff80, transparent), radial-gradient(1.5px 1.5px at 190px 190px, #c8b4ffcc, transparent), radial-gradient(900px 620px at 82% -12%, #7c5cff4d, transparent 62%), radial-gradient(800px 600px at -6% 108%, #00beff2e, transparent 60%), #04050d;--bg-size:210px 210px, 210px 210px, 210px 210px, 210px 210px, 210px 210px, auto, auto, auto;--panel:#0c1024bd;--panel-solid:#0b0f24;--panel-border:#6e82ff3d;--panel-edge:inset 0 1px 0 #a0b4ff1a;--shadow:0 0 0 1px #0006, 0 18px 50px #0000008c;--shadow-pop:0 0 0 1px #6e82ff40, 0 0 40px #6464ff2e, 0 28px 70px #000000b3;--surface:#0a0d20;--surface-2:#6e82ff12;--control:#6e82ff1a;--control-hover:#6e82ff33;--border:#6e82ff38;--text:#eaeefc;--text-2:#aab3d6;--text-muted:#7b85ad;--grid:#6e82ff1a;--accent:#6f9bff;--accent-strong:#4565f0;--accent-soft:#6f9bff29;--accent-glow:0 0 22px #506eff8c;--accent-2:#22d3ee;--heat:#ff8a3d;--cat-1:#6f9bff;--cat-2:#ff8a3d;--cat-3:#2dd4a0;--cat-4:#fbbf24;--cat-5:#f472b6;--cat-6:#a78bfa;--cat-other:#5f6890;--good:#34d98b;--warn:#fbbf24;--poor:#ff6b81;--code-string:#34d9a0;--code-keyword:#b49bff;--code-number:#fbbf24}:root[data-theme=space][data-mode=light]{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--bg:radial-gradient(1px 1px at 22px 34px, #4f46e559, transparent), radial-gradient(1px 1px at 120px 130px, #0e74904d, transparent), radial-gradient(1.5px 1.5px at 170px 70px, #4f46e54d, transparent), radial-gradient(900px 600px at 84% -12%, #818cf852, transparent 62%), radial-gradient(800px 600px at -6% 108%, #22d3ee33, transparent 60%), #eef0fa;--bg-size:210px 210px, 210px 210px, 210px 210px, auto, auto, auto;--panel:#fffc;--panel-solid:#f8f9ff;--panel-border:#4f46e533;--panel-edge:inset 0 1px 0 #ffffffe6;--shadow:0 1px 2px #1e1b640f, 0 14px 40px #1e1b641f;--shadow-pop:0 0 0 1px #4f46e526, 0 24px 60px #1e1b643d;--surface:#f8f9ff;--surface-2:#4f46e50d;--control:#ffffffe6;--control-hover:#fff;--border:#4f46e533;--text:#0f1226;--text-2:#424a6b;--text-muted:#6a7192;--grid:#4f46e51a;--accent:#4338ca;--accent-strong:#4f46e5;--accent-soft:#4f46e51a;--accent-glow:0 6px 20px #4f46e559;--accent-2:#0e7490;--heat:#e0560c;--cat-1:#4f46e5;--cat-2:#e0560c;--cat-3:#0f9f78;--cat-4:#d99100;--cat-5:#d6458a;--cat-6:#7c3aed;--cat-other:#9aa0bd;--good:#14803f;--warn:#9a5200;--poor:#c62839;--code-string:#0f7a5a;--code-keyword:#6d3fd6;--code-number:#9a5200}:root[data-theme=glass] button.primary,:root[data-theme=glass] .tabs button[aria-selected=true]{background:linear-gradient(180deg, #ffffff47, transparent 55%), var(--accent-strong)}:root[data-theme=space] button.primary{background:linear-gradient(135deg, var(--accent-strong), #8b5cf6);border-color:#0000}:root[data-theme=space] .brand-mark{background:conic-gradient(from 210deg,#22d3ee,#6f6bff,#c084fc,#22d3ee)}:root[data-theme=glass] .brand-mark{background:linear-gradient(135deg,#5ac8fa,#0a84ff 55%,#bf5af2)}*{box-sizing:border-box;scrollbar-width:thin;scrollbar-color:var(--border) transparent}html,body,#root{height:100%;margin:0}body{background:var(--bg);background-size:var(--bg-size,auto);color:var(--text);font:.8125rem/1.45 var(--font-ui);-webkit-font-smoothing:antialiased;background-attachment:fixed}button,input,select{font:inherit;color:inherit}button,select,input:not([type=checkbox]):not([type=radio]){background:var(--control);border:1px solid var(--border);border-radius:var(--radius-sm);min-height:2rem;padding:.35rem .75rem;transition:background .15s,border-color .15s,box-shadow .15s}button{border-radius:var(--btn-radius);cursor:pointer;white-space:nowrap;justify-content:center;align-items:center;gap:.4rem;font-weight:500;display:inline-flex}select{cursor:pointer}input:not([type=checkbox]):not([type=radio]):focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft);outline:none}button:hover:not(:disabled),select:hover:not(:disabled){background:var(--control-hover);border-color:color-mix(in srgb, var(--accent) 55%, var(--border))}button:disabled,select:disabled,input:disabled{opacity:.5;cursor:default}button.primary{background:var(--accent-strong);color:var(--on-accent);box-shadow:var(--accent-glow);border-color:#0000}button.primary:hover:not(:disabled){background:var(--accent-strong);filter:brightness(1.1);border-color:#0000}button.danger{background:var(--poor);color:#fff;border-color:#0000}button.danger:hover:not(:disabled){background:var(--poor);filter:brightness(1.08);border-color:#0000}button.ghost{color:var(--text-2);background:0 0;border-color:#0000}button.ghost:hover:not(:disabled){background:var(--control);border-color:var(--border);color:var(--text)}button.ghost.destructive:hover:not(:disabled){color:var(--poor)}button.link{min-height:0;color:var(--accent);text-align:left;font-weight:inherit;background:0 0;border:0;padding:0;display:inline}button.link:hover:not(:disabled){background:0 0;text-decoration:underline}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}code,.mono{font-family:var(--font-mono);font-size:12px}.muted{color:var(--text-muted);font-weight:400}.warn{color:var(--warn)}.spacer{flex:1}.pad{padding:1rem}.num{text-align:right;font-variant-numeric:tabular-nums}.empty{text-align:center;color:var(--text-muted);flex:1;place-content:center;gap:.4rem;padding:2rem;display:grid}.icon{flex:none}.app{flex-direction:column;gap:.7rem;height:100%;padding:.75rem;display:flex}.topbar,.session,.workspace{background:var(--panel);-webkit-backdrop-filter:var(--blur);backdrop-filter:var(--blur);border:1px solid var(--panel-border);border-radius:var(--radius);box-shadow:var(--shadow), var(--panel-edge)}.topbar{z-index:10;align-items:center;gap:.8rem;padding:.55rem .7rem .55rem .9rem;display:flex;position:relative}.brand{align-items:center;gap:.7rem;min-width:0;display:flex}.brand-mark{border-radius:calc(var(--radius-sm) - 1px);color:#fff;place-items:center;width:2rem;height:2rem;display:grid;box-shadow:inset 0 1px #fff6,0 4px 14px #2850dc59}.brand h1{letter-spacing:-.01em;margin:0;font-size:.95rem;font-weight:650;line-height:1.2}.brand small{color:var(--text-muted);font-size:.72rem;font-family:var(--font-mono);display:block}.report-picker{align-items:center;gap:.3rem;display:inline-flex}.report-picker select{max-width:24rem}.error{background:color-mix(in srgb, var(--poor) 14%, var(--panel-solid));border:1px solid color-mix(in srgb, var(--poor) 55%, transparent);border-radius:var(--radius-sm);align-items:center;gap:.8rem;padding:.5rem .8rem;display:flex}.error button{margin-left:auto}.popover{display:inline-block;position:relative}.caret{opacity:.7;font-size:.7em}.popover-panel,.trace-note{background:color-mix(in srgb, var(--panel-solid) 98%, transparent);-webkit-backdrop-filter:blur(24px)saturate(160%);backdrop-filter:blur(24px)saturate(160%);border:1px solid var(--panel-border);border-radius:var(--radius);box-shadow:var(--shadow-pop), var(--panel-edge)}.popover-panel{z-index:40;min-width:15rem;padding:.9rem;position:absolute;top:calc(100% + 8px)}.popover-panel.align-right{right:0}.popover-panel.align-left{left:0}.appearance,.options-list{gap:1rem;width:17rem;display:grid}.choice-group{border:0;min-width:0;margin:0;padding:0}.choice-group legend,.field-label{color:var(--text-muted);text-transform:uppercase;letter-spacing:.08em;margin-bottom:.4rem;padding:0;font-size:.7rem;font-weight:600}.choice input{opacity:0;pointer-events:none;position:absolute}.choice:focus-within{outline:2px solid var(--accent);outline-offset:2px}.theme-cards{grid-template-columns:1fr 1fr;gap:.5rem;display:grid}.theme-cards .choice{border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;background:var(--surface-2);gap:.15rem;padding:.45rem;transition:border-color .15s,box-shadow .15s;display:grid;position:relative}.theme-cards .choice:hover{border-color:color-mix(in srgb, var(--accent) 55%, var(--border))}.theme-cards .choice.selected{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}.theme-cards .choice-label{margin-top:.25rem;font-weight:600}.theme-cards small{font-size:.7rem;line-height:1.25}.swatch{border-radius:calc(var(--radius-sm) - 3px);height:2.9rem;display:block;position:relative;overflow:hidden}.swatch i{border-radius:999px;height:.5rem;position:absolute;left:.45rem}.swatch i:first-child{width:55%;top:.5rem}.swatch i:last-child{width:35%;top:1.35rem}.swatch-glass{background:radial-gradient(circle at 20% 20%,#8ec5ff,#0000 60%),radial-gradient(circle at 85% 30%,#ffb8df,#0000 55%),linear-gradient(135deg,#c9bcff,#9fd6ff)}.swatch-glass i{background:#ffffffb3;box-shadow:inset 0 1px #fff}.swatch-space{background:radial-gradient(1px 1px at 70% 25%,#fff,#0000),radial-gradient(1px 1px at 88% 70%,#bcd4ff,#0000),radial-gradient(1px 1px at 45% 80%,#fff,#0000),radial-gradient(circle at 90% 0,#7c5cff99,#0000 60%),#05071a}.swatch-space i{background:linear-gradient(90deg,#22d3ee,#6f6bff)}.segmented,.segmented-control{background:var(--surface-2);border:1px solid var(--border);border-radius:var(--btn-radius);gap:2px;padding:3px;display:inline-flex}.segmented-control{display:flex}.segmented button,.segmented-control .choice{border-radius:calc(var(--btn-radius) - 2px);min-height:1.7rem;color:var(--text-2);text-align:center;cursor:pointer;background:0 0;border:0;flex:1;padding:.15rem .8rem;font-weight:500;position:relative}.segmented-control .choice{place-items:center;display:grid}.segmented button:hover:not(:disabled){background:var(--control);border:0}.segmented button[aria-pressed=true],.segmented-control .choice.selected{background:var(--accent-soft);color:var(--accent);font-weight:600}label.switch{cursor:pointer;color:var(--text-2);-webkit-user-select:none;user-select:none;align-items:center;gap:.5rem;display:inline-flex}.switch input{appearance:none;background:var(--control);border:1px solid var(--border);cursor:pointer;border-radius:999px;flex:none;width:2rem;height:1.15rem;margin:0;transition:background .15s;position:relative}.switch input:after{content:"";background:var(--text-muted);border-radius:50%;width:calc(1.15rem - 4px);height:calc(1.15rem - 4px);transition:transform .15s,background .15s;position:absolute;top:1px;left:1px}.switch input:checked{background:var(--accent-strong);border-color:#0000}.switch input:checked:after{background:#fff;transform:translate(.85rem)}.switch input:disabled{cursor:default}.session{z-index:5;gap:.6rem;padding:.7rem .8rem;display:grid;position:relative}.session-row{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.session-row+.session-row{border-top:1px solid var(--border);padding-top:.6rem}.session label,.connect label{color:var(--text-2)}.address{min-width:14rem;font-family:var(--font-mono);flex:1;font-size:12px}.btn-group{align-items:center;gap:.4rem;display:inline-flex}.connect{gap:.7rem;display:grid}.connect-head h2{letter-spacing:-.01em;margin:0;font-size:1rem}.connect-head p{margin:.15rem 0 0}.connect-main{flex-wrap:wrap;align-items:flex-end;gap:.5rem;display:flex}.connect-main .field{flex:1;min-width:16rem}.field input{width:100%}.field-row input{flex:1;min-width:0}.advanced{border-top:1px solid var(--border);gap:.8rem;padding-top:.7rem;display:grid}.advanced-section{gap:.4rem;display:grid}.advanced-section+.advanced-section{border-top:1px dashed var(--border);padding-top:.7rem}.advanced-row{flex-wrap:wrap;align-items:flex-end;gap:.5rem;display:flex}.advanced-row .field{flex:1;min-width:16rem}.signin{border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-2);gap:.6rem;padding:.6rem .7rem;display:grid}.signin.on{border-color:color-mix(in srgb, var(--accent) 45%, var(--border))}.signin>.switch{align-items:flex-start}.signin>.switch b{color:var(--text);font-weight:600}.signin-body{gap:.55rem;display:grid}.signin-methods{flex-wrap:wrap;gap:.35rem;display:flex}.signin-method{min-height:1.8rem;color:var(--text-2);border-radius:999px;padding:.2rem .75rem}.signin-method.selected,.signin-method.selected:hover{background:var(--accent-soft);border-color:var(--accent);color:var(--accent);font-weight:600}.signin-hint{margin:0}.signin-grid{grid-template-columns:repeat(auto-fit,minmax(13rem,1fr));align-items:end;gap:.5rem .6rem;display:grid}.signin-grid .wide{grid-column:1/-1}.signin-grid p{margin:.2rem 0 0}.signin-more{justify-items:start;gap:.5rem;display:grid}.signin-more>.signin-grid{width:100%}button.small{min-height:1.6rem;padding:.1rem .5rem;font-size:.75rem}.secret-tag{border:1px solid var(--border);color:var(--text-muted);vertical-align:1px;border-radius:999px;margin-left:.4rem;padding:0 .4rem;font-size:.65rem}.signin-note{color:var(--text-muted);margin:0;font-size:.72rem}.signin-note b{color:var(--warn,var(--text))}.pill{border:1px solid var(--border);background:var(--surface-2);white-space:nowrap;border-radius:999px;align-items:center;gap:.35rem;padding:.1rem .65rem;font-size:.75rem;display:inline-flex}.pill.ok{color:var(--good);border-color:color-mix(in srgb, var(--good) 40%, transparent)}.pill.warn{color:var(--warn);border-color:color-mix(in srgb, var(--warn) 45%, transparent)}.rec-strip{flex-wrap:wrap;flex:1;align-items:center;gap:.8rem;display:flex}.rec-strip.recording{border-radius:var(--radius-sm);background:color-mix(in srgb, var(--poor) 10%, transparent);border:1px solid color-mix(in srgb, var(--poor) 35%, transparent);padding:.4rem .6rem}.rec{align-items:center;gap:.5rem;font-weight:600;display:inline-flex}.rec i{background:var(--poor);width:10px;height:10px;box-shadow:0 0 0 4px color-mix(in srgb, var(--poor) 25%, transparent);border-radius:50%;animation:1s infinite pulse}.rec i.spin{background:var(--accent);box-shadow:0 0 0 4px var(--accent-soft)}.elapsed{font-family:var(--font-mono);font-variant-numeric:tabular-nums;color:var(--text-2);font-weight:400}@keyframes pulse{50%{opacity:.3}}@media (prefers-reduced-motion:reduce){.rec i{animation:none}*{transition:none!important}}.workspace{flex-direction:column;flex:1;min-height:0;display:flex;overflow:hidden}.report-bar{border-bottom:1px solid var(--border);flex-wrap:wrap;align-items:center;gap:.8rem;padding:.6rem .9rem;display:flex}.report-title{text-overflow:ellipsis;white-space:nowrap;max-width:28rem;font-size:.9rem;font-weight:600;overflow:hidden}.report-meta{flex-wrap:wrap;gap:.35rem;display:flex}.tabs{gap:.2rem;display:flex;overflow-x:auto}.main-tabs{border-bottom:1px solid var(--border);flex-wrap:wrap;align-items:center;row-gap:.4rem;padding:.5rem .9rem;overflow-x:visible}.main-tabs .tab-list{flex-wrap:wrap;gap:.2rem;min-width:0;display:flex}.main-tabs label.switch{white-space:nowrap;margin-left:auto}.tabs button{min-height:1.9rem;color:var(--text-2);background:0 0;border:0;padding:.25rem .8rem}.tabs button:hover:not(:disabled){background:var(--control);color:var(--text);border:0}.tabs button[aria-selected=true]{background:var(--accent-strong);color:var(--on-accent);box-shadow:var(--accent-glow)}.count{background:var(--accent-soft);color:var(--accent);border-radius:999px;margin-left:.1rem;padding:0 .45rem;font-size:11px;font-weight:600}.tabs button[aria-selected=true] .count{color:inherit;background:#ffffff40}.main{flex:1;min-height:0;display:flex}.content{flex-direction:column;flex:1;min-width:0;display:flex;overflow:auto}.welcome{text-align:center;flex:1;align-content:center;justify-items:center;gap:1.6rem;padding:2rem 1.5rem;display:grid;overflow:auto}.welcome h2{letter-spacing:-.02em;margin:0;font-size:1.6rem}.welcome-lead{color:var(--text-2);margin:.4rem 0 0;font-size:.95rem}.steps{text-align:left;grid-template-columns:repeat(3,minmax(0,17rem));gap:.9rem;margin:0;padding:0;list-style:none;display:grid}.steps li{background:var(--surface-2);border:1px solid var(--border);border-radius:var(--radius);align-content:start;gap:.35rem;padding:1rem;line-height:1.55;display:grid}.step-num{background:var(--accent-soft);width:1.7rem;height:1.7rem;color:var(--accent);border-radius:50%;place-items:center;font-weight:700;display:grid}.steps h3{margin:.2rem 0 0;font-size:.9rem}.steps p{color:var(--text-2);margin:0}.welcome-foot{max-width:44rem;color:var(--text-muted);margin:0}@media (width<=900px){.steps{grid-template-columns:1fr}}.culprits{align-content:start;gap:1.2rem;padding:1rem 1.2rem;display:grid}.culprits h2,.network h2,.long-tasks h2{margin:0 0 .5rem;font-size:.9rem}.vital-label{color:var(--text-2);font-size:12px}.vital-value{font-variant-numeric:tabular-nums;font-size:1.25rem;font-weight:600}.vital-rating{font-size:12px}.rating-good{border-left-color:var(--good)}.rating-good .vital-rating{color:var(--good)}.rating-warn{border-left-color:var(--warn)}.rating-warn .vital-rating{color:var(--warn)}.rating-poor{border-left-color:var(--poor)}.rating-poor .vital-rating{color:var(--poor)}.rating-none{border-left-color:var(--border)}.rating-none .vital-rating{color:var(--text-muted)}.breakdown-bar{background:var(--surface);border-radius:4px;gap:2px;height:16px;display:flex;overflow:hidden}.breakdown-bar.small{height:8px;margin:.35rem 0}.breakdown-bar span{min-width:2px}.breakdown-legend{flex-wrap:wrap;gap:.4rem 1.2rem;margin:.5rem 0 0;padding:0;list-style:none;display:flex}.breakdown-legend i{border-radius:2px;width:10px;height:10px;margin-right:.35rem;display:inline-block}.sev-critical{border-left-color:var(--poor)}.sev-warning{border-left-color:var(--warn)}.sev-info{border-left-color:var(--accent)}.badge-sev{font-size:12px;font-weight:600}.sev-critical .badge-sev{color:var(--poor)}.sev-warning .badge-sev{color:var(--warn)}.sev-info .badge-sev{color:var(--accent)}.chip{border:1px solid var(--border);color:var(--text-2);border-radius:4px;padding:0 .4rem;font-size:11px}.chip-warn{color:var(--warn);border-color:var(--warn)}.tree-pane{flex-direction:column;flex:1;min-height:0;display:flex}.tree-toolbar{border-bottom:1px solid var(--border);flex-wrap:wrap;align-items:center;gap:.8rem;padding:.4rem 1rem;display:flex}.tree-svg{cursor:grab;background:radial-gradient(var(--grid) 1px, transparent 1px) 0 0 / 22px 22px;flex:1;width:100%;min-height:0;display:block}.tree-svg:active{cursor:grabbing}.tree-links path{fill:none;stroke:var(--border);stroke-width:1.2px}.node-hit{fill:#0000}.node:hover .node-hit{fill:var(--surface-2)}.node-dot{fill:color-mix(in srgb, var(--heat) var(--heat-pct,20%), var(--surface));stroke:var(--heat);stroke-width:1.5px;cursor:pointer}.node.vendor .node-dot{fill:var(--surface);stroke:var(--text-muted);stroke-dasharray:2 2}.node.dir .node-dot{stroke-width:2px}.node.selected .node-hit{fill:color-mix(in srgb, var(--accent) 22%, transparent)}.node.selected .node-label{font-weight:700}.node-glyph{fill:var(--text);pointer-events:none;font-size:10px}.node-label{fill:var(--text);cursor:pointer;font-size:12px}.node.dir .node-label{fill:var(--text-2);font-weight:600}.legend{border-top:1px solid var(--border);color:var(--text-2);flex-wrap:wrap;align-items:center;gap:1.2rem;padding:.4rem 1rem;display:flex}.ramp{vertical-align:middle;background:linear-gradient(90deg, color-mix(in srgb, var(--heat) 12%, var(--surface)), var(--heat));border:1px solid var(--heat);border-radius:2px;width:90px;height:10px;margin-right:.4rem;display:inline-block}.ring{border:1.5px dashed var(--text-muted);vertical-align:middle;border-radius:50%;width:10px;height:10px;margin-right:.35rem;display:inline-block}.file-panel{border-left:1px solid var(--border);flex-direction:column;flex:none;width:34rem;max-width:45vw;min-height:0;display:flex}.panel-head{border-bottom:1px solid var(--border);align-items:center;gap:.5rem;padding:.5rem .8rem;display:flex}.panel-head h2{text-overflow:ellipsis;white-space:nowrap;flex:1;margin:0;font-family:ui-monospace,Menlo,monospace;font-size:.8125rem;overflow:hidden}.metrics{grid-template-columns:repeat(3,1fr);gap:.4rem;padding:.6rem .8rem;display:grid}.metric{background:var(--surface-2);border:1px solid var(--border);border-radius:6px;padding:.3rem .5rem}.metric-value{font-variant-numeric:tabular-nums;font-weight:600}.metric.warn .metric-value{color:var(--warn)}.metric-label{color:var(--text-muted);font-size:11px}.mini-findings{gap:.3rem;margin:0 .8rem .5rem;padding:0;list-style:none;display:grid}.mini-findings li{border-left:3px solid;padding-left:.5rem}.file-panel .tabs{border-bottom:1px solid var(--border);padding:0 .8rem}.panel-body{flex-direction:column;flex:1;min-height:0;display:flex;overflow:auto}table.data{border-collapse:collapse;width:100%}table.data th{text-align:left;color:var(--text-2);background:var(--panel-solid);border-bottom:1px solid var(--border);padding:.4rem .6rem;font-weight:600;position:sticky;top:0}table.data th button{color:inherit;background:0 0;border:0;padding:0;font-weight:600}table.data td{border-bottom:1px solid var(--grid);vertical-align:top;padding:.25rem .6rem}tr.open td,tr.detail td{background:var(--surface-2)}.detail-grid{grid-template-columns:1fr 2fr;gap:1.5rem;display:grid}.detail-grid h4{text-transform:uppercase;letter-spacing:.04em;color:var(--text-2);margin:0 0 .3rem;font-size:12px}.detail-grid ul{margin:0;padding-left:1.1rem}.summary-row{color:var(--text-2);border-bottom:1px solid var(--border);flex-wrap:wrap;gap:1.4rem;padding:.6rem 1rem;display:flex}.bar{background:var(--grid);border-radius:2px;height:3px;margin-top:3px}.bar span{background:var(--heat);border-radius:2px;height:100%;display:block}.react-panel,.network{overflow:auto}.network section{margin:1.2rem 0}.timeline{background:var(--surface-2);border-bottom:1px solid var(--border);width:100%;height:130px}.timeline .task{fill:var(--cat-other)}.timeline .task.long{fill:var(--poor)}.timeline .limit{stroke:var(--warn);stroke-dasharray:4 4;vector-effect:non-scaling-stroke}.timeline .axis{fill:var(--text-muted);font-size:11px}.task-list{gap:.7rem;margin:0;padding:.8rem 1rem;list-style:none;display:grid}.task-list>li{border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--surface-2);border-left-width:4px;padding:.6rem .9rem}.task-head{flex-wrap:wrap;align-items:center;gap:.7rem;display:flex}.task-cols{grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:1rem;display:grid}.task-cols h4{text-transform:uppercase;letter-spacing:.04em;color:var(--text-2);margin:.3rem 0;font-size:12px}.row,button.link.row{text-align:left;justify-content:space-between;gap:.6rem;width:100%;display:flex}.row span{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.row b{white-space:nowrap;flex:none}.code-scroll{flex:1;align-items:flex-start;font:12px/18px ui-monospace,Menlo,Consolas,monospace;display:flex;overflow:auto}.gutter{background:var(--surface-2);border-right:1px solid var(--border);text-align:right;flex:none;position:sticky;left:0}.gutter-line{height:18px;color:var(--text-muted);white-space:nowrap;padding:0 .5rem}.gutter-hot{background:color-mix(in srgb, var(--heat) 22%, transparent);color:var(--text);font-weight:600}.code{flex:1;margin:0;padding:0 1rem}.code code.hljs{font:inherit;white-space:pre;background:0 0;padding:0;display:block}.hljs-comment,.hljs-quote{color:var(--text-muted);font-style:italic}.hljs-keyword,.hljs-selector-tag,.hljs-literal{color:var(--code-keyword)}.hljs-string,.hljs-regexp,.hljs-attr{color:var(--code-string)}.hljs-number,.hljs-built_in,.hljs-type{color:var(--code-number)}.hljs-title,.hljs-function,.hljs-name{color:var(--accent)}.culprit-box{border:1px dashed var(--border);background:var(--surface);border-radius:6px;margin:.5rem 0 .2rem;padding:.5rem .7rem}.culprit-box h4{text-transform:uppercase;letter-spacing:.05em;color:var(--text-2);margin:0 0 .3rem;font-size:11px}.culprit-box ul{gap:.25rem;margin:0;padding:0;list-style:none;display:grid}code.fn{color:var(--text);font-weight:700}.code-ref{flex-wrap:wrap;align-items:baseline;gap:.4rem;display:inline-flex}.code-ref b{font-weight:700}.editor-link{color:var(--accent);border:1px solid var(--border);border-radius:4px;padding:0 .3rem;font-size:11px;text-decoration:none}.editor-link:hover{border-color:var(--accent)}table.hot-functions td{vertical-align:middle}tr.vendor-row code.fn{color:var(--text-2);font-weight:400}.trace-button{position:relative}.trace-note{z-index:40;width:30rem;max-width:80vw;padding:.8rem 2rem .8rem 1rem;line-height:1.5;position:absolute;top:calc(100% + 8px);right:0}.trace-note.error{border-color:var(--poor)}.trace-note ol{margin:.4rem 0 0;padding-left:1.2rem}.trace-note code{word-break:break-all}.trace-note .close{background:0 0;border:0;min-height:0;padding:0 .3rem;font-size:1.1rem;line-height:1;position:absolute;top:.4rem;right:.4rem}kbd{border:1px solid var(--border);border-radius:4px;padding:0 .3rem;font-size:11px}.compare-panel{align-content:start;gap:1rem;padding:1rem 1.2rem 2rem;display:grid}.compare-intro h2{margin:0 0 .3rem;font-size:1rem}.sides{grid-template-columns:1fr auto 1fr;align-items:start;gap:.8rem;display:grid}.swap{align-self:center}.swap button{padding:.3rem .6rem;font-size:1.1rem}.side-picker{border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--surface-2);gap:.5rem;padding:.7rem .9rem;display:grid}.side-picker h3{margin:0;font-size:.9rem}.field{color:var(--text-2);gap:.2rem;display:grid}.field-row{gap:.4rem;display:flex}.field input{flex:1;min-width:0}.git-facts{grid-template-columns:5.5rem 1fr;gap:.15rem .6rem;margin:0;display:grid}.git-facts dt{color:var(--text-muted)}.git-facts dd{overflow-wrap:anywhere;margin:0}.dropzone{border:1.5px dashed var(--border);border-radius:var(--radius-sm);flex-wrap:wrap;align-items:center;gap:.6rem;padding:.6rem;display:flex}.dropzone.dragging{border-color:var(--accent);background:color-mix(in srgb, var(--accent) 10%, transparent)}.run-chips{gap:.3rem;margin:0;padding:0;list-style:none;display:grid}.run-chips li{background:var(--surface);border:1px solid var(--border);border-radius:6px;align-items:center;gap:.6rem;padding:.2rem .5rem;display:flex}.run-chips li .mono{text-overflow:ellipsis;white-space:nowrap;flex:1;overflow:hidden}.run-chips button{background:0 0;border:0;padding:0 .3rem;font-size:1rem}.compare-toolbar{border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--surface);z-index:5;flex-wrap:wrap;align-items:center;gap:.6rem;padding:.5rem .7rem;display:flex;position:sticky;top:0}.pdf-buttons{align-items:center;gap:.5rem;display:inline-flex}.compare-results{gap:1.2rem;display:grid}.compare-results h2{margin:0 0 .5rem;font-size:.95rem}.verdict-banner{border:1.5px solid var(--c);border-radius:var(--radius-sm);border-left-width:6px;padding:.7rem 1rem}.verdict-banner .big{color:var(--c);font-size:1.4rem;font-weight:700}.verdict-banner p{margin:.25rem 0}.verdict-banner .counts span{margin-right:1.2rem;font-weight:600}.good{--c:var(--good)}.bad{--c:var(--poor)}.warn{--c:var(--warn)}.same{--c:var(--text-2)}.na{--c:var(--text-muted)}.info{--c:var(--accent)}.verdict-banner .counts .good,.verdict-banner .counts .bad,.verdict-banner .counts .same,.verdict{color:var(--c)}.verdict{white-space:nowrap;font-weight:700}ul.icons{gap:.25rem;margin:0;padding:0;list-style:none;display:grid}ul.icons li{color:var(--text)}ul.icons li .i,ul.checks li .i{width:1.4rem;color:var(--c);font-weight:700;display:inline-block}ul.checks{gap:.25rem;margin:0;padding:0;list-style:none;display:grid}ul.checks li{align-items:baseline;display:flex}.compare-table td,.compare-table th{white-space:nowrap}.compare-table td:first-child{white-space:normal}.compare-table tr.dim td{color:var(--text-muted)}tr.group-row td{background:var(--surface-2);text-transform:uppercase;letter-spacing:.05em;color:var(--text-2);font-size:11px;font-weight:700}.small{font-size:11px}svg.change-bar{display:block}svg.change-bar .axis{stroke:var(--text-2);stroke-width:1px}svg.change-bar rect{fill:var(--c)}.compare-results .segmented{margin-bottom:.5rem}.detail-pane{gap:.6rem;padding-top:.6rem;display:grid}.entity-group{border:1px solid var(--border);border-left:4px solid var(--c,var(--border));border-radius:6px;padding:.3rem .7rem}.entity-group summary{cursor:pointer;padding:.2rem 0;font-weight:600}.entity-name{width:40%}.entity-name>*{margin-right:.4rem}.finding-cards{gap:.5rem;margin:0 0 .6rem;padding:0;list-style:none;display:grid}.finding-card{border:1px solid var(--border);border-left:4px solid var(--c);border-radius:6px;padding:.5rem .8rem}.finding-card h4{margin:.2rem 0}.finding-card .fix{margin:.3rem 0 0}.sev{color:var(--c);font-weight:700}.trace-note.snapshot{gap:.5rem;width:28rem;display:grid}.trace-note.snapshot p{margin:0}@media (width<=1100px){.sides{grid-template-columns:1fr}.swap{justify-self:center}}.page-map{grid-template-columns:minmax(0,1fr) 20rem;align-items:start;gap:1rem;padding:1rem 1.2rem;display:grid}.page-map.no-pins{grid-template-columns:minmax(0,1fr)}.page-caption{margin:0 0 .6rem}.page-frame{width:100%;position:relative}.page-frame img{border:1px solid var(--border);border-radius:var(--radius-sm);height:100%;box-shadow:var(--shadow);background:var(--surface);box-sizing:border-box;display:block;position:absolute;top:0}.callout-svg{pointer-events:none;width:100%;height:100%;position:absolute;inset:0}.callout{--pin:var(--heat)}.callout.sev-warn{--pin:var(--warn)}.callout.sev-mild{--pin:var(--accent)}.callout rect{fill:none;stroke:var(--pin);stroke-width:1px;stroke-opacity:.4;vector-effect:non-scaling-stroke}.callout line{stroke:var(--pin);stroke-width:1px;stroke-opacity:.5;vector-effect:non-scaling-stroke}.callout circle{fill:var(--pin);fill-opacity:.8}.callout.active rect{fill:var(--pin);fill-opacity:.16;stroke-opacity:1;stroke-width:2px}.callout.active line{stroke-opacity:1;stroke-width:2px}.callout.active circle{fill-opacity:1}button.marker{--pin:var(--heat);z-index:5;border:2px solid var(--panel-solid);background:var(--pin);color:#fff;border-radius:50%;place-items:center;width:1.7rem;height:1.7rem;min-height:0;padding:0;font-size:11px;font-weight:700;display:grid;position:absolute;transform:translate(-50%,-50%);box-shadow:0 1px 4px #00000059}button.marker.sev-warn{--pin:var(--warn)}button.marker.sev-mild{--pin:var(--accent-strong)}button.marker:hover:not(:disabled),button.marker.active{background:var(--pin);border-color:var(--panel-solid);box-shadow:0 0 0 3px color-mix(in srgb, var(--pin) 40%, transparent);transform:translate(-50%,-50%)scale(1.2)}.pin-num{color:#fff;background:var(--accent-strong);border-radius:999px;place-items:center;min-width:1.4rem;height:1.4rem;padding:0 .3rem;font-size:11px;font-weight:700;display:inline-grid}.pin-card{z-index:20;pointer-events:none;background:color-mix(in srgb, var(--panel-solid) 98%, transparent);border:1px solid var(--panel-border);border-radius:var(--radius-sm);width:20rem;max-width:90%;box-shadow:var(--shadow-pop);color:var(--text);margin:.5rem 0;padding:.8rem .9rem;position:absolute}.pin-card-title{align-items:center;gap:.5rem;margin-bottom:.2rem;display:flex}.pin-file{color:var(--text-muted);overflow-wrap:anywhere;margin-bottom:.5rem;display:block}.pin-stats{grid-template-columns:repeat(4,1fr);gap:.4rem;margin:0 0 .5rem;display:grid}.pin-stats dt{text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);font-size:10px}.pin-stats dd{font-variant-numeric:tabular-nums;margin:0;font-weight:650}.pin-card p{color:var(--text-2);margin:.3rem 0 0}.pin-card .pin-fix{color:var(--text)}.page-list h2{margin:0 0 .5rem;font-size:.9rem}.page-list ol{gap:.3rem;margin:0;padding:0;list-style:none;display:grid}button.hotspot{text-align:left;background:var(--surface-2);white-space:normal;justify-content:flex-start;gap:.6rem;width:100%;padding:.4rem .6rem}button.hotspot.active{border-color:var(--accent);background:var(--accent-soft)}.hotspot-main{flex:1;min-width:0;display:grid}.hotspot-main .mono{text-overflow:ellipsis;white-space:nowrap;font-size:11px;overflow:hidden}.hotspot-time{font-variant-numeric:tabular-nums;color:var(--text-2);font-weight:600}@media (width<=1100px){.page-map{grid-template-columns:1fr}}.visually-hidden{clip:rect(0 0 0 0);white-space:nowrap;width:1px;height:1px;position:absolute;overflow:hidden}.badge-sev{align-items:center;gap:.3rem;font-size:12px;font-weight:600;display:inline-flex}.culprits-hero{grid-template-columns:minmax(18rem,34rem) minmax(0,1fr);align-items:start;gap:1.4rem;display:grid}.hero-sphere h2,.hero-detail h2{margin:0 0 .3rem}.sphere{aspect-ratio:1;cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none;border-radius:50%;width:100%;max-width:34rem;margin:0 auto;position:relative}.sphere:active{cursor:grabbing}.sphere-glow{background:radial-gradient(circle at 35% 30%, color-mix(in srgb, var(--accent) 26%, transparent), color-mix(in srgb, var(--accent) 6%, transparent) 55%, transparent 72%);box-shadow:inset 0 0 40px color-mix(in srgb, var(--accent) 18%, transparent), 0 0 60px color-mix(in srgb, var(--accent) 14%, transparent);border-radius:50%;position:absolute;inset:12%}.sphere-wire{width:100%;height:100%;position:absolute;inset:0;overflow:visible}.sphere-wire path{fill:none;stroke:var(--accent);stroke-opacity:.22;stroke-width:.006px}button.orb{--orb:var(--accent);background:color-mix(in srgb, var(--panel-solid) 78%, transparent);border:1px solid color-mix(in srgb, var(--orb) 55%, transparent);will-change:transform, opacity;border-radius:999px;justify-items:center;gap:0;min-height:0;padding:.2rem .6rem;line-height:1.2;display:grid;position:absolute;box-shadow:0 2px 10px #0000001f}button.orb.sev-critical{--orb:var(--poor)}button.orb.sev-warning{--orb:var(--warn)}button.orb .orb-name{color:var(--text);font-weight:650}button.orb .orb-ms{color:var(--orb);font-variant-numeric:tabular-nums;font-size:.78em;font-weight:600}button.orb:hover:not(:disabled),button.orb.active{background:color-mix(in srgb, var(--orb) 18%, var(--panel-solid));border-color:var(--orb);box-shadow:0 0 0 3px color-mix(in srgb, var(--orb) 30%, transparent)}.insight{border:1px solid var(--border);border-left:4px solid var(--accent);border-radius:var(--radius-sm);background:var(--surface-2);gap:.35rem;padding:.8rem 1rem;display:grid}.insight.sev-critical{border-left-color:var(--poor)}.insight.sev-warning{border-left-color:var(--warn)}.insight.sev-critical .badge-sev{color:var(--poor)}.insight.sev-warning .badge-sev{color:var(--warn)}.insight.sev-info .badge-sev{color:var(--accent)}.insight-head{flex-wrap:wrap;align-items:baseline;gap:.6rem;display:flex}.insight-name{font-family:var(--font-mono);font-size:.95rem}.insight-ms{font-variant-numeric:tabular-nums;margin-left:auto;font-weight:700}.insight p{margin:0}.insight button{justify-self:start}.rank-list{gap:2px;margin:.8rem 0 0;padding:0;list-style:none;display:grid}button.rank-row{--orb:var(--accent);border-radius:var(--radius-sm);text-align:left;background:0 0;border-color:#0000;grid-template-columns:1.4rem minmax(0,1fr) 7rem 4.6rem;align-items:center;gap:.6rem;width:100%;min-height:1.9rem;padding:.15rem .5rem;font-weight:400;display:grid}button.rank-row.sev-critical{--orb:var(--poor)}button.rank-row.sev-warning{--orb:var(--warn)}button.rank-row.active{background:var(--accent-soft);border-color:color-mix(in srgb, var(--accent) 40%, transparent)}.rank-n{color:var(--text-muted);font-variant-numeric:tabular-nums}.rank-name{font-family:var(--font-mono);text-overflow:ellipsis;white-space:nowrap;font-size:12px;overflow:hidden}.rank-bar{background:var(--grid);border-radius:3px;height:6px;overflow:hidden}.rank-bar span{background:var(--orb);border-radius:3px;height:100%;display:block}.rank-ms{text-align:right;font-variant-numeric:tabular-nums;color:var(--text-2)}@media (width<=1000px){.culprits-hero{grid-template-columns:1fr}}.vitals-block{gap:.7rem;display:grid}.vitals-hero{grid-template-columns:repeat(auto-fit,minmax(13rem,1fr));gap:.7rem;display:grid}.vital-hero{border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--surface-2);border-left-width:4px;gap:.15rem;padding:.8rem 1rem;display:grid}.vital-hero .vital-label{color:var(--text-2)}.vital-hero .vital-value{letter-spacing:-.02em;font-variant-numeric:tabular-nums;font-size:1.9rem;font-weight:650;line-height:1.15}.vital-hero .vital-rating{font-size:12px;font-weight:600}.vital-target{font-size:11px}.rating-good{--rate:var(--good);border-left-color:var(--good)}.rating-warn{--rate:var(--warn);border-left-color:var(--warn)}.rating-poor{--rate:var(--poor);border-left-color:var(--poor)}.rating-none{--rate:var(--text-muted);border-left-color:var(--border)}.vital-rating{color:var(--rate)}.gauge{background:linear-gradient(90deg, color-mix(in srgb, var(--good) 45%, transparent) 0 var(--g), color-mix(in srgb, var(--warn) 45%, transparent) var(--g) var(--p), color-mix(in srgb, var(--poor) 45%, transparent) var(--p) 100%);border-radius:3px;height:6px;margin:.45rem 0 .2rem;position:relative}.gauge-needle{background:var(--rate);width:4px;height:12px;box-shadow:0 0 0 2px var(--panel-solid);border-radius:2px;margin-left:-2px;position:absolute;top:-3px}.vitals-secondary{grid-template-columns:repeat(auto-fit,minmax(11.5rem,1fr));gap:.4rem;display:grid}.vital-chip{border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--surface-2);border-left-width:1px;align-items:center;gap:.5rem;padding:.35rem .7rem;display:flex}.vital-chip-icon{width:1.1rem;height:1.1rem;color:var(--panel-solid);background:var(--rate);border-radius:50%;place-items:center;font-size:10px;font-weight:700;display:grid}.vital-chip-label{color:var(--text-2);flex:1}.vital-chip b{font-variant-numeric:tabular-nums}.findings-toolbar{flex-wrap:wrap;align-items:center;gap:.5rem;margin-bottom:.6rem;display:flex}.frows,.members{gap:.4rem;margin:0;padding:0;list-style:none;display:grid}.frow{--sev:var(--accent);border:1px solid var(--border);border-left:4px solid var(--sev);border-radius:var(--radius-sm);background:var(--surface-2);overflow:hidden}.frow.sev-critical{--sev:var(--poor)}.frow.sev-warning{--sev:var(--warn)}.frow.open{border-color:color-mix(in srgb, var(--sev) 45%, var(--border))}.frow.group{background:color-mix(in srgb, var(--sev) 6%, var(--surface-2))}button.frow-head{text-align:left;white-space:normal;background:0 0;border:0;border-radius:0;grid-template-columns:1.5rem minmax(0,1fr) auto 9.5rem 1rem;align-items:center;gap:.7rem;width:100%;padding:.55rem .8rem;font-weight:400;display:grid}button.frow-head:hover:not(:disabled){background:var(--control);border:0}.sev-badge{width:1.4rem;height:1.4rem;color:var(--panel-solid);background:var(--sev);border-radius:50%;place-items:center;font-size:11px;font-weight:700;display:grid}.frow-main{min-width:0;display:grid}.frow-title{text-overflow:ellipsis;white-space:nowrap;font-weight:600;overflow:hidden}.frow.open>.frow-head .frow-title{white-space:normal}.frow-sub{font-family:var(--font-mono);font-size:11px}.frow-chips{gap:.25rem;display:flex}.impact{grid-template-columns:1fr auto;align-items:center;gap:.5rem;display:grid}.impact-bar{background:var(--grid);border-radius:3px;height:6px;overflow:hidden}.impact-bar span{background:var(--sev);border-radius:3px;height:100%;display:block}.impact-ms{text-align:right;font-variant-numeric:tabular-nums;min-width:3.4rem;font-weight:600}.fbody{gap:.8rem;padding:.2rem 1rem 1rem 3.1rem;display:grid}.fix-callout{border-radius:var(--radius-sm);background:var(--accent-soft);border:1px solid color-mix(in srgb, var(--accent) 35%, transparent);padding:.6rem .9rem}.fix-callout p{margin:.15rem 0 0}.fix-label{text-transform:uppercase;letter-spacing:.08em;color:var(--accent);font-size:.7rem;font-weight:700}.fdetail{color:var(--text-2);margin:0}.fdetail b{text-transform:uppercase;letter-spacing:.08em;color:var(--text-muted);font-size:.7rem;display:block}.culprit-table th{background:0 0;position:static}.culprit-table td{vertical-align:middle}.members-title{text-transform:uppercase;letter-spacing:.08em;color:var(--text-muted);margin:.2rem 0 0;font-size:.7rem}.members .frow{background:var(--surface)}@media (width<=900px){button.frow-head{grid-template-columns:1.5rem minmax(0,1fr) 1rem}.frow-chips,.impact{display:none}.fbody{padding-left:1rem}}.eval-panel{padding:16px 20px 28px;overflow:auto}.eval-head{flex-wrap:wrap;align-items:center;gap:12px;margin-bottom:12px;display:flex}.eval-summary{flex-wrap:wrap;gap:6px;display:inline-flex}.eval-target{color:var(--text);margin-bottom:14px;font-size:13px}.eval-table-wrap{border:1px solid var(--border);border-radius:var(--radius-sm);overflow-x:auto}table.eval-matrix{border-collapse:collapse;width:100%;font-size:13px}table.eval-matrix th,table.eval-matrix td{text-align:left;border-bottom:1px solid var(--border);vertical-align:top;padding:9px 12px}table.eval-matrix th{font:inherit;letter-spacing:var(--label-tracking);text-transform:var(--label-case);color:var(--text-muted);background:var(--surface);font-size:11px}table.eval-matrix td.num,table.eval-matrix th.num{text-align:right;font-family:var(--font-mono);white-space:nowrap}table.eval-matrix tr:last-child td{border-bottom:none}table.eval-matrix tr.row-fail td{background:color-mix(in srgb, var(--poor) 8%, transparent)}.eval-path-title{font-weight:600}.eval-path-error{color:var(--poor);font-size:12px;font-family:var(--font-mono);margin-top:3px}.badge{letter-spacing:.04em;border-radius:999px;padding:2px 8px;font-size:11px;font-weight:700;display:inline-block}.badge-pass{background:color-mix(in srgb, var(--good) 20%, transparent);color:var(--good)}.badge-fail{background:color-mix(in srgb, var(--poor) 20%, transparent);color:var(--poor)}.top-finding{font-size:12px}.eval-foot{margin-top:12px;font-size:12px}
package/dist/index.html CHANGED
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Performance Profiler</title>
7
7
  <link rel="icon" href="data:," />
8
- <script type="module" crossorigin src="/assets/index-DY8VmG9y.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-Dx1YSFIx.css">
8
+ <script type="module" crossorigin src="/assets/index-mtYxDBW7.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-z14kjW8r.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@progen-labs/spectra",
3
- "version": "0.4.0",
4
- "description": "Spectra \u2014 a Chrome DevTools Protocol profiler that identifies the files, functions and components causing frontend slowness, across React, Vue, Svelte, Angular and Next.js builds.",
3
+ "version": "0.5.0",
4
+ "description": "Spectra — a Chrome DevTools Protocol profiler that identifies the files, functions and components causing frontend slowness, across React, Vue, Svelte, Angular and Next.js builds.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "spectra": "bin/spectra.js"
@@ -89,6 +89,56 @@
89
89
  "description": "All findings"
90
90
  }
91
91
  }
92
+ },
93
+ "signIn": {
94
+ "type": "object",
95
+ "description": "How Spectra signs in to your app before profiling it. Only the non-secret parts: the password, the authenticator secret, header values, cookies and storage entries come from the environment (SPECTRA_LOGIN_PASSWORD, SPECTRA_LOGIN_TOTP_SECRET, SPECTRA_LOGIN_HEADER, SPECTRA_LOGIN_COOKIES, SPECTRA_LOGIN_STORAGE).",
96
+ "additionalProperties": false,
97
+ "properties": {
98
+ "method": {
99
+ "enum": [
100
+ "form",
101
+ "link",
102
+ "http",
103
+ "token",
104
+ "manual"
105
+ ],
106
+ "description": "form: a login form; link: a URL that signs in by itself; http: the browser's login prompt; token: a header, cookies or localStorage entries; manual: you sign in in the Chrome window"
107
+ },
108
+ "url": {
109
+ "type": "string",
110
+ "description": "The login page, or the sign-in link. {url} in it becomes the page to return to"
111
+ },
112
+ "username": {
113
+ "type": "string",
114
+ "description": "The username or email"
115
+ },
116
+ "startSelector": {
117
+ "type": "string",
118
+ "description": "CSS selector for a control to click first, such as a button that opens the login, when Spectra can't find it"
119
+ },
120
+ "usernameSelector": {
121
+ "type": "string",
122
+ "description": "CSS selector for the username field, when Spectra can't find it"
123
+ },
124
+ "passwordSelector": {
125
+ "type": "string",
126
+ "description": "CSS selector for the password field, when Spectra can't find it"
127
+ },
128
+ "submitSelector": {
129
+ "type": "string",
130
+ "description": "CSS selector for the submit button, when Spectra can't find it"
131
+ },
132
+ "codeSelector": {
133
+ "type": "string",
134
+ "description": "CSS selector for the one-time code field, when Spectra can't find it"
135
+ },
136
+ "waitSeconds": {
137
+ "type": "number",
138
+ "exclusiveMinimum": 0,
139
+ "description": "How long to wait for a manual sign-in (default 300)"
140
+ }
141
+ }
92
142
  }
93
143
  }
94
144
  }
@@ -66,7 +66,7 @@ export function buildFindings(report) {
66
66
  severity: 'warning',
67
67
  category: 'Setup',
68
68
  title: `Almost nothing ran during this ${(report.meta.durationMs / 1000).toFixed(0)} s recording`,
69
- detail: `Only ${fmt(totals.activeMs)} of JavaScript executed${react.detected ? ` and React committed ${commits} times` : ''}, so there is nothing slow to point at.`,
69
+ detail: `Only ${fmt(totals.activeMs)} of JavaScript executed${react.detected ? ` and ${{ vue: 'Vue', angular: 'Angular', svelte: 'Svelte' }[react.framework] || 'React'} updated components ${commits} times` : ''}, so there is nothing slow to point at.`,
70
70
  pinned: 1,
71
71
  fix: 'Press Record, then use the page (open a dashboard, scroll, type, switch tabs) before you press Stop — or use “Record page load” to profile startup.',
72
72
  });
@@ -237,7 +237,18 @@ export function buildFindings(report) {
237
237
  ? 'Svelte'
238
238
  : fw === 'angular'
239
239
  ? 'Angular'
240
- : 'React';
240
+ : fw === 'react' || fw === 'next'
241
+ ? 'React'
242
+ : 'Components';
243
+ // renders that changed nothing: the remedy in each framework's own terms
244
+ const wastedFix = unstable =>
245
+ fw === 'vue'
246
+ ? `Pass stable props${unstable ? ` (${unstable.prop} is a new ${unstable.kind} each time)` : ''}, and use v-memo for parts that rarely change.`
247
+ : fw === 'angular'
248
+ ? `Use ChangeDetectionStrategy.OnPush${unstable ? ` and keep ${unstable.prop} the same reference until its data changes` : ', and pass new references only when data changes'}.`
249
+ : unstable
250
+ ? `Wrap in React.memo, and stabilise the props that keep changing identity (${unstable.prop}: ${unstable.kind}).`
251
+ : 'Wrap in React.memo, or stop the parent re-rendering (move state down, split the component).';
241
252
  const slowFix =
242
253
  fw === 'vue'
243
254
  ? 'Move expensive work into a computed property, use v-memo/v-once for static subtrees, and virtualise long lists.'
@@ -281,9 +292,7 @@ export function buildFindings(report) {
281
292
  ),
282
293
  ],
283
294
  impactMs: c.wastedMs,
284
- fix: unstable
285
- ? `Wrap in React.memo, and stabilise the props that keep changing identity (${unstable.prop}: ${unstable.kind}).`
286
- : 'Wrap in React.memo, or stop the parent re-rendering (move state down, split the component).',
295
+ fix: wastedFix(unstable),
287
296
  });
288
297
  });
289
298
  react.components
@@ -389,11 +398,11 @@ export function buildFindings(report) {
389
398
  add({
390
399
  id: 'react-missing',
391
400
  severity: 'info',
392
- category: CAT,
393
- title: `No ${CAT === 'React' ? 'React renderer' : CAT + ' app'} was seen on this page`,
401
+ category: 'Components',
402
+ title: 'No component framework was seen on this page',
394
403
  detail:
395
- 'The probe must be installed before the framework loads (use “Reload & record”), and component-level timings need a development build.',
396
- fix: 'Click “Record page load”, or reload the tab from this tool.',
404
+ 'Files and functions are profiled for any app. For per-component data in React, Vue or Angular, the probe must be installed before the framework loads, and component timings need a development build.',
405
+ fix: 'If the app uses one of them, click “Record page load”, or reload the tab from this tool.',
397
406
  });
398
407
  }
399
408
 
package/server/cli.js CHANGED
@@ -17,6 +17,7 @@ import { PerfSession, readReport } from './recorder.js';
17
17
  import { REPORTS_DIR, TARGET_NAME } from './config.js';
18
18
  import { intOption, parseArgs } from './args.js';
19
19
  import { loadProjectConfig } from './project-config.js';
20
+ import { SIGN_IN_HELP, logSignIn, signInFromOptions } from './signin.js';
20
21
  import { EXIT, SPECTRA_VERSION, exitAfterFlush, fmt, printBudgets, reportFile, writeResult } from './output.js';
21
22
  import { buildProfileResult } from '../shared/result.js';
22
23
 
@@ -25,7 +26,8 @@ const { opt, flag } = parseArgs(process.argv.slice(2));
25
26
  const url = opt('url');
26
27
  if (!url || flag('help')) {
27
28
  console.error(
28
- 'usage: npm run profile -- --url <page> [--mode live|load] [--runs n] [--duration seconds] [--throttle 4] [--scenario file.mjs] [--json] [--out file.json] [--config spectra.config.json] [--headed] [--save-trace] [--sign-in "http://host/login/?redirect={url}"]',
29
+ 'usage: npm run profile -- --url <page> [--mode live|load] [--runs n] [--duration seconds] [--throttle 4] [--scenario file.mjs] [--json] [--out file.json] [--config spectra.config.json] [--headed] [--save-trace] [sign-in options]\n\n' +
30
+ SIGN_IN_HELP,
29
31
  );
30
32
  process.exit(url ? EXIT.OK : EXIT.ERROR);
31
33
  }
@@ -36,7 +38,7 @@ const out = opt('out');
36
38
  const log = json ? console.error : console.log;
37
39
  const sleep = ms => new Promise(r => setTimeout(r, ms));
38
40
 
39
- let mode, runs, duration, cpuThrottle, config, scenario;
41
+ let mode, runs, duration, cpuThrottle, config, scenario, signIn;
40
42
  try {
41
43
  mode = opt('mode', 'live');
42
44
  if (!['live', 'load'].includes(mode)) throw new Error(`--mode must be live or load (got ${mode}).`);
@@ -44,6 +46,7 @@ try {
44
46
  duration = Number(opt('duration', 8)) * 1000;
45
47
  cpuThrottle = Number(opt('throttle', 1));
46
48
  config = loadProjectConfig(opt('config'));
49
+ signIn = signInFromOptions(opt, config.signIn);
47
50
  const scenarioFile = opt('scenario');
48
51
  if (scenarioFile) scenario = (await import(pathToFileURL(path.resolve(scenarioFile)).href)).default;
49
52
  } catch (err) {
@@ -82,7 +85,7 @@ async function recordOnce(session, index) {
82
85
  if (mode === 'load') return recordLoad(session, startOpts);
83
86
  // live: every run starts from the same freshly loaded page
84
87
  if (index > 0) {
85
- await session.navigate(url, opt('sign-in'));
88
+ await session.navigate(url, signIn);
86
89
  await sleep(800);
87
90
  }
88
91
  await session.start(startOpts);
@@ -128,10 +131,13 @@ function printProfile(result, reports) {
128
131
  }
129
132
 
130
133
  const session = new PerfSession();
134
+ logSignIn(session);
131
135
  const reports = [];
132
136
  let code = EXIT.OK;
133
137
  try {
134
- await session.connect({ mode: 'launch', url, headless: !flag('headed'), signIn: opt('sign-in') });
138
+ // signing in yourself needs the window, so it shows Chrome even without --headed
139
+ await session.connect({ mode: 'launch', url, headless: !flag('headed') && signIn?.method !== 'manual', signIn });
140
+ if (session.signedIn) console.error('spectra: signed in');
135
141
  await sleep(1500);
136
142
  await session.reload(); // make sure the probe was installed before the app loaded
137
143
  await sleep(1000);
@@ -13,6 +13,7 @@ import { readReport } from '../recorder.js';
13
13
  import { REPORTS_DIR, TARGET_NAME } from '../config.js';
14
14
  import { parseArgs } from '../args.js';
15
15
  import { loadProjectConfig } from '../project-config.js';
16
+ import { signInFromOptions } from '../signin.js';
16
17
  import { EXIT, SPECTRA_VERSION, exitAfterFlush, printBudgets, reportFile, writeResult } from '../output.js';
17
18
  import { buildEvaluationResult } from '../../shared/result.js';
18
19
 
@@ -28,9 +29,10 @@ const out = opt('out');
28
29
  // In --json mode stdout carries only the result; people still see the matrix.
29
30
  const log = json ? console.error : console.log;
30
31
 
31
- let config;
32
+ let config, signIn;
32
33
  try {
33
34
  config = loadProjectConfig(opt('config'));
35
+ signIn = signInFromOptions(opt, config.signIn);
34
36
  } catch (err) {
35
37
  console.error(`spectra: ${err.message}`);
36
38
  process.exit(EXIT.ERROR);
@@ -84,11 +86,19 @@ let result;
84
86
  try {
85
87
  result = await runEvaluation({
86
88
  url,
87
- headless: !flag('headed'),
89
+ // signing in yourself needs the window, so it shows Chrome even without --headed
90
+ headless: !flag('headed') && signIn?.method !== 'manual',
88
91
  limit: Number(opt('paths', 12)),
89
- signIn: opt('sign-in'),
92
+ signIn,
90
93
  onProgress: ev => {
91
- if (ev.phase === 'baseline') console.error('spectra: recording baseline page load …');
94
+ if (ev.phase === 'sign-in')
95
+ console.error(
96
+ ev.method === 'manual'
97
+ ? 'spectra: sign in in the Chrome window (any way the app asks); Spectra carries on once you are in …'
98
+ : 'spectra: signing in through the login form …',
99
+ );
100
+ else if (ev.phase === 'signed-in') console.error('spectra: signed in');
101
+ else if (ev.phase === 'baseline') console.error('spectra: recording baseline page load …');
92
102
  else if (ev.phase === 'discover') console.error('spectra: discovering actionable elements …');
93
103
  else if (ev.phase === 'synthesized')
94
104
  console.error(
@@ -65,7 +65,7 @@ function recordBaseline(session) {
65
65
  * @param {boolean} [opts.headless] default true
66
66
  * @param {number} [opts.limit] max golden paths (default 12)
67
67
  * @param {string[]} [opts.allow] selectors of destructive actions to permit
68
- * @param {string} [opts.signIn] sign-in URL template
68
+ * @param {string|object} [opts.signIn] how to sign in (signin.js normalizeSignIn)
69
69
  * @param {(ev:object)=>void} [opts.onProgress]
70
70
  */
71
71
  export async function runEvaluation({
@@ -78,9 +78,11 @@ export async function runEvaluation({
78
78
  } = {}) {
79
79
  const log = onProgress || (() => {});
80
80
  const session = new PerfSession();
81
+ session.on('state', s => s.state === 'signing-in' && log({ phase: 'sign-in', method: s.method }));
81
82
  const results = [];
82
83
  try {
83
84
  await session.connect({ mode: 'launch', url, headless, signIn });
85
+ if (session.signedIn) log({ phase: 'signed-in' });
84
86
  // Auto-dismiss alert/confirm/beforeunload dialogs so a journey can't hang.
85
87
  if (session.page) {
86
88
  session.page.on('dialog', d => d.dismiss().catch(() => {}));
@@ -4,6 +4,7 @@
4
4
  * Everything it touches is passed in (profile, compare, agent, workspace,
5
5
  * checks), so the loop itself is tested without a browser or a model.
6
6
  */
7
+ import { findingKey } from '../../shared/compare.js';
7
8
  import { fixPrompt, judge, rankFindings, scopeProblem } from './policy.js';
8
9
 
9
10
  // agent errors no other finding would get past: stop instead of trying them all
@@ -34,6 +35,7 @@ export async function runFix(o) {
34
35
  const baseline = await profile('baseline');
35
36
  session.baseline = { file: baseline.file, summary: baseline.result.summary, url: baseline.result.url };
36
37
  let current = [baseline.file];
38
+ let present = null; // finding keys the latest kept change still measures
37
39
 
38
40
  const { candidates, skipped } = rankFindings(baseline.result.findings, { exists: ws.exists, history, retry, max: maxFixes });
39
41
  session.skipped = skipped.map(s => ({ title: s.finding.title, reason: s.reason }));
@@ -46,6 +48,12 @@ export async function runFix(o) {
46
48
  for (const [i, f] of candidates.entries()) {
47
49
  if (signal?.aborted) break;
48
50
  const n = `${i + 1}/${candidates.length}`;
51
+ if (present && !present.has(f.key)) {
52
+ // one fix often clears several findings (a memoised row also stops its hot file)
53
+ session.skipped.push({ title: f.title, reason: 'already resolved by an earlier kept change' });
54
+ log(`[${n}] – ${f.title}: already resolved by an earlier kept change.`);
55
+ continue;
56
+ }
49
57
  const attempt = { finding: { title: f.title, file: f.file, line: f.line, severity: f.severity, impactMs: f.impactMs, key: f.key }, outcome: null, reason: '', changes: [] };
50
58
  session.attempts.push(attempt);
51
59
  const discard = (outcome, reason) => {
@@ -127,6 +135,7 @@ export async function runFix(o) {
127
135
  Object.assign(attempt, { outcome: 'kept', reason: verdict.reason });
128
136
  session.kept++;
129
137
  current = after.map(a => a.file);
138
+ present = new Set(after.flatMap(a => a.result.findings || []).map(findingKey));
130
139
  log(`[${n}] ✓ kept (${attempt.commit}): ${verdict.reason}`);
131
140
  }
132
141
 
@@ -70,7 +70,8 @@ test('tells the agent the finding and the rules', () => {
70
70
  });
71
71
 
72
72
  // --- the loop, with fakes
73
- function fakes({ agentChanges, checks = [true, true, true, true], verdicts }) {
73
+ const bothFindings = () => [finding('row-render'), finding('heavy', { file: 'src/heavy.js', line: 2, culprits: [] })];
74
+ function fakes({ agentChanges, checks = [true, true, true, true], verdicts, measured = bothFindings }) {
74
75
  const calls = { commits: [], undos: 0, profiles: 0, compares: [] };
75
76
  let pending = [];
76
77
  let checkRun = 0;
@@ -91,7 +92,7 @@ function fakes({ agentChanges, checks = [true, true, true, true], verdicts }) {
91
92
  workspace,
92
93
  checks: [{ name: 'npm test' }],
93
94
  runChecks: async () => [{ name: 'npm test', ok: checks[checkRun++] ?? true, tail: 'expected 120 rows' }],
94
- profile: async label => ((calls.profiles++), { file: `${label}.json`, result: { url: 'http://localhost:5199', summary: {}, findings: [finding('row-render'), finding('heavy', { file: 'src/heavy.js', line: 2, culprits: [] })] } }),
95
+ profile: async label => ((calls.profiles++), { file: `${label}.json`, result: { url: 'http://localhost:5199', summary: {}, findings: measured(label) } }),
95
96
  compare: async (before, after) => (calls.compares.push([before, after]), verdicts[verdictRun++] || comparison('neutral')),
96
97
  },
97
98
  };
@@ -112,6 +113,21 @@ test('keeps a change that passes the checks and measures faster, and builds on i
112
113
  assert.match(reportMarkdown({ ...s, runs: 3, branch: 'spectra/fix-x' }), /1 of 2 findings fixed/);
113
114
  });
114
115
 
116
+ test('skips a finding that an earlier kept change already resolved', async () => {
117
+ // the memoised row also stops the hot file: after the first fix, neither is measured
118
+ const measured = label => (label === 'baseline' ? bothFindings() : []);
119
+ const { calls, options } = fakes({ agentChanges: [edit, edit], verdicts: [comparison('improved'), comparison('improved')], measured });
120
+ let asked = 0;
121
+ const run = options.agent.run;
122
+ options.agent.run = (...a) => (asked++, run(...a));
123
+ const s = await runFix(options);
124
+ assert.equal(asked, 1, 'the agent is not asked about the resolved finding');
125
+ assert.deepEqual(s.attempts.map(a => a.outcome), ['kept']);
126
+ assert.deepEqual(s.skipped, [{ title: 'heavy is slow', reason: 'already resolved by an earlier kept change' }]);
127
+ assert.equal(calls.profiles, 2, 'baseline and one measurement');
128
+ assert.match(reportMarkdown({ ...s, runs: 3, branch: 'spectra/fix-x' }), /heavy is slow: already resolved by an earlier kept change/);
129
+ });
130
+
115
131
  test('undoes a change whose checks fail, without measuring it', async () => {
116
132
  const { calls, options } = fakes({ agentChanges: [edit], checks: [true, false], verdicts: [] });
117
133
  options.workspace.exists = f => f === 'src/Row.jsx';
@@ -356,7 +356,7 @@ function methodology() {
356
356
  ).join('');
357
357
  return `<h2>Methodology</h2>
358
358
  <div class="callout">
359
- <p><b>What is compared.</b> Profiler reports recorded in Chrome through the DevTools Protocol (tracing, V8 CPU sampling and an in-page React probe). When a side has several runs, each metric is the <b>median</b> of its runs.</p>
359
+ <p><b>What is compared.</b> Profiler reports recorded in Chrome through the DevTools Protocol (tracing, V8 CPU sampling and an in-page probe for React, Vue and Angular components). When a side has several runs, each metric is the <b>median</b> of its runs.</p>
360
360
  <p><b>What counts as a change.</b> A metric is “improved” or “degraded” only when the difference exceeds <b>all three</b> of: its absolute floor, its relative floor (both in the table below) and the <b>run-to-run spread</b> seen on either side. Everything else is “unchanged”. For every metric listed here <b>lower is better</b>.</p>
361
361
  <p><b>Overall verdict.</b> Decided only by the <i>key</i> metrics: improved if some improved and none degraded; regressed if some degraded and none improved; mixed if both; otherwise no significant change.</p>
362
362
  <p><b>Limits.</b> A single run per side cannot separate a real change from noise: record each side at least three times, on the same page, in the same mode, with the same CPU setting. Interaction recordings are only comparable if the same actions were performed. Dev builds are slower than production: trust direction and ranking more than absolute numbers.</p>
@@ -396,7 +396,7 @@ function detailedBody(cmp) {
396
396
  <h2>Highlights</h2>${headlines(cmp)}
397
397
  <h2>Comparability</h2>${checksList(cmp, { onlyProblems: false })}
398
398
  <h2 class="pagebreak">All metrics</h2>${metricsTable(cmp.metrics, { grouped: true })}
399
- <div class="pagebreak"></div>${entitySection('React components', 'components', cmp, { vendor: false })}
399
+ <div class="pagebreak"></div>${entitySection('Components', 'components', cmp, { vendor: false })}
400
400
  <div class="pagebreak"></div>${entitySection('Files', 'files', cmp, { vendor: false })}
401
401
  <div class="pagebreak"></div>${entitySection('Functions', 'functions', cmp, { vendor: false })}
402
402
  <div class="pagebreak"></div><h2>Problems found</h2>${findingsSection(cmp, { limit: 0 })}
@@ -13,13 +13,11 @@
13
13
  */
14
14
  (function () {
15
15
  if (window.__PERF_PROBE__) return;
16
- // Runtime hint injected by the profiler: { framework, bundler, isSSR }
16
+ // What the project's package.json suggests: { framework, bundler, isSSR }. Only
17
+ // a hint: the page itself decides (every adapter below waits for its own
18
+ // framework to show up), so an Angular app is profiled as Angular even when
19
+ // Spectra runs from another folder, a monorepo root or an editor.
17
20
  var SPECTRA_FRAMEWORK = typeof window.__SPECTRA_FRAMEWORK__ !== 'undefined' ? window.__SPECTRA_FRAMEWORK__ : null;
18
- var WANT_REACT =
19
- !SPECTRA_FRAMEWORK ||
20
- SPECTRA_FRAMEWORK.framework === 'react' ||
21
- SPECTRA_FRAMEWORK.framework === 'next' ||
22
- SPECTRA_FRAMEWORK.framework === 'unknown';
23
21
 
24
22
  var PERFORMED_WORK = 1;
25
23
  var TAG_FUNCTION = 0;
@@ -482,11 +480,77 @@
482
480
  ];
483
481
  }
484
482
 
483
+ // The box an element occupies, clipped to the viewport. A component host that
484
+ // is an inline wrapper (Angular's <app-x>, Vue fragments) can have no box of
485
+ // its own, so it falls back to the union of its children's boxes.
486
+ function elementRect(el, vw, vh, depth) {
487
+ var left = Infinity;
488
+ var top = Infinity;
489
+ var right = -Infinity;
490
+ var bottom = -Infinity;
491
+ var r = el.getBoundingClientRect();
492
+ if (r.width > 0 && r.height > 0) {
493
+ left = r.left;
494
+ top = r.top;
495
+ right = r.right;
496
+ bottom = r.bottom;
497
+ } else if ((depth || 0) < 3) {
498
+ for (var c = el.firstElementChild; c; c = c.nextElementSibling) {
499
+ var cr = elementRect(c, Infinity, Infinity, (depth || 0) + 1);
500
+ if (!cr) continue;
501
+ left = Math.min(left, cr[0]);
502
+ top = Math.min(top, cr[1]);
503
+ right = Math.max(right, cr[0] + cr[2]);
504
+ bottom = Math.max(bottom, cr[1] + cr[3]);
505
+ }
506
+ }
507
+ if (left === Infinity) return null;
508
+ left = Math.max(0, left);
509
+ top = Math.max(0, top);
510
+ right = Math.min(vw, right);
511
+ bottom = Math.min(vh, bottom);
512
+ if (vw !== Infinity && (right - left < 16 || bottom - top < 16)) return null;
513
+ return [Math.round(left), Math.round(top), Math.round(right - left), Math.round(bottom - top)];
514
+ }
515
+
516
+ // Angular (development mode) and Vue: find each component's element in the DOM.
517
+ // Angular: ng.getComponent(host) is the component an element hosts. Vue 3: an
518
+ // element's __vueParentComponent is the component that rendered it, and it is
519
+ // that component's root element when the component's subTree points back at it.
520
+ function frameworkLayout(byId, vw, vh) {
521
+ var ng = window.ng;
522
+ var useNg = probe.ngIds && ng && typeof ng.getComponent === 'function';
523
+ var useVue = !!probe.vueIds;
524
+ if (!useNg && !useVue) return;
525
+ var els = document.querySelectorAll('*');
526
+ for (var i = 0; i < els.length; i++) {
527
+ var el = els[i];
528
+ var id = undefined; // var is function-scoped: reset it for every element
529
+ if (useNg) {
530
+ var inst = null;
531
+ try {
532
+ inst = ng.getComponent(el);
533
+ } catch (e) {}
534
+ if (inst) id = probe.ngIds.get(inst.constructor);
535
+ }
536
+ if (id === undefined && useVue) {
537
+ var vc = el.__vueParentComponent;
538
+ if (vc && ((vc.subTree && vc.subTree.el === el) || (vc.vnode && vc.vnode.el === el))) id = probe.vueIds.get(vc.type);
539
+ }
540
+ if (id === undefined || !probe.stats[id]) continue;
541
+ var rect = elementRect(el, vw, vh);
542
+ if (rect) (byId[id] = byId[id] || []).push(rect);
543
+ }
544
+ }
545
+
485
546
  // Where every component that rendered during the recording sits in the viewport.
486
547
  probe.layout = function () {
487
548
  var vw = window.innerWidth;
488
549
  var vh = window.innerHeight;
489
550
  var byId = {};
551
+ try {
552
+ frameworkLayout(byId, vw, vh);
553
+ } catch (e) {}
490
554
  probe.roots.forEach(function (root) {
491
555
  var stack = [root.current];
492
556
  while (stack.length) {
@@ -586,9 +650,9 @@
586
650
  };
587
651
 
588
652
  window.__PERF_PROBE__ = probe;
589
- // Only attempt to hook into React when the runtime hint allows it.
653
+ // React's hook is ignored by every other framework, so it is always there.
590
654
  try {
591
- if (WANT_REACT) installHook();
655
+ installHook();
592
656
  } catch (e) {
593
657
  // Fail silently: probe should still collect vitals even if hooking fails.
594
658
  }
@@ -600,6 +664,7 @@
600
664
  // from the SFC compiler (type.__file), so no source-map step is needed.
601
665
  function installVueAdapter() {
602
666
  var vueIds = new Map();
667
+ probe.vueIds = vueIds; // for the page map: component type -> stat id
603
668
  var nextId = 0;
604
669
  var perfStart = {};
605
670
 
@@ -718,7 +783,6 @@
718
783
  if (prevEmit) try { prevEmit.apply(null, arguments); } catch (e) {}
719
784
  };
720
785
  window.__VUE_DEVTOOLS_GLOBAL_HOOK__ = hook;
721
- probe.frameworkName = 'vue';
722
786
  }
723
787
 
724
788
  // ------------------------------------------------ Angular adapter
@@ -729,6 +793,7 @@
729
793
  // like React/Vue. The ng global appears only after bootstrap, so we poll.
730
794
  function installAngularAdapter() {
731
795
  var ngIds = new Map();
796
+ probe.ngIds = ngIds; // for the page map: component class -> stat id
732
797
  var nextId = 100000; // keep clear of other adapters' id space
733
798
  var stack = [];
734
799
 
@@ -825,11 +890,12 @@
825
890
  return false;
826
891
  }
827
892
  if (!register()) {
893
+ // poll for up to 30 s, and stop as soon as another framework shows up
828
894
  var tries = 0;
829
895
  var iv = setInterval(function () {
830
896
  tries++;
831
- if (register() || tries > 200) clearInterval(iv); // poll up to ~8s
832
- }, 40);
897
+ if (register() || probe.renderers > 0 || probe.frameworkName === 'vue' || tries > 300) clearInterval(iv);
898
+ }, 100);
833
899
  }
834
900
  }
835
901
 
@@ -931,11 +997,16 @@
931
997
  } catch (e) {}
932
998
  }
933
999
 
1000
+ // Vue and Angular too, whatever the hint says: each wakes up only for its own app
1001
+ try {
1002
+ installVueAdapter();
1003
+ } catch (e) {}
1004
+ try {
1005
+ installAngularAdapter();
1006
+ } catch (e) {}
934
1007
  try {
935
1008
  var fwHint = SPECTRA_FRAMEWORK && SPECTRA_FRAMEWORK.framework;
936
- if (fwHint === 'vue') installVueAdapter();
937
- else if (fwHint === 'angular') installAngularAdapter();
938
- else if (!WANT_REACT) installFrameworkProbes(); // svelte: heuristic for now
1009
+ if (fwHint === 'svelte') installFrameworkProbes(); // svelte: heuristic for now
939
1010
  } catch (e) {}
940
1011
  installObservers();
941
1012
  })();