@progen-labs/spectra 0.4.1 → 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.
- package/README.md +56 -8
- package/bin/spectra.js +8 -4
- package/dist/assets/index-mtYxDBW7.js +11 -0
- package/dist/assets/index-z14kjW8r.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +2 -2
- package/schema/config.schema.json +50 -0
- package/server/analysis/findings.js +18 -9
- package/server/cli.js +10 -4
- package/server/explore/evaluate-cli.js +14 -4
- package/server/explore/evaluate.js +3 -1
- package/server/pdf/template.js +2 -2
- package/server/probe.browser.js +85 -14
- package/server/project-config.js +17 -5
- package/server/recorder.js +154 -15
- package/server/signin.js +524 -2
- package/server/test-signin.js +179 -0
- package/shared/compare.js +9 -9
- package/shared/snapshot.js +1 -1
- package/src/App.jsx +1 -1
- package/src/components/CompareResults.jsx +1 -1
- package/src/components/ControlBar.jsx +83 -60
- package/src/components/EvaluationPanel.jsx +1 -1
- package/src/components/FilePanel.jsx +2 -2
- package/src/components/PageMap.jsx +26 -22
- package/src/components/ReactPanel.jsx +2 -4
- package/src/components/SignIn.jsx +291 -0
- package/src/components/Vitals.jsx +1 -1
- package/src/framework.js +12 -0
- package/src/heat.js +3 -3
- package/src/styles.css +91 -0
- package/dist/assets/index-DY8VmG9y.js +0 -11
- package/dist/assets/index-Dx1YSFIx.css +0 -1
|
@@ -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-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
-
"description": "Spectra
|
|
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
|
|
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
|
-
: '
|
|
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:
|
|
393
|
-
title:
|
|
401
|
+
category: 'Components',
|
|
402
|
+
title: 'No component framework was seen on this page',
|
|
394
403
|
detail:
|
|
395
|
-
'
|
|
396
|
-
fix: '
|
|
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] [
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
92
|
+
signIn,
|
|
90
93
|
onProgress: ev => {
|
|
91
|
-
if (ev.phase === '
|
|
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]
|
|
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(() => {}));
|
package/server/pdf/template.js
CHANGED
|
@@ -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
|
|
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('
|
|
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 })}
|
package/server/probe.browser.js
CHANGED
|
@@ -13,13 +13,11 @@
|
|
|
13
13
|
*/
|
|
14
14
|
(function () {
|
|
15
15
|
if (window.__PERF_PROBE__) return;
|
|
16
|
-
//
|
|
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
|
-
//
|
|
653
|
+
// React's hook is ignored by every other framework, so it is always there.
|
|
590
654
|
try {
|
|
591
|
-
|
|
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 >
|
|
832
|
-
},
|
|
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 === '
|
|
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
|
})();
|
package/server/project-config.js
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Per-project settings: spectra.config.json in the profiled project's root
|
|
3
|
-
* (or the file passed with --config).
|
|
3
|
+
* (or the file passed with --config). It holds performance budgets and how
|
|
4
|
+
* to sign in to the app (never the password or other secrets: those come from
|
|
5
|
+
* the environment, see signin.js):
|
|
4
6
|
*
|
|
5
|
-
* {
|
|
7
|
+
* {
|
|
8
|
+
* "budgets": { "tbtMs": 200, "lcpMs": 2500, "cls": 0.1, "criticalFindings": 0 },
|
|
9
|
+
* "signIn": { "method": "form", "url": "http://localhost:3000/login", "username": "demo@example.com" }
|
|
10
|
+
* }
|
|
6
11
|
*/
|
|
7
12
|
import fs from 'node:fs';
|
|
8
13
|
import path from 'node:path';
|
|
9
14
|
import { FRONTEND_ROOT } from './config.js';
|
|
10
15
|
import { validateBudgets } from '../shared/result.js';
|
|
16
|
+
import { validateSignInConfig } from './signin.js';
|
|
11
17
|
|
|
12
18
|
export const CONFIG_FILE = 'spectra.config.json';
|
|
13
19
|
|
|
14
|
-
/** @returns {{file: string|null, budgets: object}} — throws a readable Error on bad config */
|
|
20
|
+
/** @returns {{file: string|null, budgets: object, signIn: object|null}} — throws a readable Error on bad config */
|
|
15
21
|
export function loadProjectConfig(explicitFile) {
|
|
16
22
|
const file = explicitFile ? path.resolve(explicitFile) : path.join(FRONTEND_ROOT, CONFIG_FILE);
|
|
17
23
|
if (!fs.existsSync(file)) {
|
|
18
24
|
if (explicitFile) throw new Error(`config file not found: ${file}`);
|
|
19
|
-
return { file: null, budgets: {} };
|
|
25
|
+
return { file: null, budgets: {}, signIn: null };
|
|
20
26
|
}
|
|
21
27
|
let raw;
|
|
22
28
|
try {
|
|
@@ -26,5 +32,11 @@ export function loadProjectConfig(explicitFile) {
|
|
|
26
32
|
}
|
|
27
33
|
const checked = validateBudgets(raw?.budgets);
|
|
28
34
|
if (!checked.ok) throw new Error(`${path.basename(file)}: ${checked.error}`);
|
|
29
|
-
|
|
35
|
+
let signIn;
|
|
36
|
+
try {
|
|
37
|
+
signIn = validateSignInConfig(raw?.signIn);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
throw new Error(`${path.basename(file)}: ${err.message}`);
|
|
40
|
+
}
|
|
41
|
+
return { file, budgets: checked.budgets, signIn };
|
|
30
42
|
}
|