@imenam/simple-scraper 1.0.27 → 1.0.28
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 +11 -5
- package/dist/browser.js +8 -1
- package/dist/gui.js +8 -0
- package/dist/login.js +35 -17
- package/dist/remote-input.js +67 -0
- package/dist/rpc.js +29 -4
- package/dist/signal-picker.js +144 -0
- package/package.json +1 -1
- package/public/dist/assets/{index-olIqry-_.css → index-BAs4dkz0.css} +1 -1
- package/public/dist/assets/index-D44p8v3x.js +11 -0
- package/public/dist/index.html +2 -2
- package/public/dist/assets/index-CmU4YRqF.js +0 -11
package/README.md
CHANGED
|
@@ -396,11 +396,17 @@ A **site** is an account the scraper uses while logged in: a name, the domains i
|
|
|
396
396
|
The sign tells the scraper whether a page is seen logged in:
|
|
397
397
|
|
|
398
398
|
- **L'adresse contient** (the address contains): the page address (host and path, without query or anchor) contains the value, e.g. `crm.example.com/app`;
|
|
399
|
-
- **Un élément est présent** (an element is present): a
|
|
399
|
+
- **Un élément est présent** (an element is present): a selector that only matches once logged in, e.g. `.user-menu`. Puppeteer's extended selectors are accepted, so an element can be matched by its text: `.user-menu span::-p-text(Galadrim)`.
|
|
400
|
+
|
|
401
|
+
**Choisir sur la page** opens the visited page as the scraper sees it logged in; clicking an element (your name, for instance) proposes selectors for it, from the most precise (its text inside its container) to the most general. **Tester** checks the sign as it is typed. Both check each selector twice: it must be found on the visited page with the site's cookies, and absent from the same page without cookies; a selector passing both is a reliable login sign.
|
|
400
402
|
|
|
401
403
|
### Assisted login
|
|
402
404
|
|
|
403
|
-
**Se reconnecter** opens a
|
|
405
|
+
**Se reconnecter** opens the site's login page in a headless Chromium on a blank temporary profile, on the machine running the MCP, and shows it in the GUI. You log in there yourself (credentials, two-factor authentication, SSO): clicks, typing, pasting and scrolling on the capture are replayed in the page, and the address bar allows going back or opening another address. No display is needed, so this works the same on a remote server. As soon as the login sign appears, the browser's cookies are saved for the site and it closes; **Je suis connecté** saves them without waiting for the sign. **Tout reconnecter** chains every site to reconnect, one after the other.
|
|
406
|
+
|
|
407
|
+
What you type travels from the GUI to the MCP (through the proxy when the GUI is reached remotely, which should then be served over HTTPS); it is never stored. Some sites detect headless browsers and may show a captcha or refuse the login.
|
|
408
|
+
|
|
409
|
+
Every browser of the scraper starts with Chromium's sandbox disabled (`--no-sandbox`): under a service account, on a Linux that restricts unprivileged user namespaces (Ubuntu 23.10+ with AppArmor), the sandbox cannot start.
|
|
404
410
|
|
|
405
411
|
### Keep-alive
|
|
406
412
|
|
|
@@ -422,9 +428,9 @@ Netscape cookie files (`.txt`) placed in this folder are not loaded. To start fr
|
|
|
422
428
|
|
|
423
429
|
When `PROXY_URL` is set, a web interface is registered with the MCP proxy:
|
|
424
430
|
|
|
425
|
-
- **Sites** — every site with its state (logged in, to reconnect, not checked), its session and its keep-alive; banner and **Tout reconnecter** when sessions are lost; assisted login
|
|
426
|
-
- **Site page** — domains, login page, login sign, keep-alive (interval, visited page), recent activity, cookie import and **Effacer la session**.
|
|
427
|
-
- **Sessions** — the sessions opened by the agent, with a live view (
|
|
431
|
+
- **Sites** — every site with its state (logged in, to reconnect, not checked), its session and its keep-alive; banner and **Tout reconnecter** when sessions are lost; assisted login in a remotely controlled view of the login page, which can be shown full screen.
|
|
432
|
+
- **Site page** — domains, login page, login sign (picked on the page and tested), keep-alive (interval, visited page), recent activity, cookie import and **Effacer la session**.
|
|
433
|
+
- **Sessions** — the sessions opened by the agent, with a live view (refresh rate selectable from 0.5 s to 10 s, 2.5 s by default; can be paused or shown full screen), the last action, the matching site and the console messages.
|
|
428
434
|
- **Activity** — the history of every site, filterable by site.
|
|
429
435
|
|
|
430
436
|
The interface refreshes by polling: no connection stays open.
|
package/dist/browser.js
CHANGED
|
@@ -29,6 +29,13 @@ export function getDefaultViewport() {
|
|
|
29
29
|
deviceScaleFactor: envNumber('PUPPETEER_DEVICE_SCALE_FACTOR', 1),
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Arguments de lancement de tous les navigateurs du scraper. La sandbox de
|
|
34
|
+
* Chromium est désactivée : sous un compte de service, sur un Linux qui
|
|
35
|
+
* restreint les espaces de noms utilisateur (Ubuntu 23.10+ avec AppArmor),
|
|
36
|
+
* elle ne peut pas démarrer.
|
|
37
|
+
*/
|
|
38
|
+
export const BROWSER_LAUNCH_ARGS = ['--no-sandbox', '--disable-setuid-sandbox'];
|
|
32
39
|
/** Lance le navigateur partagé et y charge les cookies de tous les sites. */
|
|
33
40
|
async function launchBrowser() {
|
|
34
41
|
const cookies = getStore()?.allCookies() ?? [];
|
|
@@ -36,7 +43,7 @@ async function launchBrowser() {
|
|
|
36
43
|
const launched = await puppeteer.launch({
|
|
37
44
|
headless,
|
|
38
45
|
defaultViewport: getDefaultViewport(),
|
|
39
|
-
args:
|
|
46
|
+
args: BROWSER_LAUNCH_ARGS,
|
|
40
47
|
});
|
|
41
48
|
if (cookies.length)
|
|
42
49
|
await launched.setCookie(...cookies);
|
package/dist/gui.js
CHANGED
|
@@ -148,7 +148,15 @@ app.post('/api/login', (req, res) => {
|
|
|
148
148
|
});
|
|
149
149
|
app.get('/api/login', (_req, res) => forward(res, 'LOGIN_STATUS'));
|
|
150
150
|
app.get('/api/login/screenshot', (_req, res) => sendJpeg(res, 'LOGIN_SCREENSHOT'));
|
|
151
|
+
app.post('/api/login/input', (req, res) => forward(res, 'LOGIN_INPUT', { action: req.body.action }, 45_000));
|
|
151
152
|
app.post('/api/login/confirm', (_req, res) => forward(res, 'LOGIN_CONFIRM', undefined, 15_000));
|
|
153
|
+
// ─── Choix du signe de connexion ──────────────────────────────────────────────
|
|
154
|
+
app.post('/api/picker', (req, res) => forward(res, 'PICKER_OPEN', { pageUrl: req.body.pageUrl }, 45_000));
|
|
155
|
+
app.get('/api/picker/screenshot', (_req, res) => sendJpeg(res, 'PICKER_SCREENSHOT'));
|
|
156
|
+
app.post('/api/picker/scroll', (req, res) => forward(res, 'PICKER_SCROLL', { dy: req.body.dy }));
|
|
157
|
+
app.post('/api/picker/pick', (req, res) => forward(res, 'PICKER_PICK', { x: req.body.x, y: req.body.y }, 60_000));
|
|
158
|
+
app.post('/api/picker/close', (_req, res) => forward(res, 'PICKER_CLOSE'));
|
|
159
|
+
app.post('/api/signal/test', (req, res) => forward(res, 'SIGNAL_TEST', { pageUrl: req.body.pageUrl, signal: req.body.signal }, 75_000));
|
|
152
160
|
app.post('/api/login/cancel', (_req, res) => forward(res, 'LOGIN_CANCEL', undefined, 15_000));
|
|
153
161
|
// ─── Sessions ─────────────────────────────────────────────────────────────────
|
|
154
162
|
app.get('/api/sessions', (_req, res) => forward(res, 'SESSION_LIST'));
|
package/dist/login.js
CHANGED
|
@@ -2,18 +2,28 @@ import fs from 'fs';
|
|
|
2
2
|
import os from 'os';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import puppeteer from 'puppeteer';
|
|
5
|
+
import { BROWSER_LAUNCH_ARGS, getDefaultViewport } from './browser.js';
|
|
5
6
|
import { replaceSiteCookies } from './cookie-sync.js';
|
|
7
|
+
import { applyRemoteAction } from './remote-input.js';
|
|
6
8
|
import { pageShowsSignal } from './site-check.js';
|
|
7
9
|
import { cookieCountLabel, requireStore } from './site-store.js';
|
|
8
10
|
const POLL_MS = 1_000;
|
|
9
11
|
let active = null;
|
|
10
12
|
let last = null;
|
|
11
|
-
/** Onglet le plus récent
|
|
13
|
+
/** Onglet le plus récent du navigateur de connexion. */
|
|
12
14
|
async function currentPage(browser) {
|
|
13
15
|
const pages = (await browser.pages()).filter(p => !p.isClosed());
|
|
14
16
|
return pages.length ? pages[pages.length - 1] : null;
|
|
15
17
|
}
|
|
16
|
-
|
|
18
|
+
async function requirePage() {
|
|
19
|
+
if (!active)
|
|
20
|
+
throw new Error('Aucune connexion en cours.');
|
|
21
|
+
const page = await currentPage(active.browser);
|
|
22
|
+
if (!page)
|
|
23
|
+
throw new Error('Le navigateur de connexion n\'a aucun onglet.');
|
|
24
|
+
return page;
|
|
25
|
+
}
|
|
26
|
+
/** Ferme le navigateur de connexion et supprime son profil temporaire. */
|
|
17
27
|
async function teardown(login) {
|
|
18
28
|
clearInterval(login.timer);
|
|
19
29
|
await login.browser.close().catch(() => undefined);
|
|
@@ -25,7 +35,7 @@ function settle(login, patch) {
|
|
|
25
35
|
if (active === login)
|
|
26
36
|
active = null;
|
|
27
37
|
}
|
|
28
|
-
/** Récupère les cookies
|
|
38
|
+
/** Récupère les cookies du navigateur de connexion et les enregistre pour le site. */
|
|
29
39
|
async function finish(login) {
|
|
30
40
|
if (login.status.phase !== 'waiting')
|
|
31
41
|
return;
|
|
@@ -34,7 +44,7 @@ async function finish(login) {
|
|
|
34
44
|
try {
|
|
35
45
|
const cookies = await login.browser.cookies();
|
|
36
46
|
if (!cookies.length)
|
|
37
|
-
throw new Error('
|
|
47
|
+
throw new Error('Le navigateur de connexion ne contient aucun cookie.');
|
|
38
48
|
const count = await replaceSiteCookies(login.status.siteId, cookies);
|
|
39
49
|
const store = requireStore();
|
|
40
50
|
const now = Date.now();
|
|
@@ -53,7 +63,7 @@ async function finish(login) {
|
|
|
53
63
|
}
|
|
54
64
|
await teardown(login);
|
|
55
65
|
}
|
|
56
|
-
/** Surveille la
|
|
66
|
+
/** Surveille la page : connexion terminée quand le signe du site apparaît. */
|
|
57
67
|
async function poll(login) {
|
|
58
68
|
if (login.status.phase !== 'waiting')
|
|
59
69
|
return;
|
|
@@ -72,9 +82,10 @@ async function poll(login) {
|
|
|
72
82
|
await finish(login);
|
|
73
83
|
}
|
|
74
84
|
/**
|
|
75
|
-
* Ouvre
|
|
76
|
-
*
|
|
77
|
-
*
|
|
85
|
+
* Ouvre un navigateur sans fenêtre, sur un profil vierge, à la page de connexion
|
|
86
|
+
* du site. L'utilisateur s'y connecte depuis l'interface, qui affiche la page et
|
|
87
|
+
* lui relaie ses clics et sa frappe ; aucun écran n'est nécessaire. Les cookies
|
|
88
|
+
* sont enregistrés pour le site dès que le signe de connexion apparaît.
|
|
78
89
|
*/
|
|
79
90
|
export async function startLogin(siteId) {
|
|
80
91
|
if (active)
|
|
@@ -83,7 +94,12 @@ export async function startLogin(siteId) {
|
|
|
83
94
|
if (!site.loginUrl)
|
|
84
95
|
throw new Error(`Le site "${site.name}" n'a pas de page de connexion.`);
|
|
85
96
|
const profileDir = fs.mkdtempSync(path.join(os.tmpdir(), 'simple-scraper-login-'));
|
|
86
|
-
const browser = await puppeteer.launch({
|
|
97
|
+
const browser = await puppeteer.launch({
|
|
98
|
+
headless: true,
|
|
99
|
+
defaultViewport: getDefaultViewport(),
|
|
100
|
+
userDataDir: profileDir,
|
|
101
|
+
args: BROWSER_LAUNCH_ARGS,
|
|
102
|
+
});
|
|
87
103
|
const page = (await browser.pages())[0] ?? (await browser.newPage());
|
|
88
104
|
const login = {
|
|
89
105
|
status: { siteId, phase: 'waiting', url: site.loginUrl, error: null, cookieCount: null, startedAt: Date.now() },
|
|
@@ -105,7 +121,6 @@ export async function startLogin(siteId) {
|
|
|
105
121
|
settle(login, { phase: 'error', error: err instanceof Error ? err.message : String(err) });
|
|
106
122
|
void teardown(login);
|
|
107
123
|
});
|
|
108
|
-
await page.bringToFront();
|
|
109
124
|
return login.status;
|
|
110
125
|
}
|
|
111
126
|
/** Connexion en cours, ou la dernière terminée. */
|
|
@@ -127,13 +142,16 @@ export async function cancelLogin() {
|
|
|
127
142
|
settle(login, { phase: 'cancelled' });
|
|
128
143
|
await teardown(login);
|
|
129
144
|
}
|
|
130
|
-
/**
|
|
145
|
+
/** Rejoue dans la page de connexion un clic, une frappe ou une navigation de l'utilisateur. */
|
|
146
|
+
export async function loginInput(action) {
|
|
147
|
+
const page = await requirePage();
|
|
148
|
+
await applyRemoteAction(page, action);
|
|
149
|
+
if (active)
|
|
150
|
+
active.status.url = page.url();
|
|
151
|
+
}
|
|
152
|
+
/** Capture JPEG de l'onglet courant du navigateur de connexion. */
|
|
131
153
|
export async function loginScreenshot() {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const page = await currentPage(active.browser);
|
|
135
|
-
if (!page)
|
|
136
|
-
throw new Error('La fenêtre de connexion n\'a aucun onglet.');
|
|
137
|
-
const buffer = await page.screenshot({ type: 'jpeg', quality: 60 });
|
|
154
|
+
const page = await requirePage();
|
|
155
|
+
const buffer = await page.screenshot({ type: 'jpeg', quality: 70 });
|
|
138
156
|
return Buffer.from(buffer).toString('base64');
|
|
139
157
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getDefaultTimeout } from './browser.js';
|
|
3
|
+
/**
|
|
4
|
+
* Action de l'utilisateur sur une page affichée dans l'interface. Les
|
|
5
|
+
* coordonnées d'un clic sont des fractions (0 à 1) de la capture, donc
|
|
6
|
+
* indépendantes de la taille à laquelle l'interface l'affiche.
|
|
7
|
+
*/
|
|
8
|
+
export const remoteActionSchema = z.discriminatedUnion('type', [
|
|
9
|
+
z.object({ type: z.literal('click'), x: z.number().min(0).max(1), y: z.number().min(0).max(1) }),
|
|
10
|
+
z.object({ type: z.literal('scroll'), dy: z.number().min(-5000).max(5000) }),
|
|
11
|
+
z.object({ type: z.literal('text'), text: z.string().min(1).max(2000) }),
|
|
12
|
+
z.object({
|
|
13
|
+
type: z.literal('key'),
|
|
14
|
+
key: z.string().min(1).max(32),
|
|
15
|
+
modifiers: z.array(z.enum(['Control', 'Shift', 'Alt', 'Meta'])).optional(),
|
|
16
|
+
}),
|
|
17
|
+
z.object({ type: z.literal('navigate'), url: z.url() }),
|
|
18
|
+
z.object({ type: z.literal('back') }),
|
|
19
|
+
]);
|
|
20
|
+
export function parseRemoteAction(data) {
|
|
21
|
+
const result = remoteActionSchema.safeParse(data);
|
|
22
|
+
if (!result.success)
|
|
23
|
+
throw new Error(`Action invalide : ${result.error.issues.map(i => i.message).join(' ')}`);
|
|
24
|
+
return result.data;
|
|
25
|
+
}
|
|
26
|
+
/** Point de la page, en pixels CSS, désigné par des fractions de sa capture. */
|
|
27
|
+
export function pointOf(page, x, y) {
|
|
28
|
+
const viewport = page.viewport();
|
|
29
|
+
if (!viewport)
|
|
30
|
+
throw new Error('La page n\'a pas de taille d\'affichage fixe.');
|
|
31
|
+
return { x: x * viewport.width, y: y * viewport.height };
|
|
32
|
+
}
|
|
33
|
+
/** Rejoue l'action dans la page. */
|
|
34
|
+
export async function applyRemoteAction(page, action) {
|
|
35
|
+
switch (action.type) {
|
|
36
|
+
case 'click': {
|
|
37
|
+
const point = pointOf(page, action.x, action.y);
|
|
38
|
+
await page.mouse.click(point.x, point.y);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
case 'scroll':
|
|
42
|
+
await page.mouse.wheel({ deltaY: action.dy });
|
|
43
|
+
return;
|
|
44
|
+
case 'text':
|
|
45
|
+
await page.keyboard.type(action.text);
|
|
46
|
+
return;
|
|
47
|
+
case 'key': {
|
|
48
|
+
const modifiers = action.modifiers ?? [];
|
|
49
|
+
for (const m of modifiers)
|
|
50
|
+
await page.keyboard.down(m);
|
|
51
|
+
try {
|
|
52
|
+
await page.keyboard.press(action.key);
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
for (const m of modifiers.slice().reverse())
|
|
56
|
+
await page.keyboard.up(m);
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
case 'navigate':
|
|
61
|
+
await page.goto(action.url, { waitUntil: 'domcontentloaded', timeout: getDefaultTimeout() });
|
|
62
|
+
return;
|
|
63
|
+
case 'back':
|
|
64
|
+
await page.goBack({ waitUntil: 'domcontentloaded', timeout: getDefaultTimeout() });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
}
|
package/dist/rpc.js
CHANGED
|
@@ -3,17 +3,20 @@ import { clearSiteCookies, replaceSiteCookies } from './cookie-sync.js';
|
|
|
3
3
|
import { parseNetscapeCookies } from './cookies.js';
|
|
4
4
|
import { getDataDir } from './data-dir.js';
|
|
5
5
|
import { nextKeepAliveAt, nextVisitAt } from './keep-alive.js';
|
|
6
|
-
import { cancelLogin, confirmLogin, getLoginStatus, loginScreenshot, startLogin } from './login.js';
|
|
6
|
+
import { cancelLogin, confirmLogin, getLoginStatus, loginInput, loginScreenshot, startLogin } from './login.js';
|
|
7
|
+
import { parseRemoteAction } from './remote-input.js';
|
|
8
|
+
import { closePicker, openPicker, pickAt, pickerScreenshot, pickerScroll, testSignal } from './signal-picker.js';
|
|
7
9
|
import { checkSite, isChecking } from './site-check.js';
|
|
8
10
|
import { cookieCountLabel, getStore, requireStore } from './site-store.js';
|
|
9
11
|
import { closeAllSessions, closeSession, getSessionLogs, listSessions, peekSession } from './sessions.js';
|
|
12
|
+
const signalSchema = z.object({ kind: z.enum(['url', 'selector']), value: z.string().trim() });
|
|
10
13
|
const siteConfigSchema = z.object({
|
|
11
14
|
name: z.string().trim().min(1, 'Le nom est requis.'),
|
|
12
15
|
domains: z
|
|
13
16
|
.array(z.string().trim().toLowerCase().regex(/^[a-z0-9.-]+$/, 'Domaine invalide.'))
|
|
14
17
|
.min(1, 'Au moins un domaine est requis.'),
|
|
15
18
|
loginUrl: z.union([z.literal(''), z.url('Page de connexion invalide.')]),
|
|
16
|
-
signal:
|
|
19
|
+
signal: signalSchema,
|
|
17
20
|
keepAlive: z.object({
|
|
18
21
|
enabled: z.boolean(),
|
|
19
22
|
intervalMin: z.number().int().min(1).max(24 * 60),
|
|
@@ -21,12 +24,18 @@ const siteConfigSchema = z.object({
|
|
|
21
24
|
}),
|
|
22
25
|
flagWhenLost: z.boolean(),
|
|
23
26
|
});
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
/** Valide une donnée de la GUI ; l'erreur reprend les messages du schéma. */
|
|
28
|
+
function parse(schema, data) {
|
|
29
|
+
const result = schema.safeParse(data);
|
|
26
30
|
if (!result.success)
|
|
27
31
|
throw new Error(result.error.issues.map(i => i.message).join(' '));
|
|
28
32
|
return result.data;
|
|
29
33
|
}
|
|
34
|
+
const pageUrlSchema = z.url('Page visitée invalide.');
|
|
35
|
+
const ratioSchema = z.number().min(0).max(1);
|
|
36
|
+
function parseSiteConfig(data) {
|
|
37
|
+
return parse(siteConfigSchema, data);
|
|
38
|
+
}
|
|
30
39
|
/** Site enrichi de ce que l'interface affiche à côté de ses réglages. */
|
|
31
40
|
function siteView(site) {
|
|
32
41
|
const store = requireStore();
|
|
@@ -116,11 +125,27 @@ const handlers = {
|
|
|
116
125
|
LOGIN_START: ({ siteId }) => startLogin(siteId),
|
|
117
126
|
LOGIN_STATUS: () => getLoginStatus(),
|
|
118
127
|
LOGIN_SCREENSHOT: async () => ({ base64: await loginScreenshot() }),
|
|
128
|
+
LOGIN_INPUT: async ({ action }) => {
|
|
129
|
+
await loginInput(parseRemoteAction(action));
|
|
130
|
+
return getLoginStatus();
|
|
131
|
+
},
|
|
119
132
|
LOGIN_CONFIRM: () => confirmLogin(),
|
|
120
133
|
LOGIN_CANCEL: async () => {
|
|
121
134
|
await cancelLogin();
|
|
122
135
|
return null;
|
|
123
136
|
},
|
|
137
|
+
PICKER_OPEN: ({ pageUrl }) => openPicker(parse(pageUrlSchema, pageUrl)),
|
|
138
|
+
PICKER_SCREENSHOT: async () => ({ base64: await pickerScreenshot() }),
|
|
139
|
+
PICKER_SCROLL: async ({ dy }) => {
|
|
140
|
+
await pickerScroll(parse(z.number().min(-5000).max(5000), dy));
|
|
141
|
+
return null;
|
|
142
|
+
},
|
|
143
|
+
PICKER_PICK: ({ x, y }) => pickAt(parse(ratioSchema, x), parse(ratioSchema, y)),
|
|
144
|
+
PICKER_CLOSE: async () => {
|
|
145
|
+
await closePicker();
|
|
146
|
+
return null;
|
|
147
|
+
},
|
|
148
|
+
SIGNAL_TEST: ({ pageUrl, signal }) => testSignal(parse(pageUrlSchema, pageUrl), parse(signalSchema, signal)),
|
|
124
149
|
SESSION_LIST: () => sessionViews(),
|
|
125
150
|
SESSION_SCREENSHOT: async ({ sessionId }) => {
|
|
126
151
|
const buffer = await peekSession(sessionId).screenshot({ type: 'jpeg', quality: 60 });
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { getBrowser, getDefaultTimeout } from './browser.js';
|
|
2
|
+
import { applyRemoteAction, pointOf } from './remote-input.js';
|
|
3
|
+
import { pageShowsSignal, urlMatchesSignal } from './site-check.js';
|
|
4
|
+
const SIGNAL_WAIT_MS = 3_000;
|
|
5
|
+
let picker = null;
|
|
6
|
+
function requirePicker() {
|
|
7
|
+
if (!picker || picker.page.isClosed())
|
|
8
|
+
throw new Error('Aucune page ouverte pour choisir le signe de connexion.');
|
|
9
|
+
return picker;
|
|
10
|
+
}
|
|
11
|
+
/** Ouvre la page visitée du site dans le navigateur partagé, donc avec les cookies des sites. */
|
|
12
|
+
export async function openPicker(pageUrl) {
|
|
13
|
+
await closePicker();
|
|
14
|
+
const page = await (await getBrowser()).newPage();
|
|
15
|
+
picker = { page, pageUrl };
|
|
16
|
+
await page.goto(pageUrl, { waitUntil: 'networkidle2', timeout: getDefaultTimeout() });
|
|
17
|
+
return { url: page.url() };
|
|
18
|
+
}
|
|
19
|
+
export async function closePicker() {
|
|
20
|
+
const current = picker;
|
|
21
|
+
picker = null;
|
|
22
|
+
await current?.page.close().catch(() => undefined);
|
|
23
|
+
}
|
|
24
|
+
export async function pickerScreenshot() {
|
|
25
|
+
const buffer = await requirePicker().page.screenshot({ type: 'jpeg', quality: 70 });
|
|
26
|
+
return Buffer.from(buffer).toString('base64');
|
|
27
|
+
}
|
|
28
|
+
/** Fait défiler la page du sélecteur ; seul le défilement y est relayé. */
|
|
29
|
+
export async function pickerScroll(dy) {
|
|
30
|
+
const action = { type: 'scroll', dy };
|
|
31
|
+
await applyRemoteAction(requirePicker().page, action);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Sélecteurs désignant l'élément situé au point donné, du plus précis au plus
|
|
35
|
+
* général : son texte dans son conteneur repérable, son chemin depuis ce
|
|
36
|
+
* conteneur, le conteneur seul, l'élément seul.
|
|
37
|
+
*/
|
|
38
|
+
function candidatesAt(x, y) {
|
|
39
|
+
const target = document.elementFromPoint(x, y);
|
|
40
|
+
if (!target)
|
|
41
|
+
return [];
|
|
42
|
+
const stable = (name) => /^[a-zA-Z_][\w-]*$/.test(name) &&
|
|
43
|
+
!/\d{3,}/.test(name) &&
|
|
44
|
+
!/^(active|open|show|hover|focus|focused|in|collapsed|selected|disabled|is-|has-|ng-)/.test(name);
|
|
45
|
+
const count = (selector) => document.querySelectorAll(selector).length;
|
|
46
|
+
/** Classe stable de l'élément la plus rare dans la page ; à égalité, la plus longue, souvent la plus parlante. */
|
|
47
|
+
const ownClass = (el) => Array.from(el.classList)
|
|
48
|
+
.filter(stable)
|
|
49
|
+
.sort((a, b) => count(`.${CSS.escape(a)}`) - count(`.${CSS.escape(b)}`) || b.length - a.length)[0];
|
|
50
|
+
const selfSel = (el) => {
|
|
51
|
+
if (el.id && stable(el.id))
|
|
52
|
+
return `#${CSS.escape(el.id)}`;
|
|
53
|
+
const cls = ownClass(el);
|
|
54
|
+
return cls ? `.${CSS.escape(cls)}` : null;
|
|
55
|
+
};
|
|
56
|
+
const part = (el) => {
|
|
57
|
+
if (el.id && stable(el.id))
|
|
58
|
+
return `#${CSS.escape(el.id)}`;
|
|
59
|
+
const cls = ownClass(el);
|
|
60
|
+
return el.tagName.toLowerCase() + (cls ? `.${CSS.escape(cls)}` : '');
|
|
61
|
+
};
|
|
62
|
+
// Conteneur : l'ancêtre repérable le plus proche qui soit unique dans la page,
|
|
63
|
+
// sinon le plus proche repérable.
|
|
64
|
+
let anchorSel = null;
|
|
65
|
+
let firstSel = null;
|
|
66
|
+
for (let el = target.parentElement, depth = 0; el && depth < 6; el = el.parentElement, depth++) {
|
|
67
|
+
if (el === document.body || el === document.documentElement)
|
|
68
|
+
break;
|
|
69
|
+
const sel = selfSel(el);
|
|
70
|
+
if (!sel)
|
|
71
|
+
continue;
|
|
72
|
+
firstSel ??= sel;
|
|
73
|
+
if (count(sel) === 1) {
|
|
74
|
+
anchorSel = sel;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
anchorSel ??= firstSel;
|
|
79
|
+
const targetSel = part(target);
|
|
80
|
+
const scoped = anchorSel ? `${anchorSel} ${targetSel}` : targetSel;
|
|
81
|
+
const out = [];
|
|
82
|
+
const text = (target.textContent ?? '').replace(/\s+/g, ' ').trim();
|
|
83
|
+
if (text && text.length <= 60 && target.children.length === 0) {
|
|
84
|
+
const quoted = JSON.stringify(text);
|
|
85
|
+
out.push({ selector: `${scoped}::-p-text(${quoted})`, label: `Le texte « ${text} » à cet endroit` });
|
|
86
|
+
}
|
|
87
|
+
if (anchorSel)
|
|
88
|
+
out.push({ selector: scoped, label: 'Cet élément, dans son conteneur' });
|
|
89
|
+
if (anchorSel)
|
|
90
|
+
out.push({ selector: anchorSel, label: 'Son conteneur' });
|
|
91
|
+
out.push({ selector: targetSel, label: 'Cet élément, n\'importe où dans la page' });
|
|
92
|
+
return out.filter((c, i) => out.findIndex(o => o.selector === c.selector) === i);
|
|
93
|
+
}
|
|
94
|
+
/** Ouvre la page dans un contexte sans cookies, comme la verrait une session perdue. */
|
|
95
|
+
async function withAnonymousPage(pageUrl, fn) {
|
|
96
|
+
const context = await (await getBrowser()).createBrowserContext();
|
|
97
|
+
try {
|
|
98
|
+
const page = await context.newPage();
|
|
99
|
+
await page.goto(pageUrl, { waitUntil: 'networkidle2', timeout: getDefaultTimeout() });
|
|
100
|
+
return await fn(page);
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
await context.close().catch(() => undefined);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function countMatches(page, selector) {
|
|
107
|
+
return (await page.$$(selector).catch(() => [])).length;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Propose les sélecteurs de l'élément cliqué, chacun compté sur la page vue
|
|
111
|
+
* connectée et sur la même page vue sans cookies. Un bon signe de connexion est
|
|
112
|
+
* trouvé dans le premier cas et absent dans le second ; ceux-là viennent en tête.
|
|
113
|
+
*/
|
|
114
|
+
export async function pickAt(x, y) {
|
|
115
|
+
const { page, pageUrl } = requirePicker();
|
|
116
|
+
const point = pointOf(page, x, y);
|
|
117
|
+
const proposals = await page.evaluate(candidatesAt, point.x, point.y);
|
|
118
|
+
if (!proposals.length)
|
|
119
|
+
return [];
|
|
120
|
+
const connected = await Promise.all(proposals.map(p => countMatches(page, p.selector)));
|
|
121
|
+
const anonymous = await withAnonymousPage(pageUrl, anon => Promise.all(proposals.map(p => countMatches(anon, p.selector))));
|
|
122
|
+
const results = proposals.map((p, i) => ({ ...p, connected: connected[i], anonymous: anonymous[i] }));
|
|
123
|
+
const good = (c) => c.connected > 0 && c.anonymous === 0;
|
|
124
|
+
return [...results.filter(good), ...results.filter(c => !good(c))];
|
|
125
|
+
}
|
|
126
|
+
/** Vérifie le signe sur la page vue connectée (attendu : trouvé) et sans cookies (attendu : absent). */
|
|
127
|
+
export async function testSignal(pageUrl, signal) {
|
|
128
|
+
const page = await (await getBrowser()).newPage();
|
|
129
|
+
let connected;
|
|
130
|
+
let connectedUrl;
|
|
131
|
+
try {
|
|
132
|
+
await page.goto(pageUrl, { waitUntil: 'networkidle2', timeout: getDefaultTimeout() });
|
|
133
|
+
connectedUrl = page.url();
|
|
134
|
+
connected = await pageShowsSignal(page, signal, SIGNAL_WAIT_MS);
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
await page.close().catch(() => undefined);
|
|
138
|
+
}
|
|
139
|
+
const anon = await withAnonymousPage(pageUrl, async (p) => ({
|
|
140
|
+
url: p.url(),
|
|
141
|
+
shown: signal.kind === 'url' ? urlMatchesSignal(p.url(), signal.value) : await pageShowsSignal(p, signal),
|
|
142
|
+
}));
|
|
143
|
+
return { connected, anonymous: anon.shown, connectedUrl, anonymousUrl: anon.url };
|
|
144
|
+
}
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
:root{--bg:#0d0f14;--sidebar:#11131a;--surface:#151821;--inset:#11131a;--raised:#1b1f2e;--hover:#1a1e2b;--border:#232735;--border-strong:#2c3142;--divider:#1f2330;--text:#e7e9f0;--text-2:#c3c8d6;--muted:#7c8296;--faint:#5d6377;--accent:#6d7cff;--accent-hover:#7f8cff;--accent-text:#8b95ff;--ok:#34d399;--warn:#f5b14c;--danger:#f87171;--radius:12px;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}*,:before,:after{box-sizing:border-box;margin:0;padding:0}body{background:var(--bg);color:var(--text);min-height:100vh;font-size:13px;line-height:1.45}a{color:inherit;text-decoration:none}button{font:inherit;color:inherit;cursor:pointer;background:0 0;border:none}button:disabled{cursor:not-allowed;opacity:.5}input,select{font:inherit;color:inherit}.muted{color:var(--muted)}.faint{color:var(--faint)}.accent{color:var(--accent-text);align-items:center;gap:4px;display:inline-flex}.ok{color:var(--ok)}.warn{color:var(--warn)}.danger{color:var(--danger)}.small{font-size:12px}.mono{font-family:JetBrains Mono,Consolas,monospace;font-size:13px;font-weight:600}.stack{flex-direction:column;min-width:0;display:flex}.ellipsis{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.push-right{margin-left:auto}.spin{animation:1s linear infinite spin}@keyframes spin{to{transform:rotate(360deg)}}.layout{min-height:100vh;display:flex}.main{flex:1;min-width:0}.page{flex-direction:column;gap:20px;max-width:1208px;padding:36px 40px 48px;display:flex}.page-head{justify-content:space-between;align-items:flex-start;gap:24px;display:flex}.page-head h1,.site-head h1{letter-spacing:-.01em;font-size:22px;font-weight:600}.page-sub{color:var(--muted);margin-top:6px}.sidebar{background:var(--sidebar);border-right:1px solid var(--divider);flex-direction:column;flex-shrink:0;width:232px;height:100vh;padding:22px 12px 20px;display:flex;position:sticky;top:0}.brand{align-items:center;gap:12px;margin-bottom:32px;padding:0 8px;display:flex}.brand-logo{width:34px;height:34px;color:var(--accent-text);background:#1d2135;border-radius:9px;place-items:center;display:grid}.brand-name{font-size:14px;font-weight:600;display:block}.brand-sub{color:var(--muted);font-size:12px;display:block}.nav{flex-direction:column;gap:4px;display:flex}.nav-item{color:#a3a9ba;border-radius:8px;align-items:center;gap:10px;height:36px;padding:0 12px;font-weight:500;transition:background .1s,color .1s;display:flex}.nav-item svg{color:var(--muted)}.nav-item:hover{background:var(--hover);color:var(--text)}.nav-item.active{background:var(--raised);color:var(--text)}.nav-item.active svg{color:var(--accent-text)}.nav-label{flex:1}.nav-count{text-align:center;min-width:24px;color:var(--muted);font-size:11px}.nav-count-alert{color:var(--danger);background:#f8717129;border-radius:9px;padding:1px 6px;font-weight:600}.keepalive-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;margin-top:auto;padding:12px 14px}.keepalive-title{align-items:center;gap:8px;font-size:12px;font-weight:600;display:flex}.keepalive-detail{color:var(--muted);margin-top:6px;font-size:12px}.dot{background:currentColor;border-radius:50%;flex-shrink:0;width:8px;height:8px;display:inline-block}.dot-ok{background:var(--ok)}.dot-muted{background:var(--faint)}.btn{white-space:nowrap;border-radius:8px;justify-content:center;align-items:center;gap:8px;height:36px;padding:0 16px;font-weight:600;transition:background .1s,border-color .1s;display:inline-flex}.btn-lg{height:38px}.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover:not(:disabled){background:var(--accent-hover)}.btn-ghost{background:var(--surface);border:1px solid var(--border-strong);color:var(--text-2);font-weight:500}.btn-ghost:hover:not(:disabled){background:var(--hover)}.btn-warn{background:var(--warn);color:#1a1405}.btn-danger-ghost{color:var(--danger);background:#f871710f;border:1px solid #f871714d;font-weight:500}.btn-danger-ghost:hover:not(:disabled){background:#f871711f}.icon-btn{width:34px;height:34px;color:var(--muted);border-radius:8px;place-items:center;display:inline-grid}.icon-btn:hover{background:var(--hover);color:var(--text)}.icon-btn.bordered{border:1px solid var(--border-strong);color:var(--text-2)}.link-btn{color:var(--accent-text);align-items:center;gap:6px;font-weight:500;display:inline-flex}.link-btn.muted{color:var(--muted)}.link{color:var(--accent-text);font-size:12px;font-weight:500}.banner{border-radius:var(--radius);border:1px solid;align-items:center;gap:14px;padding:12px 12px 12px 20px;display:flex}.banner>div{flex:1}.banner-title{font-weight:600}.banner-text{margin-top:2px;font-size:12px}.banner-warn{color:#f5d29a;background:#f5b14c12;border-color:#f5b14c47}.banner-warn>svg{color:var(--warn)}.banner-warn .banner-text{color:#b9a785}.banner-danger{color:#f8b4b4;background:#f8717112;border-color:#f8717147}.banner-danger>svg{color:var(--danger)}.hint{border:1px solid var(--border);border-radius:var(--radius);color:var(--muted);align-items:flex-start;gap:14px;padding:16px 20px;font-size:12px;line-height:1.6;display:flex}.hint svg{flex-shrink:0;margin-top:1px}.empty-card{text-align:center;border:1px dashed var(--border-strong);border-radius:var(--radius);color:var(--muted);flex-direction:column;align-items:center;gap:10px;padding:48px 24px;display:flex}.empty-card .empty-title{color:var(--text);font-size:15px;font-weight:600}.empty-card p{max-width:420px}.empty-card .btn{margin-top:6px}.site-grid,.site-row{grid-template-columns:minmax(220px,1.8fr) 150px minmax(170px,1.1fr) minmax(130px,.8fr) 190px;align-items:center;column-gap:20px;display:grid}.list-head{letter-spacing:.04em;text-transform:uppercase;color:var(--faint);padding:4px 20px 0;font-size:11px;font-weight:600}.site-list{flex-direction:column;gap:10px;margin-top:-8px;list-style:none;display:flex}.site-row{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);cursor:pointer;min-height:76px;padding:14px 16px 14px 20px;transition:border-color .1s}.site-row:hover{border-color:var(--border-strong)}.col-site{align-items:center;gap:16px;min-width:0;display:flex}.col-actions{justify-content:flex-end;align-items:center;gap:8px;display:flex}.col-actions .btn{width:140px}.row-title{font-size:14px;font-weight:600}.row-main{font-size:13px}.row-sub{color:var(--muted);font-size:12px}.favicon{border-radius:10px;flex-shrink:0;place-items:center;font-weight:700;display:inline-grid}.favicon-md{width:40px;height:40px;font-size:16px}.favicon-lg{border-radius:12px;width:48px;height:48px;font-size:19px}.tone-violet{color:#c4a3ff;background:#2a1f3d}.tone-green{color:#6ee7b7;background:#1d2f2a}.tone-blue{color:#93b4ff;background:#1d2740}.tone-amber{color:#f5b14c;background:#3a2a1a}.tone-rose{color:#fda4bf;background:#3a1d27}.tone-teal{color:#5eead4;background:#16302f}.pill{white-space:nowrap;border-radius:12px;align-items:center;gap:6px;width:fit-content;height:24px;padding:0 11px;font-size:12px;font-weight:500;display:inline-flex}.pill .dot{width:6px;height:6px}.pill-ok{color:var(--ok);background:#34d3991f}.pill-danger{color:var(--danger);background:#f871711f}.pill-muted{color:#a3a9ba;background:#7c829624}.menu-anchor{position:relative}.menu{z-index:20;background:var(--raised);border:1px solid var(--border-strong);border-radius:10px;flex-direction:column;min-width:210px;padding:6px;display:flex;position:absolute;top:40px;right:0;box-shadow:0 12px 32px #00000073}.menu button{text-align:left;color:var(--text-2);border-radius:6px;padding:8px 10px}.menu button:hover:not(:disabled){background:var(--hover)}.menu button.danger{color:var(--danger)}.menu-error{color:var(--danger);padding:6px 10px;font-size:12px}.card{background:var(--surface);border:1px solid var(--border);border-radius:14px}.card-head{border-bottom:1px solid var(--divider);padding:20px 24px 16px}.card-head h2{font-size:15px;font-weight:600}.card-head p{color:var(--muted);margin-top:4px;font-size:12px}.card-head-row{justify-content:space-between;align-items:center;gap:16px;display:flex}.card-body{flex-direction:column;gap:18px;padding:20px 24px 24px;display:flex}.card-body.dimmed{opacity:.55}.field{grid-template-columns:200px 1fr;align-items:start;gap:16px;display:grid}.field-label{color:var(--text-2);flex-direction:column;gap:4px;padding-top:8px;font-weight:500;display:flex}.field-help{color:var(--faint);font-size:12px;font-weight:400}.field-control{flex-direction:column;gap:10px;min-width:0;display:flex}.input{background:var(--inset);border:1px solid var(--border-strong);border-radius:8px;outline:none;width:100%;height:36px;padding:0 14px}.input:focus{border-color:var(--accent)}.input::placeholder{color:var(--faint)}.select{width:auto;min-width:200px}.segmented{background:var(--inset);border:1px solid var(--border);border-radius:8px;gap:2px;padding:3px;display:flex}.segmented button{height:26px;color:var(--muted);border-radius:6px;flex:1;font-size:12px;font-weight:500}.segmented button:hover{color:var(--text-2)}.segmented button.active{color:var(--text);background:#252a3b;font-weight:600}.toggle{background:var(--border-strong);border-radius:11px;flex-shrink:0;width:40px;height:22px;transition:background .15s;position:relative}.toggle span{background:#fff;border-radius:50%;width:16px;height:16px;transition:transform .15s;position:absolute;top:3px;left:3px}.toggle.on{background:var(--accent)}.toggle.on span{transform:translate(18px)}.chips{background:var(--inset);border:1px solid var(--border-strong);cursor:text;border-radius:8px;flex-wrap:wrap;align-items:center;gap:8px;min-height:36px;padding:4px;display:flex}.chips:focus-within{border-color:var(--accent)}.chip{height:26px;color:var(--text-2);background:#1d2130;border-radius:6px;align-items:center;gap:8px;padding:0 6px 0 10px;font-size:12px;display:inline-flex}.chip button{color:var(--faint);place-items:center;display:grid}.chip button:hover{color:var(--text)}.chip-input{background:0 0;border:none;outline:none;flex:1;min-width:120px;height:26px;padding:0 6px}.chip-input::placeholder{color:var(--accent-text)}.checkbox{cursor:pointer;align-items:center;gap:10px;padding-top:8px;display:inline-flex}.checkbox input{opacity:0;pointer-events:none;position:absolute}.checkbox-box{border:1px solid var(--border-strong);color:#0000;border-radius:4px;place-items:center;width:16px;height:16px;display:grid}.checkbox input:checked+.checkbox-box{background:var(--accent);border-color:var(--accent);color:#fff}.checkbox input:focus-visible+.checkbox-box{outline:2px solid var(--accent-text)}.form-error{color:var(--danger);font-size:12px}.save-bar{background:var(--raised);border:1px solid var(--border-strong);border-radius:var(--radius);justify-content:space-between;align-items:center;gap:16px;padding:12px 12px 12px 20px;display:flex;position:sticky;bottom:16px;box-shadow:0 12px 32px #00000073}.save-bar-actions{gap:10px;display:flex}.back{color:var(--muted);align-items:center;gap:4px;width:fit-content;margin-bottom:-8px;font-size:12px;display:inline-flex}.back:hover{color:var(--text)}.site-head{align-items:center;gap:16px;display:flex}.site-head-text{flex-direction:column;flex:1;gap:6px;min-width:0;display:flex}.site-head-meta{align-items:center;gap:12px;min-width:0;display:flex}.site-head-meta>span:last-child{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.site-head-actions{gap:12px;display:flex}.detail-grid{grid-template-columns:minmax(0,1fr) 376px;align-items:start;gap:24px;display:grid}.detail-main,.detail-side{flex-direction:column;gap:20px;display:flex}.events{gap:6px}.event{align-items:flex-start;gap:12px;padding:6px 0;display:flex}.event-icon{border-radius:50%;flex-shrink:0;place-items:center;width:28px;height:28px;display:grid}.tone-bg-ok{color:var(--ok);background:#34d3991f}.tone-bg-danger{color:var(--danger);background:#f8717124}.tone-bg-warn{color:var(--warn);background:#f5b14c24}.tone-bg-accent{color:var(--accent-text);background:#6d7cff29}.event-text{flex:1;min-width:0}.event-title{font-size:13px}.event-site{color:var(--muted)}.event-detail{color:var(--faint);text-overflow:ellipsis;white-space:nowrap;font-size:11px;overflow:hidden}.event-time{color:var(--muted);white-space:nowrap;font-size:12px}.kv{color:var(--muted);justify-content:space-between;display:flex}.kv span:last-child{color:var(--text)}.data-actions{grid-template-columns:1fr 1fr;gap:12px;margin-top:4px;display:grid}.data-actions .btn{padding:0 10px;font-size:12px}.help-list{color:var(--muted);gap:12px;font-size:12px;line-height:1.6}.help-list strong{color:var(--text-2);font-weight:600}.modal-overlay{z-index:50;background:#05060ab8;place-items:center;padding:24px;display:grid;position:fixed;inset:0}.modal{background:var(--surface);border:1px solid #2a2f40;border-radius:16px;width:100%;box-shadow:0 24px 64px #00000080}.modal-login{max-width:960px}.modal-head{border-bottom:1px solid var(--border);align-items:center;gap:16px;padding:24px 28px 22px 32px;display:flex}.modal-head-text{flex:1}.modal-head h3{font-size:18px;font-weight:600}.modal-login-body{grid-template-columns:300px 1fr;gap:40px;padding:28px 32px;display:grid}.modal-foot{border-top:1px solid var(--border);justify-content:space-between;align-items:center;padding:18px 32px 20px;display:flex}.modal-foot-actions{gap:12px;display:flex}.login-side{flex-direction:column;justify-content:space-between;gap:24px;display:flex}.steps{flex-direction:column;gap:32px;display:flex;position:relative}.steps:before{content:"";background:var(--border-strong);width:2px;position:absolute;top:28px;bottom:28px;left:11px}.step{gap:16px;display:flex;position:relative}.step-dot{background:var(--surface);border:2px solid var(--border-strong);width:24px;height:24px;color:var(--muted);border-radius:50%;flex-shrink:0;place-items:center;font-size:12px;font-weight:700;display:grid}.step-done .step-dot{color:var(--ok);background:#193a33;border-color:#193a33}.step-current .step-dot{background:var(--accent);border-color:var(--accent);color:#fff;box-shadow:0 0 0 4px #6d7cff2e}.step-title{color:var(--text);padding-top:2px;font-weight:600}.step-done .step-title{color:#a3a9ba}.step-todo .step-title{color:var(--muted)}.step-text{color:#a3a9ba;margin-top:4px;font-size:12px}.step-done .step-text{color:var(--muted)}.step-todo .step-text{color:var(--faint)}.note{background:var(--inset);border:1px solid var(--border);color:var(--muted);border-radius:10px;gap:10px;padding:14px;font-size:12px;line-height:1.6;display:flex}.note svg{flex-shrink:0}.login-main{flex-direction:column;gap:14px;min-width:0;display:flex}.browser-frame{border:1px solid var(--border-strong);background:#f4f5f8;border-radius:12px;overflow:hidden}.browser-bar{background:#1d2130;align-items:center;gap:16px;height:38px;padding:0 14px;display:flex}.browser-dots{gap:6px;display:flex}.browser-dots i{background:#3a3f52;border-radius:50%;width:8px;height:8px}.browser-url{background:var(--inset);color:#a3a9ba;white-space:nowrap;text-overflow:ellipsis;border-radius:6px;flex:1;align-items:center;gap:8px;height:22px;padding:0 10px;font-size:11px;display:flex;overflow:hidden}.browser-view{aspect-ratio:16/10;background:#f4f5f8;place-items:center;display:grid;overflow:hidden}.browser-view img{object-fit:contain;object-position:top;width:100%;height:100%}.browser-placeholder{text-align:center;color:#5b6070;flex-direction:column;align-items:center;gap:10px;padding:24px;font-size:13px;display:flex}.viewer-shot .browser-placeholder{color:var(--muted)}.login-status{color:var(--text-2);align-items:center;gap:8px;font-weight:500;display:flex}.sessions-layout{grid-template-columns:340px minmax(0,1fr);align-items:start;gap:24px;display:grid}.session-list{flex-direction:column;gap:12px;list-style:none;display:flex}.session-card{border-radius:var(--radius);background:var(--surface);border:1px solid var(--border);cursor:pointer;gap:14px;padding:14px;display:flex}.session-card:hover{border-color:var(--border-strong)}.session-card.selected{border-color:var(--accent);box-shadow:inset 0 0 0 .5px var(--accent);background:#181c2a}.session-thumb{object-fit:cover;object-position:top;background:#eef0f5;border-radius:6px;flex-shrink:0;width:88px;height:60px}.session-card-text{flex:1;gap:4px}.session-card-foot{justify-content:space-between;align-items:center;margin-top:4px;display:flex}.viewer{background:var(--surface);border:1px solid var(--border);border-radius:14px;overflow:hidden}.viewer-bar{border-bottom:1px solid var(--divider);align-items:center;gap:12px;padding:14px 20px;display:flex}.live{color:var(--ok);white-space:nowrap;align-items:center;gap:8px;font-size:12px;font-weight:600;display:inline-flex}.live .dot{width:6px;height:6px;box-shadow:0 0 0 3px #34d39933}.live-paused{color:var(--muted)}.live-paused .dot{box-shadow:none}.viewer-url{background:var(--inset);border:1px solid var(--border);min-width:0;height:34px;color:var(--text-2);white-space:nowrap;text-overflow:ellipsis;border-radius:8px;flex:1;align-items:center;gap:8px;padding:0 12px;font-size:12px;display:flex;overflow:hidden}.viewer-url svg{color:var(--muted);flex-shrink:0}.viewer-shot{background:var(--inset);border-radius:8px;place-items:center;min-height:240px;margin:16px 20px;display:grid;overflow:hidden}.viewer-shot img{width:100%;display:block}.viewer-info{border-top:1px solid var(--divider);grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;padding:16px 20px 20px;display:grid}.viewer-info>div{flex-direction:column;gap:4px;min-width:0;display:flex}.viewer-info .label{text-transform:uppercase;letter-spacing:.04em;color:var(--faint);font-size:11px;font-weight:600}.viewer-console{justify-content:space-between;gap:8px;display:flex}.console{border-top:1px solid var(--divider);background:var(--inset);max-height:260px;padding:12px 20px;font-family:JetBrains Mono,Consolas,monospace;font-size:12px;overflow:auto}.console-line{color:var(--text-2);gap:12px;padding:2px 0;display:flex}.console-time{color:var(--faint)}.console-type{width:56px;color:var(--muted);flex-shrink:0}.console-error .console-type,.console-error .console-text{color:var(--danger)}.console-warn .console-type,.console-warning .console-type{color:var(--warn)}.console-text{white-space:pre-wrap;word-break:break-word}@media (width<=1100px){.detail-grid,.sessions-layout{grid-template-columns:1fr}.site-grid,.site-row{grid-template-columns:minmax(200px,1fr) 140px minmax(160px,1fr) 180px}.col-keep,.list-head span:nth-child(4){display:none}}
|
|
1
|
+
:root{--bg:#0d0f14;--sidebar:#11131a;--surface:#151821;--inset:#11131a;--raised:#1b1f2e;--hover:#1a1e2b;--border:#232735;--border-strong:#2c3142;--divider:#1f2330;--text:#e7e9f0;--text-2:#c3c8d6;--muted:#7c8296;--faint:#5d6377;--accent:#6d7cff;--accent-hover:#7f8cff;--accent-text:#8b95ff;--ok:#34d399;--warn:#f5b14c;--danger:#f87171;--radius:12px;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}*,:before,:after{box-sizing:border-box;margin:0;padding:0}body{background:var(--bg);color:var(--text);min-height:100vh;font-size:13px;line-height:1.45}a{color:inherit;text-decoration:none}button{font:inherit;color:inherit;cursor:pointer;background:0 0;border:none}button:disabled{cursor:not-allowed;opacity:.5}input,select{font:inherit;color:inherit}.muted{color:var(--muted)}.faint{color:var(--faint)}.accent{color:var(--accent-text);align-items:center;gap:4px;display:inline-flex}.ok{color:var(--ok)}.warn{color:var(--warn)}.danger{color:var(--danger)}.small{font-size:12px}.mono{font-family:JetBrains Mono,Consolas,monospace;font-size:13px;font-weight:600}.stack{flex-direction:column;min-width:0;display:flex}.ellipsis{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.push-right{margin-left:auto}.spin{animation:1s linear infinite spin}@keyframes spin{to{transform:rotate(360deg)}}.layout{min-height:100vh;display:flex}.main{flex:1;min-width:0}.page{flex-direction:column;gap:20px;max-width:1208px;padding:36px 40px 48px;display:flex}.page-head{justify-content:space-between;align-items:flex-start;gap:24px;display:flex}.page-head h1,.site-head h1{letter-spacing:-.01em;font-size:22px;font-weight:600}.page-sub{color:var(--muted);margin-top:6px}.sidebar{background:var(--sidebar);border-right:1px solid var(--divider);flex-direction:column;flex-shrink:0;width:232px;height:100vh;padding:22px 12px 20px;display:flex;position:sticky;top:0}.brand{align-items:center;gap:12px;margin-bottom:32px;padding:0 8px;display:flex}.brand-logo{width:34px;height:34px;color:var(--accent-text);background:#1d2135;border-radius:9px;place-items:center;display:grid}.brand-name{font-size:14px;font-weight:600;display:block}.brand-sub{color:var(--muted);font-size:12px;display:block}.nav{flex-direction:column;gap:4px;display:flex}.nav-item{color:#a3a9ba;border-radius:8px;align-items:center;gap:10px;height:36px;padding:0 12px;font-weight:500;transition:background .1s,color .1s;display:flex}.nav-item svg{color:var(--muted)}.nav-item:hover{background:var(--hover);color:var(--text)}.nav-item.active{background:var(--raised);color:var(--text)}.nav-item.active svg{color:var(--accent-text)}.nav-label{flex:1}.nav-count{text-align:center;min-width:24px;color:var(--muted);font-size:11px}.nav-count-alert{color:var(--danger);background:#f8717129;border-radius:9px;padding:1px 6px;font-weight:600}.keepalive-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;margin-top:auto;padding:12px 14px}.keepalive-title{align-items:center;gap:8px;font-size:12px;font-weight:600;display:flex}.keepalive-detail{color:var(--muted);margin-top:6px;font-size:12px}.dot{background:currentColor;border-radius:50%;flex-shrink:0;width:8px;height:8px;display:inline-block}.dot-ok{background:var(--ok)}.dot-muted{background:var(--faint)}.btn{white-space:nowrap;border-radius:8px;justify-content:center;align-items:center;gap:8px;height:36px;padding:0 16px;font-weight:600;transition:background .1s,border-color .1s;display:inline-flex}.btn-lg{height:38px}.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover:not(:disabled){background:var(--accent-hover)}.btn-ghost{background:var(--surface);border:1px solid var(--border-strong);color:var(--text-2);font-weight:500}.btn-ghost:hover:not(:disabled){background:var(--hover)}.btn-warn{background:var(--warn);color:#1a1405}.btn-danger-ghost{color:var(--danger);background:#f871710f;border:1px solid #f871714d;font-weight:500}.btn-danger-ghost:hover:not(:disabled){background:#f871711f}.icon-btn{width:34px;height:34px;color:var(--muted);border-radius:8px;place-items:center;display:inline-grid}.icon-btn:hover{background:var(--hover);color:var(--text)}.icon-btn.bordered{border:1px solid var(--border-strong);color:var(--text-2)}.link-btn{color:var(--accent-text);align-items:center;gap:6px;font-weight:500;display:inline-flex}.link-btn.muted{color:var(--muted)}.link{color:var(--accent-text);font-size:12px;font-weight:500}.banner{border-radius:var(--radius);border:1px solid;align-items:center;gap:14px;padding:12px 12px 12px 20px;display:flex}.banner>div{flex:1}.banner-title{font-weight:600}.banner-text{margin-top:2px;font-size:12px}.banner-warn{color:#f5d29a;background:#f5b14c12;border-color:#f5b14c47}.banner-warn>svg{color:var(--warn)}.banner-warn .banner-text{color:#b9a785}.banner-danger{color:#f8b4b4;background:#f8717112;border-color:#f8717147}.banner-danger>svg{color:var(--danger)}.hint{border:1px solid var(--border);border-radius:var(--radius);color:var(--muted);align-items:flex-start;gap:14px;padding:16px 20px;font-size:12px;line-height:1.6;display:flex}.hint svg{flex-shrink:0;margin-top:1px}.empty-card{text-align:center;border:1px dashed var(--border-strong);border-radius:var(--radius);color:var(--muted);flex-direction:column;align-items:center;gap:10px;padding:48px 24px;display:flex}.empty-card .empty-title{color:var(--text);font-size:15px;font-weight:600}.empty-card p{max-width:420px}.empty-card .btn{margin-top:6px}.site-grid,.site-row{grid-template-columns:minmax(220px,1.8fr) 150px minmax(170px,1.1fr) minmax(130px,.8fr) 190px;align-items:center;column-gap:20px;display:grid}.list-head{letter-spacing:.04em;text-transform:uppercase;color:var(--faint);padding:4px 20px 0;font-size:11px;font-weight:600}.site-list{flex-direction:column;gap:10px;margin-top:-8px;list-style:none;display:flex}.site-row{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);cursor:pointer;min-height:76px;padding:14px 16px 14px 20px;transition:border-color .1s}.site-row:hover{border-color:var(--border-strong)}.col-site{align-items:center;gap:16px;min-width:0;display:flex}.col-actions{justify-content:flex-end;align-items:center;gap:8px;display:flex}.col-actions .btn{width:140px}.row-title{font-size:14px;font-weight:600}.row-main{font-size:13px}.row-sub{color:var(--muted);font-size:12px}.favicon{border-radius:10px;flex-shrink:0;place-items:center;font-weight:700;display:inline-grid}.favicon-md{width:40px;height:40px;font-size:16px}.favicon-lg{border-radius:12px;width:48px;height:48px;font-size:19px}.tone-violet{color:#c4a3ff;background:#2a1f3d}.tone-green{color:#6ee7b7;background:#1d2f2a}.tone-blue{color:#93b4ff;background:#1d2740}.tone-amber{color:#f5b14c;background:#3a2a1a}.tone-rose{color:#fda4bf;background:#3a1d27}.tone-teal{color:#5eead4;background:#16302f}.pill{white-space:nowrap;border-radius:12px;align-items:center;gap:6px;width:fit-content;height:24px;padding:0 11px;font-size:12px;font-weight:500;display:inline-flex}.pill .dot{width:6px;height:6px}.pill-ok{color:var(--ok);background:#34d3991f}.pill-danger{color:var(--danger);background:#f871711f}.pill-muted{color:#a3a9ba;background:#7c829624}.menu-anchor{position:relative}.menu{z-index:20;background:var(--raised);border:1px solid var(--border-strong);border-radius:10px;flex-direction:column;min-width:210px;padding:6px;display:flex;position:absolute;top:40px;right:0;box-shadow:0 12px 32px #00000073}.menu button{text-align:left;color:var(--text-2);border-radius:6px;padding:8px 10px}.menu button:hover:not(:disabled){background:var(--hover)}.menu button.danger{color:var(--danger)}.menu-error{color:var(--danger);padding:6px 10px;font-size:12px}.card{background:var(--surface);border:1px solid var(--border);border-radius:14px}.card-head{border-bottom:1px solid var(--divider);padding:20px 24px 16px}.card-head h2{font-size:15px;font-weight:600}.card-head p{color:var(--muted);margin-top:4px;font-size:12px}.card-head-row{justify-content:space-between;align-items:center;gap:16px;display:flex}.card-body{flex-direction:column;gap:18px;padding:20px 24px 24px;display:flex}.card-body.dimmed{opacity:.55}.field{grid-template-columns:200px 1fr;align-items:start;gap:16px;display:grid}.field-label{color:var(--text-2);flex-direction:column;gap:4px;padding-top:8px;font-weight:500;display:flex}.field-help{color:var(--faint);font-size:12px;font-weight:400}.field-control{flex-direction:column;gap:10px;min-width:0;display:flex}.input{background:var(--inset);border:1px solid var(--border-strong);border-radius:8px;outline:none;width:100%;height:36px;padding:0 14px}.input:focus{border-color:var(--accent)}.input::placeholder{color:var(--faint)}.select{width:auto;min-width:200px}.segmented{background:var(--inset);border:1px solid var(--border);border-radius:8px;gap:2px;padding:3px;display:flex}.segmented button{height:26px;color:var(--muted);border-radius:6px;flex:1;font-size:12px;font-weight:500}.segmented button:hover{color:var(--text-2)}.segmented button.active{color:var(--text);background:#252a3b;font-weight:600}.toggle{background:var(--border-strong);border-radius:11px;flex-shrink:0;width:40px;height:22px;transition:background .15s;position:relative}.toggle span{background:#fff;border-radius:50%;width:16px;height:16px;transition:transform .15s;position:absolute;top:3px;left:3px}.toggle.on{background:var(--accent)}.toggle.on span{transform:translate(18px)}.chips{background:var(--inset);border:1px solid var(--border-strong);cursor:text;border-radius:8px;flex-wrap:wrap;align-items:center;gap:8px;min-height:36px;padding:4px;display:flex}.chips:focus-within{border-color:var(--accent)}.chip{height:26px;color:var(--text-2);background:#1d2130;border-radius:6px;align-items:center;gap:8px;padding:0 6px 0 10px;font-size:12px;display:inline-flex}.chip button{color:var(--faint);place-items:center;display:grid}.chip button:hover{color:var(--text)}.chip-input{background:0 0;border:none;outline:none;flex:1;min-width:120px;height:26px;padding:0 6px}.chip-input::placeholder{color:var(--accent-text)}.checkbox{cursor:pointer;align-items:center;gap:10px;padding-top:8px;display:inline-flex}.checkbox input{opacity:0;pointer-events:none;position:absolute}.checkbox-box{border:1px solid var(--border-strong);color:#0000;border-radius:4px;place-items:center;width:16px;height:16px;display:grid}.checkbox input:checked+.checkbox-box{background:var(--accent);border-color:var(--accent);color:#fff}.checkbox input:focus-visible+.checkbox-box{outline:2px solid var(--accent-text)}.form-error{color:var(--danger);font-size:12px}.save-bar{background:var(--raised);border:1px solid var(--border-strong);border-radius:var(--radius);justify-content:space-between;align-items:center;gap:16px;padding:12px 12px 12px 20px;display:flex;position:sticky;bottom:16px;box-shadow:0 12px 32px #00000073}.save-bar-actions{gap:10px;display:flex}.back{color:var(--muted);align-items:center;gap:4px;width:fit-content;margin-bottom:-8px;font-size:12px;display:inline-flex}.back:hover{color:var(--text)}.site-head{align-items:center;gap:16px;display:flex}.site-head-text{flex-direction:column;flex:1;gap:6px;min-width:0;display:flex}.site-head-meta{align-items:center;gap:12px;min-width:0;display:flex}.site-head-meta>span:last-child{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.site-head-actions{gap:12px;display:flex}.detail-grid{grid-template-columns:minmax(0,1fr) 376px;align-items:start;gap:24px;display:grid}.detail-main,.detail-side{flex-direction:column;gap:20px;display:flex}.events{gap:6px}.event{align-items:flex-start;gap:12px;padding:6px 0;display:flex}.event-icon{border-radius:50%;flex-shrink:0;place-items:center;width:28px;height:28px;display:grid}.tone-bg-ok{color:var(--ok);background:#34d3991f}.tone-bg-danger{color:var(--danger);background:#f8717124}.tone-bg-warn{color:var(--warn);background:#f5b14c24}.tone-bg-accent{color:var(--accent-text);background:#6d7cff29}.event-text{flex:1;min-width:0}.event-title{font-size:13px}.event-site{color:var(--muted)}.event-detail{color:var(--faint);text-overflow:ellipsis;white-space:nowrap;font-size:11px;overflow:hidden}.event-time{color:var(--muted);white-space:nowrap;font-size:12px}.kv{color:var(--muted);justify-content:space-between;display:flex}.kv span:last-child{color:var(--text)}.data-actions{grid-template-columns:1fr 1fr;gap:12px;margin-top:4px;display:grid}.data-actions .btn{padding:0 10px;font-size:12px}.help-list{color:var(--muted);gap:12px;font-size:12px;line-height:1.6}.help-list strong{color:var(--text-2);font-weight:600}.modal-overlay{z-index:50;background:#05060ab8;place-items:center;padding:24px;display:grid;position:fixed;inset:0}.modal{background:var(--surface);border:1px solid #2a2f40;border-radius:16px;width:100%;box-shadow:0 24px 64px #00000080}.modal-login,.modal-picker{flex-direction:column;max-width:min(1560px,96vw);max-height:calc(100vh - 48px);display:flex}.modal-login-body,.modal-picker-body{min-height:0;overflow:auto}.modal-login .remote-view,.modal-picker .remote-view{justify-content:center;display:flex}.modal-login .remote-view img,.modal-picker .remote-view img{width:auto;max-width:100%;max-height:calc(100vh - 320px)}.modal-head{border-bottom:1px solid var(--border);align-items:center;gap:16px;padding:24px 28px 22px 32px;display:flex}.modal-head-text{flex:1}.modal-head h3{font-size:18px;font-weight:600}.modal-login-body{grid-template-columns:260px minmax(0,1fr);gap:32px;padding:24px 32px;display:grid}.modal-picker-body{grid-template-columns:minmax(0,1fr) 320px;gap:28px;padding:24px 32px 28px;display:grid}.modal-foot{border-top:1px solid var(--border);justify-content:space-between;align-items:center;padding:18px 32px 20px;display:flex}.modal-foot-actions{gap:12px;display:flex}.login-side{flex-direction:column;justify-content:space-between;gap:24px;display:flex}.steps{flex-direction:column;gap:32px;display:flex;position:relative}.steps:before{content:"";background:var(--border-strong);width:2px;position:absolute;top:28px;bottom:28px;left:11px}.step{gap:16px;display:flex;position:relative}.step-dot{background:var(--surface);border:2px solid var(--border-strong);width:24px;height:24px;color:var(--muted);border-radius:50%;flex-shrink:0;place-items:center;font-size:12px;font-weight:700;display:grid}.step-done .step-dot{color:var(--ok);background:#193a33;border-color:#193a33}.step-current .step-dot{background:var(--accent);border-color:var(--accent);color:#fff;box-shadow:0 0 0 4px #6d7cff2e}.step-title{color:var(--text);padding-top:2px;font-weight:600}.step-done .step-title{color:#a3a9ba}.step-todo .step-title{color:var(--muted)}.step-text{color:#a3a9ba;margin-top:4px;font-size:12px}.step-done .step-text{color:var(--muted)}.step-todo .step-text{color:var(--faint)}.note{background:var(--inset);border:1px solid var(--border);color:var(--muted);border-radius:10px;gap:10px;padding:14px;font-size:12px;line-height:1.6;display:flex}.note svg{flex-shrink:0}.login-main{flex-direction:column;gap:14px;min-width:0;display:flex}.browser-frame{border:1px solid var(--border-strong);background:#f4f5f8;border-radius:12px;overflow:hidden}.browser-bar{background:#1d2130;align-items:center;gap:16px;height:38px;padding:0 14px;display:flex}.browser-dots{gap:6px;display:flex}.browser-dots i{background:#3a3f52;border-radius:50%;width:8px;height:8px}.browser-url{background:var(--inset);color:#a3a9ba;white-space:nowrap;text-overflow:ellipsis;border-radius:6px;flex:1;align-items:center;gap:8px;height:22px;padding:0 10px;font-size:11px;display:flex;overflow:hidden}.browser-view{background:#f4f5f8;place-items:center;min-height:320px;display:grid;overflow:hidden}.browser-placeholder{text-align:center;color:#5b6070;flex-direction:column;align-items:center;gap:10px;padding:24px;font-size:13px;display:flex}.viewer-shot .browser-placeholder{color:var(--muted)}.login-status{color:var(--text-2);align-items:center;gap:8px;font-weight:500;display:flex}.sessions-layout{grid-template-columns:340px minmax(0,1fr);align-items:start;gap:24px;display:grid}.session-list{flex-direction:column;gap:12px;list-style:none;display:flex}.session-card{border-radius:var(--radius);background:var(--surface);border:1px solid var(--border);cursor:pointer;gap:14px;padding:14px;display:flex}.session-card:hover{border-color:var(--border-strong)}.session-card.selected{border-color:var(--accent);box-shadow:inset 0 0 0 .5px var(--accent);background:#181c2a}.session-thumb{object-fit:cover;object-position:top;background:#eef0f5;border-radius:6px;flex-shrink:0;width:88px;height:60px}.session-card-text{flex:1;gap:4px}.session-card-foot{justify-content:space-between;align-items:center;margin-top:4px;display:flex}.viewer{background:var(--surface);border:1px solid var(--border);border-radius:14px;overflow:hidden}.viewer-bar{border-bottom:1px solid var(--divider);align-items:center;gap:12px;padding:14px 20px;display:flex}.live{color:var(--ok);white-space:nowrap;align-items:center;gap:8px;font-size:12px;font-weight:600;display:inline-flex}.live .dot{width:6px;height:6px;box-shadow:0 0 0 3px #34d39933}.live-paused{color:var(--muted)}.live-paused .dot{box-shadow:none}.viewer-url{background:var(--inset);border:1px solid var(--border);min-width:0;height:34px;color:var(--text-2);white-space:nowrap;text-overflow:ellipsis;border-radius:8px;flex:1;align-items:center;gap:8px;padding:0 12px;font-size:12px;display:flex;overflow:hidden}.viewer-url svg{color:var(--muted);flex-shrink:0}.viewer-shot{background:var(--inset);border-radius:8px;place-items:center;min-height:240px;margin:16px 20px;display:grid;overflow:hidden}.viewer-shot img{width:100%;display:block}.viewer-info{border-top:1px solid var(--divider);grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;padding:16px 20px 20px;display:grid}.viewer-info>div{flex-direction:column;gap:4px;min-width:0;display:flex}.viewer-info .label{text-transform:uppercase;letter-spacing:.04em;color:var(--faint);font-size:11px;font-weight:600}.viewer-console{justify-content:space-between;gap:8px;display:flex}.console{border-top:1px solid var(--divider);background:var(--inset);max-height:260px;padding:12px 20px;font-family:JetBrains Mono,Consolas,monospace;font-size:12px;overflow:auto}.console-line{color:var(--text-2);gap:12px;padding:2px 0;display:flex}.console-time{color:var(--faint)}.console-type{width:56px;color:var(--muted);flex-shrink:0}.console-error .console-type,.console-error .console-text{color:var(--danger)}.console-warn .console-type,.console-warning .console-type{color:var(--warn)}.console-text{white-space:pre-wrap;word-break:break-word}@media (width<=1100px){.detail-grid,.sessions-layout{grid-template-columns:1fr}.site-grid,.site-row{grid-template-columns:minmax(200px,1fr) 140px minmax(160px,1fr) 180px}.col-keep,.list-head span:nth-child(4){display:none}}.remote-view{outline:none;align-self:start;width:100%;position:relative}.remote-view img{cursor:default;-webkit-user-select:none;user-select:none;width:100%;height:auto;display:block}.remote-view.crosshair img{cursor:crosshair}.remote-view.focused{box-shadow:inset 0 0 0 2px var(--accent)}.remote-hint{color:var(--text-2);pointer-events:none;background:#0d0f14d1;border-radius:999px;align-items:center;gap:6px;padding:6px 12px;font-size:12px;display:inline-flex;position:absolute;bottom:12px;left:50%;transform:translate(-50%)}.remote-broken{color:var(--text-2);background:#0d0f1499;place-items:center;display:grid;position:absolute;inset:0}.browser-btn{color:#a3a9ba;border-radius:6px;place-items:center;width:24px;height:22px;display:grid}.browser-btn:hover:not(:disabled){background:var(--inset);color:var(--text)}.browser-url input{min-width:0;color:inherit;background:0 0;border:none;outline:none;flex:1;font-size:11px}.refresh-select{color:var(--muted);white-space:nowrap;align-items:center;gap:6px;font-size:12px;display:inline-flex}.refresh-select select{background:var(--inset);border:1px solid var(--border-strong);height:28px;color:var(--text-2);border-radius:6px;padding:0 6px;font-size:12px}.btn-sm{gap:6px;height:30px;padding:0 12px;font-size:12px}.signal-tools{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.signal-result{flex-wrap:wrap;gap:10px;margin-left:4px;display:inline-flex}.verdict{align-items:center;gap:4px;font-size:12px;font-weight:500;display:inline-flex}.verdict.ok{color:var(--ok)}.verdict.danger{color:var(--danger)}.picker-icon{width:40px;height:40px;color:var(--accent-text);background:#6d7cff29;border-radius:10px;flex-shrink:0;place-items:center;display:grid}.picker-side{flex-direction:column;gap:12px;min-width:0;max-height:640px;display:flex;overflow:auto}.picker-side h4{font-size:13px;font-weight:600}.picker-wait{align-items:center;gap:6px;display:inline-flex}.candidate{background:var(--inset);border:1px solid var(--border);border-radius:10px;flex-direction:column;gap:8px;padding:12px 14px;display:flex}.candidate.good{border-color:#34d39966}.candidate-label{color:var(--muted);font-size:12px}.candidate-selector{color:var(--text);word-break:break-all;font-family:JetBrains Mono,Consolas,monospace;font-size:12px}.candidate-verdicts{flex-wrap:wrap;gap:10px;display:flex}.candidate .btn{align-self:flex-start;height:30px;font-size:12px}.browser-frame:fullscreen{background:var(--bg);border:none;border-radius:0;flex-direction:column;display:flex}.browser-frame:fullscreen .browser-view{background:var(--bg);flex:1;min-height:0}.browser-frame:fullscreen .remote-view{place-items:center;width:100%;height:100%;display:grid}.browser-frame:fullscreen .remote-view img{width:auto;max-width:100%;height:auto;max-height:calc(100vh - 38px)}.viewer:fullscreen{border:none;border-radius:0;overflow:auto}.viewer:fullscreen .viewer-shot img{width:auto;max-width:100%;max-height:calc(100vh - 190px);margin:0 auto}
|