tewip-ai 0.77.0 → 0.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -0
- package/dist/engine/access/access-command.js +10 -1
- package/dist/engine/access/mobile.js +212 -0
- package/dist/engine/access/wcag.js +25 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1216,6 +1216,39 @@ With `--act` it carries the decision out, and this is where the refusals matter
|
|
|
1216
1216
|
- Everything it did or refused goes to `.tewip/actions.jsonl`, with the run that justified it.
|
|
1217
1217
|
- It **will not repeat itself**: the same attempt is refused until a new run gives it new evidence.
|
|
1218
1218
|
|
|
1219
|
+
## Can everybody use the phone app? (Phase 143)
|
|
1220
|
+
|
|
1221
|
+
```bash
|
|
1222
|
+
tewip access # web markup and mobile screens, in one command
|
|
1223
|
+
```
|
|
1224
|
+
|
|
1225
|
+
Every accessibility tool worth having points at a rendered page. A phone app has no page to point
|
|
1226
|
+
at without a simulator, a device farm or a build, so in practice nobody checks the mobile half -
|
|
1227
|
+
and the mobile half is where the two barriers that matter most are invisible by design. A fixed
|
|
1228
|
+
text size and a missing label both look perfect on the reviewer's screen. They appear when somebody
|
|
1229
|
+
turns their font size up, or turns VoiceOver on, which is after shipping.
|
|
1230
|
+
|
|
1231
|
+
`tewip access` now reads **SwiftUI** and **Jetpack Compose** source alongside web markup:
|
|
1232
|
+
|
|
1233
|
+
- **An image with no name.** SwiftUI: no `.accessibilityLabel` and nothing marking it decoration.
|
|
1234
|
+
Compose: `contentDescription = null` inside something tappable, or a description that names
|
|
1235
|
+
nothing - `"image"`, `"icon"`, `""`. An SF Symbol is left to a person, because VoiceOver reads
|
|
1236
|
+
the symbol name and sometimes that is good enough.
|
|
1237
|
+
- **Text that will not grow.** `fontSize = 14.dp` in Compose ignores the font scale entirely; `sp`
|
|
1238
|
+
honours it. `.font(.system(size: 13))` in SwiftUI opts out of Dynamic Type unless it carries
|
|
1239
|
+
`relativeTo:`.
|
|
1240
|
+
- **A tap target smaller than a finger**, and only where something is actually tappable.
|
|
1241
|
+
- **Something made clickable with no role**, so TalkBack never says it is a control.
|
|
1242
|
+
|
|
1243
|
+
**Measured on two real apps.** Google's Compose samples: **0 barriers**, 5 for a person to settle.
|
|
1244
|
+
isowords, a shipped SwiftUI game: 20 barriers, 62 to settle. Both found this check wrong first:
|
|
1245
|
+
|
|
1246
|
+
- isowords has `Image(systemName: "xmark.circle.fill").font(.system(size: 24))`, where `.font` sets
|
|
1247
|
+
the size of a glyph. Ten findings claimed text would not grow about something that is not text.
|
|
1248
|
+
- Google's JetNews has a `Row` with `.semantics { customActions = … }` - the correct handling, and
|
|
1249
|
+
the reason the sample exists. A pattern wanting `semantics(` walked past the trailing-lambda form
|
|
1250
|
+
everybody writes, and accused the reference implementation of the barrier it demonstrates fixing.
|
|
1251
|
+
|
|
1219
1252
|
## What is your AI agent allowed to do? (Phase 139)
|
|
1220
1253
|
|
|
1221
1254
|
```bash
|
|
@@ -14,6 +14,7 @@ import { dirname, join, relative, resolve } from 'node:path';
|
|
|
14
14
|
import { scanSourceFiles } from '../walk.js';
|
|
15
15
|
import { readHandWritten } from '../../reporters/store.js';
|
|
16
16
|
import { JS_LIKE, MARKUP, tags } from './markup.js';
|
|
17
|
+
import { MOBILE, mobileBarriers } from './mobile.js';
|
|
17
18
|
import { checkFile } from './checks.js';
|
|
18
19
|
import { readAxeReport } from './reports.js';
|
|
19
20
|
import { isLighthouseFile, readLighthouse } from '../perf/lighthouse.js';
|
|
@@ -48,7 +49,8 @@ export function findBarriers(root, only) {
|
|
|
48
49
|
// scanner's 5,000-file cap over *all* text, which on n8n let 9 of 1,456 markup files through -
|
|
49
50
|
// and none survived the test-file filter, so `access` reported no barriers having opened
|
|
50
51
|
// nothing. A clean accessibility report is the one that must never be an artifact of a budget.
|
|
51
|
-
const
|
|
52
|
+
const reads = (f) => MARKUP.test(f) || MOBILE.test(f);
|
|
53
|
+
const scan = existsSync(base) && reads(base) ? { files: [base], total: 1 } : scanSourceFiles(base, 20_000, reads);
|
|
52
54
|
const all = scan.files;
|
|
53
55
|
// Asking for a path by name overrides the exclusion: if you point at a fixture, you want it read.
|
|
54
56
|
const files = only ? all : all.filter((f) => !TEST_MARKUP.test(relative(root, f)));
|
|
@@ -66,6 +68,13 @@ export function findBarriers(root, only) {
|
|
|
66
68
|
if (source.length > 400_000)
|
|
67
69
|
continue;
|
|
68
70
|
filesRead++;
|
|
71
|
+
const where = relative(root, path).replaceAll('\\', '/');
|
|
72
|
+
// A phone screen is markup too, and the same question - can everybody use this - but it is not
|
|
73
|
+
// written in tags, so it goes through its own reader rather than the tag extractor.
|
|
74
|
+
if (MOBILE.test(path)) {
|
|
75
|
+
findings.push(...mobileBarriers(where, source));
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
69
78
|
const jsLike = JS_LIKE.test(path);
|
|
70
79
|
// One page, rather than a file of components: only then do repeated ids land in one document.
|
|
71
80
|
const isDocument = /\.html?$/i.test(path);
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
const NL = String.fromCharCode(10);
|
|
2
|
+
export const MOBILE = /\.(?:swift|kt)$/i;
|
|
3
|
+
/** Apple's minimum is 44pt, Google's is 48dp. A control smaller than that is hard to hit. */
|
|
4
|
+
const MIN_TAP = { swift: 44, kotlin: 48 };
|
|
5
|
+
/** Placeholder text that is a label in form only. */
|
|
6
|
+
const PLACEHOLDER = /^(?:|\s*|icon|image|picture|photo|img|button|todo|tbd|placeholder|null|none|n\/a|\.\.\.)$/i;
|
|
7
|
+
const lineOf = (text, index) => text.slice(0, index).split(NL).length;
|
|
8
|
+
const shown = (s) => s.replace(/\s+/g, ' ').trim().slice(0, 90);
|
|
9
|
+
/**
|
|
10
|
+
* The call's own arguments, up to the matching bracket.
|
|
11
|
+
*
|
|
12
|
+
* Needed because a modifier that supplies the label sits *after* the call it labels -
|
|
13
|
+
* `Image("x").accessibilityLabel("A cat")` - so a check on the call alone would accuse every
|
|
14
|
+
* labelled image in the file.
|
|
15
|
+
*/
|
|
16
|
+
function callAndModifiers(text, from) {
|
|
17
|
+
let depth = 0;
|
|
18
|
+
let i = text.indexOf('(', from);
|
|
19
|
+
if (i === -1)
|
|
20
|
+
return text.slice(from, from + 120);
|
|
21
|
+
for (; i < text.length && i < from + 2000; i += 1) {
|
|
22
|
+
const ch = text[i];
|
|
23
|
+
if (ch === '(' || ch === '[' || ch === '{')
|
|
24
|
+
depth += 1;
|
|
25
|
+
else if (ch === ')' || ch === ']' || ch === '}') {
|
|
26
|
+
depth -= 1;
|
|
27
|
+
if (depth === 0)
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Then the chained modifiers that follow, which is where SwiftUI puts accessibility.
|
|
32
|
+
let end = i + 1;
|
|
33
|
+
while (end < text.length && end < from + 4000) {
|
|
34
|
+
const rest = text.slice(end);
|
|
35
|
+
const chained = /^\s*\.\s*\w+\s*\(/.exec(rest);
|
|
36
|
+
if (!chained)
|
|
37
|
+
break;
|
|
38
|
+
let d = 0;
|
|
39
|
+
let j = end + rest.indexOf('(');
|
|
40
|
+
for (; j < text.length; j += 1) {
|
|
41
|
+
if (text[j] === '(')
|
|
42
|
+
d += 1;
|
|
43
|
+
else if (text[j] === ')') {
|
|
44
|
+
d -= 1;
|
|
45
|
+
if (d === 0)
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
end = j + 1;
|
|
50
|
+
}
|
|
51
|
+
return text.slice(from, end);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* What a chained modifier is attached to.
|
|
55
|
+
*
|
|
56
|
+
* SwiftUI puts each modifier on its own line under the thing it modifies, so the owner is the
|
|
57
|
+
* first line above that does not itself begin with a dot. Needed because `.font(.system(size:))`
|
|
58
|
+
* means two different things depending on what it hangs off: a text size on a `Text`, and a glyph
|
|
59
|
+
* size on an `Image`.
|
|
60
|
+
*/
|
|
61
|
+
function chainOwner(text, at) {
|
|
62
|
+
const before = text.slice(0, at).split(NL);
|
|
63
|
+
for (let i = before.length - 1; i >= 0 && i > before.length - 8; i -= 1) {
|
|
64
|
+
const line = before[i].trim();
|
|
65
|
+
if (line === '' || line.startsWith('.'))
|
|
66
|
+
continue;
|
|
67
|
+
return line;
|
|
68
|
+
}
|
|
69
|
+
return '';
|
|
70
|
+
}
|
|
71
|
+
function swiftUI(file, text) {
|
|
72
|
+
const out = [];
|
|
73
|
+
// An image with no label and nothing saying it is decoration.
|
|
74
|
+
for (const m of text.matchAll(/\bImage\s*\(/g)) {
|
|
75
|
+
const at = m.index ?? 0;
|
|
76
|
+
const whole = callAndModifiers(text, at);
|
|
77
|
+
// `Image(decorative:)` is the documented way to say this conveys nothing. So is hiding it.
|
|
78
|
+
if (/\bdecorative\s*:/.test(whole) || /accessibilityHidden\s*\(\s*true\s*\)/.test(whole))
|
|
79
|
+
continue;
|
|
80
|
+
if (/accessibilityLabel\s*\(/.test(whole) || /accessibilityElement\s*\(/.test(whole))
|
|
81
|
+
continue;
|
|
82
|
+
// An SF Symbol carries a name VoiceOver can read; an asset name is a file name.
|
|
83
|
+
const symbol = /\bImage\s*\(\s*systemName\s*:/.test(whole);
|
|
84
|
+
out.push({
|
|
85
|
+
rule: symbol ? 'mobile-image-label' : 'mobile-image-label',
|
|
86
|
+
confidence: symbol ? 'ask-a-person' : 'barrier',
|
|
87
|
+
file,
|
|
88
|
+
line: lineOf(text, at),
|
|
89
|
+
element: shown(whole),
|
|
90
|
+
why: symbol
|
|
91
|
+
? 'an SF Symbol with no accessibilityLabel: VoiceOver falls back to the symbol name, which is a developer name, not a description'
|
|
92
|
+
: 'no accessibilityLabel, and nothing marks it as decoration',
|
|
93
|
+
next: symbol
|
|
94
|
+
? 'add .accessibilityLabel("…") saying what it conveys here, or .accessibilityHidden(true) if the text beside it already says it'
|
|
95
|
+
: 'add .accessibilityLabel("…"), or use Image(decorative:) if it conveys nothing',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
// A text size that ignores the reader's font setting.
|
|
99
|
+
for (const m of text.matchAll(/\.\s*font\s*\(\s*\.\s*system\s*\(\s*size\s*:\s*(\d+)/g)) {
|
|
100
|
+
const at = m.index ?? 0;
|
|
101
|
+
const whole = callAndModifiers(text, at);
|
|
102
|
+
// `relativeTo:` opts the fixed size back into Dynamic Type, which is the documented fix.
|
|
103
|
+
if (/relativeTo\s*:/.test(whole))
|
|
104
|
+
continue;
|
|
105
|
+
// On an Image, `.font(.system(size:))` sets the size of an SF Symbol, not of any text. isowords
|
|
106
|
+
// has `Image(systemName: "xmark.circle.fill").font(.system(size: 24))`, and calling that text
|
|
107
|
+
// that will not grow is a confident wrong answer about a glyph.
|
|
108
|
+
if (/\bImage\s*\(/.test(chainOwner(text, at)))
|
|
109
|
+
continue;
|
|
110
|
+
out.push({
|
|
111
|
+
rule: 'mobile-text-not-scalable',
|
|
112
|
+
confidence: 'barrier',
|
|
113
|
+
file,
|
|
114
|
+
line: lineOf(text, at),
|
|
115
|
+
element: shown(m[0]),
|
|
116
|
+
why: `a fixed ${m[1]}pt size, so this text does not grow when the reader turns their font size up`,
|
|
117
|
+
next: 'use a semantic font like .font(.body), or .font(.system(size: 14, relativeTo: .body))',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
// A tap target declared smaller than a finger.
|
|
121
|
+
for (const m of text.matchAll(/\.\s*frame\s*\(\s*width\s*:\s*(\d+)\s*,\s*height\s*:\s*(\d+)/g)) {
|
|
122
|
+
const w = Number(m[1]);
|
|
123
|
+
const h = Number(m[2]);
|
|
124
|
+
if (w >= MIN_TAP.swift && h >= MIN_TAP.swift)
|
|
125
|
+
continue;
|
|
126
|
+
const at = m.index ?? 0;
|
|
127
|
+
// Only when it is actually tappable: a small decorative frame is nobody's problem.
|
|
128
|
+
const around = text.slice(Math.max(0, at - 400), at + 400);
|
|
129
|
+
if (!/\bButton\s*\(|onTapGesture|\.\s*tapGesture|NavigationLink|\bToggle\s*\(/.test(around))
|
|
130
|
+
continue;
|
|
131
|
+
out.push({
|
|
132
|
+
rule: 'mobile-tap-target',
|
|
133
|
+
confidence: 'ask-a-person',
|
|
134
|
+
file,
|
|
135
|
+
line: lineOf(text, at),
|
|
136
|
+
element: shown(m[0]),
|
|
137
|
+
why: `a tappable area declared ${w}x${h}pt, under the 44pt Apple asks for - unless padding around it makes the hit area bigger, which this cannot see`,
|
|
138
|
+
next: 'give it .frame(minWidth: 44, minHeight: 44), or enough padding to reach 44pt',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
function compose(file, text) {
|
|
144
|
+
const out = [];
|
|
145
|
+
// Compose makes contentDescription compulsory, so the failure is what gets passed to it.
|
|
146
|
+
for (const m of text.matchAll(/\b(?:Image|Icon)\s*\([^)]{0,400}?contentDescription\s*=\s*(null|"([^"]*)")/g)) {
|
|
147
|
+
const at = m.index ?? 0;
|
|
148
|
+
const whole = m[0];
|
|
149
|
+
const isNull = m[1] === 'null';
|
|
150
|
+
const value = m[2] ?? '';
|
|
151
|
+
// `null` is the documented way to say an image is decorative, and inside a labelled button it
|
|
152
|
+
// is right. On its own it is the commonest way an icon ends up with no name at all.
|
|
153
|
+
const around = text.slice(Math.max(0, at - 300), at + 300);
|
|
154
|
+
const insideClickable = /\b(?:IconButton|Button|Clickable|clickable)\b/.test(around);
|
|
155
|
+
if (isNull && !insideClickable)
|
|
156
|
+
continue;
|
|
157
|
+
if (!isNull && !PLACEHOLDER.test(value))
|
|
158
|
+
continue;
|
|
159
|
+
out.push({
|
|
160
|
+
rule: 'mobile-image-label',
|
|
161
|
+
confidence: isNull ? 'barrier' : 'barrier',
|
|
162
|
+
file,
|
|
163
|
+
line: lineOf(text, at),
|
|
164
|
+
element: shown(whole),
|
|
165
|
+
why: isNull
|
|
166
|
+
? 'contentDescription = null inside something tappable: TalkBack announces a button with no name'
|
|
167
|
+
: `contentDescription is ${value === '' ? 'empty' : `"${value}"`}, which names nothing`,
|
|
168
|
+
next: 'give contentDescription the words a person needs, or keep null only when a text label beside it already says it',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
// dp for a text size is always wrong: dp ignores the font scale, sp honours it.
|
|
172
|
+
for (const m of text.matchAll(/\bfontSize\s*=\s*(\d+)\s*\.\s*dp\b/g)) {
|
|
173
|
+
const at = m.index ?? 0;
|
|
174
|
+
out.push({
|
|
175
|
+
rule: 'mobile-text-not-scalable',
|
|
176
|
+
confidence: 'barrier',
|
|
177
|
+
file,
|
|
178
|
+
line: lineOf(text, at),
|
|
179
|
+
element: shown(m[0]),
|
|
180
|
+
why: `a text size in dp, which ignores the reader's font-size setting entirely. sp is the unit that honours it`,
|
|
181
|
+
next: `use ${m[1]}.sp instead of ${m[1]}.dp`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
// Something made tappable without being told it is a control.
|
|
185
|
+
// `clickable\s*[({]` and not `clickable\s*\(`: Kotlin's trailing lambda means the commonest form
|
|
186
|
+
// by far is `Modifier.clickable { open() }`, with a brace. Written with the paren alone this
|
|
187
|
+
// matched the form almost nobody uses and stayed silent on the one everybody does.
|
|
188
|
+
for (const m of text.matchAll(/\b(Box|Row|Column|Text|Surface|Spacer)\s*\(\s*[^)]{0,300}?Modifier\s*[^)]{0,300}?\.\s*clickable\s*[({]/g)) {
|
|
189
|
+
const at = m.index ?? 0;
|
|
190
|
+
const whole = callAndModifiers(text, at);
|
|
191
|
+
// `semantics\s*[({]` for the same reason as `clickable` above, and this one was caught by
|
|
192
|
+
// accusing Google's own JetNews sample of the exact thing it exists to demonstrate: its Row has
|
|
193
|
+
// `.semantics { customActions = listOf(CustomAccessibilityAction(...)) }`, with a brace, and a
|
|
194
|
+
// pattern wanting a paren walked straight past it. A check that reports the reference
|
|
195
|
+
// implementation as a barrier is not a strict check, it is a broken one.
|
|
196
|
+
if (/\bsemantics\s*[({]|\brole\s*=\s*Role\.|\bRole\.\w+|\bonClickLabel\s*=/.test(whole))
|
|
197
|
+
continue;
|
|
198
|
+
out.push({
|
|
199
|
+
rule: 'mobile-control-unnamed',
|
|
200
|
+
confidence: 'ask-a-person',
|
|
201
|
+
file,
|
|
202
|
+
line: lineOf(text, at),
|
|
203
|
+
element: shown(m[0]),
|
|
204
|
+
why: `a ${m[1]} made clickable with no role and no semantics, so TalkBack does not announce it as a control`,
|
|
205
|
+
next: 'pass Modifier.clickable(onClickLabel = "…", role = Role.Button), or use a Button',
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
export function mobileBarriers(file, text) {
|
|
211
|
+
return /\.swift$/i.test(file) ? swiftUI(file, text) : compose(file, text);
|
|
212
|
+
}
|
|
@@ -7,6 +7,31 @@
|
|
|
7
7
|
// trace to a numbered criterion is a finding they can put in front of a lawyer or a regulator; a
|
|
8
8
|
// finding that only says "bad contrast" is not. So every rule carries its criterion and its level.
|
|
9
9
|
export const RULES = {
|
|
10
|
+
// Phase 143: the mobile screens, which nobody checks because there is no page to point at.
|
|
11
|
+
'mobile-image-label': {
|
|
12
|
+
criterion: '1.1.1 Non-text Content',
|
|
13
|
+
level: 'A',
|
|
14
|
+
severity: 'blocker',
|
|
15
|
+
stops: 'VoiceOver or TalkBack announces "button" and stops, or reads out an asset file name',
|
|
16
|
+
},
|
|
17
|
+
'mobile-text-not-scalable': {
|
|
18
|
+
criterion: '1.4.4 Resize Text',
|
|
19
|
+
level: 'AA',
|
|
20
|
+
severity: 'blocker',
|
|
21
|
+
stops: 'somebody who turns their font size up gets the same small text, because the app opted out',
|
|
22
|
+
},
|
|
23
|
+
'mobile-tap-target': {
|
|
24
|
+
criterion: '2.5.8 Target Size (Minimum)',
|
|
25
|
+
level: 'AA',
|
|
26
|
+
severity: 'serious',
|
|
27
|
+
stops: 'anybody whose hands are unsteady, on a moving bus, or using one thumb',
|
|
28
|
+
},
|
|
29
|
+
'mobile-control-unnamed': {
|
|
30
|
+
criterion: '4.1.2 Name, Role, Value',
|
|
31
|
+
level: 'A',
|
|
32
|
+
severity: 'serious',
|
|
33
|
+
stops: 'a screen reader never says this is something you can press',
|
|
34
|
+
},
|
|
10
35
|
'image-alt': {
|
|
11
36
|
criterion: '1.1.1 Non-text Content',
|
|
12
37
|
level: 'A',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.78.0",
|
|
4
4
|
"mcpName": "io.github.hmamut39/tewip",
|
|
5
5
|
"description": "Tewip finds why each failing test failed - real bug, flaky test, environment, test data or changed selector - for any stack, from the command line, CI, or your editor.",
|
|
6
6
|
"keywords": [
|