@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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { reportFramework } from '../framework.js';
|
|
1
2
|
import { useMemo, useState } from 'react';
|
|
2
3
|
import { fmtMs, fmtNumber } from '../format.js';
|
|
3
4
|
|
|
@@ -34,7 +35,7 @@ function buildPins(report, hideVendor) {
|
|
|
34
35
|
return pool
|
|
35
36
|
.sort((a, b) => b.c.selfMs + b.c.wastedMs - (a.c.selfMs + a.c.wastedMs))
|
|
36
37
|
.slice(0, MAX_PINS)
|
|
37
|
-
.map((p, i) => ({ ...p, key: `${p.c.id}`, n: i + 1 }));
|
|
38
|
+
.map((p, i) => ({ ...p, fw: report.react?.framework || 'react', key: `${p.c.id}`, n: i + 1 }));
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
function severityOf(c, maxSelf) {
|
|
@@ -48,6 +49,14 @@ function topReason(c) {
|
|
|
48
49
|
return n ? `${n}× ${REASON_LABELS[key]}` : null;
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
// what to do about renders that changed nothing, in the framework's own terms
|
|
53
|
+
function wastedFix(fw) {
|
|
54
|
+
if (fw === 'vue') return <>pass stable props (no new objects or functions inline), or use <code>v-memo</code> for parts that rarely change.</>;
|
|
55
|
+
if (fw === 'angular') return <>use <code>ChangeDetectionStrategy.OnPush</code> and pass new references only when data changes.</>;
|
|
56
|
+
if (fw === 'react' || fw === 'next') return <>wrap in <code>React.memo</code> or stop the parent re-rendering.</>;
|
|
57
|
+
return <>stop the parent re-rendering it when nothing changed.</>;
|
|
58
|
+
}
|
|
59
|
+
|
|
51
60
|
function Details({ pin }) {
|
|
52
61
|
const { c } = pin;
|
|
53
62
|
const reason = topReason(c);
|
|
@@ -88,8 +97,7 @@ function Details({ pin }) {
|
|
|
88
97
|
)}
|
|
89
98
|
{!unstable && c.wasted > 0 && (
|
|
90
99
|
<p className="pin-fix">
|
|
91
|
-
Fix: {fmtNumber(c.wasted)} renders changed nothing —
|
|
92
|
-
<code>React.memo</code> or stop the parent re-rendering.
|
|
100
|
+
Fix: {fmtNumber(c.wasted)} renders changed nothing — {wastedFix(pin.fw)}
|
|
93
101
|
</p>
|
|
94
102
|
)}
|
|
95
103
|
{pin.count > 1 && (
|
|
@@ -237,8 +245,9 @@ function Callouts({ pins, pageView, active, onActivate, onOpenFile, maxSelf }) {
|
|
|
237
245
|
|
|
238
246
|
/**
|
|
239
247
|
* Screenshot of the page at the moment recording stopped, with the slowest
|
|
240
|
-
* React
|
|
241
|
-
* the file, render counts and a fix; click it to open the
|
|
248
|
+
* components (React, Vue or Angular) outlined where they sit on screen. Hover
|
|
249
|
+
* or focus a pin for the file, render counts and a fix; click it to open the
|
|
250
|
+
* source. Pages without component data still show the screenshot.
|
|
242
251
|
*/
|
|
243
252
|
export default function PageMap({ report, hideVendor, onOpenFile }) {
|
|
244
253
|
const { pageView } = report;
|
|
@@ -265,30 +274,23 @@ export default function PageMap({ report, hideVendor, onOpenFile }) {
|
|
|
265
274
|
<div className="empty">
|
|
266
275
|
<b>No page map for this report</b>
|
|
267
276
|
<span>
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
none.
|
|
271
|
-
</span>
|
|
272
|
-
</div>
|
|
273
|
-
);
|
|
274
|
-
if (!pins.length)
|
|
275
|
-
return (
|
|
276
|
-
<div className="empty">
|
|
277
|
-
<b>No slow components are visible on screen</b>
|
|
278
|
-
<span>
|
|
279
|
-
Nothing that rendered during the recording is currently on screen (or
|
|
280
|
-
everything is hidden by “Hide node_modules”).
|
|
277
|
+
Recordings from Spectra 0.5 on include a screenshot of every page.
|
|
278
|
+
This one was made with an earlier version, or the screenshot failed.
|
|
281
279
|
</span>
|
|
282
280
|
</div>
|
|
283
281
|
);
|
|
284
282
|
|
|
283
|
+
const fw = reportFramework(report);
|
|
285
284
|
return (
|
|
286
|
-
<div className=
|
|
285
|
+
<div className={`page-map${pins.length ? '' : ' no-pins'}`}>
|
|
287
286
|
<div className="page-stage">
|
|
288
287
|
<p className="muted page-caption">
|
|
289
|
-
Screenshot taken when the recording stopped.
|
|
290
|
-
|
|
291
|
-
|
|
288
|
+
Screenshot taken when the recording stopped.{' '}
|
|
289
|
+
{pins.length
|
|
290
|
+
? 'Numbers in the margins point to the components that spent the most time rendering and are on screen now; hover a number for details.'
|
|
291
|
+
: fw
|
|
292
|
+
? `None of the ${fw} components that did measurable work are on screen now${hideVendor ? ', or they are hidden by “Hide node_modules”' : ''}.`
|
|
293
|
+
: 'No React, Vue or Angular components were seen, so there is nothing to pin; the Culprits and File tree tabs show where the time went.'}
|
|
292
294
|
</p>
|
|
293
295
|
<Callouts
|
|
294
296
|
pins={pins}
|
|
@@ -299,6 +301,7 @@ export default function PageMap({ report, hideVendor, onOpenFile }) {
|
|
|
299
301
|
onOpenFile={onOpenFile}
|
|
300
302
|
/>
|
|
301
303
|
</div>
|
|
304
|
+
{pins.length > 0 && (
|
|
302
305
|
<aside className="page-list" aria-label="Components on this page">
|
|
303
306
|
<h2>Hot spots on this page</h2>
|
|
304
307
|
<ol>
|
|
@@ -324,6 +327,7 @@ export default function PageMap({ report, hideVendor, onOpenFile }) {
|
|
|
324
327
|
))}
|
|
325
328
|
</ol>
|
|
326
329
|
</aside>
|
|
330
|
+
)}
|
|
327
331
|
</div>
|
|
328
332
|
);
|
|
329
333
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { frameworkName } from '../framework.js';
|
|
1
2
|
import { useMemo, useState } from 'react';
|
|
2
3
|
import { fmtMs, fmtNumber, splitPath } from '../format.js';
|
|
3
4
|
|
|
@@ -101,10 +102,7 @@ export default function ReactPanel({ report, hideVendor, onOpenFile }) {
|
|
|
101
102
|
);
|
|
102
103
|
}, [react.components, sort, hideVendor]);
|
|
103
104
|
|
|
104
|
-
const fwLabel =
|
|
105
|
-
{ vue: 'Vue', svelte: 'Svelte', angular: 'Angular', next: 'React' }[
|
|
106
|
-
react.framework
|
|
107
|
-
] || 'React';
|
|
105
|
+
const fwLabel = frameworkName(react.framework) || 'React';
|
|
108
106
|
if (!react.detected) {
|
|
109
107
|
return (
|
|
110
108
|
<div className="empty">
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// "My app needs a sign-in": how Spectra's Chrome signs in to the app before
|
|
2
|
+
// profiling it. Every kind of web login has a method (server/signin.js does
|
|
3
|
+
// the work). The page URL, the method, the login page, the username and the
|
|
4
|
+
// selectors are remembered in this browser; passwords, codes, tokens and
|
|
5
|
+
// cookies never are: they live in this tab's memory only, until it reloads.
|
|
6
|
+
import { useState } from 'react';
|
|
7
|
+
import { ChevronIcon } from './Icons.jsx';
|
|
8
|
+
|
|
9
|
+
const KEY = 'perf-profiler:sign-in-v2';
|
|
10
|
+
const OLD_KEY = 'perf-profiler:sign-in'; // a sign-in URL template, before 0.5
|
|
11
|
+
|
|
12
|
+
export const SIGN_IN_METHODS = [
|
|
13
|
+
{
|
|
14
|
+
id: 'form',
|
|
15
|
+
title: 'Login form',
|
|
16
|
+
hint: 'A username and password typed into your app’s login page. One step or two, any identity provider, optional authenticator codes.',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: 'manual',
|
|
20
|
+
title: 'I’ll sign in myself',
|
|
21
|
+
hint: 'Chrome opens on the login page and waits for you. For SMS or email codes, passkeys, push approvals or CAPTCHAs.',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'http',
|
|
25
|
+
title: 'Browser prompt',
|
|
26
|
+
hint: 'The browser’s own username and password box (HTTP Basic or Digest), common on staging servers.',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'token',
|
|
30
|
+
title: 'Token or cookie',
|
|
31
|
+
hint: 'A header, cookies or localStorage entries your app already accepts, set before it loads.',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'link',
|
|
35
|
+
title: 'Sign-in link',
|
|
36
|
+
hint: 'A URL that signs in by itself, such as a dev-only auto-login.',
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
export const methodTitle = id => SIGN_IN_METHODS.find(m => m.id === id)?.title || 'Sign-in';
|
|
40
|
+
|
|
41
|
+
const PLAIN = ['enabled', 'method', 'url', 'username', 'waitMinutes', 'headerName', 'startSelector', 'usernameSelector', 'passwordSelector', 'submitSelector', 'codeSelector'];
|
|
42
|
+
const SECRET = ['password', 'totpSecret', 'headerValue', 'cookies', 'storage'];
|
|
43
|
+
const EMPTY = { enabled: false, method: 'form', url: '', username: '', waitMinutes: 5, headerName: '', startSelector: '', usernameSelector: '', passwordSelector: '', submitSelector: '', codeSelector: '' };
|
|
44
|
+
|
|
45
|
+
// secrets for this tab only: they survive closing and relaunching Chrome, not a reload
|
|
46
|
+
let secrets = { password: '', totpSecret: '', headerValue: '', cookies: '', storage: '' };
|
|
47
|
+
|
|
48
|
+
function load() {
|
|
49
|
+
try {
|
|
50
|
+
const saved = JSON.parse(localStorage.getItem(KEY) || 'null');
|
|
51
|
+
if (saved) return { ...EMPTY, ...saved };
|
|
52
|
+
const old = localStorage.getItem(OLD_KEY);
|
|
53
|
+
if (old) return { ...EMPTY, enabled: true, method: 'link', url: old };
|
|
54
|
+
} catch {
|
|
55
|
+
// storage blocked: start empty
|
|
56
|
+
}
|
|
57
|
+
return { ...EMPTY };
|
|
58
|
+
}
|
|
59
|
+
function save(value) {
|
|
60
|
+
try {
|
|
61
|
+
localStorage.setItem(KEY, JSON.stringify(Object.fromEntries(PLAIN.map(k => [k, value[k]]))));
|
|
62
|
+
localStorage.removeItem(OLD_KEY);
|
|
63
|
+
} catch {
|
|
64
|
+
// storage blocked: nothing is remembered
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** What is missing before this sign-in can work, or '' when it is ready. */
|
|
69
|
+
export function missing(v) {
|
|
70
|
+
if (!v.enabled) return '';
|
|
71
|
+
if (v.method === 'form' && !v.username && !v.password) return 'Add the username and password.';
|
|
72
|
+
if (v.method === 'http' && !v.username) return 'Add the username and password.';
|
|
73
|
+
if (v.method === 'link' && !v.url) return 'Add the sign-in link.';
|
|
74
|
+
if (v.method === 'token' && !(v.headerValue || v.cookies || v.storage)) return 'Add a header, a cookie or a storage entry.';
|
|
75
|
+
return '';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The sign-in to send to the server: null when it is off. */
|
|
79
|
+
export function toSignIn(v) {
|
|
80
|
+
if (!v.enabled) return null;
|
|
81
|
+
return {
|
|
82
|
+
method: v.method,
|
|
83
|
+
url: v.url,
|
|
84
|
+
username: v.username,
|
|
85
|
+
password: v.password,
|
|
86
|
+
totpSecret: v.totpSecret,
|
|
87
|
+
startSelector: v.startSelector,
|
|
88
|
+
usernameSelector: v.usernameSelector,
|
|
89
|
+
passwordSelector: v.passwordSelector,
|
|
90
|
+
submitSelector: v.submitSelector,
|
|
91
|
+
codeSelector: v.codeSelector,
|
|
92
|
+
headerName: v.headerName,
|
|
93
|
+
headerValue: v.headerValue,
|
|
94
|
+
cookies: v.cookies,
|
|
95
|
+
storage: v.storage,
|
|
96
|
+
waitMs: Math.max(1, Number(v.waitMinutes) || 5) * 60_000,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The sign-in form's state: [value, update(patch)]. */
|
|
101
|
+
export function useSignIn() {
|
|
102
|
+
const [value, setValue] = useState(() => ({ ...load(), ...secrets }));
|
|
103
|
+
const update = patch =>
|
|
104
|
+
setValue(v => {
|
|
105
|
+
const next = { ...v, ...patch };
|
|
106
|
+
secrets = Object.fromEntries(SECRET.map(k => [k, next[k]]));
|
|
107
|
+
save(next);
|
|
108
|
+
return next;
|
|
109
|
+
});
|
|
110
|
+
return [value, update];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function Text({ label, hint, value, onChange, type = 'text', placeholder, secret, wide }) {
|
|
114
|
+
return (
|
|
115
|
+
<label className={`field${wide ? ' wide' : ''}`} title={hint}>
|
|
116
|
+
<span>
|
|
117
|
+
{label}
|
|
118
|
+
{secret && (
|
|
119
|
+
<span className="secret-tag" title="Kept in this tab’s memory only: never saved, logged or put in a report.">
|
|
120
|
+
not saved
|
|
121
|
+
</span>
|
|
122
|
+
)}
|
|
123
|
+
</span>
|
|
124
|
+
<input
|
|
125
|
+
type={type}
|
|
126
|
+
value={value}
|
|
127
|
+
placeholder={placeholder}
|
|
128
|
+
onChange={e => onChange(e.target.value)}
|
|
129
|
+
autoComplete={secret ? 'off' : undefined}
|
|
130
|
+
spellCheck={false}
|
|
131
|
+
/>
|
|
132
|
+
</label>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function Selectors({ value, update }) {
|
|
137
|
+
const [open, setOpen] = useState(Boolean(value.usernameSelector || value.passwordSelector || value.submitSelector || value.startSelector || value.codeSelector || value.totpSecret));
|
|
138
|
+
const set = k => v => update({ [k]: v });
|
|
139
|
+
return (
|
|
140
|
+
<div className="signin-more">
|
|
141
|
+
<button type="button" className="ghost small" aria-expanded={open} onClick={() => setOpen(o => !o)}>
|
|
142
|
+
One-time codes and fields <ChevronIcon isUp={open} />
|
|
143
|
+
</button>
|
|
144
|
+
{open && (
|
|
145
|
+
<div className="signin-grid">
|
|
146
|
+
<Text
|
|
147
|
+
label="Authenticator secret"
|
|
148
|
+
hint="For apps that ask for a 6-digit code from an authenticator app: the base32 secret behind the QR code (Spectra makes the codes, RFC 6238). Codes sent by SMS or email need “I’ll sign in myself”."
|
|
149
|
+
value={value.totpSecret}
|
|
150
|
+
onChange={set('totpSecret')}
|
|
151
|
+
type="password"
|
|
152
|
+
placeholder="JBSWY3DPEHPK3PXP"
|
|
153
|
+
secret
|
|
154
|
+
wide
|
|
155
|
+
/>
|
|
156
|
+
<p className="muted wide">
|
|
157
|
+
Spectra finds the fields by itself, in iframes and web components too. When it can’t, point at them with
|
|
158
|
+
CSS selectors:
|
|
159
|
+
</p>
|
|
160
|
+
<Text label="Button that opens the form" value={value.startSelector} onChange={set('startSelector')} placeholder="#open-login" />
|
|
161
|
+
<Text label="Username field" value={value.usernameSelector} onChange={set('usernameSelector')} placeholder="input[name=email]" />
|
|
162
|
+
<Text label="Password field" value={value.passwordSelector} onChange={set('passwordSelector')} placeholder="input[name=password]" />
|
|
163
|
+
<Text label="Submit button" value={value.submitSelector} onChange={set('submitSelector')} placeholder="button[type=submit]" />
|
|
164
|
+
<Text label="Code field" value={value.codeSelector} onChange={set('codeSelector')} placeholder="input[name=otp]" />
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function Fields({ value, update }) {
|
|
172
|
+
const set = k => v => update({ [k]: v });
|
|
173
|
+
const loginPage = (
|
|
174
|
+
<Text
|
|
175
|
+
label="Login page (optional)"
|
|
176
|
+
hint="Where to sign in. Leave it empty to sign in wherever the app sends a signed-out visitor. {url} in it becomes the page to return to."
|
|
177
|
+
value={value.url}
|
|
178
|
+
onChange={set('url')}
|
|
179
|
+
type="url"
|
|
180
|
+
placeholder="https://app.example.com/login"
|
|
181
|
+
wide
|
|
182
|
+
/>
|
|
183
|
+
);
|
|
184
|
+
const user = <Text label="Username or email" value={value.username} onChange={set('username')} placeholder="me@example.com" />;
|
|
185
|
+
const password = <Text label="Password" value={value.password} onChange={set('password')} type="password" secret />;
|
|
186
|
+
switch (value.method) {
|
|
187
|
+
case 'form':
|
|
188
|
+
return (
|
|
189
|
+
<>
|
|
190
|
+
<div className="signin-grid">
|
|
191
|
+
{loginPage}
|
|
192
|
+
{user}
|
|
193
|
+
{password}
|
|
194
|
+
</div>
|
|
195
|
+
<Selectors value={value} update={update} />
|
|
196
|
+
</>
|
|
197
|
+
);
|
|
198
|
+
case 'manual':
|
|
199
|
+
return (
|
|
200
|
+
<div className="signin-grid">
|
|
201
|
+
{loginPage}
|
|
202
|
+
<Text label="Wait up to (minutes)" value={value.waitMinutes} onChange={set('waitMinutes')} type="number" />
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
case 'http':
|
|
206
|
+
return (
|
|
207
|
+
<div className="signin-grid">
|
|
208
|
+
{user}
|
|
209
|
+
{password}
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
case 'token':
|
|
213
|
+
return (
|
|
214
|
+
<div className="signin-grid">
|
|
215
|
+
<Text label="Header name" value={value.headerName} onChange={set('headerName')} placeholder="Authorization" />
|
|
216
|
+
<Text
|
|
217
|
+
label="Header value"
|
|
218
|
+
hint="Sent only with requests to the page’s own origin, never to third parties."
|
|
219
|
+
value={value.headerValue}
|
|
220
|
+
onChange={set('headerValue')}
|
|
221
|
+
type="password"
|
|
222
|
+
placeholder="Bearer eyJ…"
|
|
223
|
+
secret
|
|
224
|
+
/>
|
|
225
|
+
<Text label="Cookies" value={value.cookies} onChange={set('cookies')} type="password" placeholder="session=abc123; theme=dark" secret />
|
|
226
|
+
<Text
|
|
227
|
+
label="localStorage entries"
|
|
228
|
+
value={value.storage}
|
|
229
|
+
onChange={set('storage')}
|
|
230
|
+
type="password"
|
|
231
|
+
placeholder="auth_token=abc123"
|
|
232
|
+
secret
|
|
233
|
+
/>
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
default:
|
|
237
|
+
return (
|
|
238
|
+
<div className="signin-grid">
|
|
239
|
+
<Text
|
|
240
|
+
label="Sign-in link"
|
|
241
|
+
hint="Opened when the page asks for a login. {url} becomes the page to return to. Do not put tokens in it: it is remembered."
|
|
242
|
+
value={value.url}
|
|
243
|
+
onChange={set('url')}
|
|
244
|
+
placeholder="http://localhost:3000/dev-login?redirect={url}"
|
|
245
|
+
wide
|
|
246
|
+
/>
|
|
247
|
+
</div>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export default function SignInPanel({ value, update }) {
|
|
253
|
+
const method = SIGN_IN_METHODS.find(m => m.id === value.method) || SIGN_IN_METHODS[0];
|
|
254
|
+
const gap = missing(value);
|
|
255
|
+
return (
|
|
256
|
+
<div className={`signin${value.enabled ? ' on' : ''}`}>
|
|
257
|
+
<label className="switch">
|
|
258
|
+
<input type="checkbox" role="switch" checked={value.enabled} onChange={e => update({ enabled: e.target.checked })} />
|
|
259
|
+
<span>
|
|
260
|
+
<b>My app needs a sign-in</b>
|
|
261
|
+
<span className="muted"> · Chrome starts signed out, so Spectra signs in before it profiles</span>
|
|
262
|
+
</span>
|
|
263
|
+
</label>
|
|
264
|
+
{value.enabled && (
|
|
265
|
+
<div className="signin-body">
|
|
266
|
+
<div className="signin-methods" role="radiogroup" aria-label="How the app signs in">
|
|
267
|
+
{SIGN_IN_METHODS.map(m => (
|
|
268
|
+
<button
|
|
269
|
+
key={m.id}
|
|
270
|
+
type="button"
|
|
271
|
+
role="radio"
|
|
272
|
+
aria-checked={m.id === method.id}
|
|
273
|
+
className={`signin-method${m.id === method.id ? ' selected' : ''}`}
|
|
274
|
+
onClick={() => update({ method: m.id })}
|
|
275
|
+
>
|
|
276
|
+
{m.title}
|
|
277
|
+
</button>
|
|
278
|
+
))}
|
|
279
|
+
</div>
|
|
280
|
+
<p className="muted signin-hint">{method.hint}</p>
|
|
281
|
+
<Fields value={value} update={update} />
|
|
282
|
+
<p className="signin-note">
|
|
283
|
+
{gap ? <b>{gap} </b> : null}
|
|
284
|
+
Passwords, codes, tokens and cookies stay in this tab’s memory and in Spectra’s: never saved to disk, logged
|
|
285
|
+
or put in a report. Use a test account where you can.
|
|
286
|
+
</p>
|
|
287
|
+
</div>
|
|
288
|
+
)}
|
|
289
|
+
</div>
|
|
290
|
+
);
|
|
291
|
+
}
|
package/src/framework.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// What to call the component framework a page or report uses. Spectra is not
|
|
2
|
+
// tied to React: Vue and Angular get the same component-level data, and any
|
|
3
|
+
// other app still gets files and functions, so labels name the framework that
|
|
4
|
+
// was actually seen, or stay neutral ("components").
|
|
5
|
+
export const FRAMEWORK_NAMES = { react: 'React', next: 'Next.js', vue: 'Vue', angular: 'Angular', svelte: 'Svelte' };
|
|
6
|
+
|
|
7
|
+
/** A framework id ('react', 'vue', …) as a display name; '' when unknown. */
|
|
8
|
+
export const frameworkName = id => FRAMEWORK_NAMES[id] || '';
|
|
9
|
+
|
|
10
|
+
/** The framework a report's component data came from, as a display name ('' when none). */
|
|
11
|
+
export const reportFramework = report =>
|
|
12
|
+
report?.react?.detected ? frameworkName(report.react.framework || 'react') || 'React' : '';
|
package/src/heat.js
CHANGED
|
@@ -15,9 +15,9 @@ export const FILE_METRICS = [
|
|
|
15
15
|
},
|
|
16
16
|
{
|
|
17
17
|
id: 'reactMs',
|
|
18
|
-
label: '
|
|
18
|
+
label: 'Component render time',
|
|
19
19
|
unit: 'ms',
|
|
20
|
-
hint: 'Time
|
|
20
|
+
hint: 'Time spent rendering components defined in this file (self time), in React, Vue or Angular.',
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
23
|
id: 'wasted',
|
|
@@ -27,7 +27,7 @@ export const FILE_METRICS = [
|
|
|
27
27
|
},
|
|
28
28
|
{
|
|
29
29
|
id: 'renders',
|
|
30
|
-
label: '
|
|
30
|
+
label: 'Component renders',
|
|
31
31
|
unit: '',
|
|
32
32
|
hint: 'How many times components in this file rendered.',
|
|
33
33
|
},
|
package/src/styles.css
CHANGED
|
@@ -550,6 +550,93 @@ label.switch {
|
|
|
550
550
|
flex: 1;
|
|
551
551
|
min-width: 16rem;
|
|
552
552
|
}
|
|
553
|
+
|
|
554
|
+
/* ---- sign-in ---- */
|
|
555
|
+
.signin {
|
|
556
|
+
display: grid;
|
|
557
|
+
gap: 0.6rem;
|
|
558
|
+
padding: 0.6rem 0.7rem;
|
|
559
|
+
border: 1px solid var(--border);
|
|
560
|
+
border-radius: var(--radius);
|
|
561
|
+
background: var(--surface-2);
|
|
562
|
+
}
|
|
563
|
+
.signin.on {
|
|
564
|
+
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
|
565
|
+
}
|
|
566
|
+
.signin > .switch {
|
|
567
|
+
align-items: flex-start;
|
|
568
|
+
}
|
|
569
|
+
.signin > .switch b {
|
|
570
|
+
color: var(--text);
|
|
571
|
+
font-weight: 600;
|
|
572
|
+
}
|
|
573
|
+
.signin-body {
|
|
574
|
+
display: grid;
|
|
575
|
+
gap: 0.55rem;
|
|
576
|
+
}
|
|
577
|
+
.signin-methods {
|
|
578
|
+
display: flex;
|
|
579
|
+
flex-wrap: wrap;
|
|
580
|
+
gap: 0.35rem;
|
|
581
|
+
}
|
|
582
|
+
.signin-method {
|
|
583
|
+
min-height: 1.8rem;
|
|
584
|
+
padding: 0.2rem 0.75rem;
|
|
585
|
+
border-radius: 999px;
|
|
586
|
+
color: var(--text-2);
|
|
587
|
+
}
|
|
588
|
+
.signin-method.selected,
|
|
589
|
+
.signin-method.selected:hover {
|
|
590
|
+
background: var(--accent-soft);
|
|
591
|
+
border-color: var(--accent);
|
|
592
|
+
color: var(--accent);
|
|
593
|
+
font-weight: 600;
|
|
594
|
+
}
|
|
595
|
+
.signin-hint {
|
|
596
|
+
margin: 0;
|
|
597
|
+
}
|
|
598
|
+
.signin-grid {
|
|
599
|
+
display: grid;
|
|
600
|
+
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
|
601
|
+
gap: 0.5rem 0.6rem;
|
|
602
|
+
align-items: end;
|
|
603
|
+
}
|
|
604
|
+
.signin-grid .wide {
|
|
605
|
+
grid-column: 1 / -1;
|
|
606
|
+
}
|
|
607
|
+
.signin-grid p {
|
|
608
|
+
margin: 0.2rem 0 0;
|
|
609
|
+
}
|
|
610
|
+
.signin-more {
|
|
611
|
+
display: grid;
|
|
612
|
+
gap: 0.5rem;
|
|
613
|
+
justify-items: start;
|
|
614
|
+
}
|
|
615
|
+
.signin-more > .signin-grid {
|
|
616
|
+
width: 100%;
|
|
617
|
+
}
|
|
618
|
+
button.small {
|
|
619
|
+
min-height: 1.6rem;
|
|
620
|
+
padding: 0.1rem 0.5rem;
|
|
621
|
+
font-size: 0.75rem;
|
|
622
|
+
}
|
|
623
|
+
.secret-tag {
|
|
624
|
+
margin-left: 0.4rem;
|
|
625
|
+
padding: 0 0.4rem;
|
|
626
|
+
border-radius: 999px;
|
|
627
|
+
border: 1px solid var(--border);
|
|
628
|
+
font-size: 0.65rem;
|
|
629
|
+
color: var(--text-muted);
|
|
630
|
+
vertical-align: 1px;
|
|
631
|
+
}
|
|
632
|
+
.signin-note {
|
|
633
|
+
margin: 0;
|
|
634
|
+
font-size: 0.72rem;
|
|
635
|
+
color: var(--text-muted);
|
|
636
|
+
}
|
|
637
|
+
.signin-note b {
|
|
638
|
+
color: var(--warn, var(--text));
|
|
639
|
+
}
|
|
553
640
|
.pill {
|
|
554
641
|
display: inline-flex;
|
|
555
642
|
align-items: center;
|
|
@@ -1699,6 +1786,10 @@ svg.change-bar rect {
|
|
|
1699
1786
|
padding: 1rem 1.2rem;
|
|
1700
1787
|
align-items: start;
|
|
1701
1788
|
}
|
|
1789
|
+
/* a page without components to pin: the screenshot takes the whole width */
|
|
1790
|
+
.page-map.no-pins {
|
|
1791
|
+
grid-template-columns: minmax(0, 1fr);
|
|
1792
|
+
}
|
|
1702
1793
|
.page-caption {
|
|
1703
1794
|
margin: 0 0 0.6rem;
|
|
1704
1795
|
}
|