jevway 0.0.0-stage → 0.1.2
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/INSTALL-FOR-AGENTS.md +138 -0
- package/LICENSE +11 -0
- package/LICENSE.jev-ultrafast +21 -0
- package/README.md +218 -2
- package/dist/browser/bot-wall.js +105 -0
- package/dist/browser/cdp.js +112 -0
- package/dist/browser/chrome.js +147 -0
- package/dist/browser/devices.js +20 -0
- package/dist/browser/frames.js +516 -0
- package/dist/browser/snapshot.js +611 -0
- package/dist/browser/tab.js +938 -0
- package/dist/browser/words.js +51 -0
- package/dist/cli.js +112 -0
- package/dist/jev/action-space.js +48 -0
- package/dist/jev/box.js +58 -0
- package/dist/jev/decide.js +405 -0
- package/dist/jev/instructions.js +67 -0
- package/dist/jev/model.js +189 -0
- package/dist/jev/replay.js +213 -0
- package/dist/jev/secrets.js +60 -0
- package/dist/journeys/diagnostics.js +85 -0
- package/dist/journeys/journey-doc.js +50 -0
- package/dist/journeys/journey-session.js +503 -0
- package/dist/journeys/pick-next-move.js +59 -0
- package/dist/journeys/render-viewer.js +153 -0
- package/dist/journeys/screen-boxes.js +49 -0
- package/dist/journeys/screen-judgments.js +149 -0
- package/dist/mcp.js +361 -0
- package/dist/setup/doctor-command.js +81 -0
- package/dist/setup/doctor.js +163 -0
- package/dist/setup/env-file.js +50 -0
- package/dist/task/checks.js +156 -0
- package/dist/task/evidence.js +96 -0
- package/dist/task/hosts.js +91 -0
- package/dist/task/look.js +17 -0
- package/dist/task/recording-page.js +310 -0
- package/dist/task/recording.js +232 -0
- package/dist/task/run-limit.js +66 -0
- package/dist/task/run-task.js +1607 -0
- package/dist/task/start-task.js +91 -0
- package/dist/task/upload-files.js +43 -0
- package/package.json +80 -4
- package/skill/jevway/SKILL.md +73 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A self-contained journey viewer next to `journey.json`: flow tree, per-flow screen strips with the tapped
|
|
3
|
+
* hotspot outlined, agent descriptions and insights, Jev's screen type and patterns, and QA findings.
|
|
4
|
+
* Screenshots load from `screens/`, so the folder can be zipped, served, or published as-is.
|
|
5
|
+
*/
|
|
6
|
+
export const renderViewer = (doc) => `<!doctype html>
|
|
7
|
+
<html lang="en">
|
|
8
|
+
<head>
|
|
9
|
+
<meta charset="utf-8">
|
|
10
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
11
|
+
<title>${escapeHtml(doc.site)} journeys</title>
|
|
12
|
+
<style>
|
|
13
|
+
:root { --bg:#f5f4f1; --panel:#fff; --ink:#1b1b19; --muted:#6b6a65; --line:#e3e1db; --accent:#e5484d; --chip:#efede8; --high:#d93036; --medium:#c47a00; --low:#6b6a65; }
|
|
14
|
+
@media (prefers-color-scheme: dark) { :root { --bg:#111110; --panel:#1a1a19; --ink:#eeeeec; --muted:#a09f9a; --line:#2c2c2a; --chip:#252523; --accent:#ff6369; } }
|
|
15
|
+
* { box-sizing: border-box; }
|
|
16
|
+
body { margin:0; font:14px/1.45 ui-sans-serif, system-ui, -apple-system, sans-serif; background:var(--bg); color:var(--ink); }
|
|
17
|
+
.layout { display:grid; grid-template-columns: 260px 1fr; min-height:100vh; }
|
|
18
|
+
aside { border-right:1px solid var(--line); padding:20px 16px; position:sticky; top:0; height:100vh; overflow:auto; }
|
|
19
|
+
aside h1 { font-size:18px; margin:0 0 4px; }
|
|
20
|
+
.meta { color:var(--muted); font-size:12px; margin-bottom:16px; }
|
|
21
|
+
.stats { display:flex; gap:12px; margin-bottom:18px; font-size:12px; color:var(--muted); }
|
|
22
|
+
.stats b { display:block; font-size:18px; color:var(--ink); }
|
|
23
|
+
nav a { display:block; padding:5px 8px; border-radius:6px; color:inherit; text-decoration:none; }
|
|
24
|
+
nav a:hover { background:var(--chip); }
|
|
25
|
+
nav a small { color:var(--muted); }
|
|
26
|
+
.tabs { display:flex; gap:4px; padding:16px 24px 0; }
|
|
27
|
+
.tabs button { border:0; background:none; color:var(--muted); font:inherit; padding:6px 10px; border-bottom:2px solid transparent; cursor:pointer; }
|
|
28
|
+
.tabs button[aria-selected=true] { color:var(--ink); border-color:var(--ink); }
|
|
29
|
+
main { min-width:0; }
|
|
30
|
+
section.view { padding:8px 24px 48px; }
|
|
31
|
+
.flow { margin:22px 0 34px; }
|
|
32
|
+
.flow h2 { font-size:16px; margin:0; }
|
|
33
|
+
.flow .sub { color:var(--muted); font-size:12px; margin:2px 0 12px; }
|
|
34
|
+
.strip { display:flex; gap:18px; overflow-x:auto; padding-bottom:10px; scroll-snap-type:x proximity; }
|
|
35
|
+
.card { flex:0 0 220px; scroll-snap-align:start; }
|
|
36
|
+
body.landscape .card { flex-basis:460px; } body.landscape .grid { grid-template-columns:repeat(auto-fill, minmax(380px, 1fr)); }
|
|
37
|
+
.shot { position:relative; border-radius:18px; overflow:hidden; border:1px solid var(--line); background:var(--panel); cursor:zoom-in; }
|
|
38
|
+
.shot img { display:block; width:100%; }
|
|
39
|
+
.hotspot { position:absolute; border:2px solid var(--accent); border-radius:6px; background:rgb(229 72 77 / .12); pointer-events:none; }
|
|
40
|
+
.primary { position:absolute; border:2px dashed var(--accent); border-radius:6px; opacity:.7; pointer-events:none; }
|
|
41
|
+
.num { position:absolute; top:8px; left:8px; background:var(--ink); color:var(--bg); font-size:11px; border-radius:99px; padding:1px 7px; }
|
|
42
|
+
.card .action { margin-top:8px; font-weight:600; font-size:12.5px; }
|
|
43
|
+
.card .action::before { content:"→ "; color:var(--accent); }
|
|
44
|
+
.card p { margin:4px 0; font-size:12.5px; }
|
|
45
|
+
.card .insight { color:var(--muted); font-style:italic; }
|
|
46
|
+
.chips { display:flex; flex-wrap:wrap; gap:4px; margin-top:6px; }
|
|
47
|
+
.chip { background:var(--chip); border-radius:99px; padding:1px 7px; font-size:11px; color:var(--muted); }
|
|
48
|
+
.chip.type { color:var(--ink); }
|
|
49
|
+
.qa { font-size:11px; margin-top:6px; }
|
|
50
|
+
.qa span { display:inline-block; margin:0 4px 2px 0; }
|
|
51
|
+
.sev-high { color:var(--high); } .sev-medium { color:var(--medium); } .sev-low { color:var(--low); }
|
|
52
|
+
.grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(200px, 1fr)); gap:18px; margin-top:18px; }
|
|
53
|
+
table { width:100%; border-collapse:collapse; margin-top:18px; font-size:13px; }
|
|
54
|
+
td, th { text-align:left; padding:7px 8px; border-bottom:1px solid var(--line); vertical-align:top; }
|
|
55
|
+
dialog { border:0; border-radius:16px; padding:0; max-width:min(920px, 94vw); background:var(--panel); color:var(--ink); }
|
|
56
|
+
dialog::backdrop { background:rgb(0 0 0 / .55); }
|
|
57
|
+
.detail { display:grid; grid-template-columns:minmax(0, 360px) 1fr; gap:20px; padding:20px; }
|
|
58
|
+
.detail img { width:100%; border-radius:14px; border:1px solid var(--line); }
|
|
59
|
+
.empty { color:var(--muted); padding:40px 0; }
|
|
60
|
+
@media (max-width: 760px) {
|
|
61
|
+
.layout { grid-template-columns:1fr; } aside { position:static; height:auto; border-right:0; border-bottom:1px solid var(--line); }
|
|
62
|
+
.tabs, section.view { padding-left:16px; padding-right:16px; } .detail { grid-template-columns:1fr; }
|
|
63
|
+
}
|
|
64
|
+
</style>
|
|
65
|
+
</head>
|
|
66
|
+
<body>
|
|
67
|
+
<div class="layout">
|
|
68
|
+
<aside>
|
|
69
|
+
<h1 id="site"></h1>
|
|
70
|
+
<div class="meta" id="meta"></div>
|
|
71
|
+
<div class="stats" id="stats"></div>
|
|
72
|
+
<nav id="tree"></nav>
|
|
73
|
+
</aside>
|
|
74
|
+
<main>
|
|
75
|
+
<div class="tabs" role="tablist">
|
|
76
|
+
<button role="tab" data-view="flows" aria-selected="true">Flows</button>
|
|
77
|
+
<button role="tab" data-view="screens" aria-selected="false">Screens</button>
|
|
78
|
+
<button role="tab" data-view="qa" aria-selected="false">QA</button>
|
|
79
|
+
</div>
|
|
80
|
+
<section class="view" id="flows"></section>
|
|
81
|
+
<section class="view" id="screens" hidden></section>
|
|
82
|
+
<section class="view" id="qa" hidden></section>
|
|
83
|
+
</main>
|
|
84
|
+
</div>
|
|
85
|
+
<dialog id="lightbox"></dialog>
|
|
86
|
+
<script type="application/json" id="journey">${JSON.stringify(doc).replace(/</g, '\\u003c')}</script>
|
|
87
|
+
<script>
|
|
88
|
+
const doc = JSON.parse(document.getElementById('journey').textContent);
|
|
89
|
+
// Parents sort before their children ("Homes" < "Homes/Home details").
|
|
90
|
+
doc.flows.sort((a, b) => a.name.localeCompare(b.name));
|
|
91
|
+
const screens = Object.fromEntries(doc.screens.map(s => [s.id, s]));
|
|
92
|
+
const esc = t => String(t ?? '').replace(/[&<>"]/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"' })[c]);
|
|
93
|
+
const $ = id => document.getElementById(id);
|
|
94
|
+
const qaBadges = s => s.qa.length ? '<div class="qa">' + [...new Map(s.qa.map(f => [f.kind, f])).values()].map(f => '<span class="sev-' + f.severity + '">● ' + esc(f.kind.replace(/_/g, ' ')) + '</span>').join('') + '</div>' : '';
|
|
95
|
+
const typeChips = s => '<div class="chips"><span class="chip type">' + esc(s.type) + '</span>' + s.patterns.slice(0, 4).map(p => '<span class="chip">' + esc(p.replace(/_/g, ' ')) + '</span>').join('') + '</div>';
|
|
96
|
+
const boxStyle = b => 'left:' + b.x * 100 + '%;top:' + b.y * 100 + '%;width:' + b.width * 100 + '%;height:' + b.height * 100 + '%';
|
|
97
|
+
const shot = (s, hotspot, num) => '<div class="shot" data-screen="' + s.id + '"><img loading="lazy" src="' + esc(s.screenshot) + '" alt="' + esc(s.title) + '">' +
|
|
98
|
+
(hotspot ? '<div class="hotspot" style="' + boxStyle(hotspot) + '"></div>' : '') +
|
|
99
|
+
(s.primaryAction ? '<div class="primary" title="Primary action (Box)" style="' + boxStyle(s.primaryAction) + '"></div>' : '') +
|
|
100
|
+
'<span class="num">' + esc(num) + '</span></div>';
|
|
101
|
+
|
|
102
|
+
// Desktop journeys get wide cards; phone journeys keep narrow portrait strips.
|
|
103
|
+
if (!/iphone|pixel/.test(doc.device)) document.body.classList.add('landscape');
|
|
104
|
+
$('site').textContent = doc.site;
|
|
105
|
+
$('meta').textContent = doc.device + ' · ' + new Date(doc.createdAt).toLocaleString() + (doc.intent ? ' · ' + doc.intent : '');
|
|
106
|
+
const qaCount = doc.screens.reduce((n, s) => n + s.qa.length, 0);
|
|
107
|
+
$('stats').innerHTML = [['Flows', doc.flows.filter(f => f.screens.length).length], ['Screens', doc.screens.length], ['QA', qaCount]]
|
|
108
|
+
.map(([label, value]) => '<div><b>' + value + '</b>' + label + '</div>').join('');
|
|
109
|
+
|
|
110
|
+
const depth = name => name.split('/').length - 1;
|
|
111
|
+
$('tree').innerHTML = doc.flows.map((f, i) => '<a href="#flow-' + i + '" style="padding-left:' + (8 + depth(f.name) * 14) + 'px">' +
|
|
112
|
+
esc(f.name.split('/').pop()) + (f.screens.length ? ' <small>' + f.screens.length + '</small>' : '') + '</a>').join('');
|
|
113
|
+
|
|
114
|
+
const flows = doc.flows.filter(f => f.screens.length);
|
|
115
|
+
$('flows').innerHTML = flows.length ? flows.map(f => '<div class="flow" id="flow-' + doc.flows.indexOf(f) + '"><h2>' + esc(f.name) + '</h2><div class="sub">' + f.screens.length + ' screens</div><div class="strip">' +
|
|
116
|
+
f.screens.map((step, i) => { const s = screens[step.screen]; return '<div class="card">' + shot(s, step.hotspot, i + 1) + typeChips(s) +
|
|
117
|
+
(s.description ? '<p>' + esc(s.description) + '</p>' : '') + (s.insight ? '<p class="insight">' + esc(s.insight) + '</p>' : '') +
|
|
118
|
+
(step.action ? '<div class="action">' + esc(step.action) + '</div>' : '') + qaBadges(s) + '</div>'; }).join('') +
|
|
119
|
+
'</div></div>').join('') : '<p class="empty">No steps yet: the journey has a start screen only.</p>';
|
|
120
|
+
|
|
121
|
+
$('screens').innerHTML = '<div class="grid">' + doc.screens.map(s => '<div class="card" style="flex:none">' + shot(s, null, s.id) + typeChips(s) +
|
|
122
|
+
'<p>' + esc(s.description || s.title) + '</p>' + qaBadges(s) + '</div>').join('') + '</div>';
|
|
123
|
+
|
|
124
|
+
const findings = doc.screens.flatMap(s => s.qa.map(f => ({ ...f, screen: s.id })));
|
|
125
|
+
const rank = { high: 0, medium: 1, low: 2 };
|
|
126
|
+
$('qa').innerHTML = findings.length ? '<table><tr><th>Severity</th><th>Screen</th><th>Finding</th><th>Detail</th></tr>' +
|
|
127
|
+
findings.sort((a, b) => rank[a.severity] - rank[b.severity]).map(f => '<tr><td class="sev-' + f.severity + '">' + f.severity + '</td><td><a href="#" data-screen="' + f.screen + '">' + f.screen + '</a></td><td>' +
|
|
128
|
+
esc(f.kind.replace(/_/g, ' ')) + '</td><td>' + esc(f.detail) + '</td></tr>').join('') + '</table>' : '<p class="empty">No QA findings.</p>';
|
|
129
|
+
|
|
130
|
+
document.querySelectorAll('[role=tab]').forEach(tab => tab.addEventListener('click', () => {
|
|
131
|
+
document.querySelectorAll('[role=tab]').forEach(t => t.setAttribute('aria-selected', String(t === tab)));
|
|
132
|
+
document.querySelectorAll('section.view').forEach(v => v.hidden = v.id !== tab.dataset.view);
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
document.addEventListener('click', event => {
|
|
136
|
+
const target = event.target.closest('[data-screen]');
|
|
137
|
+
if (!target) return;
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
const s = screens[target.dataset.screen];
|
|
140
|
+
const into = doc.steps.filter(step => step.to === s.id).map(step => step.from + ' → ' + esc(step.action.label));
|
|
141
|
+
$('lightbox').innerHTML = '<div class="detail"><img src="' + esc(s.screenshot) + '" alt=""><div><h2 style="margin-top:0">' + esc(s.title) + '</h2>' +
|
|
142
|
+
'<p class="meta">' + s.id + ' · <a href="' + esc(s.url) + '" target="_blank" rel="noreferrer">' + esc(s.url) + '</a></p>' + typeChips(s) +
|
|
143
|
+
(s.description ? '<p>' + esc(s.description) + '</p>' : '') + (s.insight ? '<p class="insight"><i>' + esc(s.insight) + '</i></p>' : '') +
|
|
144
|
+
(into.length ? '<p><b>Reached from</b><br>' + into.join('<br>') + '</p>' : '') +
|
|
145
|
+
(s.qa.length ? '<p><b>QA</b><br>' + s.qa.map(f => '<span class="sev-' + f.severity + '">' + esc(f.kind) + '</span>: ' + esc(f.detail)).join('<br>') + '</p>' : '') +
|
|
146
|
+
'<p><button onclick="this.closest(\\'dialog\\').close()">Close</button></p></div></div>';
|
|
147
|
+
$('lightbox').showModal();
|
|
148
|
+
});
|
|
149
|
+
</script>
|
|
150
|
+
</body>
|
|
151
|
+
</html>
|
|
152
|
+
`;
|
|
153
|
+
const escapeHtml = (text) => text.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { noul } from '@typesafe-ai/sdk';
|
|
2
|
+
import { matchTarget, overlap, readBox, toViewportRect } from '../jev/box.js';
|
|
3
|
+
// A tappable thing the screenshot shows counts as missing from the DOM only when the model is this sure of it.
|
|
4
|
+
const UNLISTED_SURE = 0.6;
|
|
5
|
+
const BOX_SURE = 0.5;
|
|
6
|
+
/** Box questions over a screen's marked screenshot (Jev 1.14 on the TypeSafe API); ride along with the decision. */
|
|
7
|
+
export const screenBoxQuestions = () => ({
|
|
8
|
+
primary_action_box: { type: 'box', instructions: 'the primary action on this screen in `screenshot`' },
|
|
9
|
+
unlisted_control: noul('In `screenshot`, is there a tappable control (button, link, icon, tab) that has NO numbered outline? ' +
|
|
10
|
+
'Ignore plain text, images that are not controls, and the outlines themselves.'),
|
|
11
|
+
unlisted_control_box: {
|
|
12
|
+
type: 'box',
|
|
13
|
+
instructions: 'the most prominent tappable control in `screenshot` that has no numbered outline',
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
/**
|
|
17
|
+
* The screen's primary action as a viewer hotspot, and a QA finding when the screenshot shows a control the
|
|
18
|
+
* DOM reader never reported (canvas, iframe, image without a role): automation and assistive tech miss it too.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* const { primaryAction, finding } = readScreenBoxes(decision.answers, page);
|
|
22
|
+
*/
|
|
23
|
+
export const readScreenBoxes = (answers, page) => {
|
|
24
|
+
const primary = readBox(answers.primary_action_box);
|
|
25
|
+
const targets = Object.fromEntries(page.actions.filter((action) => action.rect).map((action) => [action.id, action]));
|
|
26
|
+
const primaryAction = primary && {
|
|
27
|
+
x: primary.edges.left,
|
|
28
|
+
y: primary.edges.top,
|
|
29
|
+
width: primary.edges.right - primary.edges.left,
|
|
30
|
+
height: primary.edges.bottom - primary.edges.top,
|
|
31
|
+
confidence: primary.confidence,
|
|
32
|
+
...labelOf(matchTarget(toViewportRect(primary.edges, page), targets).index, targets),
|
|
33
|
+
};
|
|
34
|
+
const unlisted = readBox(answers.unlisted_control_box);
|
|
35
|
+
const sure = (answers.unlisted_control?.noul ?? 0) >= UNLISTED_SURE && (unlisted?.confidence ?? 0) >= BOX_SURE;
|
|
36
|
+
const rect = unlisted && toViewportRect(unlisted.edges, page);
|
|
37
|
+
// Any overlap with a reported control means the DOM does know about it.
|
|
38
|
+
const known = rect && Object.values(targets).some((action) => overlap(rect, action.rect) > 0.1);
|
|
39
|
+
const finding = sure && rect && !known
|
|
40
|
+
? {
|
|
41
|
+
kind: 'control_not_in_dom',
|
|
42
|
+
severity: 'medium',
|
|
43
|
+
detail: `A tappable control at ${percent(unlisted.edges.left)} × ${percent(unlisted.edges.top)} has no DOM control (canvas, iframe, or image without a role)`,
|
|
44
|
+
}
|
|
45
|
+
: undefined;
|
|
46
|
+
return { primaryAction, finding };
|
|
47
|
+
};
|
|
48
|
+
const labelOf = (id, targets) => id ? { element: targets[id].label } : {};
|
|
49
|
+
const percent = (fraction) => `${Math.round(fraction * 100)}%`;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { choice, noul } from '@typesafe-ai/sdk';
|
|
2
|
+
export const SCREEN_TYPES = {
|
|
3
|
+
home: 'Landing or home screen: the entry point with top-level navigation and featured content.',
|
|
4
|
+
search: 'A search input screen or search sheet where the user enters a query, destination, or dates.',
|
|
5
|
+
results: 'A list or grid of search results, listings, products, or articles to choose from.',
|
|
6
|
+
detail: 'A single item in depth: product, listing, article, profile of one entity.',
|
|
7
|
+
filters: 'Controls to narrow or sort results: filter sheet, sort menu, facets.',
|
|
8
|
+
map: 'A map-centred view of places or results.',
|
|
9
|
+
form: 'A data-entry form that is not login or payment: contact, booking details, preferences.',
|
|
10
|
+
auth: 'Login, sign-up, or account verification wall.',
|
|
11
|
+
checkout: 'Cart, reservation summary, payment, or checkout step.',
|
|
12
|
+
confirmation: 'Success or confirmation after completing an action.',
|
|
13
|
+
account: 'The signed-in user area: profile, trips, orders, wishlists, settings.',
|
|
14
|
+
navigation_menu: 'An open navigation menu or drawer listing site sections.',
|
|
15
|
+
modal: 'A dialog, popup, cookie/consent banner, or bottom sheet covering the page that is not one of the above.',
|
|
16
|
+
onboarding: 'Intro, tutorial, or feature announcement steps.',
|
|
17
|
+
content: 'Informational or editorial content: help, about, blog, policy.',
|
|
18
|
+
error: 'An error page or failure state: 404, crash, access denied.',
|
|
19
|
+
};
|
|
20
|
+
export const PATTERNS = {
|
|
21
|
+
bottom_navigation: 'A persistent bottom tab bar with section icons.',
|
|
22
|
+
top_app_bar: 'A header bar with logo, title, or back button.',
|
|
23
|
+
menu_button: 'A hamburger or overflow menu button that opens navigation.',
|
|
24
|
+
search_bar: 'A visible search field or search pill.',
|
|
25
|
+
card_list: 'A vertical list or grid of repeated cards.',
|
|
26
|
+
carousel: 'A horizontally scrolling row of items or images.',
|
|
27
|
+
filter_chips: 'Tappable chips or pills that filter or switch categories.',
|
|
28
|
+
sheet_or_dialog: 'A bottom sheet, modal dialog, or overlay covering content.',
|
|
29
|
+
date_picker: 'A calendar or date range picker.',
|
|
30
|
+
map_view: 'An interactive map.',
|
|
31
|
+
form_fields: 'Several text inputs, selects, or toggles to fill in.',
|
|
32
|
+
primary_cta: 'One dominant call-to-action button (Reserve, Buy, Continue, Sign up).',
|
|
33
|
+
consent_banner: 'A cookie or privacy consent banner.',
|
|
34
|
+
price_display: 'Prices, totals, or fees shown to the user.',
|
|
35
|
+
};
|
|
36
|
+
const NEW_SCREEN = 'NEW_SCREEN';
|
|
37
|
+
/**
|
|
38
|
+
* Screen understanding asked in the same Jev request as the next-step decision: what screen this is,
|
|
39
|
+
* which UI patterns it uses, whether it repeats a visited screen, and QA states worth flagging.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* decide(ask, { ...input, extraQuestions: screenQuestions(visited) }) // still one round trip
|
|
43
|
+
*/
|
|
44
|
+
export const screenQuestions = (visited) => ({
|
|
45
|
+
screen_type: choice('What kind of screen is the user looking at right now? Judge the dominant purpose of the visible content and controls (page.text and elements). The URL and title are weak evidence: sheets, dialogs and single-page apps often keep the previous URL and title.', SCREEN_TYPES),
|
|
46
|
+
...Object.fromEntries(Object.entries(PATTERNS).map(([pattern, definition]) => [
|
|
47
|
+
`pattern_${pattern}`,
|
|
48
|
+
noul({ question: 'Is this UI pattern visible on the current screen?', pattern: definition }),
|
|
49
|
+
])),
|
|
50
|
+
...(visited.length && {
|
|
51
|
+
same_screen: choice({
|
|
52
|
+
question: "Is the current page the same screen as one already visited? Compare the visible content and controls with each visited screen's excerpt. Same screen means the same purpose and same main content, even if scroll position, a counter, or rotating images differ. A sheet or dialog opened over a page is a DIFFERENT screen from that page, even with the same URL and title. Different items of the same kind (another listing, another article) are NOT the same screen.",
|
|
53
|
+
visited_screens: visited.slice(-20),
|
|
54
|
+
}, {
|
|
55
|
+
...Object.fromEntries(visited.slice(-20).map((screen) => [screen.id, `${screen.type}: ${screen.title}`])),
|
|
56
|
+
[NEW_SCREEN]: 'A screen that has not been visited yet.',
|
|
57
|
+
}),
|
|
58
|
+
}),
|
|
59
|
+
auth_wall: noul('Does the current screen require logging in or signing up before the user can continue?'),
|
|
60
|
+
error_state: noul('Does the screen show an error message, failure, or broken state to the user?'),
|
|
61
|
+
empty_state: noul('Does the screen show an empty state: an area meant to list content says or shows there is nothing (no results, nothing saved, no trips)? An unfilled form or search field is NOT an empty state.'),
|
|
62
|
+
dead_end: noul('Is this a dead end for a mobile user: no clear way forward and no visible way back or to navigate?'),
|
|
63
|
+
});
|
|
64
|
+
export const readScreenJudgments = (answers) => {
|
|
65
|
+
const sameScreen = answers.same_screen;
|
|
66
|
+
const sameConfident = sameScreen && sameScreen.choice !== NEW_SCREEN && sameScreen.probabilities[sameScreen.choice] >= 0.7;
|
|
67
|
+
return {
|
|
68
|
+
type: answers.screen_type.choice,
|
|
69
|
+
typeConfidence: answers.screen_type.confidence,
|
|
70
|
+
patterns: Object.keys(PATTERNS).filter((pattern) => answers[`pattern_${pattern}`]?.noul >= 0.5),
|
|
71
|
+
sameAs: sameConfident ? sameScreen.choice : null,
|
|
72
|
+
authWall: answers.auth_wall.noul,
|
|
73
|
+
errorState: answers.error_state.noul,
|
|
74
|
+
emptyState: answers.empty_state.noul,
|
|
75
|
+
deadEnd: answers.dead_end.noul,
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
const MIN_TAP_TARGET = 44;
|
|
79
|
+
const TAP_ROLES = new Set([
|
|
80
|
+
'button',
|
|
81
|
+
'checkbox',
|
|
82
|
+
'radio',
|
|
83
|
+
'switch',
|
|
84
|
+
'tab',
|
|
85
|
+
'menuitem',
|
|
86
|
+
'combobox',
|
|
87
|
+
'textbox',
|
|
88
|
+
'searchbox',
|
|
89
|
+
]);
|
|
90
|
+
/**
|
|
91
|
+
* Deterministic mobile QA from the snapshot and diagnostics, plus Jev's state judgments.
|
|
92
|
+
* Thresholds: 44×44 CSS px tap targets (Apple HIG), any horizontal overflow, Jev states ≥ 0.6.
|
|
93
|
+
*/
|
|
94
|
+
export const qaFindings = (page, diagnostics, judgments, overflowPx, mobile) => {
|
|
95
|
+
const findings = [];
|
|
96
|
+
for (const error of diagnostics.consoleErrors)
|
|
97
|
+
findings.push({ kind: 'console_error', severity: 'medium', detail: error });
|
|
98
|
+
for (const failure of diagnostics.failedRequests) {
|
|
99
|
+
findings.push({
|
|
100
|
+
kind: 'failed_request',
|
|
101
|
+
severity: failure.status && failure.status < 500 ? 'low' : 'medium',
|
|
102
|
+
detail: `${failure.status ?? failure.error} ${failure.url}`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (overflowPx > 1) {
|
|
106
|
+
findings.push({
|
|
107
|
+
kind: 'horizontal_overflow',
|
|
108
|
+
severity: 'high',
|
|
109
|
+
detail: `Page is ${Math.round(overflowPx)}px wider than the viewport`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
const controls = uniqueByNode(page.actions.filter((action) => action.kind === 'click' || action.kind === 'fill'));
|
|
113
|
+
if (mobile) {
|
|
114
|
+
const small = controls.filter((action) => TAP_ROLES.has(action.role ?? '') &&
|
|
115
|
+
action.rect &&
|
|
116
|
+
(action.rect.w < MIN_TAP_TARGET || action.rect.h < MIN_TAP_TARGET));
|
|
117
|
+
if (small.length) {
|
|
118
|
+
findings.push({
|
|
119
|
+
kind: 'small_tap_target',
|
|
120
|
+
severity: 'medium',
|
|
121
|
+
detail: `${small.length} control(s) under ${MIN_TAP_TARGET}×${MIN_TAP_TARGET}px: ${small
|
|
122
|
+
.slice(0, 5)
|
|
123
|
+
.map((action) => `"${action.label}" ${Math.round(action.rect.w)}×${Math.round(action.rect.h)}`)
|
|
124
|
+
.join(', ')}`,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const unlabeled = controls.filter((action) => action.label === action.role);
|
|
129
|
+
if (unlabeled.length) {
|
|
130
|
+
findings.push({
|
|
131
|
+
kind: 'unlabeled_control',
|
|
132
|
+
severity: 'medium',
|
|
133
|
+
detail: `${unlabeled.length} control(s) without an accessible name (${[...new Set(unlabeled.map((a) => a.role))].join(', ')})`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const states = [
|
|
137
|
+
['error_state', judgments.errorState, 'high', 'Screen shows an error or broken state'],
|
|
138
|
+
['empty_state', judgments.emptyState, 'low', 'Screen shows an empty state'],
|
|
139
|
+
['dead_end', judgments.deadEnd, 'medium', 'No clear way forward or back'],
|
|
140
|
+
];
|
|
141
|
+
for (const [kind, probability, severity, detail] of states) {
|
|
142
|
+
if (probability >= 0.6)
|
|
143
|
+
findings.push({ kind, severity, detail: `${detail} (Jev ${probability.toFixed(2)})` });
|
|
144
|
+
}
|
|
145
|
+
return findings;
|
|
146
|
+
};
|
|
147
|
+
const uniqueByNode = (actions) => [
|
|
148
|
+
...new Map(actions.map((a) => [a.node, a])).values(),
|
|
149
|
+
];
|