@webability/cli 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +6 -0
  2. package/dist/cli.js +871 -122
  3. package/package.json +9 -6
package/README.md CHANGED
@@ -47,6 +47,12 @@ threshold:
47
47
  serious: 5
48
48
  ```
49
49
 
50
+ ## Links
51
+
52
+ - [WebAbility](https://webability.io) — AI-powered web accessibility platform
53
+ - [Abilyo](https://abilyo.com) — Developer tools for accessibility testing
54
+ - [Documentation](https://abilyo.com/docs)
55
+
50
56
  ## License
51
57
 
52
58
  MIT
package/dist/cli.js CHANGED
@@ -1,4 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
3
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
4
+ }) : x)(function(x) {
5
+ if (typeof require !== "undefined") return require.apply(this, arguments);
6
+ throw Error('Dynamic require of "' + x + '" is not supported');
7
+ });
2
8
 
3
9
  // src/cli.ts
4
10
  import { Command } from "commander";
@@ -67,15 +73,6 @@ async function getReport(r2Key, apiKey) {
67
73
  );
68
74
  return fetchReportByR2Key;
69
75
  }
70
- async function login(email, password) {
71
- const { login: result } = await graphql(
72
- `mutation($email: String!, $password: String!) { login(email: $email, password: $password) { token } }`,
73
- { email, password },
74
- "public"
75
- // login doesn't need a key
76
- );
77
- return result.token;
78
- }
79
76
 
80
77
  // src/config.ts
81
78
  import Conf from "conf";
@@ -107,25 +104,640 @@ function clearConfig() {
107
104
  }
108
105
 
109
106
  // src/local-scan.ts
110
- import { scan as scan2 } from "@webability/core";
107
+ import { scan as scan2, detectFramework } from "@webability/core";
111
108
  var WCAG_TAG_MAP = {
112
109
  "A": ["wcag2a"],
113
110
  "AA": ["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"],
114
111
  "AAA": ["wcag2a", "wcag2aa", "wcag2aaa", "wcag21aa", "wcag22aa"]
115
112
  };
113
+ var IMPACT_COLORS = {
114
+ critical: "#ef4444",
115
+ serious: "#f97316",
116
+ moderate: "#eab308",
117
+ minor: "#6b7280"
118
+ };
119
+ var IMPACT_ICONS = {
120
+ critical: "\u2717",
121
+ serious: "\u25B2",
122
+ moderate: "\u25CF",
123
+ minor: "\u25CB"
124
+ };
125
+ function buildHighlightScript(issues, apiKey, framework) {
126
+ const serialized = JSON.stringify(
127
+ issues.map((i, idx) => ({
128
+ id: idx,
129
+ selector: i.selector,
130
+ impact: i.impact,
131
+ wcag: i.wcag,
132
+ message: i.message.slice(0, 200),
133
+ fixDesc: i.fix?.suggestedValue ? `Set ${i.fix.attribute}="${i.fix.suggestedValue}"` : "",
134
+ fixAttr: i.fix?.attribute || "",
135
+ fixVal: i.fix?.suggestedValue || "",
136
+ fixCur: i.fix?.currentValue || "",
137
+ canApply: !!(i.fix?.suggestedValue && !i.fix?.needsManualReview)
138
+ }))
139
+ );
140
+ return `
141
+ (function() {
142
+ const issues = ${serialized};
143
+ const colors = ${JSON.stringify(IMPACT_COLORS)};
144
+ const icons = ${JSON.stringify(IMPACT_ICONS)};
145
+ const API_KEY = ${JSON.stringify(apiKey || "")};
146
+ const API_URL = 'https://api.webability.io';
147
+ const FRAMEWORK = ${JSON.stringify(framework || "plain-css")};
148
+ const activeFilters = { critical: true, serious: true, moderate: true, minor: true };
149
+ const overlays = [];
150
+
151
+ function h(tag, props, children) {
152
+ const el = document.createElement(tag);
153
+ if (props) {
154
+ for (const [k, v] of Object.entries(props)) {
155
+ if (k === 'style' && typeof v === 'object') Object.assign(el.style, v);
156
+ else if (k === 'className') el.className = v;
157
+ else if (k.startsWith('on')) el.addEventListener(k.slice(2).toLowerCase(), v);
158
+ else el.setAttribute(k, v);
159
+ }
160
+ }
161
+ if (typeof children === 'string') el.textContent = children;
162
+ else if (Array.isArray(children)) children.forEach(c => { if (c) el.appendChild(c); });
163
+ return el;
164
+ }
165
+
166
+ const style = document.createElement('style');
167
+ style.textContent = [
168
+ '@keyframes abilyo-reveal{from{opacity:0;transform:scale(.97)}to{opacity:1;transform:scale(1)}}',
169
+ '.abilyo-overlay{position:absolute;pointer-events:auto;z-index:2147483640;outline:2px solid red;outline-offset:2px;border-radius:3px;box-sizing:border-box;cursor:pointer;transition:opacity .2s,outline-color .2s;animation:abilyo-reveal .25s ease-out backwards}',
170
+ '.abilyo-overlay:hover{outline-width:3px;outline-offset:1px}',
171
+ '.abilyo-overlay.abilyo-active{outline:3px solid #445AE7!important;outline-offset:2px}',
172
+ '.abilyo-overlay.abilyo-hidden{opacity:0;pointer-events:none}',
173
+ '.abilyo-overlay.abilyo-dimmed{opacity:.12;filter:blur(1px)}',
174
+ '.abilyo-spotlight{position:fixed;inset:0;z-index:2147483639;background:rgba(0,0,0,.35);backdrop-filter:blur(2px);pointer-events:none;transition:opacity .2s}',
175
+ '.abilyo-label{position:absolute;top:-22px;left:-2px;font:bold 11px/14px system-ui,sans-serif;color:#fff;padding:2px 6px;border-radius:3px 3px 0 0;white-space:nowrap;pointer-events:none;z-index:2147483641}',
176
+ '#abilyo-panel{position:fixed;top:12px;right:12px;z-index:2147483647;width:380px;max-height:calc(100vh - 24px);background:#fff;border-radius:12px;box-shadow:0 8px 32px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);font:13px system-ui,-apple-system,sans-serif;color:#1e1e1e;display:flex;flex-direction:column;overflow:hidden}',
177
+ '#abilyo-panel *{box-sizing:border-box}',
178
+ '.ap-header{display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid #eee;flex-shrink:0}',
179
+ '.ap-logo{width:20px;height:20px;background:#445AE7;border-radius:4px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:800;font-size:12px}',
180
+ '.ap-title{font-weight:700;font-size:14px;flex:1}',
181
+ '.ap-count{font-size:12px;color:#fff;background:#445AE7;padding:2px 8px;border-radius:10px;font-weight:600}',
182
+ '.ap-close{width:28px;height:28px;border:none;background:#f3f3f3;border-radius:6px;cursor:pointer;font-size:14px;color:#666;display:flex;align-items:center;justify-content:center}',
183
+ '.ap-close:hover{background:#e5e5e5}',
184
+ '.ap-toolbar{display:flex;gap:6px;padding:8px 16px;border-bottom:1px solid #eee;flex-shrink:0}',
185
+ '.ap-tbtn{padding:4px 10px;border-radius:6px;border:1px solid #ddd;background:#fff;cursor:pointer;font:500 11px system-ui;color:#555;transition:all .15s;display:flex;align-items:center;gap:4px}',
186
+ '.ap-tbtn:hover{background:#f5f5f5;border-color:#bbb}',
187
+ '.ap-tbtn.active{background:#445AE7;color:#fff;border-color:#445AE7}',
188
+ '.ap-tbtn-icon{font-size:13px}',
189
+ '.ap-score{margin-left:auto;font:700 13px system-ui;color:#445AE7}',
190
+ '.ap-filters{display:flex;gap:6px;padding:10px 16px;border-bottom:1px solid #eee;flex-shrink:0}',
191
+ '.ap-fbtn{display:flex;align-items:center;gap:4px;padding:4px 10px;border-radius:6px;border:1.5px solid #ddd;background:#fff;cursor:pointer;font:600 12px system-ui;transition:all .15s}',
192
+ '.ap-fbtn.active{border-color:currentColor;background:currentColor;color:#fff!important}',
193
+ '.ap-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}',
194
+ '.ap-list{flex:1;overflow-y:auto;padding:4px 0;scrollbar-width:thin}',
195
+ '.ap-row{display:flex;align-items:flex-start;gap:10px;padding:10px 16px;cursor:pointer;border-bottom:1px solid #f5f5f5;transition:background .1s}',
196
+ '.ap-row:hover{background:#f8f9ff}',
197
+ '.ap-row.active{background:#eef1ff}',
198
+ '.ap-icon{width:22px;height:22px;border-radius:5px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:800;color:#fff;flex-shrink:0;margin-top:1px}',
199
+ '.ap-content{flex:1;min-width:0}',
200
+ '.ap-wcag{font-size:11px;font-weight:700;color:#445AE7;background:#eef1ff;padding:1px 6px;border-radius:3px;margin-right:6px}',
201
+ '.ap-sev{font-size:12px;font-weight:600}',
202
+ '.ap-msg{font-size:12px;color:#555;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
203
+ '.ap-fix{font-size:11px;color:#16a34a;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
204
+ '#ap-detail{display:none;flex-shrink:0;border-top:1px solid #eee;padding:14px 16px;background:#fafbfc;max-height:200px;overflow-y:auto}',
205
+ '.apd-head{display:flex;align-items:center;gap:8px;margin-bottom:8px}',
206
+ '.apd-wcag{font-weight:700;color:#445AE7;font-size:14px}',
207
+ '.apd-badge{font-size:11px;font-weight:700;padding:2px 8px;border-radius:4px;color:#fff;text-transform:uppercase}',
208
+ '.apd-msg{font-size:13px;color:#333;line-height:1.4;margin-bottom:6px}',
209
+ '.apd-fix{font-size:12px;color:#16a34a;background:#f0fdf4;padding:6px 10px;border-radius:6px;margin-top:6px}',
210
+ '.apd-sel{font-size:11px;color:#888;font-family:monospace;margin-top:6px;word-break:break-all}',
211
+ '.apd-actions{display:flex;gap:8px;margin-top:10px}',
212
+ '.apd-btn{padding:6px 14px;border-radius:6px;border:none;cursor:pointer;font:600 12px system-ui;transition:all .15s}',
213
+ '.apd-preview{background:#eef1ff;color:#445AE7}',
214
+ '.apd-preview:hover{background:#dde5ff}',
215
+ '.apd-preview.active{background:#445AE7;color:#fff}',
216
+ '.apd-apply{background:#16a34a;color:#fff}',
217
+ '.apd-apply:hover{background:#15803d}',
218
+ '.apd-apply.applied{background:#6b7280;cursor:default}',
219
+ '.apd-before-after{display:flex;gap:12px;margin-top:8px;font-size:12px}',
220
+ '.apd-ba-col{flex:1;padding:8px;border-radius:6px}',
221
+ '.apd-ba-before{background:#fef2f2;border:1px solid #fecaca}',
222
+ '.apd-ba-after{background:#f0fdf4;border:1px solid #bbf7d0}',
223
+ '.apd-ba-label{font-weight:700;font-size:10px;text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px;color:#888}',
224
+ '.apd-ba-value{font-family:monospace;font-size:11px;word-break:break-all}',
225
+ '.apd-ai{background:linear-gradient(135deg,#8b5cf6,#6366f1);color:#fff;position:relative;overflow:hidden}',
226
+ '.apd-ai:hover{background:linear-gradient(135deg,#7c3aed,#4f46e5)}',
227
+ '.apd-ai:disabled{opacity:.7;cursor:wait}',
228
+ '.apd-ai-sparkle{display:inline-block;margin-right:4px}',
229
+ '@keyframes apd-shimmer{0%{background-position:-200% 0}100%{background-position:200% 0}}',
230
+ '.apd-ai-loading{background:linear-gradient(90deg,#8b5cf6 25%,#c4b5fd 50%,#8b5cf6 75%);background-size:200% 100%;animation:apd-shimmer 1.5s infinite}',
231
+ '.apd-ai-result{margin-top:8px;padding:10px;background:linear-gradient(135deg,#f5f3ff,#ede9fe);border:1px solid #c4b5fd;border-radius:8px}',
232
+ '.apd-ai-explain{font-size:12px;color:#5b21b6;margin-bottom:6px;line-height:1.4}',
233
+ '.apd-ai-code{font-size:11px;font-family:monospace;color:#6d28d9;background:#ede9fe;padding:4px 8px;border-radius:4px;word-break:break-all}',
234
+ '#abilyo-pill{display:none;position:fixed;top:12px;right:12px;z-index:2147483647;background:#445AE7;color:#fff;border-radius:10px;padding:8px 14px;cursor:pointer;font:700 13px/1 system-ui;box-shadow:0 4px 16px rgba(68,90,231,.35);transition:transform .15s}',
235
+ '#abilyo-pill:hover{transform:scale(1.05)}',
236
+ ].join('\\n');
237
+ document.head.appendChild(style);
238
+
239
+ // --- Create overlays with staggered reveal ---
240
+ const seen = new Set();
241
+ const elements = [];
242
+ let visibleIdx = 0;
243
+ for (const issue of issues) {
244
+ if (seen.has(issue.selector)) { overlays.push(null); elements.push(null); continue; }
245
+ seen.add(issue.selector);
246
+ try {
247
+ const el = document.querySelector(issue.selector);
248
+ if (!el) { overlays.push(null); elements.push(null); continue; }
249
+ const rect = el.getBoundingClientRect();
250
+ if (rect.width === 0 && rect.height === 0) { overlays.push(null); elements.push(null); continue; }
251
+
252
+ const color = colors[issue.impact] || '#ef4444';
253
+ const delay = Math.min(visibleIdx * 30, 600);
254
+ const overlay = h('div', {
255
+ className: 'abilyo-overlay',
256
+ 'data-impact': issue.impact,
257
+ 'data-id': String(issue.id),
258
+ style: { top: (rect.top+window.scrollY)+'px', left: (rect.left+window.scrollX)+'px', width: rect.width+'px', height: rect.height+'px', outlineColor: color, background: color+'0d', animationDelay: delay+'ms' },
259
+ onClick: () => selectIssue(issue.id),
260
+ }, [
261
+ h('div', { className: 'abilyo-label', style: { background: color, animationDelay: delay+'ms' } }, icons[issue.impact]+' '+issue.wcag)
262
+ ]);
263
+ document.body.appendChild(overlay);
264
+ overlays.push(overlay);
265
+ elements.push(el);
266
+ visibleIdx++;
267
+ } catch { overlays.push(null); elements.push(null); }
268
+ }
269
+
270
+ // --- Reposition overlays on scroll/resize ---
271
+ let repositionRaf = 0;
272
+ function repositionOverlays() {
273
+ for (let i = 0; i < overlays.length; i++) {
274
+ const ov = overlays[i]; const el = elements[i];
275
+ if (!ov || !el) continue;
276
+ const rect = el.getBoundingClientRect();
277
+ ov.style.top = (rect.top + window.scrollY) + 'px';
278
+ ov.style.left = (rect.left + window.scrollX) + 'px';
279
+ ov.style.width = rect.width + 'px';
280
+ ov.style.height = rect.height + 'px';
281
+ }
282
+ }
283
+ function scheduleReposition() {
284
+ cancelAnimationFrame(repositionRaf);
285
+ repositionRaf = requestAnimationFrame(repositionOverlays);
286
+ }
287
+ window.addEventListener('scroll', scheduleReposition, { passive: true });
288
+ window.addEventListener('resize', scheduleReposition);
289
+
290
+ // --- Counts ---
291
+ const counts = { critical: 0, serious: 0, moderate: 0, minor: 0 };
292
+ issues.forEach(i => counts[i.impact]++);
293
+
294
+ // --- Dedup for list ---
295
+ const deduped = [];
296
+ const seenList = new Set();
297
+ for (const issue of issues) {
298
+ if (seenList.has(issue.selector)) continue;
299
+ seenList.add(issue.selector);
300
+ deduped.push(issue);
301
+ }
302
+
303
+ // --- Build panel ---
304
+ const listEl = h('div', { className: 'ap-list', id: 'ap-list' });
305
+ const detailEl = h('div', { id: 'ap-detail' });
306
+ const filtersEl = h('div', { className: 'ap-filters' });
307
+ const toolbarEl = h('div', { className: 'ap-toolbar' });
308
+
309
+ // Toggle overlays button
310
+ let overlaysVisible = true;
311
+ const toggleBtn = h('button', { className: 'ap-tbtn active', onClick: () => {
312
+ overlaysVisible = !overlaysVisible;
313
+ toggleBtn.classList.toggle('active', overlaysVisible);
314
+ document.querySelectorAll('.abilyo-overlay').forEach(o => { (o).style.display = overlaysVisible ? '' : 'none'; });
315
+ } }, [h('span', { className: 'ap-tbtn-icon' }, '\\u25CF'), document.createTextNode('Overlays')]);
316
+ toolbarEl.appendChild(toggleBtn);
317
+
318
+ // Score display
319
+ const score = issues.length === 0 ? 100 : Math.max(0, 100 - issues.length);
320
+ const scoreColor = score >= 90 ? '#16a34a' : score >= 50 ? '#eab308' : '#ef4444';
321
+ toolbarEl.appendChild(h('div', { className: 'ap-score', style: { color: scoreColor } }, score + '%'));
322
+
323
+ const pill = h('div', { id: 'abilyo-pill', onClick: () => { panel.style.display = 'flex'; pill.style.display = 'none'; } }, 'A \\u00B7 ' + issues.length + ' issues');
324
+
325
+ const minBtn = h('button', { className: 'ap-close', title: 'Minimize', onClick: () => { panel.style.display = 'none'; pill.style.display = 'block'; } }, '\\u2014');
326
+
327
+ const panel = h('div', { id: 'abilyo-panel' }, [
328
+ h('div', { className: 'ap-header' }, [
329
+ h('div', { className: 'ap-logo' }, 'A'),
330
+ h('div', { className: 'ap-title' }, 'Abilyo'),
331
+ h('div', { className: 'ap-count' }, String(issues.length) + ' issues'),
332
+ minBtn,
333
+ ]),
334
+ toolbarEl,
335
+ filtersEl,
336
+ listEl,
337
+ detailEl,
338
+ ]);
339
+ document.body.appendChild(panel);
340
+ document.body.appendChild(pill);
341
+
342
+ // --- Filter buttons ---
343
+ for (const sev of ['critical','serious','moderate','minor']) {
344
+ if (counts[sev] === 0) continue;
345
+ const dot = h('span', { className: 'ap-dot', style: { background: '#fff' } });
346
+ const btn = h('button', {
347
+ className: 'ap-fbtn active',
348
+ style: { color: colors[sev] },
349
+ title: sev,
350
+ onClick: () => {
351
+ activeFilters[sev] = !activeFilters[sev];
352
+ btn.classList.toggle('active', activeFilters[sev]);
353
+ dot.style.background = activeFilters[sev] ? '#fff' : colors[sev];
354
+ applyFilters();
355
+ },
356
+ }, [dot, h('span', { style: { fontWeight: '700' } }, String(counts[sev]))]);
357
+ filtersEl.appendChild(btn);
358
+ }
359
+
360
+ // --- Rendering ---
361
+ let activeId = -1;
362
+
363
+ function renderList() {
364
+ listEl.replaceChildren();
365
+ for (const issue of deduped) {
366
+ if (!activeFilters[issue.impact]) continue;
367
+ const contentChildren = [
368
+ h('span', { className: 'ap-wcag' }, issue.wcag),
369
+ h('span', { className: 'ap-sev' }, issue.impact.toUpperCase()),
370
+ h('div', { className: 'ap-msg' }, issue.message),
371
+ ];
372
+ if (issue.fixDesc) contentChildren.push(h('div', { className: 'ap-fix' }, 'Fix: ' + issue.fixDesc));
373
+
374
+ const row = h('div', {
375
+ className: 'ap-row' + (issue.id === activeId ? ' active' : ''),
376
+ onClick: () => selectIssue(issue.id),
377
+ onMouseenter: () => { const o = overlays[issue.id]; if (o) { o.style.outlineWidth = '3px'; o.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } },
378
+ onMouseleave: () => { const o = overlays[issue.id]; if (o && issue.id !== activeId) o.style.outlineWidth = '2px'; },
379
+ }, [
380
+ h('div', { className: 'ap-icon', style: { background: colors[issue.impact] } }, icons[issue.impact]),
381
+ h('div', { className: 'ap-content' }, contentChildren),
382
+ ]);
383
+ listEl.appendChild(row);
384
+ }
385
+ }
386
+
387
+ const appliedFixes = new Set();
388
+ const previewState = { id: -1, attr: '', hadAttr: false, original: '' };
389
+
390
+ function revertPreview() {
391
+ if (previewState.id < 0) return;
392
+ const prevIssue = issues[previewState.id];
393
+ const prevEl = prevIssue ? document.querySelector(prevIssue.selector) : null;
394
+ if (prevEl && previewState.attr) {
395
+ if (previewState.hadAttr) prevEl.setAttribute(previewState.attr, previewState.original);
396
+ else prevEl.removeAttribute(previewState.attr);
397
+ }
398
+ previewState.id = -1; previewState.attr = ''; previewState.hadAttr = false; previewState.original = '';
399
+ }
400
+
401
+ function previewFix(issue) {
402
+ const el = document.querySelector(issue.selector);
403
+ if (!el) return false;
404
+ if (previewState.id === issue.id) { revertPreview(); return false; }
405
+ revertPreview();
406
+ const attr = issue.fixAttr || 'style';
407
+ previewState.attr = attr;
408
+ previewState.hadAttr = el.hasAttribute(attr);
409
+ previewState.original = el.getAttribute(attr) || '';
410
+ previewState.id = issue.id;
411
+ if (attr === 'style') {
412
+ el.setAttribute('style', (previewState.original ? previewState.original + ';' : '') + issue.fixVal);
413
+ } else {
414
+ el.setAttribute(attr, issue.fixVal);
415
+ }
416
+ return true;
417
+ }
418
+
419
+ function applyFix(issue) {
420
+ if (appliedFixes.has(issue.id)) return;
421
+ const el = document.querySelector(issue.selector);
422
+ if (!el) return;
423
+ if (issue.fixAttr === 'style') {
424
+ const cur = el.getAttribute('style') || '';
425
+ el.setAttribute('style', (cur ? cur + ';' : '') + issue.fixVal);
426
+ } else if (issue.fixAttr) {
427
+ el.setAttribute(issue.fixAttr, issue.fixVal);
428
+ }
429
+ appliedFixes.add(issue.id);
430
+ previewState.id = -1;
431
+ const ov = overlays[issue.id];
432
+ if (ov) { ov.style.outlineColor = '#16a34a'; ov.querySelector('.abilyo-label').style.background = '#16a34a'; ov.querySelector('.abilyo-label').textContent = '\\u2713 Fixed'; }
433
+ }
434
+
435
+ function selectIssue(id) {
436
+ activeId = id;
437
+ const issue = issues[id];
438
+ if (!issue) return;
439
+
440
+ if (previewState.id >= 0 && previewState.id !== id) revertPreview();
441
+
442
+ document.querySelectorAll('.abilyo-overlay').forEach(o => {
443
+ o.classList.remove('abilyo-active');
444
+ o.classList.add('abilyo-dimmed');
445
+ });
446
+ const ov = overlays[id];
447
+ if (ov) {
448
+ ov.classList.remove('abilyo-dimmed');
449
+ ov.classList.add('abilyo-active');
450
+ ov.scrollIntoView({ behavior: 'smooth', block: 'center' });
451
+ }
452
+
453
+ detailEl.style.display = 'block';
454
+ detailEl.replaceChildren();
455
+ detailEl.appendChild(h('div', { className: 'apd-head' }, [
456
+ h('span', { className: 'apd-wcag' }, 'WCAG ' + issue.wcag),
457
+ h('span', { className: 'apd-badge', style: { background: colors[issue.impact] } }, issue.impact),
458
+ ]));
459
+ detailEl.appendChild(h('div', { className: 'apd-msg' }, issue.message));
460
+
461
+ if (issue.canApply && !appliedFixes.has(issue.id)) {
462
+ const ba = h('div', { className: 'apd-before-after' }, [
463
+ h('div', { className: 'apd-ba-col apd-ba-before' }, [
464
+ h('div', { className: 'apd-ba-label' }, 'Before'),
465
+ h('div', { className: 'apd-ba-value' }, issue.fixCur || '(none)'),
466
+ ]),
467
+ h('div', { className: 'apd-ba-col apd-ba-after' }, [
468
+ h('div', { className: 'apd-ba-label' }, 'After'),
469
+ h('div', { className: 'apd-ba-value' }, issue.fixVal),
470
+ ]),
471
+ ]);
472
+ detailEl.appendChild(ba);
473
+
474
+ const previewBtn = h('button', { className: 'apd-btn apd-preview' }, '\\uD83D\\uDD0D Preview');
475
+ const applyBtn = h('button', { className: 'apd-btn apd-apply' }, '\\u2713 Apply Fix');
476
+
477
+ previewBtn.addEventListener('click', () => {
478
+ const on = previewFix(issue);
479
+ previewBtn.classList.toggle('active', on);
480
+ previewBtn.textContent = on ? '\\u21A9 Revert' : '\\uD83D\\uDD0D Preview';
481
+ });
482
+ applyBtn.addEventListener('click', () => {
483
+ applyFix(issue);
484
+ applyBtn.classList.add('applied');
485
+ applyBtn.textContent = '\\u2713 Applied';
486
+ previewBtn.style.display = 'none';
487
+ });
488
+
489
+ detailEl.appendChild(h('div', { className: 'apd-actions' }, [previewBtn, applyBtn]));
490
+ } else if (appliedFixes.has(issue.id)) {
491
+ detailEl.appendChild(h('div', { className: 'apd-fix' }, '\\u2713 Fix applied'));
492
+ } else if (issue.fixDesc) {
493
+ detailEl.appendChild(h('div', { className: 'apd-fix' }, '\\uD83D\\uDCA1 ' + issue.fixDesc));
494
+ }
495
+
496
+ // AI Fix button \u2014 available to everyone, no login needed
497
+ if (!appliedFixes.has(issue.id)) {
498
+ const aiResultContainer = h('div');
499
+ const aiBtn = h('button', { className: 'apd-btn apd-ai' }, [
500
+ h('span', { className: 'apd-ai-sparkle' }, '\\u2728'),
501
+ document.createTextNode('AI Fix'),
502
+ ]);
503
+ aiBtn.addEventListener('click', async () => {
504
+ aiBtn.disabled = true;
505
+ aiBtn.classList.add('apd-ai-loading');
506
+ aiBtn.textContent = '\\u2728 Generating...';
507
+ try {
508
+ const el = document.querySelector(issue.selector);
509
+ const elHtml = el ? el.outerHTML.slice(0, 600) : '';
510
+ const parent = el && el.parentElement ? el.parentElement.outerHTML.slice(0, 400) : '';
511
+ // Detect brand colors from the page
512
+ const allEls = document.querySelectorAll('a, button, h1, h2, .bg-primary, [class*="brand"], [class*="primary"]');
513
+ const colorSet = new Set();
514
+ allEls.forEach(e => {
515
+ const s = getComputedStyle(e);
516
+ if (s.color && s.color !== 'rgb(0, 0, 0)') colorSet.add(s.color);
517
+ if (s.backgroundColor && s.backgroundColor !== 'rgba(0, 0, 0, 0)') colorSet.add(s.backgroundColor);
518
+ });
519
+ const brandCols = Array.from(colorSet).slice(0, 8);
520
+
521
+ const headers = { 'Content-Type': 'application/json' };
522
+ if (API_KEY) headers['Authorization'] = 'Bearer ' + API_KEY;
523
+ const res = await fetch(API_URL + '/cli/ai-fix', {
524
+ method: 'POST', headers,
525
+ body: JSON.stringify({
526
+ issue: { type: issue.wcag, wcag: issue.wcag, impact: issue.impact, message: issue.message, selector: issue.selector, fixCur: issue.fixCur },
527
+ html: elHtml, context: parent, url: window.location.href,
528
+ framework: FRAMEWORK, brandColors: brandCols,
529
+ }),
530
+ });
531
+ if (!res.ok) throw new Error('API error');
532
+ const data = await res.json();
533
+ aiBtn.classList.remove('apd-ai-loading');
534
+ aiBtn.textContent = '\\u2728 AI Fix';
535
+ aiBtn.disabled = false;
536
+
537
+ const alts = data.alternatives || [];
538
+ aiResultContainer.replaceChildren();
539
+
540
+ for (let ai = 0; ai < alts.length; ai++) {
541
+ const alt = alts[ai];
542
+ const altBox = h('div', { className: 'apd-ai-result', style: ai > 0 ? { marginTop: '6px' } : {} }, [
543
+ h('div', { style: { fontWeight: '700', fontSize: '12px', color: '#5b21b6', marginBottom: '4px' } }, alt.label || 'Option ' + (ai+1)),
544
+ h('div', { className: 'apd-ai-explain' }, alt.explanation || ''),
545
+ h('div', { className: 'apd-ai-code' }, alt.frameworkCode || (alt.attribute + '="' + alt.value + '"')),
546
+ ]);
547
+
548
+ const altActions = h('div', { className: 'apd-actions', style: { marginTop: '6px' } });
549
+ const pBtn = h('button', { className: 'apd-btn apd-preview', style: { fontSize: '11px', padding: '4px 10px' } }, '\\uD83D\\uDD0D Preview');
550
+ const aBtn = h('button', { className: 'apd-btn apd-apply', style: { fontSize: '11px', padding: '4px 10px' } }, '\\u2713 Apply');
551
+
552
+ pBtn.addEventListener('click', () => {
553
+ const t = document.querySelector(issue.selector);
554
+ if (!t) return;
555
+ if (previewState.id === issue.id) { revertPreview(); pBtn.textContent = '\\uD83D\\uDD0D Preview'; pBtn.classList.remove('active'); return; }
556
+ revertPreview();
557
+ const previewAttr = (alt.attribute === 'style' || alt.previewCss) ? 'style' : (alt.attribute || 'style');
558
+ previewState.attr = previewAttr;
559
+ previewState.hadAttr = t.hasAttribute(previewAttr);
560
+ previewState.original = t.getAttribute(previewAttr) || '';
561
+ previewState.id = issue.id;
562
+ if (alt.attribute === 'style') t.setAttribute('style', (previewState.original ? previewState.original + ';' : '') + alt.value);
563
+ else if (alt.previewCss) t.setAttribute('style', (previewState.original ? previewState.original + ';' : '') + alt.previewCss);
564
+ else if (alt.attribute) t.setAttribute(alt.attribute, alt.value);
565
+ pBtn.textContent = '\\u21A9 Revert'; pBtn.classList.add('active');
566
+ });
567
+ aBtn.addEventListener('click', () => {
568
+ const t = document.querySelector(issue.selector);
569
+ if (!t) return;
570
+ if (alt.attribute === 'style') { const c = t.getAttribute('style') || ''; t.setAttribute('style', (c ? c + ';' : '') + alt.value); }
571
+ else if (alt.attribute) t.setAttribute(alt.attribute, alt.value);
572
+ appliedFixes.add(issue.id); previewState.id = -1;
573
+ const o = overlays[issue.id];
574
+ if (o) { o.style.outlineColor = '#8b5cf6'; const l = o.querySelector('.abilyo-label'); if (l) { l.style.background = '#8b5cf6'; l.textContent = '\\u2728 AI Fixed'; } }
575
+ aBtn.classList.add('applied'); aBtn.textContent = '\\u2713 Applied'; pBtn.style.display = 'none';
576
+ });
577
+ altActions.appendChild(pBtn);
578
+ altActions.appendChild(aBtn);
579
+ altBox.appendChild(altActions);
580
+ aiResultContainer.appendChild(altBox);
581
+ }
582
+ } catch {
583
+ aiBtn.classList.remove('apd-ai-loading');
584
+ aiBtn.textContent = '\\u2728 AI Fix (retry)';
585
+ aiBtn.disabled = false;
586
+ }
587
+ });
588
+ detailEl.appendChild(h('div', { className: 'apd-actions' }, [aiBtn]));
589
+ detailEl.appendChild(aiResultContainer);
590
+ }
591
+
592
+ detailEl.appendChild(h('div', { className: 'apd-sel' }, issue.selector));
593
+ renderList();
594
+ }
595
+
596
+ function clearSpotlight() {
597
+ activeId = -1;
598
+ document.querySelectorAll('.abilyo-overlay').forEach(o => { o.classList.remove('abilyo-active','abilyo-dimmed'); });
599
+ detailEl.style.display = 'none';
600
+ renderList();
601
+ }
602
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') clearSpotlight(); });
603
+
604
+ function applyFilters() {
605
+ overlays.forEach((ov, i) => {
606
+ if (!ov) return;
607
+ const impact = issues[i] && issues[i].impact;
608
+ ov.classList.toggle('abilyo-hidden', !activeFilters[impact]);
609
+ });
610
+ renderList();
611
+ }
612
+
613
+ renderList();
614
+ })();
615
+ `;
616
+ }
116
617
  async function localScan(url, options) {
117
618
  const wcagTags = WCAG_TAG_MAP[options.wcag.toUpperCase()] ?? WCAG_TAG_MAP["AA"];
118
619
  const viewport = ["mobile", "tablet", "desktop"].includes(options.viewport ?? "") ? options.viewport : "desktop";
119
- return scan2(url, {
620
+ const pw = await import("playwright");
621
+ const browser = await pw.chromium.launch({ headless: !options.show });
622
+ const context = await browser.newContext({ viewport: viewport === "mobile" ? { width: 375, height: 667 } : viewport === "tablet" ? { width: 768, height: 1024 } : { width: 1280, height: 720 } });
623
+ const page = await context.newPage();
624
+ const targetUrl = url.startsWith("http") ? url : `https://${url}`;
625
+ await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 3e4 });
626
+ const fw = await detectFramework(page);
627
+ const result = await scan2(page, {
120
628
  wcagTags,
121
629
  includeAxe: true,
122
630
  dismissModals: true,
123
631
  deep: options.deep,
124
632
  deepApiUrl: options.deep ? "https://api.webability.io" : void 0,
125
- deepApiKey: options.deep ? options.apiKey : void 0,
126
- viewport,
127
- browser: { headless: true, timeout: 3e4 }
633
+ deepApiKey: options.deep ? options.apiKey : void 0
128
634
  });
635
+ if (options.show && result.issues.length > 0) {
636
+ await page.evaluate(buildHighlightScript(result.issues, options.apiKey, fw.framework));
637
+ await page.evaluate(() => window.scrollTo(0, 0));
638
+ return { ...result, framework: fw.framework, _browser: browser };
639
+ }
640
+ await browser.close();
641
+ return { ...result, framework: fw.framework };
642
+ }
643
+ async function closeBrowser(result) {
644
+ if (result._browser) {
645
+ await result._browser.close();
646
+ delete result._browser;
647
+ }
648
+ }
649
+
650
+ // src/flow-scan.ts
651
+ import { scan as scan3, detectFramework as detectFramework2 } from "@webability/core";
652
+ var WCAG_TAG_MAP2 = {
653
+ "A": ["wcag2a"],
654
+ "AA": ["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"],
655
+ "AAA": ["wcag2a", "wcag2aa", "wcag2aaa", "wcag21aa", "wcag22aa"]
656
+ };
657
+ async function flowScan(startUrl, options = {}) {
658
+ const wcagTags = WCAG_TAG_MAP2[(options.wcag || "AA").toUpperCase()] ?? WCAG_TAG_MAP2["AA"];
659
+ const targetUrl = startUrl.startsWith("http") ? startUrl : `https://${startUrl}`;
660
+ const t0 = Date.now();
661
+ const pw = await import("playwright");
662
+ const browser = await pw.chromium.launch({ headless: false });
663
+ const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
664
+ const page = await context.newPage();
665
+ const pageResults = [];
666
+ let framework = "plain-css";
667
+ let scanning = false;
668
+ let scanQueued = false;
669
+ const scannedUrls = /* @__PURE__ */ new Set();
670
+ const scanCurrentPage = async () => {
671
+ if (scanning) {
672
+ scanQueued = true;
673
+ return;
674
+ }
675
+ scanning = true;
676
+ try {
677
+ await page.waitForLoadState("domcontentloaded", { timeout: 5e3 }).catch(() => {
678
+ });
679
+ await page.waitForTimeout(800);
680
+ const url = page.url();
681
+ if (scannedUrls.has(url)) {
682
+ scanning = false;
683
+ return;
684
+ }
685
+ scannedUrls.add(url);
686
+ if (!framework || framework === "plain-css") {
687
+ const fw = await detectFramework2(page).catch(() => ({ framework: "plain-css" }));
688
+ framework = fw.framework;
689
+ }
690
+ const result = await scan3(page, { wcagTags, includeAxe: true, dismissModals: false });
691
+ pageResults.push({ url, issues: result.issues, scannedAt: (/* @__PURE__ */ new Date()).toISOString() });
692
+ options.onPageScan?.(url, result.issues.length);
693
+ } catch {
694
+ } finally {
695
+ scanning = false;
696
+ if (scanQueued) {
697
+ scanQueued = false;
698
+ setTimeout(scanCurrentPage, 100);
699
+ }
700
+ }
701
+ };
702
+ page.on("framenavigated", (frame) => {
703
+ if (frame === page.mainFrame()) {
704
+ setTimeout(scanCurrentPage, 600);
705
+ }
706
+ });
707
+ await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 3e4 });
708
+ await new Promise((resolve) => {
709
+ browser.on("disconnected", () => resolve());
710
+ process.once("SIGINT", () => resolve());
711
+ });
712
+ await browser.close().catch(() => {
713
+ });
714
+ const issueMap = /* @__PURE__ */ new Map();
715
+ for (const pr of pageResults) {
716
+ for (const issue of pr.issues) {
717
+ const key = `${issue.type}::${issue.selector}::${issue.wcag}`;
718
+ const existing = issueMap.get(key);
719
+ if (existing) {
720
+ if (!existing.foundOn.includes(pr.url)) {
721
+ existing.foundOn.push(pr.url);
722
+ existing.pageCount = existing.foundOn.length;
723
+ }
724
+ } else {
725
+ issueMap.set(key, { ...issue, foundOn: [pr.url], pageCount: 1 });
726
+ }
727
+ }
728
+ }
729
+ return {
730
+ pages: pageResults,
731
+ uniqueIssues: Array.from(issueMap.values()).sort((a, b) => {
732
+ const order = { critical: 0, serious: 1, moderate: 2, minor: 3 };
733
+ const aOrd = order[a.impact] ?? 2;
734
+ const bOrd = order[b.impact] ?? 2;
735
+ if (aOrd !== bOrd) return aOrd - bOrd;
736
+ return b.pageCount - a.pageCount;
737
+ }),
738
+ framework,
739
+ totalDuration: Date.now() - t0
740
+ };
129
741
  }
130
742
 
131
743
  // src/init.ts
@@ -154,83 +766,83 @@ function initConfig() {
154
766
 
155
767
  // src/display.ts
156
768
  import chalk2 from "chalk";
769
+ var DIM = chalk2.dim;
770
+ var BOLD = chalk2.bold;
771
+ var BLUE = chalk2.blue;
772
+ var GREEN = chalk2.green;
773
+ var RED = chalk2.red;
774
+ var YELLOW = chalk2.yellow;
775
+ var CYAN = chalk2.cyan;
776
+ var WHITE = chalk2.white;
157
777
  function header() {
158
778
  console.log();
159
- console.log(chalk2.bold.blue(" Abilyo") + chalk2.dim(" by WebAbility"));
160
- console.log(chalk2.dim(" https://abilyo.com"));
779
+ console.log(` ${BLUE("\u25C6")} ${BOLD("Abilyo")} ${DIM("WCAG Scanner")}`);
161
780
  console.log();
162
781
  }
163
782
  function scoreCard(score, url) {
164
- const color = score >= 80 ? chalk2.green : score >= 50 ? chalk2.yellow : chalk2.red;
165
- const label = score >= 80 ? "COMPLIANT" : score >= 50 ? "NEEDS WORK" : "NON-COMPLIANT";
166
- const bar = renderBar(score);
167
- console.log(chalk2.bold(` ${url}`));
168
- console.log();
169
- console.log(` Score: ${color.bold(score + "%")} ${chalk2.dim(label)}`);
170
- console.log(` ${bar}`);
783
+ const color = score >= 80 ? GREEN : score >= 50 ? YELLOW : RED;
784
+ const icon = score >= 80 ? GREEN("\u2713") : score >= 50 ? YELLOW("\u26A0") : RED("\u2717");
785
+ const label = score >= 80 ? "Passing" : score >= 50 ? "Needs work" : "Failing";
786
+ console.log(` ${DIM("URL")} ${WHITE(url)}`);
787
+ console.log(` ${DIM("Score")} ${color.bold(score + "%")} ${icon} ${DIM(label)}`);
788
+ console.log(` ${DIM(" ")}${renderBar(score)}`);
171
789
  console.log();
172
790
  }
173
791
  function renderBar(score) {
174
- const width = 40;
175
- const filled = Math.round(score / 100 * width);
176
- const empty = width - filled;
177
- const color = score >= 80 ? chalk2.green : score >= 50 ? chalk2.yellow : chalk2.red;
178
- return color("\u2588".repeat(filled)) + chalk2.dim("\u2591".repeat(empty));
792
+ const w = 32;
793
+ const filled = Math.round(score / 100 * w);
794
+ const empty = w - filled;
795
+ const color = score >= 80 ? GREEN : score >= 50 ? YELLOW : RED;
796
+ return color("\u2501".repeat(filled)) + DIM("\u2501".repeat(empty));
179
797
  }
180
- function issueTable(issues) {
181
- if (issues.length === 0) {
182
- console.log(chalk2.green(" No issues found!"));
183
- return;
184
- }
185
- const severityColor = (s) => {
186
- switch (s.toLowerCase()) {
187
- case "high":
188
- case "critical":
189
- case "serious":
190
- return chalk2.red;
191
- case "medium":
192
- case "moderate":
193
- return chalk2.yellow;
194
- case "low":
195
- case "minor":
196
- return chalk2.dim;
197
- default:
198
- return chalk2.white;
199
- }
200
- };
201
- const high = issues.filter((i) => ["high", "critical", "serious"].includes(i.severity.toLowerCase()));
202
- const medium = issues.filter((i) => ["medium", "moderate"].includes(i.severity.toLowerCase()));
203
- const low = issues.filter((i) => ["low", "minor"].includes(i.severity.toLowerCase()));
204
- console.log(chalk2.bold(" Issues"));
205
- console.log(` ${chalk2.red.bold(high.length + " High")} ${chalk2.yellow.bold(medium.length + " Medium")} ${chalk2.dim.bold(low.length + " Low")}`);
798
+ function summaryLine(s) {
799
+ const parts = [];
800
+ if (s.critical > 0) parts.push(RED.bold(`${s.critical} critical`));
801
+ if (s.serious > 0) parts.push(YELLOW(`${s.serious} serious`));
802
+ if (s.moderate > 0) parts.push(DIM(`${s.moderate} moderate`));
803
+ if (s.minor > 0) parts.push(DIM(`${s.minor} minor`));
804
+ console.log(` ${BOLD(String(s.total))} issues ${parts.join(DIM(" \xB7 "))}`);
206
805
  console.log();
207
- for (const issue of issues.slice(0, 30)) {
208
- const sev = severityColor(issue.severity);
209
- const tag = sev(`[${issue.severity.toUpperCase().slice(0, 4).padEnd(4)}]`);
210
- const wcag = chalk2.cyan(issue.wcag.padEnd(8));
211
- const msg = issue.message.slice(0, 80);
212
- console.log(` ${tag} ${wcag} ${msg}`);
806
+ }
807
+ function issueBlock(opts) {
808
+ const icon = opts.severity === "critical" ? RED("\u2717") : opts.severity === "serious" ? YELLOW("\u25B2") : DIM("\u25CF");
809
+ const wcag = DIM(`${opts.wcag}`);
810
+ const count = opts.count && opts.count > 1 ? DIM(` \xD7${opts.count}`) : "";
811
+ console.log(` ${icon} ${wcag}${count} ${opts.problem.slice(0, 72)}`);
812
+ if (opts.contrast) {
813
+ console.log(` ${CYAN("fix")} ${opts.fix.slice(0, 70)}`);
814
+ console.log(` ${DIM(`${opts.contrast.current}:1 \u2192 ${opts.contrast.suggested}:1 (min ${opts.contrast.required}:1)`)}`);
815
+ } else if (opts.before && opts.after) {
816
+ console.log(` ${CYAN("fix")} ${DIM(opts.before)} ${DIM("\u2192")} ${GREEN(opts.after)}`);
817
+ } else {
818
+ console.log(` ${CYAN("fix")} ${opts.fix.slice(0, 70)}`);
213
819
  }
214
- if (issues.length > 30) {
215
- console.log(chalk2.dim(` ... and ${issues.length - 30} more`));
820
+ if (opts.html && opts.count === 1) {
821
+ const snippet = opts.html.slice(0, 60).replace(/\n/g, " ");
822
+ console.log(` ${DIM(snippet)}`);
823
+ }
824
+ if (opts.selectors && opts.selectors.length > 0 && opts.count && opts.count > 1) {
825
+ const shown = opts.selectors.slice(0, 2).map((s) => s.slice(0, 30)).join(DIM(", "));
826
+ const more = opts.count > 2 ? DIM(` +${opts.count - 2} more`) : "";
827
+ console.log(` ${DIM(shown + more)}`);
828
+ } else if (opts.selector) {
829
+ console.log(` ${DIM(opts.selector.slice(0, 55))}`);
216
830
  }
217
831
  console.log();
218
832
  }
219
- function summary(stats) {
220
- console.log(chalk2.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
221
- console.log(` Elements scanned: ${chalk2.bold(stats.scanned)}`);
222
- console.log(` Axe violations: ${chalk2.bold(stats.errors)}`);
223
- console.log(` Advanced checks: ${chalk2.bold(stats.advanced)}`);
224
- console.log();
225
- }
226
- function ctaBox() {
227
- console.log(chalk2.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
228
- console.log(chalk2.bold.blue(" Fix these issues automatically"));
229
- console.log(chalk2.dim(" https://abilyo.com"));
833
+ function footer(opts) {
834
+ console.log(DIM(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
835
+ if (opts?.hasIssues) {
836
+ console.log(` ${DIM("Next steps:")}`);
837
+ console.log(` ${CYAN("abilyo scan " + (opts.url || "<url>") + " --format json")} ${DIM("CI/CD output")}`);
838
+ console.log(` ${CYAN("abilyo scan " + (opts.url || "<url>") + " --format html > report.html")} ${DIM("share report")}`);
839
+ console.log();
840
+ }
841
+ console.log(` ${BLUE("\u25C6")} ${DIM("Auto-fix at")} ${BLUE("abilyo.com")}`);
230
842
  console.log();
231
843
  }
232
844
  function errorMsg(msg) {
233
- console.log(chalk2.red(` Error: ${msg}`));
845
+ console.log(RED(` \u2717 Error: ${msg}`));
234
846
  console.log();
235
847
  }
236
848
  function jsonOutput(data) {
@@ -248,13 +860,7 @@ function csvOutput(issues) {
248
860
  // src/cli.ts
249
861
  var program = new Command();
250
862
  program.name("abilyo").description("Abilyo by WebAbility \u2014 WCAG accessibility scanner").version("1.0.0");
251
- program.command("scan <url>").description("Scan a website for accessibility issues").option("-f, --format <format>", "Output format: table, json, csv, sarif, html", "table").option("--wcag <level>", "WCAG level: A, AA, AAA", "AA").option("--deep", "Run a deeper scan with additional checks (Pro plan)").option("--remote", "Run scan on WebAbility servers instead of locally").option("--upload", "Upload results to dashboard (requires login)").option("--exit", "Exit with code 1 if issues found (CI mode)").option("--no-axe", "Skip axe-core (use only WebAbility detectors)").option("--viewport <size>", "Viewport: mobile, tablet, desktop", "desktop").action(async (url, opts) => {
252
- if (!isActivated()) {
253
- header();
254
- errorMsg("Abilyo is currently invite-only. Run `abilyo activate <code>` with your access code.");
255
- console.log(chalk3.dim(" Request access at https://abilyo.com/early-access"));
256
- return process.exit(1);
257
- }
863
+ program.command("scan <url>").description("Scan a website for accessibility issues").option("-f, --format <format>", "Output format: table, json, csv, sarif, html", "table").option("--wcag <level>", "WCAG level: A, AA, AAA", "AA").option("--deep", "Run a deeper scan with additional checks (Pro plan)").option("--remote", "Run scan on WebAbility servers instead of locally").option("--upload", "Upload results to dashboard (requires login)").option("--exit", "Exit with code 1 if issues found (CI mode)").option("--no-axe", "Skip axe-core (use only WebAbility detectors)").option("--viewport <size>", "Viewport: mobile, tablet, desktop", "desktop").option("--show", "Open browser and highlight issues visually").action(async (url, opts) => {
258
864
  if (!url.startsWith("http")) url = `https://${url}`;
259
865
  if (opts.deep || opts.upload) {
260
866
  const apiKey = getApiKey();
@@ -284,14 +890,18 @@ program.command("scan <url>").description("Scan a website for accessibility issu
284
890
  return;
285
891
  }
286
892
  if (opts.format === "table") header();
893
+ const t0 = Date.now();
287
894
  const spinner = ora({ text: "Scanning...", prefixText: " " }).start();
895
+ let result;
288
896
  try {
289
- const result = await localScan(url, {
897
+ result = await localScan(url, {
290
898
  wcag: opts.wcag,
291
899
  deep: opts.deep ?? false,
292
- apiKey: getApiKey() ?? void 0
900
+ apiKey: getApiKey() ?? void 0,
901
+ show: opts.show ?? false
293
902
  });
294
- spinner.stop();
903
+ const elapsed = ((Date.now() - t0) / 1e3).toFixed(1);
904
+ spinner.succeed(`Scanned ${result.issues.length} issues in ${elapsed}s`);
295
905
  if (opts.format === "json") {
296
906
  const sanitized = {
297
907
  ...result,
@@ -312,29 +922,69 @@ program.command("scan <url>").description("Scan a website for accessibility issu
312
922
  console.log(toHtmlReport(result));
313
923
  } else {
314
924
  scoreCard(result.summary.total === 0 ? 100 : Math.max(0, 100 - result.summary.total), url);
925
+ summaryLine(result.summary);
315
926
  if (result.issues.length > 0) {
316
- issueTable(result.issues.map((i) => ({
317
- severity: i.impact,
318
- wcag: i.wcag,
319
- message: i.message,
320
- element: i.selector
321
- })));
927
+ const { generateDevSuggestion } = await import("@webability/core");
928
+ const fw = result.framework || "plain-css";
929
+ const grouped = [];
930
+ const seen = /* @__PURE__ */ new Map();
931
+ for (const issue of result.issues) {
932
+ const s = generateDevSuggestion(issue, fw);
933
+ const key = `${issue.type}|${s.fix.slice(0, 50)}`;
934
+ const idx = seen.get(key);
935
+ if (idx !== void 0) {
936
+ grouped[idx].count++;
937
+ if (grouped[idx].selectors.length < 3) grouped[idx].selectors.push(issue.selector.slice(0, 50));
938
+ } else {
939
+ seen.set(key, grouped.length);
940
+ grouped.push({ suggestion: s, issue, count: 1, selectors: [issue.selector.slice(0, 50)] });
941
+ }
942
+ }
943
+ grouped.sort((a, b) => {
944
+ const order = { critical: 0, serious: 1, moderate: 2, minor: 3 };
945
+ return (order[a.issue.impact] ?? 2) - (order[b.issue.impact] ?? 2);
946
+ });
947
+ for (const g of grouped.slice(0, 20)) {
948
+ issueBlock({
949
+ severity: g.issue.impact,
950
+ wcag: g.issue.wcag,
951
+ problem: g.suggestion.problem,
952
+ fix: g.suggestion.fix,
953
+ count: g.count,
954
+ selector: g.count === 1 ? g.issue.selector : void 0,
955
+ selectors: g.count > 1 ? g.selectors : void 0,
956
+ contrast: g.suggestion.contrast,
957
+ before: g.suggestion.before,
958
+ after: g.suggestion.after,
959
+ html: g.issue.html
960
+ });
961
+ }
962
+ if (grouped.length > 20) {
963
+ console.log(chalk3.dim(` ... +${grouped.length - 20} more`));
964
+ console.log();
965
+ }
322
966
  }
967
+ footer({ hasIssues: result.issues.length > 0, url: url.replace("https://", "") });
968
+ }
969
+ if (opts.show && result.issues.length > 0) {
970
+ console.log(chalk3.cyan(" \u25C6 Browser open \u2014 issues highlighted. Press Enter to close."));
323
971
  console.log();
324
- summary({
325
- scanned: 0,
326
- errors: result.summary.critical + result.summary.serious,
327
- warnings: result.summary.moderate,
328
- notices: result.summary.minor,
329
- advanced: 0
972
+ await new Promise((resolve) => {
973
+ process.stdin.resume();
974
+ process.stdin.once("data", () => {
975
+ process.stdin.pause();
976
+ resolve();
977
+ });
330
978
  });
331
- ctaBox();
979
+ await closeBrowser(result);
332
980
  }
333
981
  if (opts.exit && result.summary.total > 0) {
334
982
  process.exit(1);
335
983
  }
336
984
  } catch (err) {
337
985
  spinner.stop();
986
+ if (result) await closeBrowser(result).catch(() => {
987
+ });
338
988
  errorMsg(err.message);
339
989
  process.exit(1);
340
990
  }
@@ -380,15 +1030,11 @@ async function remoteScan(url, opts) {
380
1030
  csvOutput(issues);
381
1031
  } else {
382
1032
  scoreCard(report.score || 0, url);
383
- issueTable(issues);
384
- summary({
385
- scanned: report.totalElements || 0,
386
- errors: report.axe?.violations?.length || 0,
387
- warnings: 0,
388
- notices: 0,
389
- advanced: (report.ByFunctions || []).reduce((sum, f) => sum + (f.count || 0), 0)
390
- });
391
- ctaBox();
1033
+ summaryLine({ critical: 0, serious: issues.filter((i) => i.severity === "serious").length, moderate: issues.filter((i) => i.severity === "moderate").length, minor: 0, total: issues.length });
1034
+ for (const i of issues.slice(0, 20)) {
1035
+ issueBlock({ severity: i.severity, wcag: i.wcag, problem: i.message, fix: "Review this issue", selector: i.element });
1036
+ }
1037
+ footer();
392
1038
  }
393
1039
  if (opts.exit && issues.length > 0) process.exit(1);
394
1040
  } catch (err) {
@@ -397,38 +1043,141 @@ async function remoteScan(url, opts) {
397
1043
  process.exit(1);
398
1044
  }
399
1045
  }
1046
+ program.command("flow <url>").description("Scan a multi-page user journey \u2014 one consolidated report").option("--wcag <level>", "WCAG level: A, AA, AAA", "AA").action(async (url, opts) => {
1047
+ if (!url.startsWith("http")) url = `https://${url}`;
1048
+ header();
1049
+ console.log(chalk3.bold(" Flow Scan"));
1050
+ console.log();
1051
+ console.log(` Starting at: ${chalk3.cyan(url)}`);
1052
+ console.log(chalk3.dim(" Click through your user journey. Close the browser when done."));
1053
+ console.log();
1054
+ let pagesScanned = 0;
1055
+ const result = await flowScan(url, {
1056
+ wcag: opts.wcag,
1057
+ onPageScan: (pageUrl, count) => {
1058
+ pagesScanned++;
1059
+ console.log(` ${chalk3.green("\u2713")} ${chalk3.dim(String(pagesScanned).padStart(2))} ${pageUrl.replace(/^https?:\/\//, "").slice(0, 60)} ${chalk3.dim("\u2014 " + count + " issues")}`);
1060
+ }
1061
+ });
1062
+ console.log();
1063
+ console.log(chalk3.bold(" \u2500\u2500\u2500 Flow Report \u2500\u2500\u2500"));
1064
+ console.log();
1065
+ console.log(` Pages scanned: ${chalk3.cyan(result.pages.length)}`);
1066
+ console.log(` Unique issues: ${chalk3.cyan(result.uniqueIssues.length)}`);
1067
+ console.log(` Total duration: ${chalk3.dim((result.totalDuration / 1e3).toFixed(1) + "s")}`);
1068
+ console.log();
1069
+ const counts = { critical: 0, serious: 0, moderate: 0, minor: 0 };
1070
+ result.uniqueIssues.forEach((i) => {
1071
+ counts[i.impact]++;
1072
+ });
1073
+ summaryLine({ ...counts, total: result.uniqueIssues.length });
1074
+ const topIssues = result.uniqueIssues.slice(0, 15);
1075
+ for (const issue of topIssues) {
1076
+ const pageStr = issue.pageCount > 1 ? chalk3.yellow(` \xD7${issue.pageCount} pages`) : "";
1077
+ issueBlock({
1078
+ severity: issue.impact,
1079
+ wcag: issue.wcag,
1080
+ problem: issue.message,
1081
+ fix: issue.fix?.suggestedValue ? `Set ${issue.fix.attribute}="${issue.fix.suggestedValue}"` : "Review this issue",
1082
+ selector: issue.selector
1083
+ });
1084
+ if (issue.pageCount > 1) {
1085
+ console.log(chalk3.dim(` Found on${pageStr}:`));
1086
+ for (const url2 of issue.foundOn.slice(0, 3)) {
1087
+ console.log(chalk3.dim(" " + url2.replace(/^https?:\/\//, "")));
1088
+ }
1089
+ }
1090
+ }
1091
+ if (result.uniqueIssues.length > 15) {
1092
+ console.log(chalk3.dim(` ... +${result.uniqueIssues.length - 15} more unique issues`));
1093
+ }
1094
+ console.log();
1095
+ footer({ hasIssues: result.uniqueIssues.length > 0, url: url.replace("https://", "") });
1096
+ });
400
1097
  program.command("init").description("Create .webability.yml config file").action(() => {
401
1098
  header();
402
1099
  initConfig();
403
1100
  console.log();
404
1101
  });
405
- program.command("login").description("Authenticate with your WebAbility account").option("-k, --key <key>", "API key (or set WEBABILITY_API_KEY env var)").action(async (opts) => {
1102
+ program.command("login").description("Authenticate with your WebAbility account").option("-k, --key <key>", "API key directly (for CI/CD)").action(async (opts) => {
406
1103
  header();
407
1104
  if (opts.key) {
408
1105
  setApiKey(opts.key);
409
- console.log(chalk3.green(" API key saved."));
1106
+ console.log(chalk3.green(" \u2713 API key saved."));
410
1107
  console.log();
411
1108
  return;
412
1109
  }
413
- const readline = await import("readline");
414
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
415
- const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
416
- console.log(chalk3.bold(" Login to WebAbility"));
417
- console.log();
418
- const email = await ask(" Email: ");
419
- const password = await ask(" Password: ");
420
- rl.close();
421
- const spinner = ora({ text: "Authenticating...", prefixText: " " }).start();
1110
+ const spinner = ora({ text: "Starting login...", prefixText: " " }).start();
422
1111
  try {
423
- const token = await login(email, password);
424
- setApiKey(token);
425
- spinner.succeed("Logged in! API key saved.");
1112
+ const res = await fetch("https://api.webability.io/cli/device-code", {
1113
+ method: "POST",
1114
+ headers: { "Content-Type": "application/json" }
1115
+ });
1116
+ if (!res.ok) {
1117
+ spinner.stop();
1118
+ const loginUrl = "https://app.webability.io/settings/api-keys";
1119
+ console.log(` Open this URL to get your API key:`);
1120
+ console.log();
1121
+ console.log(` ${chalk3.cyan(loginUrl)}`);
1122
+ console.log();
1123
+ console.log(` Then run: ${chalk3.cyan("abilyo login --key YOUR_KEY")}`);
1124
+ console.log();
1125
+ openUrl(loginUrl);
1126
+ return;
1127
+ }
1128
+ const { deviceCode, userCode, verificationUrl, expiresIn } = await res.json();
1129
+ spinner.stop();
1130
+ console.log(` ${chalk3.bold("Login to WebAbility")}`);
426
1131
  console.log();
427
- } catch (err) {
428
- spinner.fail(err.message);
1132
+ console.log(` Open: ${chalk3.cyan(verificationUrl)}`);
1133
+ console.log(` Code: ${chalk3.bold.yellow(userCode)}`);
1134
+ console.log();
1135
+ openUrl(verificationUrl);
1136
+ const pollSpinner = ora({ text: "Waiting for approval...", prefixText: " " }).start();
1137
+ const pollInterval = 3e3;
1138
+ const maxPolls = Math.floor((expiresIn || 300) * 1e3 / pollInterval);
1139
+ for (let i = 0; i < maxPolls; i++) {
1140
+ await new Promise((r) => setTimeout(r, pollInterval));
1141
+ try {
1142
+ const tokenRes = await fetch("https://api.webability.io/cli/device-token", {
1143
+ method: "POST",
1144
+ headers: { "Content-Type": "application/json" },
1145
+ body: JSON.stringify({ deviceCode })
1146
+ });
1147
+ if (tokenRes.ok) {
1148
+ const { token } = await tokenRes.json();
1149
+ setApiKey(token);
1150
+ pollSpinner.succeed("Logged in!");
1151
+ console.log();
1152
+ return;
1153
+ }
1154
+ const body = await tokenRes.json().catch(() => ({}));
1155
+ if (body.error === "expired") {
1156
+ pollSpinner.fail("Login expired. Run `abilyo login` again.");
1157
+ return process.exit(1);
1158
+ }
1159
+ } catch {
1160
+ }
1161
+ }
1162
+ pollSpinner.fail("Login timed out.");
429
1163
  process.exit(1);
1164
+ } catch {
1165
+ spinner.stop();
1166
+ const loginUrl = "https://app.webability.io/settings/api-keys";
1167
+ console.log(` Open: ${chalk3.cyan(loginUrl)}`);
1168
+ console.log(` Then: ${chalk3.cyan("abilyo login --key YOUR_KEY")}`);
1169
+ console.log();
430
1170
  }
431
1171
  });
1172
+ function openUrl(url) {
1173
+ try {
1174
+ const { execFile } = __require("child_process");
1175
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
1176
+ execFile(cmd, [url], () => {
1177
+ });
1178
+ } catch {
1179
+ }
1180
+ }
432
1181
  program.command("whoami").description("Show current authentication status").action(() => {
433
1182
  const key = getApiKey();
434
1183
  header();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webability/cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Abilyo by WebAbility — WCAG accessibility scanner for your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,19 +13,21 @@
13
13
  "clean": "rm -rf dist"
14
14
  },
15
15
  "keywords": [
16
+ "a11y",
17
+ "abilyo",
16
18
  "accessibility",
17
- "wcag",
18
19
  "ada",
19
- "a11y",
20
20
  "audit",
21
+ "cli",
21
22
  "scanner",
22
- "cli"
23
+ "wcag",
24
+ "webability"
23
25
  ],
24
26
  "author": "WebAbility <support@webability.io>",
25
27
  "license": "MIT",
26
28
  "repository": {
27
29
  "type": "git",
28
- "url": "https://github.com/snayyar00/webability-packages",
30
+ "url": "https://github.com/snayyar00/abilyo",
29
31
  "directory": "cli"
30
32
  },
31
33
  "dependencies": {
@@ -45,5 +47,6 @@
45
47
  },
46
48
  "engines": {
47
49
  "node": ">=18"
48
- }
50
+ },
51
+ "homepage": "https://abilyo.com"
49
52
  }