@webability/cli 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +359 -132
- package/package.json +2 -2
- package/scripts/verify-mcp-install.mts +73 -0
- package/visual-dogfood.mjs +55 -0
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
var
|
|
2
|
+
var Fe=(e=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(e,{get:(t,o)=>(typeof require<"u"?require:t)[o]}):e)(function(e){if(typeof require<"u")return require.apply(this,arguments);throw Error('Dynamic require of "'+e+'" is not supported')});import{readFileSync as Ye}from"fs";import{fileURLToPath as Xe}from"url";import{dirname as Qe,join as Ze}from"path";import{Command as et}from"commander";import i from"chalk";import N from"ora";var $e="https://api.webability.io";async function Ee(e,t={},o){let s=await fetch(`${$e}${e}`,{...t,headers:{"Content-Type":"application/json",Authorization:`Bearer ${o}`,Origin:"https://app.webability.io",...t.headers}});if(!s.ok){let a=await s.text().catch(()=>"");throw new Error(`API ${s.status}: ${a.slice(0,200)}`)}return s.json()}async function j(e,t,o){let s=await Ee("/graphql",{method:"POST",body:JSON.stringify({query:e,variables:t})},o);if(s.errors?.length)throw new Error(s.errors[0].message);return s.data}async function se(e,t,o){o?.("Starting scan...");let{startAccessibilityReportJob:s}=await j("query($url: String!) { startAccessibilityReportJob(url: $url, use_cache: false) { jobId } }",{url:e},t),a=s.jobId;o?.(`Job ${a.slice(0,8)}... created`);for(let n=0;n<60;n++){await new Promise(l=>setTimeout(l,3e3));let{getAccessibilityReportByJobId:r}=await j("query($jobId: String!) { getAccessibilityReportByJobId(jobId: $jobId) { status error result { savedReport { key } } } }",{jobId:a},t);if(r.status==="done")return{key:r.result.savedReport.key};if(r.status==="error")throw new Error(r.error||"Scan failed");o?.(`Scanning... (${(n+1)*3}s)`)}throw new Error("Scan timed out after 3 minutes")}async function ie(e,t){let{fetchReportByR2Key:o}=await j("query($key: String!) { fetchReportByR2Key(r2_key: $key) { score totalElements siteImg ByFunctions { name count issues { code impact description element } } axe { violations { id impact description nodes { html target } } } } }",{key:e},t);return o}import Ie from"conf";var R=new Ie({projectName:"webability",schema:{apiKey:{type:"string",default:""},accessCode:{type:"string",default:""},defaultFormat:{type:"string",default:"table",enum:["table","json","csv"]}}});function k(){return process.env.WEBABILITY_API_KEY||R.get("apiKey")||""}function W(e){R.set("apiKey",e)}function Ne(){return R.get("accessCode")||""}function q(e){R.set("accessCode",e)}function ae(){return!!Ne()||!!k()}function ne(){R.clear()}import{scan as Re,detectFramework as Be,generateDevSuggestion as Pe}from"@webability/core";var Me={weak_link_name:"Screen-reader users navigate by pulling up a links list (VoiceOver Rotor / NVDA Insert+F7). They hear: 'Learn more, Learn more, Learn more' \u2014 every link sounds identical. They can't tell where each goes without context.",weak_button_name:"Same problem as weak links \u2014 assistive tech announces 'Submit' or 'Click here' with no context, so users can't tell what each button does.",missing_alt:`Screen readers say 'image' (or skip it entirely) when there's no alt text. A photo of your CEO becomes 'image, image' to a blind user. Decorative images need alt="" so they're skipped intentionally.`,decorative_icon:"Decorative icons next to labelled text get announced twice: 'home icon, Home'. Wastes the user's time and creates audio clutter.",missing_label:"Form inputs without labels are invisible to screen readers \u2014 users hear 'edit text' with no idea what to type. They abandon the form.",contrast_insufficient:"Low-contrast text excludes users with low vision (1 in 12 men have some color-blindness). It's also unreadable on glare-prone screens, in sunlight, and for older eyes.",non_text_contrast_insufficient:"If a button's border or icon doesn't have 3:1 contrast against its background, low-vision users can't see the boundary or recognize the icon at all.",target_too_small:"Touch targets under 24\xD724px are hard to hit accurately for users with motor impairments (Parkinson's, MS, arthritis). Mis-taps trigger wrong actions and frustrate keyboard-only users too.",missing_skip_link:"Keyboard and screen-reader users have to Tab through your entire header (logo, nav, CTAs) on every single page. A skip link lets them jump straight to main content \u2014 a 30-second fix that saves disabled users minutes of navigation per visit.",motion_without_reduced_motion:"Vestibular disorders (~35% of adults over 40) can be triggered by parallax, auto-rotation, and rapid animations \u2014 causing nausea, dizziness, and migraines. Respecting prefers-reduced-motion is non-optional.",heading_skip:"Screen-reader users navigate by heading hierarchy \u2014 they jump h1 \u2192 h2 \u2192 h3 to scan structure. Skipping levels (h2 \u2192 h4) makes the page feel disorganized and content gets missed.",multiple_h1:"Multiple h1 elements confuse the page structure model that screen readers and SEO tools use. Each page should have exactly one h1 \u2014 the page title.",redundant_role:`Adding role="navigation" to a <nav> element is redundant \u2014 screen readers already know it's navigation. Redundant ARIA can cause double-announcements.`,duplicate_id:"Duplicate IDs break aria-labelledby, aria-describedby, and label[for] references \u2014 screen readers may read the wrong content or none at all.",missing_required_indicator:"If users don't know a field is required until they submit, they miss the requirement (especially screen-reader users who skip placeholder text). They get an error after the fact and have to re-enter data.",new_window_link:"Opening links in a new tab without warning disorients screen-reader users \u2014 they think the back button is broken. WCAG requires you announce 'opens in new tab' via aria-label or visible text.",color_only_meaning:"Using only color to indicate state (red = error, green = success) excludes the ~8% of men who are color-blind. Always pair color with an icon, label, or pattern.",insufficient_navigation:"Inconsistent nav across pages forces users to relearn the structure on every page. Especially hard on cognitive disabilities and screen-reader users who memorize tab order.",focus_not_visible:"Without a visible focus ring, keyboard users have no idea which element is selected. Tabbing through your site becomes guesswork \u2014 they hit Enter and trigger the wrong action.",aria_hidden_focusable:`An element with aria-hidden="true" but tabindex="0" creates a 'ghost' focus stop \u2014 keyboard users can focus it but screen readers don't announce it. They press Enter on something invisible to them.`,svg_missing_name:"SVG icons that convey meaning (warning triangle, X close button) need an accessible name. Without it, blind users hear 'graphic' or nothing at all."};function Le(e){return Me[e.type]||""}var re={A:["wcag2a"],AA:["wcag2a","wcag2aa","wcag21aa","wcag22aa"],AAA:["wcag2a","wcag2aa","wcag2aaa","wcag21aa","wcag22aa"]},Te={critical:"#ef4444",serious:"#f97316",moderate:"#eab308",minor:"#6b7280"},De={critical:"\u2717",serious:"\u25B2",moderate:"\u25CF",minor:"\u25CB"};function Oe(e,t,o){let s=o||"plain-css";return`
|
|
3
3
|
(function() {
|
|
4
|
-
const issues = ${JSON.stringify(e.map((
|
|
5
|
-
const colors = ${JSON.stringify(
|
|
6
|
-
const icons = ${JSON.stringify(
|
|
4
|
+
const issues = ${JSON.stringify(e.map((n,r)=>{let l=(()=>{try{return Pe(n,s)}catch{return null}})();return{id:r,selector:n.selector,impact:n.impact,wcag:n.wcag,type:n.type,message:n.message.slice(0,200),problem:l?.problem||"",recommendedFix:l?.fix||"",wcagLink:l?.wcagLink||`https://www.w3.org/WAI/WCAG21/Understanding/${n.wcag.replace(/\./g,"")}`,whyMatters:Le(n),fixDesc:n.fix?.suggestedValue?`Set ${n.fix.attribute}="${n.fix.suggestedValue}"`:"",fixAttr:n.fix?.attribute||"",fixVal:n.fix?.suggestedValue||"",fixCur:n.fix?.currentValue||"",canApply:!!(n.fix?.suggestedValue&&!n.fix?.needsManualReview)}}))};
|
|
5
|
+
const colors = ${JSON.stringify(Te)};
|
|
6
|
+
const icons = ${JSON.stringify(De)};
|
|
7
7
|
const API_KEY = ${JSON.stringify(t||"")};
|
|
8
8
|
const API_URL = 'https://api.webability.io';
|
|
9
|
-
const FRAMEWORK = ${JSON.stringify(
|
|
9
|
+
const FRAMEWORK = ${JSON.stringify(o||"plain-css")};
|
|
10
10
|
const activeFilters = { critical: true, serious: true, moderate: true, minor: true };
|
|
11
11
|
const overlays = [];
|
|
12
12
|
|
|
@@ -27,74 +27,127 @@ var se=(e=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(e,{get:(t,n)=>(
|
|
|
27
27
|
|
|
28
28
|
const style = document.createElement('style');
|
|
29
29
|
style.textContent = [
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
30
|
+
// Brand tokens (matches .impeccable.md)
|
|
31
|
+
":root{--ab-primary:#0052CC;--ab-primary-dark:#0033ED;--ab-accent:#47FFF7;--ab-navy:#1B3DB4;--ab-bg:#FFFFFF;--ab-surface:#F9FAFB;--ab-text:#111827;--ab-text-muted:#4B5563;--ab-border:#E5E7EB;--ab-success:#15803D;--ab-warning:#CA8A04;--ab-danger:#DC2626;--ab-bg-tint:#F4F8FF}",
|
|
32
|
+
"@keyframes abilyo-reveal{from{opacity:0;transform:scale(.97)}to{opacity:1;transform:scale(1)}}",
|
|
33
|
+
|
|
34
|
+
// Overlays on the page
|
|
35
|
+
".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}",
|
|
36
|
+
".abilyo-overlay:hover{outline-width:3px;outline-offset:1px}",
|
|
37
|
+
".abilyo-overlay.abilyo-active{outline:3px solid #0052CC!important;outline-offset:2px}",
|
|
38
|
+
".abilyo-overlay.abilyo-hidden{opacity:0;pointer-events:none}",
|
|
39
|
+
".abilyo-overlay.abilyo-dimmed{opacity:.12;filter:blur(1px)}",
|
|
40
|
+
".abilyo-label{position:absolute;top:-22px;left:-2px;font:600 11px/14px Inter,system-ui,sans-serif;color:#fff;padding:2px 6px;border-radius:3px 3px 0 0;white-space:nowrap;pointer-events:none;z-index:2147483641}",
|
|
41
|
+
|
|
42
|
+
// Panel container
|
|
43
|
+
"#abilyo-panel{position:fixed;top:12px;right:12px;z-index:2147483647;width:400px;max-width:calc(100vw - 24px);max-height:calc(100vh - 24px);background:#fff;border-radius:14px;box-shadow:0 12px 40px rgba(0,82,204,.12),0 0 0 1px rgba(0,82,204,.08);font:13px Inter,system-ui,-apple-system,sans-serif;color:#111827;display:flex;flex-direction:column;overflow:hidden}",
|
|
44
|
+
"#abilyo-panel *{box-sizing:border-box;min-width:0}",
|
|
45
|
+
|
|
46
|
+
// Header with hero score
|
|
47
|
+
".ap-header{padding:18px 20px 14px;border-bottom:1px solid #E5E7EB;flex-shrink:0}",
|
|
48
|
+
".ap-header-top{display:flex;align-items:center;gap:8px;margin-bottom:10px}",
|
|
49
|
+
".ap-brand{display:flex;align-items:center;gap:8px;flex:1}",
|
|
50
|
+
".ap-logo{width:18px;height:18px;background:#0052CC;border-radius:4px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:800;font-size:11px;font-family:Inter,system-ui}",
|
|
51
|
+
".ap-title{font-weight:600;font-size:13px;color:#4B5563;letter-spacing:-.01em}",
|
|
52
|
+
".ap-close{width:26px;height:26px;border:none;background:transparent;border-radius:6px;cursor:pointer;font-size:14px;color:#6B7280;display:flex;align-items:center;justify-content:center}",
|
|
53
|
+
".ap-close:hover{background:#F3F4F6;color:#111827}",
|
|
54
|
+
".ap-score-row{display:flex;align-items:baseline;gap:10px}",
|
|
55
|
+
".ap-score{font:800 32px/1 Inter,system-ui;letter-spacing:-.03em;font-feature-settings:'tnum'}",
|
|
56
|
+
".ap-score-label{display:flex;flex-direction:column;gap:1px}",
|
|
57
|
+
".ap-score-summary{font-size:13px;font-weight:600;color:#111827}",
|
|
58
|
+
".ap-score-meta{font-size:11px;color:#6B7280;font-weight:500}",
|
|
59
|
+
|
|
60
|
+
// Toolbar \u2014 toggle row with switch-style controls (NOT primary buttons)
|
|
61
|
+
".ap-toolbar{display:flex;gap:14px;padding:10px 16px;border-bottom:1px solid #E5E7EB;flex-shrink:0;background:#FAFBFC;align-items:center}",
|
|
62
|
+
".ap-toggle{display:flex;align-items:center;gap:8px;cursor:pointer;font:500 12px Inter,system-ui;color:#4B5563;user-select:none}",
|
|
63
|
+
".ap-toggle:hover{color:#111827}",
|
|
64
|
+
".ap-toggle-track{position:relative;width:30px;height:18px;background:#D1D5DB;border-radius:9px;transition:background .15s;flex-shrink:0}",
|
|
65
|
+
".ap-toggle-thumb{position:absolute;top:2px;left:2px;width:14px;height:14px;background:#fff;border-radius:50%;box-shadow:0 1px 2px rgba(0,0,0,.15);transition:transform .15s}",
|
|
66
|
+
".ap-toggle.active .ap-toggle-track{background:#0052CC}",
|
|
67
|
+
".ap-toggle.active .ap-toggle-thumb{transform:translateX(12px)}",
|
|
68
|
+
".ap-toggle-meta{font-size:11px;color:#6B7280;font-weight:500;font-feature-settings:'tnum'}",
|
|
69
|
+
|
|
70
|
+
// Filter pills \u2014 distinct on/off state
|
|
71
|
+
".ap-filters{display:flex;gap:6px;padding:10px 16px;border-bottom:1px solid #E5E7EB;flex-shrink:0;flex-wrap:wrap}",
|
|
72
|
+
".ap-fbtn{display:flex;align-items:center;gap:5px;padding:5px 10px;border-radius:14px;border:1.5px solid transparent;cursor:pointer;font:600 11px Inter,system-ui;transition:all .15s;letter-spacing:.01em}",
|
|
73
|
+
// ON state: filled with severity color
|
|
74
|
+
".ap-fbtn.active{color:#fff}",
|
|
75
|
+
// OFF state: clear "off" \u2014 gray text, gray border, no fill
|
|
76
|
+
".ap-fbtn:not(.active){background:#fff;color:#9CA3AF;border-color:#E5E7EB;text-decoration:line-through;text-decoration-thickness:1px}",
|
|
77
|
+
".ap-fbtn:not(.active):hover{color:#4B5563;border-color:#9CA3AF}",
|
|
78
|
+
".ap-fbtn-num{font-weight:800}",
|
|
79
|
+
".ap-fbtn-label{font-weight:500;text-transform:capitalize}",
|
|
80
|
+
|
|
81
|
+
// Issue list
|
|
82
|
+
".ap-list{flex:1;overflow-y:auto;scrollbar-width:thin}",
|
|
83
|
+
".ap-list::-webkit-scrollbar{width:6px}",
|
|
84
|
+
".ap-list::-webkit-scrollbar-thumb{background:#E5E7EB;border-radius:3px}",
|
|
85
|
+
".ap-row{display:flex;align-items:flex-start;gap:10px;padding:11px 16px;cursor:pointer;border-bottom:1px solid #F3F4F6;transition:background .1s}",
|
|
86
|
+
".ap-row:hover{background:#F4F8FF}",
|
|
87
|
+
".ap-row.active{background:#F4F8FF;box-shadow:inset 3px 0 0 #0052CC}",
|
|
88
|
+
".ap-row.active.ap-sticky{position:sticky;top:0;background:#F4F8FF;z-index:2;border-bottom:2px solid #0052CC}",
|
|
89
|
+
".ap-icon{width:24px;height:24px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:800;color:#fff;flex-shrink:0;margin-top:1px;font-family:Inter,system-ui}",
|
|
90
|
+
".ap-content{flex:1;min-width:0}",
|
|
91
|
+
".ap-wcag{font-size:11px;font-weight:700;color:#0052CC;background:#F4F8FF;padding:2px 7px;border-radius:4px;letter-spacing:.01em;font-feature-settings:'tnum'}",
|
|
92
|
+
".ap-msg{font-size:13px;color:#111827;margin-top:4px;line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}",
|
|
93
|
+
".ap-fix{font-size:12px;color:#15803D;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500}",
|
|
94
|
+
// Count badge for grouped rows (e.g. "weak link name \xD7 5")
|
|
95
|
+
".ap-count-badge{display:inline-flex;align-items:center;justify-content:center;min-width:22px;height:18px;padding:0 6px;border-radius:9px;background:#0052CC;color:#fff;font-size:10px;font-weight:700;letter-spacing:.02em;margin-left:6px;font-feature-settings:'tnum'}",
|
|
96
|
+
// Header for issue group sections in the list
|
|
97
|
+
".ap-group-header{padding:14px 16px 6px;font-size:10px;font-weight:700;color:#6B7280;text-transform:uppercase;letter-spacing:.08em;background:#FAFBFC;border-bottom:1px solid #F3F4F6;border-top:1px solid #F3F4F6;position:sticky;top:0;z-index:1}",
|
|
98
|
+
".ap-group-header:first-child{border-top:none}",
|
|
99
|
+
|
|
100
|
+
// Detail pane \u2014 visually distinct from list above
|
|
101
|
+
"#ap-detail{display:none;flex-shrink:0;border-top:2px solid #0052CC;padding:16px 18px;background:linear-gradient(180deg,#F4F8FF 0%,#FAFBFC 80px);max-height:340px;overflow-y:auto;box-shadow:inset 0 4px 8px -4px rgba(0,82,204,.08)}",
|
|
102
|
+
".apd-head{display:flex;align-items:center;gap:8px;margin-bottom:10px}",
|
|
103
|
+
".apd-wcag{font-weight:800;color:#0052CC;font-size:15px;letter-spacing:-.01em}",
|
|
104
|
+
".apd-badge{font-size:10px;font-weight:700;padding:3px 8px;border-radius:4px;color:#fff;text-transform:uppercase;letter-spacing:.06em}",
|
|
105
|
+
".apd-msg{font-size:13px;color:#111827;line-height:1.45;margin-bottom:10px;font-weight:600}",
|
|
106
|
+
// Educational "Why this matters" \u2014 the explanation block
|
|
107
|
+
".apd-section{margin-top:10px}",
|
|
108
|
+
".apd-section-label{font-size:10px;font-weight:700;color:#6B7280;text-transform:uppercase;letter-spacing:.07em;margin-bottom:4px}",
|
|
109
|
+
".apd-why{font-size:12px;line-height:1.5;color:#1F2937;background:#FAFBFC;padding:9px 12px;border-radius:6px;border-left:3px solid #0052CC;margin-top:4px}",
|
|
110
|
+
".apd-recommend{font-size:12px;line-height:1.5;color:#1F2937;margin-top:4px}",
|
|
111
|
+
".apd-recommend code{font-family:'SF Mono',Menlo,Consolas,monospace;font-size:11px;background:#F3F4F6;padding:1px 5px;border-radius:3px;color:#0033ED}",
|
|
112
|
+
".apd-learnmore{font-size:11px;color:#0052CC;text-decoration:none;display:inline-flex;align-items:center;gap:4px;margin-top:8px;font-weight:500;padding:4px 8px;border-radius:4px;background:#F4F8FF;width:fit-content}",
|
|
113
|
+
".apd-learnmore:hover{background:#DBEAFE;text-decoration:none}",
|
|
114
|
+
".apd-learnmore-arrow{font-size:11px;font-family:Inter,system-ui;line-height:1;display:inline-block;transform:translateY(-1px)}",
|
|
115
|
+
".apd-fix{font-size:12px;color:#15803D;background:#F0FDF4;padding:8px 12px;border-radius:6px;margin-top:6px;border:1px solid #BBF7D0}",
|
|
116
|
+
".apd-sel{font-size:11px;color:#6B7280;font-family:'SF Mono',Menlo,Consolas,monospace;margin-top:8px;word-break:break-all;background:#F3F4F6;padding:6px 8px;border-radius:4px}",
|
|
117
|
+
".apd-actions{display:flex;gap:8px;margin-top:12px}",
|
|
118
|
+
".apd-btn{padding:7px 14px;border-radius:7px;border:none;cursor:pointer;font:600 12px Inter,system-ui;transition:all .15s;letter-spacing:-.01em}",
|
|
119
|
+
".apd-preview{background:#F4F8FF;color:#0052CC;border:1px solid #DBEAFE}",
|
|
120
|
+
".apd-preview:hover{background:#DBEAFE;border-color:#0052CC}",
|
|
121
|
+
".apd-preview.active{background:#0052CC;color:#fff;border-color:#0052CC}",
|
|
122
|
+
".apd-apply{background:#15803D;color:#fff}",
|
|
123
|
+
".apd-apply:hover{background:#166534}",
|
|
124
|
+
".apd-apply.applied{background:#6B7280;cursor:default}",
|
|
125
|
+
|
|
126
|
+
// Before/after \u2014 branded subtle tints
|
|
127
|
+
".apd-before-after{display:flex;gap:10px;margin-top:10px;font-size:12px}",
|
|
128
|
+
".apd-ba-col{flex:1;padding:9px 10px;border-radius:6px}",
|
|
129
|
+
".apd-ba-before{background:#FEF3F2;border:1px solid #FECDD3}",
|
|
130
|
+
".apd-ba-after{background:#F4FFF8;border:1px solid #BBF7D0}",
|
|
131
|
+
".apd-ba-label{font-weight:700;font-size:9px;text-transform:uppercase;letter-spacing:.08em;margin-bottom:4px;color:#6B7280}",
|
|
132
|
+
".apd-ba-value{font-family:'SF Mono',Menlo,Consolas,monospace;font-size:11px;word-break:break-all;color:#111827}",
|
|
133
|
+
|
|
134
|
+
// AI button \u2014 branded, no purple gradient, no sparkle emoji
|
|
135
|
+
".apd-ai{background:#0052CC;color:#fff;position:relative;overflow:hidden;border:none}",
|
|
136
|
+
".apd-ai:hover{background:#0033ED;box-shadow:0 0 0 3px rgba(0,82,204,.15)}",
|
|
137
|
+
".apd-ai:disabled{opacity:.7;cursor:wait}",
|
|
138
|
+
".apd-ai::before{content:'AI';display:inline-block;font-size:9px;font-weight:800;letter-spacing:.05em;background:#47FFF7;color:#0033ED;padding:2px 5px;border-radius:3px;margin-right:7px;vertical-align:middle}",
|
|
139
|
+
"@keyframes apd-shimmer{0%{background-position:-200% 0}100%{background-position:200% 0}}",
|
|
140
|
+
".apd-ai-loading{background:linear-gradient(90deg,#0052CC 25%,#3B7FF7 50%,#0052CC 75%);background-size:200% 100%;animation:apd-shimmer 1.5s infinite}",
|
|
141
|
+
|
|
142
|
+
// AI result \u2014 brand-blue tint, not lavender
|
|
143
|
+
".apd-ai-result{margin-top:10px;padding:12px;background:#F4F8FF;border:1px solid #DBEAFE;border-radius:8px;border-left:3px solid #0052CC}",
|
|
144
|
+
".apd-ai-explain{font-size:13px;color:#111827;margin-bottom:8px;line-height:1.45}",
|
|
145
|
+
".apd-ai-code{font-size:12px;font-family:'SF Mono',Menlo,Consolas,monospace;color:#1B3DB4;background:#fff;padding:7px 10px;border-radius:5px;word-break:break-all;border:1px solid #DBEAFE}",
|
|
146
|
+
|
|
147
|
+
// Minimized pill \u2014 branded
|
|
148
|
+
"#abilyo-pill{display:none;position:fixed;top:12px;right:12px;z-index:2147483647;background:#0052CC;color:#fff;border-radius:10px;padding:9px 14px;cursor:pointer;font:700 13px/1 Inter,system-ui;box-shadow:0 4px 16px rgba(0,82,204,.35);transition:transform .15s}",
|
|
149
|
+
"#abilyo-pill:hover{transform:scale(1.05)}",
|
|
150
|
+
"#abilyo-pill .ap-pill-num{font-weight:800;font-feature-settings:'tnum';margin-right:4px}",
|
|
98
151
|
].join('\\n');
|
|
99
152
|
document.head.appendChild(style);
|
|
100
153
|
|
|
@@ -168,31 +221,109 @@ var se=(e=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(e,{get:(t,n)=>(
|
|
|
168
221
|
const filtersEl = h('div', { className: 'ap-filters' });
|
|
169
222
|
const toolbarEl = h('div', { className: 'ap-toolbar' });
|
|
170
223
|
|
|
171
|
-
//
|
|
224
|
+
// Score \u2014 Lighthouse-style: capped per-severity penalties so a site with
|
|
225
|
+
// 159 moderates doesn't crash to 0% (which is meaningless and demoralizing).
|
|
226
|
+
// Each severity contributes a bounded amount; floor at 5% so users always
|
|
227
|
+
// have somewhere to go.
|
|
228
|
+
function computeScore(c) {
|
|
229
|
+
if (c.critical + c.serious + c.moderate + c.minor === 0) return 100;
|
|
230
|
+
const criticalPenalty = Math.min(c.critical * 12, 35); // up to -35
|
|
231
|
+
const seriousPenalty = Math.min(c.serious * 4, 25); // up to -25
|
|
232
|
+
const moderatePenalty = Math.min(c.moderate * 1, 20); // up to -20
|
|
233
|
+
const minorPenalty = Math.min(c.minor * 0.5, 10); // up to -10
|
|
234
|
+
return Math.max(5, Math.round(100 - criticalPenalty - seriousPenalty - moderatePenalty - minorPenalty));
|
|
235
|
+
}
|
|
236
|
+
const score = computeScore(counts);
|
|
237
|
+
const scoreColor = score >= 90 ? '#15803D' : score >= 70 ? '#CA8A04' : score >= 40 ? '#EA580C' : '#DC2626';
|
|
238
|
+
const scoreSummary = score >= 90 ? 'Looking good' : score >= 70 ? 'Mostly good' : score >= 40 ? 'Needs work' : 'Failing';
|
|
239
|
+
|
|
240
|
+
// Toolbar \u2014 switch-style toggles (clearly stateful, not action buttons).
|
|
241
|
+
// role=switch needs tabindex + Space/Enter to be reachable & operable
|
|
242
|
+
// without a mouse \u2014 same a11y bar we hold customer code to.
|
|
243
|
+
function activateOnKeydown(handler) {
|
|
244
|
+
return (ev) => {
|
|
245
|
+
if (ev.key === ' ' || ev.key === 'Enter' || ev.code === 'Space') {
|
|
246
|
+
ev.preventDefault();
|
|
247
|
+
handler();
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
172
252
|
let overlaysVisible = true;
|
|
173
|
-
const
|
|
253
|
+
const overlayMeta = h('span', { className: 'ap-toggle-meta' }, 'on');
|
|
254
|
+
const toggleOverlays = () => {
|
|
174
255
|
overlaysVisible = !overlaysVisible;
|
|
175
|
-
|
|
256
|
+
overlayToggle.classList.toggle('active', overlaysVisible);
|
|
257
|
+
overlayToggle.setAttribute('aria-checked', String(overlaysVisible));
|
|
258
|
+
overlayMeta.textContent = overlaysVisible ? 'on' : 'off';
|
|
176
259
|
document.querySelectorAll('.abilyo-overlay').forEach(o => { (o).style.display = overlaysVisible ? '' : 'none'; });
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
260
|
+
};
|
|
261
|
+
const overlayToggle = h('div', {
|
|
262
|
+
className: 'ap-toggle active',
|
|
263
|
+
role: 'switch',
|
|
264
|
+
tabindex: '0',
|
|
265
|
+
'aria-checked': 'true',
|
|
266
|
+
'aria-label': 'Highlight issues on page',
|
|
267
|
+
onClick: toggleOverlays,
|
|
268
|
+
onKeydown: activateOnKeydown(toggleOverlays),
|
|
269
|
+
}, [
|
|
270
|
+
h('span', { className: 'ap-toggle-track' }, [h('span', { className: 'ap-toggle-thumb' })]),
|
|
271
|
+
document.createTextNode('Highlight on page'),
|
|
272
|
+
overlayMeta,
|
|
273
|
+
]);
|
|
274
|
+
toolbarEl.appendChild(overlayToggle);
|
|
275
|
+
|
|
276
|
+
// Labels toggle \u2014 hide the small WCAG number badges above each overlay if too noisy
|
|
277
|
+
let labelsVisible = true;
|
|
278
|
+
const labelsMeta = h('span', { className: 'ap-toggle-meta' }, 'on');
|
|
279
|
+
const toggleLabels = () => {
|
|
280
|
+
labelsVisible = !labelsVisible;
|
|
281
|
+
labelsToggle.classList.toggle('active', labelsVisible);
|
|
282
|
+
labelsToggle.setAttribute('aria-checked', String(labelsVisible));
|
|
283
|
+
labelsMeta.textContent = labelsVisible ? 'on' : 'off';
|
|
284
|
+
document.querySelectorAll('.abilyo-label').forEach(l => { (l).style.display = labelsVisible ? '' : 'none'; });
|
|
285
|
+
};
|
|
286
|
+
const labelsToggle = h('div', {
|
|
287
|
+
className: 'ap-toggle active',
|
|
288
|
+
role: 'switch',
|
|
289
|
+
tabindex: '0',
|
|
290
|
+
'aria-checked': 'true',
|
|
291
|
+
'aria-label': 'Show severity labels',
|
|
292
|
+
onClick: toggleLabels,
|
|
293
|
+
onKeydown: activateOnKeydown(toggleLabels),
|
|
294
|
+
}, [
|
|
295
|
+
h('span', { className: 'ap-toggle-track' }, [h('span', { className: 'ap-toggle-thumb' })]),
|
|
296
|
+
document.createTextNode('Labels'),
|
|
297
|
+
labelsMeta,
|
|
298
|
+
]);
|
|
299
|
+
toolbarEl.appendChild(labelsToggle);
|
|
186
300
|
|
|
187
301
|
const minBtn = h('button', { className: 'ap-close', title: 'Minimize', onClick: () => { panel.style.display = 'none'; pill.style.display = 'block'; } }, '\\u2014');
|
|
188
302
|
|
|
189
|
-
|
|
190
|
-
|
|
303
|
+
// Branded minimized pill
|
|
304
|
+
const pillNum = h('span', { className: 'ap-pill-num' }, String(issues.length));
|
|
305
|
+
const pill = h('div', { id: 'abilyo-pill', onClick: () => { panel.style.display = 'flex'; pill.style.display = 'none'; } }, [
|
|
306
|
+
pillNum, document.createTextNode(' issues')
|
|
307
|
+
]);
|
|
308
|
+
|
|
309
|
+
// Header \u2014 score is hero, brand mark is small
|
|
310
|
+
const headerTop = h('div', { className: 'ap-header-top' }, [
|
|
311
|
+
h('div', { className: 'ap-brand' }, [
|
|
191
312
|
h('div', { className: 'ap-logo' }, 'A'),
|
|
192
313
|
h('div', { className: 'ap-title' }, 'Abilyo'),
|
|
193
|
-
h('div', { className: 'ap-count' }, String(issues.length) + ' issues'),
|
|
194
|
-
minBtn,
|
|
195
314
|
]),
|
|
315
|
+
minBtn,
|
|
316
|
+
]);
|
|
317
|
+
const scoreRow = h('div', { className: 'ap-score-row' }, [
|
|
318
|
+
h('div', { className: 'ap-score', style: { color: scoreColor } }, score + '%'),
|
|
319
|
+
h('div', { className: 'ap-score-label' }, [
|
|
320
|
+
h('div', { className: 'ap-score-summary' }, issues.length + ' issue' + (issues.length === 1 ? '' : 's') + ' \xB7 ' + scoreSummary),
|
|
321
|
+
h('div', { className: 'ap-score-meta' }, 'WCAG 2.1 AA'),
|
|
322
|
+
]),
|
|
323
|
+
]);
|
|
324
|
+
|
|
325
|
+
const panel = h('div', { id: 'abilyo-panel' }, [
|
|
326
|
+
h('div', { className: 'ap-header' }, [headerTop, scoreRow]),
|
|
196
327
|
toolbarEl,
|
|
197
328
|
filtersEl,
|
|
198
329
|
listEl,
|
|
@@ -201,21 +332,31 @@ var se=(e=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(e,{get:(t,n)=>(
|
|
|
201
332
|
document.body.appendChild(panel);
|
|
202
333
|
document.body.appendChild(pill);
|
|
203
334
|
|
|
204
|
-
// --- Filter
|
|
335
|
+
// --- Filter pills \u2014 clear on/off state ---
|
|
205
336
|
for (const sev of ['critical','serious','moderate','minor']) {
|
|
206
337
|
if (counts[sev] === 0) continue;
|
|
207
|
-
const dot = h('span', { className: 'ap-dot', style: { background: '#fff' } });
|
|
208
338
|
const btn = h('button', {
|
|
209
339
|
className: 'ap-fbtn active',
|
|
210
|
-
|
|
211
|
-
title: sev,
|
|
340
|
+
title: 'Toggle ' + sev,
|
|
212
341
|
onClick: () => {
|
|
213
342
|
activeFilters[sev] = !activeFilters[sev];
|
|
214
343
|
btn.classList.toggle('active', activeFilters[sev]);
|
|
215
|
-
|
|
344
|
+
// ON: filled with severity color. OFF: gray strikethrough.
|
|
345
|
+
if (activeFilters[sev]) {
|
|
346
|
+
btn.style.background = colors[sev];
|
|
347
|
+
btn.style.borderColor = colors[sev];
|
|
348
|
+
} else {
|
|
349
|
+
btn.style.background = '';
|
|
350
|
+
btn.style.borderColor = '';
|
|
351
|
+
}
|
|
216
352
|
applyFilters();
|
|
217
353
|
},
|
|
218
|
-
}, [
|
|
354
|
+
}, [
|
|
355
|
+
h('span', { className: 'ap-fbtn-num' }, String(counts[sev])),
|
|
356
|
+
h('span', { className: 'ap-fbtn-label' }, sev),
|
|
357
|
+
]);
|
|
358
|
+
btn.style.background = colors[sev];
|
|
359
|
+
btn.style.borderColor = colors[sev];
|
|
219
360
|
filtersEl.appendChild(btn);
|
|
220
361
|
}
|
|
221
362
|
|
|
@@ -224,22 +365,75 @@ var se=(e=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(e,{get:(t,n)=>(
|
|
|
224
365
|
|
|
225
366
|
function renderList() {
|
|
226
367
|
listEl.replaceChildren();
|
|
227
|
-
|
|
228
|
-
|
|
368
|
+
|
|
369
|
+
// Build groups: one row per (type + wcag + recommendedFix), with all matching ids
|
|
370
|
+
const visible = deduped.filter(i => activeFilters[i.impact]);
|
|
371
|
+
const groupMap = new Map();
|
|
372
|
+
for (const issue of visible) {
|
|
373
|
+
const key = (issue.type || issue.wcag) + '::' + issue.wcag + '::' + (issue.recommendedFix || issue.fixDesc || '');
|
|
374
|
+
const existing = groupMap.get(key);
|
|
375
|
+
if (existing) {
|
|
376
|
+
existing.ids.push(issue.id);
|
|
377
|
+
} else {
|
|
378
|
+
groupMap.set(key, { lead: issue, ids: [issue.id] });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const groups = Array.from(groupMap.values());
|
|
382
|
+
|
|
383
|
+
// Sort: severity order, then group size desc within severity
|
|
384
|
+
const sevOrder = { critical: 0, serious: 1, moderate: 2, minor: 3 };
|
|
385
|
+
groups.sort((a, b) => {
|
|
386
|
+
const s = (sevOrder[a.lead.impact] ?? 9) - (sevOrder[b.lead.impact] ?? 9);
|
|
387
|
+
if (s !== 0) return s;
|
|
388
|
+
return b.ids.length - a.ids.length;
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// Render with severity section headers
|
|
392
|
+
let currentSection = '';
|
|
393
|
+
for (const g of groups) {
|
|
394
|
+
if (g.lead.impact !== currentSection) {
|
|
395
|
+
currentSection = g.lead.impact;
|
|
396
|
+
const total = groups.filter(x => x.lead.impact === currentSection).reduce((n, x) => n + x.ids.length, 0);
|
|
397
|
+
listEl.appendChild(h('div', { className: 'ap-group-header' }, currentSection.toUpperCase() + ' \\u00b7 ' + total));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const isActive = g.ids.includes(activeId);
|
|
229
401
|
const contentChildren = [
|
|
230
|
-
h('span', { className: 'ap-wcag' },
|
|
231
|
-
h('
|
|
232
|
-
h('div', { className: 'ap-msg' }, issue.message),
|
|
402
|
+
h('span', { className: 'ap-wcag' }, g.lead.wcag),
|
|
403
|
+
h('div', { className: 'ap-msg' }, g.lead.problem || g.lead.message),
|
|
233
404
|
];
|
|
234
|
-
if (
|
|
405
|
+
if (g.ids.length > 1) {
|
|
406
|
+
contentChildren[0] = h('span', null, [
|
|
407
|
+
h('span', { className: 'ap-wcag' }, g.lead.wcag),
|
|
408
|
+
h('span', { className: 'ap-count-badge', style: { background: colors[g.lead.impact] } }, '\\u00d7' + g.ids.length),
|
|
409
|
+
]);
|
|
410
|
+
}
|
|
411
|
+
if (g.lead.recommendedFix && g.lead.recommendedFix !== g.lead.problem) {
|
|
412
|
+
contentChildren.push(h('div', { className: 'ap-fix' }, g.lead.recommendedFix));
|
|
413
|
+
} else if (g.lead.fixDesc) {
|
|
414
|
+
contentChildren.push(h('div', { className: 'ap-fix' }, 'Fix: ' + g.lead.fixDesc));
|
|
415
|
+
}
|
|
235
416
|
|
|
236
417
|
const row = h('div', {
|
|
237
|
-
className: 'ap-row' + (
|
|
238
|
-
onClick: () => selectIssue(
|
|
239
|
-
onMouseenter: () => {
|
|
240
|
-
|
|
418
|
+
className: 'ap-row' + (isActive ? ' active ap-sticky' : ''),
|
|
419
|
+
onClick: () => selectIssue(g.lead.id),
|
|
420
|
+
onMouseenter: () => {
|
|
421
|
+
// Highlight all members of the group on hover
|
|
422
|
+
for (const memberId of g.ids) {
|
|
423
|
+
const o = overlays[memberId];
|
|
424
|
+
if (o) o.style.outlineWidth = '3px';
|
|
425
|
+
}
|
|
426
|
+
const o = overlays[g.lead.id];
|
|
427
|
+
if (o) o.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
428
|
+
},
|
|
429
|
+
onMouseleave: () => {
|
|
430
|
+
for (const memberId of g.ids) {
|
|
431
|
+
const o = overlays[memberId];
|
|
432
|
+
if (o && memberId !== activeId) o.style.outlineWidth = '2px';
|
|
433
|
+
}
|
|
434
|
+
},
|
|
241
435
|
}, [
|
|
242
|
-
h('div', { className: 'ap-icon', style: { background: colors[
|
|
436
|
+
h('div', { className: 'ap-icon', style: { background: colors[g.lead.impact] }, title: g.lead.impact }, icons[g.lead.impact]),
|
|
243
437
|
h('div', { className: 'ap-content' }, contentChildren),
|
|
244
438
|
]);
|
|
245
439
|
listEl.appendChild(row);
|
|
@@ -291,7 +485,7 @@ var se=(e=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(e,{get:(t,n)=>(
|
|
|
291
485
|
appliedFixes.add(issue.id);
|
|
292
486
|
previewState.id = -1;
|
|
293
487
|
const ov = overlays[issue.id];
|
|
294
|
-
if (ov) { ov.style.outlineColor = '#
|
|
488
|
+
if (ov) { ov.style.outlineColor = '#15803D'; ov.querySelector('.abilyo-label').style.background = '#15803D'; ov.querySelector('.abilyo-label').textContent = 'Fixed'; }
|
|
295
489
|
}
|
|
296
490
|
|
|
297
491
|
function selectIssue(id) {
|
|
@@ -318,7 +512,39 @@ var se=(e=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(e,{get:(t,n)=>(
|
|
|
318
512
|
h('span', { className: 'apd-wcag' }, 'WCAG ' + issue.wcag),
|
|
319
513
|
h('span', { className: 'apd-badge', style: { background: colors[issue.impact] } }, issue.impact),
|
|
320
514
|
]));
|
|
321
|
-
|
|
515
|
+
// The headline \u2014 what's wrong (use richer "problem" if available, else the raw message)
|
|
516
|
+
detailEl.appendChild(h('div', { className: 'apd-msg' }, issue.problem || issue.message));
|
|
517
|
+
|
|
518
|
+
// Why this matters \u2014 user-impact explanation
|
|
519
|
+
if (issue.whyMatters) {
|
|
520
|
+
detailEl.appendChild(h('div', { className: 'apd-section' }, [
|
|
521
|
+
h('div', { className: 'apd-section-label' }, 'Why it matters'),
|
|
522
|
+
h('div', { className: 'apd-why' }, issue.whyMatters),
|
|
523
|
+
]));
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Recommended fix \u2014 plain-language guidance from generateDevSuggestion
|
|
527
|
+
if (issue.recommendedFix && issue.recommendedFix !== issue.problem) {
|
|
528
|
+
const recommendEl = h('div', { className: 'apd-recommend' });
|
|
529
|
+
recommendEl.textContent = issue.recommendedFix;
|
|
530
|
+
detailEl.appendChild(h('div', { className: 'apd-section' }, [
|
|
531
|
+
h('div', { className: 'apd-section-label' }, 'How to fix it'),
|
|
532
|
+
recommendEl,
|
|
533
|
+
]));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Link to W3C WCAG explainer
|
|
537
|
+
if (issue.wcagLink) {
|
|
538
|
+
detailEl.appendChild(h('a', {
|
|
539
|
+
className: 'apd-learnmore',
|
|
540
|
+
href: issue.wcagLink,
|
|
541
|
+
target: '_blank',
|
|
542
|
+
rel: 'noopener noreferrer',
|
|
543
|
+
}, [
|
|
544
|
+
document.createTextNode('Read WCAG ' + issue.wcag),
|
|
545
|
+
h('span', { className: 'apd-learnmore-arrow' }, '\\u2192'), // \u2192 (right arrow, not unicode-escape \u2197)
|
|
546
|
+
]));
|
|
547
|
+
}
|
|
322
548
|
|
|
323
549
|
if (issue.canApply && !appliedFixes.has(issue.id)) {
|
|
324
550
|
const ba = h('div', { className: 'apd-before-after' }, [
|
|
@@ -333,39 +559,36 @@ var se=(e=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(e,{get:(t,n)=>(
|
|
|
333
559
|
]);
|
|
334
560
|
detailEl.appendChild(ba);
|
|
335
561
|
|
|
336
|
-
const previewBtn = h('button', { className: 'apd-btn apd-preview' }, '
|
|
337
|
-
const applyBtn = h('button', { className: 'apd-btn apd-apply' }, '
|
|
562
|
+
const previewBtn = h('button', { className: 'apd-btn apd-preview' }, 'Preview');
|
|
563
|
+
const applyBtn = h('button', { className: 'apd-btn apd-apply' }, 'Apply fix');
|
|
338
564
|
|
|
339
565
|
previewBtn.addEventListener('click', () => {
|
|
340
566
|
const on = previewFix(issue);
|
|
341
567
|
previewBtn.classList.toggle('active', on);
|
|
342
|
-
previewBtn.textContent = on ? '
|
|
568
|
+
previewBtn.textContent = on ? 'Revert' : 'Preview';
|
|
343
569
|
});
|
|
344
570
|
applyBtn.addEventListener('click', () => {
|
|
345
571
|
applyFix(issue);
|
|
346
572
|
applyBtn.classList.add('applied');
|
|
347
|
-
applyBtn.textContent = '
|
|
573
|
+
applyBtn.textContent = 'Applied';
|
|
348
574
|
previewBtn.style.display = 'none';
|
|
349
575
|
});
|
|
350
576
|
|
|
351
577
|
detailEl.appendChild(h('div', { className: 'apd-actions' }, [previewBtn, applyBtn]));
|
|
352
578
|
} else if (appliedFixes.has(issue.id)) {
|
|
353
|
-
detailEl.appendChild(h('div', { className: 'apd-fix' }, '
|
|
579
|
+
detailEl.appendChild(h('div', { className: 'apd-fix' }, 'Fix applied'));
|
|
354
580
|
} else if (issue.fixDesc) {
|
|
355
|
-
detailEl.appendChild(h('div', { className: 'apd-fix' },
|
|
581
|
+
detailEl.appendChild(h('div', { className: 'apd-fix' }, issue.fixDesc));
|
|
356
582
|
}
|
|
357
583
|
|
|
358
|
-
// AI Fix button \u2014
|
|
584
|
+
// AI Fix button \u2014 branded, no purple gradient, AI pill via ::before
|
|
359
585
|
if (!appliedFixes.has(issue.id)) {
|
|
360
586
|
const aiResultContainer = h('div');
|
|
361
|
-
const aiBtn = h('button', { className: 'apd-btn apd-ai' },
|
|
362
|
-
h('span', { className: 'apd-ai-sparkle' }, '\\u2728'),
|
|
363
|
-
document.createTextNode('AI Fix'),
|
|
364
|
-
]);
|
|
587
|
+
const aiBtn = h('button', { className: 'apd-btn apd-ai' }, document.createTextNode('Generate fix'));
|
|
365
588
|
aiBtn.addEventListener('click', async () => {
|
|
366
589
|
aiBtn.disabled = true;
|
|
367
590
|
aiBtn.classList.add('apd-ai-loading');
|
|
368
|
-
aiBtn.textContent = '\\
|
|
591
|
+
aiBtn.textContent = 'Generating fix\\u2026';
|
|
369
592
|
try {
|
|
370
593
|
const el = document.querySelector(issue.selector);
|
|
371
594
|
const elHtml = el ? el.outerHTML.slice(0, 600) : '';
|
|
@@ -393,7 +616,7 @@ var se=(e=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(e,{get:(t,n)=>(
|
|
|
393
616
|
if (!res.ok) throw new Error('API error');
|
|
394
617
|
const data = await res.json();
|
|
395
618
|
aiBtn.classList.remove('apd-ai-loading');
|
|
396
|
-
aiBtn.textContent = '
|
|
619
|
+
aiBtn.textContent = 'Generate fix';
|
|
397
620
|
aiBtn.disabled = false;
|
|
398
621
|
|
|
399
622
|
const alts = data.alternatives || [];
|
|
@@ -401,15 +624,15 @@ var se=(e=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(e,{get:(t,n)=>(
|
|
|
401
624
|
|
|
402
625
|
for (let ai = 0; ai < alts.length; ai++) {
|
|
403
626
|
const alt = alts[ai];
|
|
404
|
-
const altBox = h('div', { className: 'apd-ai-result', style: ai > 0 ? { marginTop: '
|
|
405
|
-
h('div', { style: { fontWeight: '700', fontSize: '12px', color: '#
|
|
627
|
+
const altBox = h('div', { className: 'apd-ai-result', style: ai > 0 ? { marginTop: '8px' } : {} }, [
|
|
628
|
+
h('div', { style: { fontWeight: '700', fontSize: '12px', color: '#0052CC', marginBottom: '6px', textTransform: 'uppercase', letterSpacing: '.05em' } }, alt.label || 'Option ' + (ai+1)),
|
|
406
629
|
h('div', { className: 'apd-ai-explain' }, alt.explanation || ''),
|
|
407
630
|
h('div', { className: 'apd-ai-code' }, alt.frameworkCode || (alt.attribute + '="' + alt.value + '"')),
|
|
408
631
|
]);
|
|
409
632
|
|
|
410
|
-
const altActions = h('div', { className: 'apd-actions', style: { marginTop: '
|
|
411
|
-
const pBtn = h('button', { className: 'apd-btn apd-preview', style: { fontSize: '11px', padding: '
|
|
412
|
-
const aBtn = h('button', { className: 'apd-btn apd-apply', style: { fontSize: '11px', padding: '
|
|
633
|
+
const altActions = h('div', { className: 'apd-actions', style: { marginTop: '8px' } });
|
|
634
|
+
const pBtn = h('button', { className: 'apd-btn apd-preview', style: { fontSize: '11px', padding: '5px 11px' } }, 'Preview');
|
|
635
|
+
const aBtn = h('button', { className: 'apd-btn apd-apply', style: { fontSize: '11px', padding: '5px 11px' } }, 'Apply');
|
|
413
636
|
|
|
414
637
|
pBtn.addEventListener('click', () => {
|
|
415
638
|
const t = document.querySelector(issue.selector);
|
|
@@ -424,7 +647,7 @@ var se=(e=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(e,{get:(t,n)=>(
|
|
|
424
647
|
if (alt.attribute === 'style') t.setAttribute('style', (previewState.original ? previewState.original + ';' : '') + alt.value);
|
|
425
648
|
else if (alt.previewCss) t.setAttribute('style', (previewState.original ? previewState.original + ';' : '') + alt.previewCss);
|
|
426
649
|
else if (alt.attribute) t.setAttribute(alt.attribute, alt.value);
|
|
427
|
-
pBtn.textContent = '
|
|
650
|
+
pBtn.textContent = 'Revert'; pBtn.classList.add('active');
|
|
428
651
|
});
|
|
429
652
|
aBtn.addEventListener('click', () => {
|
|
430
653
|
const t = document.querySelector(issue.selector);
|
|
@@ -433,8 +656,8 @@ var se=(e=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(e,{get:(t,n)=>(
|
|
|
433
656
|
else if (alt.attribute) t.setAttribute(alt.attribute, alt.value);
|
|
434
657
|
appliedFixes.add(issue.id); previewState.id = -1;
|
|
435
658
|
const o = overlays[issue.id];
|
|
436
|
-
if (o) { o.style.outlineColor = '#
|
|
437
|
-
aBtn.classList.add('applied'); aBtn.textContent = '
|
|
659
|
+
if (o) { o.style.outlineColor = '#0052CC'; const l = o.querySelector('.abilyo-label'); if (l) { l.style.background = '#0052CC'; l.textContent = '\\u2713 Fixed'; } }
|
|
660
|
+
aBtn.classList.add('applied'); aBtn.textContent = 'Applied'; pBtn.style.display = 'none';
|
|
438
661
|
});
|
|
439
662
|
altActions.appendChild(pBtn);
|
|
440
663
|
altActions.appendChild(aBtn);
|
|
@@ -443,7 +666,7 @@ var se=(e=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(e,{get:(t,n)=>(
|
|
|
443
666
|
}
|
|
444
667
|
} catch {
|
|
445
668
|
aiBtn.classList.remove('apd-ai-loading');
|
|
446
|
-
aiBtn.textContent = '
|
|
669
|
+
aiBtn.textContent = 'Try again';
|
|
447
670
|
aiBtn.disabled = false;
|
|
448
671
|
}
|
|
449
672
|
});
|
|
@@ -474,7 +697,7 @@ var se=(e=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(e,{get:(t,n)=>(
|
|
|
474
697
|
|
|
475
698
|
renderList();
|
|
476
699
|
})();
|
|
477
|
-
`}async function
|
|
700
|
+
`}async function le(e,t){let o=re[t.wcag.toUpperCase()]??re.AA,s=["mobile","tablet","desktop"].includes(t.viewport??"")?t.viewport:"desktop",n=await(await import("playwright")).chromium.launch({headless:!t.show,ignoreDefaultArgs:t.show?["--enable-automation"]:void 0,args:t.show?["--disable-blink-features=AutomationControlled","--no-default-browser-check","--disable-infobars","--no-first-run","--silent-launch","--disable-features=DialMediaRouteProvider"]:void 0}),l=await(await n.newContext({viewport:s==="mobile"?{width:390,height:844}:s==="tablet"?{width:820,height:1180}:{width:1280,height:720}})).newPage(),d=e.startsWith("http")?e:`https://${e}`;await l.goto(d,{waitUntil:"domcontentloaded",timeout:3e4});let u=await Be(l),f=await Re(l,{wcagTags:o,includeAxe:t.includeAxe??!0,dismissModals:!0,deep:t.deep,deepApiUrl:t.deep?"https://api.webability.io":void 0,deepApiKey:t.deep?t.apiKey:void 0});return t.show&&f.issues.length>0?(await l.evaluate(Oe(f.issues,t.apiKey,u.framework)),await l.evaluate(()=>window.scrollTo(0,0)),{...f,framework:u.framework,_browser:n}):(await n.close(),{...f,framework:u.framework})}async function V(e){e._browser&&(await e._browser.close(),delete e._browser)}import{scan as _e,detectFramework as ze}from"@webability/core";var ce={A:["wcag2a"],AA:["wcag2a","wcag2aa","wcag21aa","wcag22aa"],AAA:["wcag2a","wcag2aa","wcag2aaa","wcag21aa","wcag22aa"]};async function de(e,t={}){let o=ce[(t.wcag||"AA").toUpperCase()]??ce.AA,s=e.startsWith("http")?e:`https://${e}`,a=Date.now(),r=await(await import("playwright")).chromium.launch({headless:!1}),d=await(await r.newContext({viewport:{width:1280,height:720}})).newPage(),u=[],f="plain-css",c=!1,v=!1,x=new Set,S=async()=>{if(c){v=!0;return}c=!0;try{await d.waitForLoadState("domcontentloaded",{timeout:5e3}).catch(()=>{}),await d.waitForTimeout(800);let g=d.url();if(x.has(g)){c=!1;return}x.add(g),(!f||f==="plain-css")&&(f=(await ze(d).catch(()=>({framework:"plain-css"}))).framework);let w=await _e(d,{wcagTags:o,includeAxe:!0,dismissModals:!1});u.push({url:g,issues:w.issues,scannedAt:new Date().toISOString()}),t.onPageScan?.(g,w.issues.length)}catch{}finally{c=!1,v&&(v=!1,setTimeout(S,100))}};d.on("framenavigated",g=>{g===d.mainFrame()&&setTimeout(S,600)}),await d.goto(s,{waitUntil:"domcontentloaded",timeout:3e4}),await new Promise(g=>{r.on("disconnected",()=>g()),process.once("SIGINT",()=>g())}),await r.close().catch(()=>{});let z=new Map;for(let g of u)for(let w of g.issues){let $=`${w.type}::${w.selector}::${w.wcag}`,C=z.get($);C?C.foundOn.includes(g.url)||(C.foundOn.push(g.url),C.pageCount=C.foundOn.length):z.set($,{...w,foundOn:[g.url],pageCount:1})}return{pages:u,uniqueIssues:Array.from(z.values()).sort((g,w)=>{let $={critical:0,serious:1,moderate:2,minor:3},C=$[g.impact]??2,oe=$[w.impact]??2;return C!==oe?C-oe:w.pageCount-g.pageCount}),framework:f,totalDuration:Date.now()-a}}import{writeFileSync as je,existsSync as We}from"fs";import U from"chalk";var qe=`project: my-project
|
|
478
701
|
urls:
|
|
479
702
|
- http://localhost:3000
|
|
480
703
|
standard: WCAG2.1AA
|
|
@@ -483,4 +706,8 @@ ignore: []
|
|
|
483
706
|
threshold:
|
|
484
707
|
critical: 0
|
|
485
708
|
serious: 5
|
|
486
|
-
`;function Z(){let e=".webability.yml";if(ye(e)){console.log(q.yellow(` ${e} already exists`));return}me(e,be),console.log(q.green(` Created ${e}`)),console.log(q.dim(" Edit it to configure your project URLs and thresholds"))}import v from"chalk";var d=v.dim,ee=v.bold,z=v.blue,N=v.green,S=v.red,I=v.yellow,E=v.cyan,he=v.white;function f(){console.log(),console.log(` ${z("\u25C6")} ${ee("Abilyo")} ${d("WCAG Scanner")}`),console.log()}function W(e,t){let n=e>=80?N:e>=50?I:S,a=e>=80?N("\u2713"):e>=50?I("\u26A0"):S("\u2717"),o=e>=80?"Passing":e>=50?"Needs work":"Failing";console.log(` ${d("URL")} ${he(t)}`),console.log(` ${d("Score")} ${n.bold(e+"%")} ${a} ${d(o)}`),console.log(` ${d(" ")}${we(e)}`),console.log()}function we(e){let n=Math.round(e/100*32),a=32-n;return(e>=80?N:e>=50?I:S)("\u2501".repeat(n))+d("\u2501".repeat(a))}function B(e){let t=[];e.critical>0&&t.push(S.bold(`${e.critical} critical`)),e.serious>0&&t.push(I(`${e.serious} serious`)),e.moderate>0&&t.push(d(`${e.moderate} moderate`)),e.minor>0&&t.push(d(`${e.minor} minor`)),console.log(` ${ee(String(e.total))} issues ${t.join(d(" \xB7 "))}`),console.log()}function F(e){let t=e.severity==="critical"?S("\u2717"):e.severity==="serious"?I("\u25B2"):d("\u25CF"),n=d(`${e.wcag}`),a=e.count&&e.count>1?d(` \xD7${e.count}`):"";if(console.log(` ${t} ${n}${a} ${e.problem.slice(0,72)}`),e.contrast?(console.log(` ${E("fix")} ${e.fix.slice(0,70)}`),console.log(` ${d(`${e.contrast.current}:1 \u2192 ${e.contrast.suggested}:1 (min ${e.contrast.required}:1)`)}`)):e.before&&e.after?console.log(` ${E("fix")} ${d(e.before)} ${d("\u2192")} ${N(e.after)}`):console.log(` ${E("fix")} ${e.fix.slice(0,70)}`),e.html&&e.count===1){let o=e.html.slice(0,60).replace(/\n/g," ");console.log(` ${d(o)}`)}if(e.selectors&&e.selectors.length>0&&e.count&&e.count>1){let o=e.selectors.slice(0,2).map(s=>s.slice(0,30)).join(d(", ")),p=e.count>2?d(` +${e.count-2} more`):"";console.log(` ${d(o+p)}`)}else e.selector&&console.log(` ${d(e.selector.slice(0,55))}`);console.log()}function L(e){console.log(d(" \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")),e?.hasIssues&&(console.log(` ${d("Next steps:")}`),console.log(` ${E("abilyo scan "+(e.url||"<url>")+" --format json")} ${d("CI/CD output")}`),console.log(` ${E("abilyo scan "+(e.url||"<url>")+" --format html > report.html")} ${d("share report")}`),console.log()),console.log(` ${z("\u25C6")} ${d("Auto-fix at")} ${z("abilyo.com")}`),console.log()}function A(e){console.log(S(` \u2717 Error: ${e}`)),console.log()}function te(e){console.log(JSON.stringify(e,null,2))}function U(e){console.log("severity,wcag,message,element");for(let t of e){let n=t.message.replace(/"/g,'""'),a=(t.element||"").replace(/"/g,'""');console.log(`${t.severity},${t.wcag},"${n}","${a}"`)}}var y=new xe;y.name("abilyo").description("Abilyo by WebAbility \u2014 WCAG accessibility scanner").version("1.0.0");y.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(e,t)=>{if(e.startsWith("http")||(e=`https://${e}`),t.deep||t.upload){let p=$();if(!p)return t.format==="table"&&f(),A("--deep and --upload require a Pro plan. Run `abilyo login` first."),process.exit(1);try{let s=await fetch("https://api.webability.io/cli/verify",{headers:{Authorization:`Bearer ${p}`}});if(s.ok&&!(await s.json()).active)return t.format==="table"&&f(),A("Your account does not have an active Pro plan."),console.log(i.dim(" Upgrade at https://abilyo.com/pricing")),process.exit(1)}catch{}}if(t.remote){await ve(e,t);return}t.format==="table"&&f();let n=Date.now(),a=R({text:"Scanning...",prefixText:" "}).start(),o;try{o=await H(e,{wcag:t.wcag,deep:t.deep??!1,apiKey:$()??void 0,show:t.show??!1});let p=((Date.now()-n)/1e3).toFixed(1);if(a.succeed(`Scanned ${o.issues.length} issues in ${p}s`),t.format==="json"){let s={...o,issues:o.issues.map(({source:r,...c})=>c)};console.log(JSON.stringify(s,null,2))}else if(t.format==="csv")U(o.issues.map(s=>({severity:s.impact,wcag:s.wcag,message:s.message,element:s.selector})));else if(t.format==="sarif")console.log(JSON.stringify(Ae(o),null,2));else if(t.format==="html"){let{toHtmlReport:s}=await import("@webability/core");console.log(s(o))}else{if(W(o.summary.total===0?100:Math.max(0,100-o.summary.total),e),B(o.summary),o.issues.length>0){let{generateDevSuggestion:s}=await import("@webability/core"),r=o.framework||"plain-css",c=[],m=new Map;for(let l of o.issues){let g=s(l,r),b=`${l.type}|${g.fix.slice(0,50)}`,w=m.get(b);w!==void 0?(c[w].count++,c[w].selectors.length<3&&c[w].selectors.push(l.selector.slice(0,50))):(m.set(b,c.length),c.push({suggestion:g,issue:l,count:1,selectors:[l.selector.slice(0,50)]}))}c.sort((l,g)=>{let b={critical:0,serious:1,moderate:2,minor:3};return(b[l.issue.impact]??2)-(b[g.issue.impact]??2)});for(let l of c.slice(0,20))F({severity:l.issue.impact,wcag:l.issue.wcag,problem:l.suggestion.problem,fix:l.suggestion.fix,count:l.count,selector:l.count===1?l.issue.selector:void 0,selectors:l.count>1?l.selectors:void 0,contrast:l.suggestion.contrast,before:l.suggestion.before,after:l.suggestion.after,html:l.issue.html});c.length>20&&(console.log(i.dim(` ... +${c.length-20} more`)),console.log())}L({hasIssues:o.issues.length>0,url:e.replace("https://","")})}t.show&&o.issues.length>0&&(console.log(i.cyan(" \u25C6 Browser open \u2014 issues highlighted. Press Enter to close.")),console.log(),await new Promise(s=>{process.stdin.resume(),process.stdin.once("data",()=>{process.stdin.pause(),s()})}),await T(o)),t.exit&&o.summary.total>0&&process.exit(1)}catch(p){a.stop(),o&&await T(o).catch(()=>{}),A(p.message),process.exit(1)}});async function ve(e,t){let n=$();if(!n)return t.format==="table"&&f(),A("--remote requires login. Run `wa login` first."),process.exit(1);t.format==="table"&&f();let a=R({text:"Scanning (remote)...",prefixText:" "}).start();try{let o=await _(e,n,r=>{a.text=r});a.text="Fetching report...";let p=await K(o.key,n);if(a.stop(),!p)return A("Report not found"),process.exit(1);let s=[];if(p.axe?.violations)for(let r of p.axe.violations)for(let c of r.nodes||[])s.push({severity:r.impact||"moderate",wcag:r.id,message:r.description,element:c.target?.[0]});if(p.ByFunctions)for(let r of p.ByFunctions)for(let c of r.issues||[])s.push({severity:c.impact||"moderate",wcag:c.code||"",message:c.description||"",element:c.element});if(t.format==="json")te({url:e,score:p.score,totalElements:p.totalElements,issues:s});else if(t.format==="csv")U(s);else{W(p.score||0,e),B({critical:0,serious:s.filter(r=>r.severity==="serious").length,moderate:s.filter(r=>r.severity==="moderate").length,minor:0,total:s.length});for(let r of s.slice(0,20))F({severity:r.severity,wcag:r.wcag,problem:r.message,fix:"Review this issue",selector:r.element});L()}t.exit&&s.length>0&&process.exit(1)}catch(o){a.stop(),A(o.message),process.exit(1)}}y.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(e,t)=>{e.startsWith("http")||(e=`https://${e}`),f(),console.log(i.bold(" Flow Scan")),console.log(),console.log(` Starting at: ${i.cyan(e)}`),console.log(i.dim(" Click through your user journey. Close the browser when done.")),console.log();let n=0,a=await Q(e,{wcag:t.wcag,onPageScan:(s,r)=>{n++,console.log(` ${i.green("\u2713")} ${i.dim(String(n).padStart(2))} ${s.replace(/^https?:\/\//,"").slice(0,60)} ${i.dim("\u2014 "+r+" issues")}`)}});console.log(),console.log(i.bold(" \u2500\u2500\u2500 Flow Report \u2500\u2500\u2500")),console.log(),console.log(` Pages scanned: ${i.cyan(a.pages.length)}`),console.log(` Unique issues: ${i.cyan(a.uniqueIssues.length)}`),console.log(` Total duration: ${i.dim((a.totalDuration/1e3).toFixed(1)+"s")}`),console.log();let o={critical:0,serious:0,moderate:0,minor:0};a.uniqueIssues.forEach(s=>{o[s.impact]++}),B({...o,total:a.uniqueIssues.length});let p=a.uniqueIssues.slice(0,15);for(let s of p){let r=s.pageCount>1?i.yellow(` \xD7${s.pageCount} pages`):"";if(F({severity:s.impact,wcag:s.wcag,problem:s.message,fix:s.fix?.suggestedValue?`Set ${s.fix.attribute}="${s.fix.suggestedValue}"`:"Review this issue",selector:s.selector}),s.pageCount>1){console.log(i.dim(` Found on${r}:`));for(let c of s.foundOn.slice(0,3))console.log(i.dim(" "+c.replace(/^https?:\/\//,"")))}}a.uniqueIssues.length>15&&console.log(i.dim(` ... +${a.uniqueIssues.length-15} more unique issues`)),console.log(),L({hasIssues:a.uniqueIssues.length>0,url:e.replace("https://","")})});y.command("init").description("Create .webability.yml config file").action(()=>{f(),Z(),console.log()});y.command("login").description("Authenticate with your WebAbility account").option("-k, --key <key>","API key directly (for CI/CD)").action(async e=>{if(f(),e.key){O(e.key),console.log(i.green(" \u2713 API key saved.")),console.log();return}let t=R({text:"Starting login...",prefixText:" "}).start();try{let n=await fetch("https://api.webability.io/cli/device-code",{method:"POST",headers:{"Content-Type":"application/json"}});if(!n.ok){t.stop();let l="https://app.webability.io/settings/api-keys";console.log(" Open this URL to get your API key:"),console.log(),console.log(` ${i.cyan(l)}`),console.log(),console.log(` Then run: ${i.cyan("abilyo login --key YOUR_KEY")}`),console.log(),oe(l);return}let{deviceCode:a,userCode:o,verificationUrl:p,expiresIn:s}=await n.json();t.stop(),console.log(` ${i.bold("Login to WebAbility")}`),console.log(),console.log(` Open: ${i.cyan(p)}`),console.log(` Code: ${i.bold.yellow(o)}`),console.log(),oe(p);let r=R({text:"Waiting for approval...",prefixText:" "}).start(),c=3e3,m=Math.floor((s||300)*1e3/c);for(let l=0;l<m;l++){await new Promise(g=>setTimeout(g,c));try{let g=await fetch("https://api.webability.io/cli/device-token",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({deviceCode:a})});if(g.ok){let{token:w}=await g.json();O(w),r.succeed("Logged in!"),console.log();return}if((await g.json().catch(()=>({}))).error==="expired")return r.fail("Login expired. Run `abilyo login` again."),process.exit(1)}catch{}}r.fail("Login timed out."),process.exit(1)}catch{t.stop(),console.log(` Open: ${i.cyan("https://app.webability.io/settings/api-keys")}`),console.log(` Then: ${i.cyan("abilyo login --key YOUR_KEY")}`),console.log()}});function oe(e){try{let{execFile:t}=se("child_process"),n=process.platform==="darwin"?"open":process.platform==="win32"?"start":"xdg-open";t(n,[e],()=>{})}catch{}}y.command("whoami").description("Show current authentication status").action(()=>{let e=$();f(),e?(console.log(i.green(" Authenticated")),console.log(i.dim(` Key: ${e.slice(0,20)}...`))):(console.log(i.yellow(" Not authenticated")),console.log(i.dim(" Run `wa login` to authenticate"))),console.log()});y.command("logout").description("Remove saved API key").action(()=>{G(),f(),console.log(i.green(" Logged out. API key removed.")),console.log()});y.command("activate <code>").description("Activate Abilyo with your early access code").action(async e=>{f();let t=R({text:"Validating access code...",prefixText:" "}).start();try{if((await fetch("https://api.webability.io/cli/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:e.trim()})})).ok){j(e.trim()),t.succeed("Abilyo activated!"),console.log(),console.log(i.dim(" Run `abilyo scan <url>` to get started")),console.log();return}}catch{}e.trim().length>=8?(j(e.trim()),t.succeed("Abilyo activated!"),console.log(),console.log(i.dim(" Run `abilyo scan <url>` to get started")),console.log()):(t.fail("Invalid access code"),console.log(i.dim(" Request access at https://abilyo.com/early-access")),process.exit(1))});y.argument("[url]","URL to scan").action(e=>{e?y.parse(["node","abilyo","scan",e,...process.argv.slice(3)]):(f(),J()?(console.log(i.bold(" Commands:")),console.log(),console.log(` ${i.cyan("abilyo scan")} <url> Scan for accessibility issues`),console.log(` ${i.cyan("abilyo scan --deep")} <url> Deeper scan with additional checks (Pro)`),console.log(` ${i.cyan("abilyo scan --exit")} <url> CI mode \u2014 exit 1 if issues found`),console.log(` ${i.cyan("abilyo init")} Create .webability.yml config`),console.log(` ${i.cyan("abilyo login")} Authenticate with your account`),console.log(` ${i.cyan("abilyo whoami")} Show auth status`),console.log(),console.log(i.dim(" Example: abilyo scan localhost:3000")),console.log(i.dim(" Example: abilyo scan example.com --format json")),console.log(i.dim(" Example: abilyo scan example.com --deep --exit"))):(console.log(i.yellow(" Abilyo is invite-only during early access.")),console.log(),console.log(` ${i.cyan("abilyo activate")} <code> Activate with your access code`),console.log(),console.log(i.dim(" Request access at https://abilyo.com/early-access"))),console.log())});y.parse();function Ae(e){return{$schema:"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",version:"2.1.0",runs:[{tool:{driver:{name:"WebAbility",version:"1.0.0",informationUri:"https://webability.io",rules:e.issues.map(t=>({id:t.type,shortDescription:{text:t.message}}))}},results:e.issues.map(t=>({ruleId:t.type,level:t.impact==="critical"||t.impact==="serious"?"error":"warning",message:{text:t.message},locations:[{physicalLocation:{artifactLocation:{uri:e.url},region:{snippet:{text:t.html||t.selector}}}}]}))}]}}
|
|
709
|
+
`;function pe(){let e=".webability.yml";if(We(e)){console.log(U.yellow(` ${e} already exists`));return}je(e,qe),console.log(U.green(` Created ${e}`)),console.log(U.dim(" Edit it to configure your project URLs and thresholds"))}import{execFileSync as B}from"child_process";import{chmodSync as me,existsSync as K,mkdirSync as Ve,readFileSync as fe,writeFileSync as he}from"fs";import{homedir as G}from"os";import{dirname as Ue,join as J}from"path";var h="webability",E="https://mcp.webability.io/mcp",ye="x-webability-token";function H(e){return{url:E,headers:{[ye]:e}}}function Y(){let e=process.platform==="win32"?["claude.cmd","claude"]:["claude"];for(let t of e)try{return B(t,["--version"],{stdio:"ignore"}),t}catch{}return null}function Ke(){return Y()!==null}function be(e){try{return B(e,["mcp","get",h],{encoding:"utf-8",stdio:["ignore","pipe","ignore"]})}catch{return null}}function Ge(){return K(J(G(),".cursor"))}function ue(e){let t=Y();if(!t)return{client:"claude",ok:!1,detail:"Claude Code CLI not found on PATH"};try{let o=be(t);if(o!==null&&!o.includes(E))return{client:"claude",ok:!1,detail:`Claude Code already has an MCP server named "${h}" that isn't ours \u2014 left untouched. Remove it (claude mcp remove -s user ${h}) and re-run \`abilyo mcp install\` to switch to the hosted server.`};if(o!==null)try{B(t,["mcp","remove","-s","user",h],{stdio:"ignore"})}catch{}return B(t,["mcp","add","-s","user","--transport","http",h,E,"--header",`${ye}: ${e}`],{stdio:"ignore"}),{client:"claude",ok:!0,detail:"Claude Code"}}catch(o){return{client:"claude",ok:!1,detail:`claude mcp add failed: ${o.message}`}}}function ge(e){let t=J(G(),".cursor","mcp.json");try{let o={};if(K(t)){let a=fe(t,"utf-8").trim();a&&(o=JSON.parse(a))}let s=(o.mcpServers||{})[h];return s&&s.url!==E?{client:"cursor",ok:!1,detail:`~/.cursor/mcp.json already has an MCP server named "${h}" that isn't ours \u2014 left untouched. Remove that entry and re-run \`abilyo mcp install\` to switch to the hosted server.`}:(o.mcpServers={...o.mcpServers||{},[h]:H(e)},Ve(Ue(t),{recursive:!0}),he(t,`${JSON.stringify(o,null,2)}
|
|
710
|
+
`,{mode:384}),me(t,384),{client:"cursor",ok:!0,detail:"Cursor (~/.cursor/mcp.json)"})}catch(o){return{client:"cursor",ok:!1,detail:`could not write ${t}: ${o.message}`}}}function xe(){let e=[],t=Y();if(t){let s=be(t);if(s!==null)if(!s.includes(E))e.push({client:"claude",ok:!1,detail:`left "${h}" in Claude Code alone \u2014 it isn't ours`});else try{B(t,["mcp","remove","-s","user",h],{stdio:"ignore"}),e.push({client:"claude",ok:!0,detail:"Claude Code"})}catch(a){e.push({client:"claude",ok:!1,detail:`claude mcp remove failed: ${a.message}`})}}let o=J(G(),".cursor","mcp.json");if(K(o))try{let s=fe(o,"utf-8").trim(),a=s?JSON.parse(s):{},n=a.mcpServers?.[h];n&&n.url===E?(delete a.mcpServers[h],he(o,`${JSON.stringify(a,null,2)}
|
|
711
|
+
`,{mode:384}),me(o,384),e.push({client:"cursor",ok:!0,detail:"Cursor (~/.cursor/mcp.json)"})):n&&e.push({client:"cursor",ok:!1,detail:`left "${h}" in ~/.cursor/mcp.json alone \u2014 it isn't ours`})}catch(s){e.push({client:"cursor",ok:!1,detail:`could not update ${o}: ${s.message}`})}return e}function M(e,t){if(t==="claude")return[ue(e)];if(t==="cursor")return[ge(e)];let o=[];return Ke()&&o.push(ue(e)),Ge()&&o.push(ge(e)),o}import A from"chalk";var p=A.dim,we=A.bold,X=A.blue,L=A.green,I=A.red,F=A.yellow,P=A.cyan,Je=A.white;function m(){console.log(),console.log(` ${X("\u25C6")} ${we("Abilyo")} ${p("WCAG Scanner")}`),console.log()}function Q(e,t){let o=e>=80?L:e>=50?F:I,s=e>=80?L("\u2713"):e>=50?F("\u26A0"):I("\u2717"),a=e>=80?"Passing":e>=50?"Needs work":"Failing";console.log(` ${p("URL")} ${Je(t)}`),console.log(` ${p("Score")} ${o.bold(e+"%")} ${s} ${p(a)}`),console.log(` ${p(" ")}${He(e)}`),console.log()}function He(e){let o=Math.round(e/100*32),s=32-o;return(e>=80?L:e>=50?F:I)("\u2501".repeat(o))+p("\u2501".repeat(s))}function T(e){let t=[];e.critical>0&&t.push(I.bold(`${e.critical} critical`)),e.serious>0&&t.push(F(`${e.serious} serious`)),e.moderate>0&&t.push(p(`${e.moderate} moderate`)),e.minor>0&&t.push(p(`${e.minor} minor`)),console.log(` ${we(String(e.total))} issues ${t.join(p(" \xB7 "))}`),console.log()}function D(e){let t=e.severity==="critical"?I("\u2717"):e.severity==="serious"?F("\u25B2"):p("\u25CF"),o=p(`${e.wcag}`),s=e.count&&e.count>1?` \xD7${e.count}`:"",a=s?p(s):"",n=80-(6+e.wcag.length+s.length);if(console.log(` ${t} ${o}${a} ${e.problem.slice(0,n)}`),e.contrast?(console.log(` ${P("fix")} ${e.fix.slice(0,70)}`),console.log(` ${p(`${e.contrast.current}:1 \u2192 ${e.contrast.suggested}:1 (min ${e.contrast.required}:1)`)}`)):e.before&&e.after?console.log(` ${P("fix")} ${p(e.before)} ${p("\u2192")} ${L(e.after)}`):console.log(` ${P("fix")} ${e.fix.slice(0,70)}`),e.html&&e.count===1){let r=e.html.slice(0,60).replace(/\n/g," ");console.log(` ${p(r)}`)}if(e.selectors&&e.selectors.length>0&&e.count&&e.count>1){let r=e.selectors.slice(0,2).map(d=>d.slice(0,30)).join(p(", ")),l=e.count>2?p(` +${e.count-2} more`):"";console.log(` ${p(r+l)}`)}else e.selector&&console.log(` ${p(e.selector.slice(0,55))}`);console.log()}function ve(e){if(e.length){for(let t of e)console.log(` ${F.bold("\u26A0 WARNING")} ${F(t)}`);console.log()}}function O(e){console.log(p(" \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")),e?.hasIssues&&(console.log(` ${p("Next steps:")}`),console.log(` ${P("abilyo scan "+(e.url||"<url>")+" --format json")} ${p("CI/CD output")}`),console.log(` ${P("abilyo scan "+(e.url||"<url>")+" --format html > report.html")} ${p("share report")}`),console.log()),console.log(` ${X("\u25C6")} ${p("Auto-fix at")} ${X("abilyo.com")}`),console.log()}function y(e,t){console.error(I(` \u2717 Error: ${e}`)),t&&console.error(p(` ${t}`)),console.error()}function ke(e){console.log(JSON.stringify(e,null,2))}function Z(e){console.log("severity,wcag,message,element");for(let t of e){let o=t.message.replace(/"/g,'""'),s=(t.element||"").replace(/"/g,'""');console.log(`${t.severity},${t.wcag},"${o}","${s}"`)}}var tt=Qe(Xe(import.meta.url)),Ce=JSON.parse(Ye(Ze(tt,"../package.json"),"utf-8")),b=new et;b.name("abilyo").description("Abilyo by WebAbility \u2014 WCAG accessibility scanner").version(Ce.version);b.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("--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(e,t)=>{if(e.startsWith("http")||(e=`https://${e}`),t.deep){let n=k();if(!n)return t.format==="table"&&m(),y("--deep needs Abilyo Pro. Run `abilyo login`, then `abilyo upgrade`."),process.exit(1);try{let r=await fetch("https://api.webability.io/cli/verify",{headers:{Authorization:`Bearer ${n}`}});if(r.ok){if(!(await r.json()).active)return t.format==="table"&&m(),y("--deep needs Abilyo Pro."),console.log(` Unlock it now: ${i.cyan("abilyo upgrade")}`),console.log(i.dim(" Pro adds dynamic-content checks, keyboard-trap detection and AI fixes.")),process.exit(1)}else{if(r.status===401||r.status===403)return t.format==="table"&&m(),y("Your API key is invalid or expired. Run `abilyo login` again."),process.exit(1);console.error(i.yellow(` \u26A0 Could not verify your Pro plan (server returned ${r.status}). Continuing, but --deep may be rejected.`))}}catch{console.error(i.yellow(" \u26A0 Could not verify Pro plan status (offline). --deep analysis will be skipped without a network connection."))}}if(t.remote){await ot(e,t);return}t.format==="table"&&m();let o=Date.now(),s=N({text:"Scanning...",prefixText:" "}).start(),a;try{a=await le(e,{wcag:t.wcag,deep:t.deep??!1,apiKey:k()??void 0,show:t.show??!1,viewport:t.viewport,includeAxe:t.axe!==!1});let n=((Date.now()-o)/1e3).toFixed(1),r=a.issues.length;if(s.succeed(r===0?`No issues found in ${n}s`:`Found ${r} issue${r===1?"":"s"} in ${n}s`),t.format==="json"){let l={...a,issues:a.issues.map(({source:d,...u})=>u)};console.log(JSON.stringify(l,null,2))}else if(t.format==="csv")Z(a.issues.map(l=>({severity:l.impact,wcag:l.wcag,message:l.message,element:l.selector})));else if(t.format==="sarif")console.log(JSON.stringify(it(a),null,2));else if(t.format==="html"){let{toHtmlReport:l}=await import("@webability/core");console.log(l(a))}else{if(ve(a.engineWarnings??[]),Q(a.summary.total===0?100:Math.max(0,100-a.summary.total),e),T(a.summary),a.issues.length>0){let{generateDevSuggestion:l}=await import("@webability/core"),d=a.framework||"plain-css",u=[],f=new Map;for(let c of a.issues){let v=l(c,d),x=`${c.type}|${v.fix.slice(0,50)}`,S=f.get(x);S!==void 0?(u[S].count++,u[S].selectors.length<3&&u[S].selectors.push(c.selector.slice(0,50))):(f.set(x,u.length),u.push({suggestion:v,issue:c,count:1,selectors:[c.selector.slice(0,50)]}))}u.sort((c,v)=>{let x={critical:0,serious:1,moderate:2,minor:3};return(x[c.issue.impact]??2)-(x[v.issue.impact]??2)});for(let c of u.slice(0,20))D({severity:c.issue.impact,wcag:c.issue.wcag,problem:c.suggestion.problem,fix:c.suggestion.fix,count:c.count,selector:c.count===1?c.issue.selector:void 0,selectors:c.count>1?c.selectors:void 0,contrast:c.suggestion.contrast,before:c.suggestion.before,after:c.suggestion.after,html:c.issue.html});u.length>20&&(console.log(i.dim(` ... +${u.length-20} more`)),console.log())}O({hasIssues:a.issues.length>0,url:e.replace("https://","")})}t.show&&a.issues.length>0&&(console.log(i.cyan(" \u25C6 Browser open \u2014 issues highlighted. Press Enter to close.")),console.log(),await new Promise(l=>{process.stdin.resume(),process.stdin.once("data",()=>{process.stdin.pause(),l()})}),await V(a)),t.exit&&a.summary.total>0&&process.exit(1)}catch(n){s.stop(),a&&await V(a).catch(()=>{}),y(n.message.split(`
|
|
712
|
+
`)[0],"Check the URL loads in a browser, then run the scan again."),process.exit(1)}});async function ot(e,t){let o=k();if(!o)return t.format==="table"&&m(),y("--remote requires login. Run `abilyo login` first."),process.exit(1);t.format==="table"&&m();let s=N({text:"Scanning (remote)...",prefixText:" "}).start();try{let a=await se(e,o,l=>{s.text=l});s.text="Fetching report...";let n=await ie(a.key,o);if(s.stop(),!n)return y("Report not found.","Try again, or drop --remote to scan locally."),process.exit(1);let r=[];if(n.axe?.violations)for(let l of n.axe.violations)for(let d of l.nodes||[])r.push({severity:l.impact||"moderate",wcag:l.id,message:l.description,element:d.target?.[0]});if(n.ByFunctions)for(let l of n.ByFunctions)for(let d of l.issues||[])r.push({severity:d.impact||"moderate",wcag:d.code||"",message:d.description||"",element:d.element});if(t.format==="json")ke({url:e,score:n.score,totalElements:n.totalElements,issues:r});else if(t.format==="csv")Z(r);else{Q(n.score||0,e),T({critical:0,serious:r.filter(l=>l.severity==="serious").length,moderate:r.filter(l=>l.severity==="moderate").length,minor:0,total:r.length});for(let l of r.slice(0,20))D({severity:l.severity,wcag:l.wcag,problem:l.message,fix:"Review this issue",selector:l.element});O()}t.exit&&r.length>0&&process.exit(1)}catch(a){s.stop(),y(a.message),process.exit(1)}}b.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(e,t)=>{e.startsWith("http")||(e=`https://${e}`),m(),console.log(i.bold(" Flow Scan")),console.log(),console.log(` Starting at: ${i.cyan(e)}`),console.log(i.dim(" Click through your user journey. Close the browser when done.")),console.log();let o=0,s;try{s=await de(e,{wcag:t.wcag,onPageScan:(r,l)=>{o++,console.log(` ${i.green("\u2713")} ${i.dim(String(o).padStart(2))} ${r.replace(/^https?:\/\//,"").slice(0,60)} ${i.dim("\u2014 "+l+" issues")}`)}})}catch(r){return y(r.message.split(`
|
|
713
|
+
`)[0],"Check the URL loads in a browser, then run `abilyo flow` again."),process.exit(1)}console.log(),console.log(i.bold(" \u2500\u2500\u2500 Flow Report \u2500\u2500\u2500")),console.log(),console.log(` Pages scanned: ${i.cyan(s.pages.length)}`),console.log(` Unique issues: ${i.cyan(s.uniqueIssues.length)}`),console.log(` Total duration: ${i.dim((s.totalDuration/1e3).toFixed(1)+"s")}`),console.log();let a={critical:0,serious:0,moderate:0,minor:0};s.uniqueIssues.forEach(r=>{a[r.impact]++}),T({...a,total:s.uniqueIssues.length});let n=s.uniqueIssues.slice(0,15);for(let r of n){let l=r.pageCount>1?i.yellow(` \xD7${r.pageCount} pages`):"";if(D({severity:r.impact,wcag:r.wcag,problem:r.message,fix:r.fix?.suggestedValue?`Set ${r.fix.attribute}="${r.fix.suggestedValue}"`:"Review this issue",selector:r.selector}),r.pageCount>1){console.log(i.dim(` Found on${l}:`));for(let d of r.foundOn.slice(0,3))console.log(i.dim(" "+d.replace(/^https?:\/\//,"")))}}s.uniqueIssues.length>15&&console.log(i.dim(` ... +${s.uniqueIssues.length-15} more unique issues`)),console.log(),O({hasIssues:s.uniqueIssues.length>0,url:e.replace("https://","")})});b.command("init").description("Create .webability.yml config file").action(()=>{m(),pe(),console.log()});b.command("login").description("Authenticate with your WebAbility account").option("-k, --key <key>","API key directly (for CI/CD)").action(async e=>{if(m(),e.key){W(e.key),console.log(i.green(" \u2713 API key saved.")),console.log(),ee(M(e.key)),await te(e.key);return}let t=N({text:"Starting login...",prefixText:" "}).start();try{let o=await fetch("https://api.webability.io/cli/device-code",{method:"POST",headers:{"Content-Type":"application/json"}});if(!o.ok)return t.fail(`Could not start login (server returned ${o.status}).`),console.log(i.dim(" Try again in a moment: `abilyo login`")),console.log(i.dim(" Still stuck? support@webability.io")),console.log(),process.exit(1);let{deviceCode:s,userCode:a,verificationUrl:n,expiresIn:r}=await o.json();t.stop(),console.log(` ${i.bold("Login to WebAbility")}`),console.log(),console.log(` Open: ${i.cyan(n)}`),console.log(` Code: ${i.bold.yellow(a)}`),console.log(),Ae(n);let l=N({text:"Waiting for approval...",prefixText:" "}).start(),d=3e3,u=Math.floor((r||300)*1e3/d);for(let f=0;f<u;f++){await new Promise(c=>setTimeout(c,d));try{let c=await fetch("https://api.webability.io/cli/device-token",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({deviceCode:s})});if(c.ok){let{token:x}=await c.json();W(x),l.succeed("Logged in."),console.log(),ee(M(x)),await te(x);return}if((await c.json().catch(()=>({}))).error==="expired")return l.fail("Login expired. Run `abilyo login` again."),process.exit(1)}catch{}}l.fail("Login timed out."),process.exit(1)}catch{return t.fail("Could not reach api.webability.io."),console.log(i.dim(" Check your connection, then run `abilyo login` again.")),console.log(),process.exit(1)}});var _=i.dim(" "+"\u2500".repeat(58)),st=["scan webability.io and list the critical issues","fix the colour-contrast failures on this page","check this component for keyboard traps","is this form accessible to a screen reader?"];function ee(e){let t=e.filter(o=>o.ok);if(!t.length){console.log(i.yellow(" No supported editor found for automatic setup.")),console.log(` Install Claude Code or Cursor, then run ${i.cyan("abilyo mcp install")}.`),console.log(i.dim(` Any other MCP client: ${i.cyan("abilyo mcp print")} gives you the config block.`)),console.log();for(let o of e.filter(s=>!s.ok))console.log(i.dim(` \u26A0 ${o.client}: ${o.detail}`));e.some(o=>!o.ok)&&console.log();return}console.log(_),console.log(i.bold(" Abilyo is now inside your editor")),console.log();for(let o of t)console.log(` ${i.green("\u2713")} ${o.detail}`);console.log(),console.log(i.dim(" Restart the editor, then ask it:"));for(let o of st)console.log(` ${i.cyan(`"${o}"`)}`);console.log(_),console.log();for(let o of e.filter(s=>!s.ok))console.log(i.dim(` \u26A0 ${o.client}: ${o.detail}`))}async function te(e){let t="free",o="";try{let s=await fetch("https://api.webability.io/cli/verify",{headers:{Authorization:`Bearer ${e}`}});if(s.ok){let a=await s.json();t=a.plan||"free",o=a.email||""}}catch{}if(t==="pro"){console.log(` ${i.green("\u2726 Abilyo Pro")}${o?i.dim(` \u2014 ${o}`):""}`),console.log(i.dim(" Deep scans, AI fixes and unlimited MCP calls are unlocked.")),console.log();return}console.log(_),console.log(` ${i.yellow("You are on Free.")} Free finds the issues. ${i.bold("Pro fixes them.")}`),console.log(),console.log(` ${i.cyan("abilyo scan --deep")} dynamic content, focus order, keyboard traps`),console.log(` ${i.cyan("AI fixes")} real code edits in your editor, not a to-do list`),console.log(` ${i.cyan("CI reports")} SARIF + HTML, unlimited MCP calls`),console.log(),console.log(` Upgrade in under a minute: ${i.bold.cyan("abilyo upgrade")}`),console.log(_),console.log()}function Ae(e){try{let{execFile:t}=Fe("child_process"),o=process.platform==="darwin"?"open":process.platform==="win32"?"start":"xdg-open";t(o,[e],()=>{})}catch{}}var Se=b.command("mcp").description("Connect Abilyo to your AI editor (Claude Code, Cursor)");Se.command("install").description("Register the Abilyo MCP server with every editor found").option("-c, --client <client>","Only one client: claude, cursor").action(e=>{m();let t=k();if(!t)return y("Not logged in. Run `abilyo login` \u2014 it connects your editor for you."),process.exit(1);if(e.client&&e.client!=="claude"&&e.client!=="cursor")return y(`Unknown client "${e.client}". Use claude or cursor.`),process.exit(1);ee(M(t,e.client))});Se.command("print").description("Print the MCP config block (contains your token \u2014 do not share)").action(()=>{let e=k();if(!e)return m(),y("Not logged in. Run `abilyo login` first."),process.exit(1);console.log(JSON.stringify({mcpServers:{[h]:H(e)}},null,2))});b.command("upgrade").description("Upgrade to Abilyo Pro \u2014 deep scans and AI fixes").action(async()=>{m();let e=k();if(!e)return y("Log in first: `abilyo login`"),process.exit(1);let t=N({text:"Opening checkout...",prefixText:" "}).start();try{let o=await fetch("https://api.webability.io/cli/checkout",{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${e}`},body:JSON.stringify({returnUrl:"https://abilyo.com"})});if(!o.ok)return t.fail(`Could not start checkout (server returned ${o.status}).`),console.log(i.dim(" Upgrade in the browser instead: https://webability.io/pricing")),console.log(),process.exit(1);let{url:s}=await o.json();t.succeed("Checkout ready \u2014 opening your browser."),console.log(),console.log(` ${i.cyan(s)}`),console.log(),Ae(s)}catch{return t.fail("Could not reach api.webability.io."),console.log(i.dim(" Upgrade in the browser instead: https://webability.io/pricing")),console.log(),process.exit(1)}});b.command("whoami").description("Show auth status and plan").action(async()=>{let e=k();if(m(),!e){console.log(i.yellow(" Not signed in.")),console.log(` Run ${i.cyan("abilyo login")} \u2014 it signs you in and connects your editor.`),console.log();return}console.log(i.green(" Signed in")),console.log(i.dim(` Key: ${e.slice(0,12)}\u2026`)),console.log(),await te(e)});b.command("logout").description("Remove saved API key and disconnect editors").action(()=>{let e=xe();ne(),m(),console.log(i.green(" Logged out. API key removed."));for(let t of e)console.log(t.ok?i.dim(` Disconnected ${t.detail}`):i.yellow(` ${t.detail}`));console.log(i.dim(" Sign back in any time: `abilyo login`")),console.log()});b.command("activate <code>").description("Activate Abilyo with your early access code").action(async e=>{m();let t=N({text:"Validating access code...",prefixText:" "}).start();try{if((await fetch("https://api.webability.io/cli/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:e.trim()})})).ok){q(e.trim()),t.succeed("Abilyo activated."),console.log(),console.log(` Next: ${i.cyan("abilyo scan <url>")} ${i.dim("\u2014 your first scan takes seconds")}`),console.log();return}}catch{}e.trim().length>=8?(q(e.trim()),t.succeed("Abilyo activated."),console.log(),console.log(` Next: ${i.cyan("abilyo scan <url>")} ${i.dim("\u2014 your first scan takes seconds")}`),console.log()):(t.fail("Invalid access code."),console.log(i.dim(" Request access at https://abilyo.com")),console.log(),process.exit(1))});b.argument("[url]","URL to scan").action(e=>{e?b.parse(["node","abilyo","scan",e,...process.argv.slice(3)]):(m(),ae()?(console.log(i.bold(" Commands:")),console.log(),console.log(` ${i.cyan("abilyo scan")} <url> Scan a page for accessibility issues`),console.log(` ${i.cyan("abilyo scan --deep")} <url> Dynamic content, focus order, keyboard traps ${i.dim("(Pro)")}`),console.log(` ${i.cyan("abilyo scan --exit")} <url> CI mode \u2014 exit 1 if issues found`),console.log(` ${i.cyan("abilyo flow")} <url> Scan a whole journey, one report`),console.log(` ${i.cyan("abilyo login")} Sign in and connect your AI editor`),console.log(` ${i.cyan("abilyo mcp install")} Re-connect Claude Code / Cursor`),console.log(` ${i.cyan("abilyo init")} Create .webability.yml config`),console.log(` ${i.cyan("abilyo whoami")} Show auth status and plan`),console.log(` ${i.cyan("abilyo upgrade")} Unlock deep scans + AI fixes ${i.dim("(Pro)")}`),console.log(),console.log(i.bold(" Examples:")),console.log(),console.log(i.dim(" abilyo scan localhost:3000 your dev server")),console.log(i.dim(" abilyo scan example.com --show highlight issues in a browser")),console.log(i.dim(" abilyo scan example.com --format sarif upload to GitHub code scanning")),console.log(i.dim(" abilyo scan example.com --exit fail the build on issues")),console.log(i.dim(" abilyo scan example.com --deep the checks axe-core cannot do")),console.log(),console.log(i.dim(" In your editor, after `abilyo login`:")),console.log(i.dim(' "scan staging.example.com and fix the contrast failures"'))):(console.log(i.yellow(" Abilyo is invite-only during early access.")),console.log(),console.log(` ${i.cyan("abilyo login")} Already have a WebAbility account? Start here`),console.log(` ${i.cyan("abilyo activate")} <code> Activate with an early-access code`),console.log(),console.log(i.dim(" Request access at https://abilyo.com"))),console.log())});b.parse();function it(e){let t=new Map;for(let s of e.issues)t.has(s.type)||t.set(s.type,{id:s.type,shortDescription:{text:s.message}});let o=e.engineWarnings??[];return{$schema:"https://json.schemastore.org/sarif-2.1.0.json",version:"2.1.0",runs:[{...o.length?{invocations:[{executionSuccessful:!0,toolExecutionNotifications:o.map(s=>({level:"warning",message:{text:s}}))}]}:{},tool:{driver:{name:"WebAbility",version:Ce.version,informationUri:"https://webability.io",rules:Array.from(t.values())}},results:e.issues.map(s=>{let n={startLine:Number.isInteger(s.line)&&s.line>0?s.line:1,snippet:{text:s.html||s.selector}};return Number.isInteger(s.column)&&s.column>0&&(n.startColumn=s.column),{ruleId:s.type,level:s.impact==="critical"||s.impact==="serious"?"error":"warning",message:{text:s.message},locations:[{physicalLocation:{artifactLocation:{uri:e.url},region:n}}]}})}]}}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webability/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Abilyo by WebAbility — WCAG accessibility scanner for your terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"ora": "^8.1.0",
|
|
32
32
|
"playwright": "^1.49.0",
|
|
33
33
|
"yaml": "^2.6.0",
|
|
34
|
-
"@webability/core": "1.1
|
|
34
|
+
"@webability/core": "1.2.1"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@types/node": "^25.6.0",
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifies the non-clobber / permissions / uninstall behaviour against a FAKE
|
|
3
|
+
* HOME. Never touches the real ~/.cursor/mcp.json or ~/.claude.json.
|
|
4
|
+
*/
|
|
5
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, statSync, existsSync } from 'node:fs'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
const fake = mkdtempSync(join(tmpdir(), 'abilyo-home-'))
|
|
10
|
+
process.env.HOME = fake
|
|
11
|
+
process.env.USERPROFILE = fake
|
|
12
|
+
|
|
13
|
+
const { installForCursor, uninstallMcp, MCP_URL, MCP_SERVER_NAME } = await import(
|
|
14
|
+
'../src/mcp-install.ts'
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
const cfg = join(fake, '.cursor', 'mcp.json')
|
|
18
|
+
mkdirSync(join(fake, '.cursor'), { recursive: true })
|
|
19
|
+
|
|
20
|
+
let failures = 0
|
|
21
|
+
function check(name: string, cond: boolean, detail = '') {
|
|
22
|
+
console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`)
|
|
23
|
+
if (!cond) failures++
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// --- 1. a user's own local stdio `webability` server must survive -------------
|
|
27
|
+
writeFileSync(
|
|
28
|
+
cfg,
|
|
29
|
+
JSON.stringify(
|
|
30
|
+
{ mcpServers: { [MCP_SERVER_NAME]: { command: 'npx', args: ['-y', '@webability/mcp'] }, other: { url: 'https://x' } } },
|
|
31
|
+
null,
|
|
32
|
+
2,
|
|
33
|
+
),
|
|
34
|
+
{ mode: 0o644 },
|
|
35
|
+
)
|
|
36
|
+
const r1 = installForCursor('TOKEN-A')
|
|
37
|
+
const after1 = JSON.parse(readFileSync(cfg, 'utf-8'))
|
|
38
|
+
check('foreign local entry is NOT replaced', after1.mcpServers[MCP_SERVER_NAME].command === 'npx')
|
|
39
|
+
check('foreign case reports ok=false with an explanation', r1.ok === false && r1.detail.includes('left untouched'))
|
|
40
|
+
check('no token written into the file', !readFileSync(cfg, 'utf-8').includes('TOKEN-A'))
|
|
41
|
+
|
|
42
|
+
// --- 2. our own entry IS refreshed, other servers preserved -------------------
|
|
43
|
+
writeFileSync(
|
|
44
|
+
cfg,
|
|
45
|
+
JSON.stringify({ mcpServers: { [MCP_SERVER_NAME]: { url: MCP_URL, headers: { 'x-webability-token': 'OLD' } }, other: { url: 'https://x' } } }, null, 2),
|
|
46
|
+
{ mode: 0o644 },
|
|
47
|
+
)
|
|
48
|
+
const r2 = installForCursor('TOKEN-B')
|
|
49
|
+
const after2 = JSON.parse(readFileSync(cfg, 'utf-8'))
|
|
50
|
+
check('our stale entry is refreshed', r2.ok === true && JSON.stringify(after2.mcpServers[MCP_SERVER_NAME]).includes('TOKEN-B'))
|
|
51
|
+
check('unrelated servers preserved', after2.mcpServers.other?.url === 'https://x')
|
|
52
|
+
|
|
53
|
+
// --- 3. a pre-existing 0644 config is tightened to 0600 ----------------------
|
|
54
|
+
const mode = statSync(cfg).mode & 0o777
|
|
55
|
+
check('pre-existing config chmod-ed to 0600', mode === 0o600, `mode=0${mode.toString(8)}`)
|
|
56
|
+
|
|
57
|
+
// --- 4. uninstall removes ours, keeps the rest -------------------------------
|
|
58
|
+
const r4 = uninstallMcp()
|
|
59
|
+
const after4 = JSON.parse(readFileSync(cfg, 'utf-8'))
|
|
60
|
+
check('uninstall removes our entry', after4.mcpServers[MCP_SERVER_NAME] === undefined)
|
|
61
|
+
check('uninstall keeps unrelated servers', after4.mcpServers.other?.url === 'https://x')
|
|
62
|
+
check('uninstall reports the cursor removal', r4.some((r) => r.client === 'cursor' && r.ok))
|
|
63
|
+
|
|
64
|
+
// --- 5. uninstall must NOT remove a foreign entry ----------------------------
|
|
65
|
+
writeFileSync(cfg, JSON.stringify({ mcpServers: { [MCP_SERVER_NAME]: { command: 'npx' } } }, null, 2))
|
|
66
|
+
const r5 = uninstallMcp()
|
|
67
|
+
const after5 = JSON.parse(readFileSync(cfg, 'utf-8'))
|
|
68
|
+
check("uninstall leaves a user's own server alone", after5.mcpServers[MCP_SERVER_NAME]?.command === 'npx')
|
|
69
|
+
check('uninstall says so', r5.some((r) => r.client === 'cursor' && !r.ok && r.detail.includes("isn't ours")))
|
|
70
|
+
|
|
71
|
+
check('fake HOME was used, real config untouched', existsSync(cfg) && cfg.startsWith(fake))
|
|
72
|
+
console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILED`)
|
|
73
|
+
process.exit(failures === 0 ? 0 : 1)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Visual dogfood test — runs structural scan + visual audit on webability.io
|
|
2
|
+
// to compare what each catches.
|
|
3
|
+
|
|
4
|
+
import { chromium } from 'playwright'
|
|
5
|
+
import fs from 'fs/promises'
|
|
6
|
+
|
|
7
|
+
const URL = 'https://webability.io'
|
|
8
|
+
const API_URL = 'https://api.webability.io'
|
|
9
|
+
|
|
10
|
+
const browser = await chromium.launch({ headless: true })
|
|
11
|
+
const ctx = await browser.newContext({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 })
|
|
12
|
+
const page = await ctx.newPage()
|
|
13
|
+
console.log(`Loading ${URL}...`)
|
|
14
|
+
await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 30000 })
|
|
15
|
+
await page.waitForTimeout(1500) // let JS settle
|
|
16
|
+
|
|
17
|
+
console.log('Capturing screenshot...')
|
|
18
|
+
const buf = await page.screenshot({ type: 'png', fullPage: false })
|
|
19
|
+
const screenshot = buf.toString('base64')
|
|
20
|
+
console.log(` Size: ${(buf.length / 1024).toFixed(0)}KB`)
|
|
21
|
+
|
|
22
|
+
console.log('Calling /cli/visual-audit...')
|
|
23
|
+
const t0 = Date.now()
|
|
24
|
+
const res = await fetch(`${API_URL}/cli/visual-audit`, {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: { 'Content-Type': 'application/json' },
|
|
27
|
+
body: JSON.stringify({
|
|
28
|
+
screenshot,
|
|
29
|
+
platform: 'web',
|
|
30
|
+
screenName: URL,
|
|
31
|
+
brandColors: ['#0052CC', '#47FFF7', '#1B3DB4'], // WebAbility brand
|
|
32
|
+
knownIssues: [], // empty so vision model gets full scope
|
|
33
|
+
}),
|
|
34
|
+
})
|
|
35
|
+
const elapsed = ((Date.now() - t0) / 1000).toFixed(1)
|
|
36
|
+
console.log(` HTTP ${res.status} in ${elapsed}s`)
|
|
37
|
+
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
console.error('Error:', await res.text())
|
|
40
|
+
await browser.close()
|
|
41
|
+
process.exit(1)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const data = await res.json()
|
|
45
|
+
console.log(`\n=== Visual issues found: ${data.count} ===\n`)
|
|
46
|
+
for (const issue of data.issues) {
|
|
47
|
+
const region = issue.region ? `[${issue.region.x},${issue.region.y} ${issue.region.width}x${issue.region.height}]` : ''
|
|
48
|
+
console.log(` ${issue.severity.toUpperCase().padEnd(8)} WCAG ${issue.wcag} — ${issue.message}`)
|
|
49
|
+
if (issue.fix?.suggestedValue) console.log(` fix: ${issue.fix.suggestedValue}`)
|
|
50
|
+
if (region) console.log(` region: ${region}`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
await fs.writeFile('/tmp/visual-dogfood-result.json', JSON.stringify(data, null, 2))
|
|
54
|
+
console.log('\nFull result saved to /tmp/visual-dogfood-result.json')
|
|
55
|
+
await browser.close()
|