mandrel 2.50.0 → 2.52.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/.agents/audit-checklists/accessibility.md +1 -0
- package/.agents/audit-checklists/mobile.md +35 -0
- package/.agents/audit-checklists/ux-ui.md +0 -1
- package/.agents/docs/workflows.md +2 -1
- package/.agents/schemas/audit-rules.json +30 -0
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +34 -0
- package/.agents/scripts/lib/orchestration/light-backstop.js +62 -6
- package/.agents/scripts/lib/orchestration/light-escalation.js +29 -3
- package/.agents/scripts/lib/orchestration/light-suitability.js +105 -19
- package/.agents/scripts/lib/orchestration/retro-proposals.js +36 -2
- package/.agents/scripts/lib/orchestration/worktree-dirty.js +80 -0
- package/.agents/workflows/audit-accessibility.md +13 -7
- package/.agents/workflows/audit-mobile.md +242 -0
- package/.agents/workflows/audit-ux-ui.md +11 -6
- package/.agents/workflows/helpers/deliver-light.md +13 -7
- package/docs/CHANGELOG.md +14 -0
- package/package.json +1 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<!-- GENERATED FILE — do not edit by hand.
|
|
2
|
+
Source of truth: .agents/workflows/audit-mobile.md
|
|
3
|
+
Regenerate: node .agents/scripts/generate-lens-checklists.js
|
|
4
|
+
Drift is gated by: npm run docs:check
|
|
5
|
+
-->
|
|
6
|
+
|
|
7
|
+
# Mobile & Tablet UX Audit — authoring checklist
|
|
8
|
+
|
|
9
|
+
> Audit mobile and tablet UX — layout and viewport correctness, touch ergonomics, responsive assets, and whether anything actually verifies them at a small viewport (static-first, with an optional runtime viewport pass)
|
|
10
|
+
|
|
11
|
+
Self-check your change against this lens's concerns before you ship:
|
|
12
|
+
|
|
13
|
+
- [ ] Breakpoint scale
|
|
14
|
+
- [ ] Viewport contract
|
|
15
|
+
- [ ] Declared device matrix
|
|
16
|
+
- [ ] Runtime target (optional)
|
|
17
|
+
- [ ] Viewport meta
|
|
18
|
+
- [ ] Fixed dimensions
|
|
19
|
+
- [ ] Viewport-height units
|
|
20
|
+
- [ ] Horizontal overflow
|
|
21
|
+
- [ ] Safe-area insets
|
|
22
|
+
- [ ] Hover-only interaction
|
|
23
|
+
- [ ] Control size and spacing
|
|
24
|
+
- [ ] Input ergonomics
|
|
25
|
+
- [ ] Gesture conflicts
|
|
26
|
+
- [ ] Images
|
|
27
|
+
- [ ] Media and embeds
|
|
28
|
+
- [ ] Typography and spacing
|
|
29
|
+
- [ ] Coverage — is anything exercised at a non-desktop viewport?
|
|
30
|
+
- [ ] Effectiveness — does that exercise assert anything mobile-specific?
|
|
31
|
+
- [ ] Resolve the target from config — never a hardcoded URL.
|
|
32
|
+
- [ ] Sample routes from the navigability SSOT.
|
|
33
|
+
- [ ] Drive two form factors per route.
|
|
34
|
+
- [ ] Median-of-3 or provisional.
|
|
35
|
+
- [ ] Leave the viewport as you found it.
|
|
@@ -32,7 +32,7 @@ by `node .agents/scripts/generate-workflows-doc.js`; `npm run docs:check`
|
|
|
32
32
|
fails when it drifts from the on-disk workflow set. To change a command’s
|
|
33
33
|
description, edit the workflow file’s front-matter and regenerate.
|
|
34
34
|
|
|
35
|
-
## Commands (
|
|
35
|
+
## Commands (29)
|
|
36
36
|
|
|
37
37
|
| Command | Description |
|
|
38
38
|
| --- | --- |
|
|
@@ -45,6 +45,7 @@ description, edit the workflow file’s front-matter and regenerate.
|
|
|
45
45
|
| `/audit-dependencies` | Audit `package.json` for unused, outdated, and major-version-stale dependencies; surface Node-engine drift and propose upgrade batches. |
|
|
46
46
|
| `/audit-devops` | Audit CI/CD workflows, container images, infrastructure-as-code, and deployment pipelines; surface failure modes and hardening gaps. |
|
|
47
47
|
| `/audit-documentation` | Audit the repository's main documentation for staleness, semantic drift, and completeness; emit a structured High/Medium/Low findings report. |
|
|
48
|
+
| `/audit-mobile` | Audit mobile and tablet UX — layout and viewport correctness, touch ergonomics, responsive assets, and whether anything actually verifies them at a small viewport (static-first, with an optional runtime viewport pass) |
|
|
48
49
|
| `/audit-navigability` | Audit the whole route tree against the consumer's nav-registry SSOT — every route has a persona nav door and no nav href is dead. A deliberately-global lens exempt from the cross-epic-leak guard and routed onto route-adding change sets. |
|
|
49
50
|
| `/audit-performance` | Audit performance by measuring first — profile hot paths, I/O, memory, and payload against the repo's own numbers — and audit interleaving/partial-failure correctness (TOCTOU, unawaited promises, non-atomic writes) as a first-class dimension. |
|
|
50
51
|
| `/audit-privacy` | Audit logs, telemetry, and persistence paths for PII leakage and retention violations; surface secrets exposure and consent gaps. |
|
|
@@ -299,6 +299,36 @@
|
|
|
299
299
|
"scope": "local",
|
|
300
300
|
"substitutionKeys": []
|
|
301
301
|
},
|
|
302
|
+
"audit-mobile": {
|
|
303
|
+
"triggers": {
|
|
304
|
+
"gates": ["gate2", "gate3"],
|
|
305
|
+
"keywords": [
|
|
306
|
+
"mobile",
|
|
307
|
+
"tablet",
|
|
308
|
+
"responsive",
|
|
309
|
+
"breakpoint",
|
|
310
|
+
"viewport",
|
|
311
|
+
"touch"
|
|
312
|
+
],
|
|
313
|
+
"filePatterns": [
|
|
314
|
+
"**/*.html",
|
|
315
|
+
"**/*.astro",
|
|
316
|
+
"**/*.css",
|
|
317
|
+
"**/*.{scss,sass,less}",
|
|
318
|
+
"**/styles/**",
|
|
319
|
+
"**/components/**/*.{js,jsx,ts,tsx,vue,svelte}",
|
|
320
|
+
"**/app/**/{page,layout,route,head,default,template,loading,error,not-found}.{js,jsx,ts,tsx}",
|
|
321
|
+
"**/pages/**/*.{js,jsx,ts,tsx,vue}",
|
|
322
|
+
"**/routes/**/*.{jsx,tsx,vue,svelte}",
|
|
323
|
+
"**/tailwind.config.{js,ts,cjs,mjs}",
|
|
324
|
+
"**/playwright.config.{js,ts,cjs,mjs}",
|
|
325
|
+
"**/cypress.config.{js,ts,cjs,mjs}"
|
|
326
|
+
]
|
|
327
|
+
},
|
|
328
|
+
"target": "web",
|
|
329
|
+
"scope": "local",
|
|
330
|
+
"substitutionKeys": []
|
|
331
|
+
},
|
|
302
332
|
"audit-navigability": {
|
|
303
333
|
"triggers": {
|
|
304
334
|
"gates": ["gate2", "gate3"],
|
|
@@ -95,6 +95,40 @@ export const RUNTIME_FRICTION_CATEGORIES = Object.freeze({
|
|
|
95
95
|
REVIEW_BLOCK_OVERRIDDEN: 'review-block-overridden',
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* The friction category one light-path refusal files under.
|
|
100
|
+
*
|
|
101
|
+
* `LIGHT_SCOPE_REJECTED` alone used to be the category for every refusal, and
|
|
102
|
+
* the retro composer aggregates, de-duplicates and labels on the category
|
|
103
|
+
* string ALONE — so an empty-diff refusal and a `public-api` sensitive-path
|
|
104
|
+
* refusal landed in one bucket and filed one "recurred 2 times across 2
|
|
105
|
+
* Stories" follow-up whose two occurrences had nothing in common but the
|
|
106
|
+
* category (issue #5237; consumer Beestera/swarm-os#2408). The roll-up's shape
|
|
107
|
+
* fingerprint could not separate them either: it hashes detail KEYS, and every
|
|
108
|
+
* refusal carries an identical key set.
|
|
109
|
+
*
|
|
110
|
+
* Encoding the refusal class in the category is what splits them, and it
|
|
111
|
+
* splits them in every consumer at once — bucket aggregation, the graduator's
|
|
112
|
+
* idempotency marker and the `friction::<category>` label all read this one
|
|
113
|
+
* string. N refusals of the SAME class still coalesce, which is the recurrence
|
|
114
|
+
* evidence the light ceilings are recalibrated from. New categories need no
|
|
115
|
+
* seeding: the graduator mints missing `friction::*` labels before filing.
|
|
116
|
+
*
|
|
117
|
+
* A class-less refusal keeps the bare category unchanged — that is the
|
|
118
|
+
* suitability gate, which refuses a prompt before any diff exists and so has
|
|
119
|
+
* no diff-derived class to carry.
|
|
120
|
+
*
|
|
121
|
+
* @param {string|null} [refusalClass] A
|
|
122
|
+
* {@link module:lib/orchestration/light-suitability.LIGHT_REFUSAL_CLASSES}
|
|
123
|
+
* value, or nullish for the unclassified refusal.
|
|
124
|
+
* @returns {string}
|
|
125
|
+
*/
|
|
126
|
+
export function lightScopeRejectedCategory(refusalClass) {
|
|
127
|
+
const suffix = typeof refusalClass === 'string' ? refusalClass.trim() : '';
|
|
128
|
+
const base = RUNTIME_FRICTION_CATEGORIES.LIGHT_SCOPE_REJECTED;
|
|
129
|
+
return suffix === '' ? base : `${base}-${suffix}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
98
132
|
/** Cap on free-form reason text copied into a signal's `details`. */
|
|
99
133
|
const REASON_PREVIEW_LIMIT = 500;
|
|
100
134
|
|
|
@@ -18,16 +18,26 @@
|
|
|
18
18
|
* other consumer are looking at the same change set.
|
|
19
19
|
* - `--numstat` gives per-file line counts, the only surface carrying them.
|
|
20
20
|
*
|
|
21
|
+
* Both read committed state, which is why a third read exists
|
|
22
|
+
* ({@link module:lib/orchestration/worktree-dirty.hasUncommittedWork}): an
|
|
23
|
+
* empty diff is ambiguous between "no work" and "work not committed yet", and
|
|
24
|
+
* only one of those is about scope.
|
|
25
|
+
*
|
|
21
26
|
* @module lib/orchestration/light-backstop
|
|
22
27
|
*/
|
|
23
28
|
|
|
29
|
+
import { gitSpawn } from '../git-utils.js';
|
|
24
30
|
import { computeChangeSet } from './change-set.js';
|
|
25
31
|
import { readNumstatRows, summarizeDiffMagnitude } from './diff-magnitude.js';
|
|
26
32
|
import {
|
|
27
33
|
handleBlockedBackstop,
|
|
28
34
|
preserveRefusedWork,
|
|
29
35
|
} from './light-escalation.js';
|
|
30
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
checkLightDiffBackstop,
|
|
38
|
+
LIGHT_REFUSAL_CLASSES,
|
|
39
|
+
} from './light-suitability.js';
|
|
40
|
+
import { hasUncommittedWork } from './worktree-dirty.js';
|
|
31
41
|
|
|
32
42
|
/** Exit code when the diff backstop blocked the land. */
|
|
33
43
|
const EXIT_BACKSTOP_BLOCKED = 3;
|
|
@@ -41,6 +51,8 @@ const EXIT_BACKSTOP_BLOCKED = 3;
|
|
|
41
51
|
* cwd?: string,
|
|
42
52
|
* computeFn?: typeof computeChangeSet,
|
|
43
53
|
* readRowsFn?: typeof readNumstatRows,
|
|
54
|
+
* dirtyProbeFn?: typeof hasUncommittedWork,
|
|
55
|
+
* gitFn?: typeof gitSpawn,
|
|
44
56
|
* injectedRules?: object,
|
|
45
57
|
* }} args
|
|
46
58
|
* @returns {ReturnType<typeof checkLightDiffBackstop>}
|
|
@@ -51,6 +63,8 @@ function runDiffBackstop({
|
|
|
51
63
|
cwd = process.cwd(),
|
|
52
64
|
computeFn = computeChangeSet,
|
|
53
65
|
readRowsFn = readNumstatRows,
|
|
66
|
+
dirtyProbeFn = hasUncommittedWork,
|
|
67
|
+
gitFn = gitSpawn,
|
|
54
68
|
injectedRules,
|
|
55
69
|
} = {}) {
|
|
56
70
|
const headRef = `story-${storyId}`;
|
|
@@ -61,9 +75,49 @@ function runDiffBackstop({
|
|
|
61
75
|
changedFiles: files,
|
|
62
76
|
magnitude,
|
|
63
77
|
injectedRules,
|
|
78
|
+
storyBranch: headRef,
|
|
79
|
+
// Only an ENUMERATED-empty diff can be explained by uncommitted work, so
|
|
80
|
+
// the probe's two git calls are spent only where they can change what the
|
|
81
|
+
// refusal tells the agent to do.
|
|
82
|
+
uncommittedWork:
|
|
83
|
+
Array.isArray(files) && files.length === 0
|
|
84
|
+
? dirtyProbeFn({ branch: headRef, cwd, gitFn })
|
|
85
|
+
: false,
|
|
64
86
|
});
|
|
65
87
|
}
|
|
66
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Is this refusal the one that is NOT about scope?
|
|
91
|
+
*
|
|
92
|
+
* @param {{ refusalClass?: string|null }} result
|
|
93
|
+
* @returns {boolean}
|
|
94
|
+
*/
|
|
95
|
+
function isUncommittedRefusal(result) {
|
|
96
|
+
return result.refusalClass === LIGHT_REFUSAL_CLASSES.UNCOMMITTED_WORK;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Close the refusal log line: what became of the work, and what to run next.
|
|
101
|
+
*
|
|
102
|
+
* A `null` preservation is the uncommitted-work refusal by construction — that
|
|
103
|
+
* is the one path that does not push, because there is nothing a push could
|
|
104
|
+
* preserve: it would publish a branch at its base and then report uncommitted
|
|
105
|
+
* work as safe on `origin`, which is the opposite of true.
|
|
106
|
+
*
|
|
107
|
+
* @param {{
|
|
108
|
+
* storyId: number,
|
|
109
|
+
* preservation: { detail: string }|null,
|
|
110
|
+
* nextCommand: string,
|
|
111
|
+
* }} args
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
function describeBlockedTail({ storyId, preservation, nextCommand }) {
|
|
115
|
+
return preservation === null
|
|
116
|
+
? `nothing is committed yet, so there is no work to preserve; ` +
|
|
117
|
+
`commit on story-${storyId}, then re-run: "${nextCommand}"`
|
|
118
|
+
: `${preservation.detail}; recycle the receipt with "${nextCommand}"`;
|
|
119
|
+
}
|
|
120
|
+
|
|
67
121
|
/**
|
|
68
122
|
* Resolve the backstop pass into everything the CLI needs to print and exit
|
|
69
123
|
* with: the verdict, the recycle command on a refusal (`null` when clean), the
|
|
@@ -82,8 +136,8 @@ function runDiffBackstop({
|
|
|
82
136
|
* handleBlockedFn?: typeof handleBlockedBackstop,
|
|
83
137
|
* preserveFn?: typeof preserveRefusedWork,
|
|
84
138
|
* }} args Any further keys (`baseRef`, `cwd`, `computeFn`, `readRowsFn`,
|
|
85
|
-
* `injectedRules`) forward to the backstop run, so
|
|
86
|
-
* drivable through this one entry point.
|
|
139
|
+
* `dirtyProbeFn`, `gitFn`, `injectedRules`) forward to the backstop run, so
|
|
140
|
+
* the git-surface join is drivable through this one entry point.
|
|
87
141
|
* @returns {Promise<{
|
|
88
142
|
* result: ReturnType<typeof checkLightDiffBackstop>,
|
|
89
143
|
* nextCommand: string|null,
|
|
@@ -109,7 +163,9 @@ export async function resolveBackstopOutcome({
|
|
|
109
163
|
message: `[deliver-light] diff backstop clean for Story #${storyId}.`,
|
|
110
164
|
};
|
|
111
165
|
}
|
|
112
|
-
const preservation =
|
|
166
|
+
const preservation = isUncommittedRefusal(result)
|
|
167
|
+
? null
|
|
168
|
+
: preserveFn({ storyId, cwd: seams.cwd });
|
|
113
169
|
const nextCommand = await handleBlockedFn({ storyId, result, preservation });
|
|
114
170
|
return {
|
|
115
171
|
result,
|
|
@@ -118,7 +174,7 @@ export async function resolveBackstopOutcome({
|
|
|
118
174
|
exitCode: EXIT_BACKSTOP_BLOCKED,
|
|
119
175
|
message:
|
|
120
176
|
`[deliver-light] diff backstop BLOCKED Story #${storyId}: ` +
|
|
121
|
-
`${result.reasons.join('; ')} —
|
|
122
|
-
|
|
177
|
+
`${result.reasons.join('; ')} — ` +
|
|
178
|
+
describeBlockedTail({ storyId, preservation, nextCommand }),
|
|
123
179
|
};
|
|
124
180
|
}
|
|
@@ -41,8 +41,10 @@
|
|
|
41
41
|
import { getStoryBranch, gitSpawn } from '../git-utils.js';
|
|
42
42
|
import {
|
|
43
43
|
emitRuntimeFriction,
|
|
44
|
+
lightScopeRejectedCategory,
|
|
44
45
|
RUNTIME_FRICTION_CATEGORIES,
|
|
45
46
|
} from '../observability/runtime-friction.js';
|
|
47
|
+
import { LIGHT_REFUSAL_CLASSES } from './light-suitability.js';
|
|
46
48
|
|
|
47
49
|
/**
|
|
48
50
|
* The `/mandrel-plan` invocation that owns a Story the light path could not land.
|
|
@@ -54,6 +56,21 @@ function buildRecycleCommand(storyId) {
|
|
|
54
56
|
return `/mandrel-plan ${storyId}`;
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The command that re-runs the backstop once the work is committed.
|
|
61
|
+
*
|
|
62
|
+
* The one refusal that is NOT about scope gets its own next step: an empty diff
|
|
63
|
+
* over a dirty worktree means the backstop ran before the commit, and handing
|
|
64
|
+
* that run to `/mandrel-plan` recycles a receipt whose implementation is fine
|
|
65
|
+
* — the wrong door, dressed as an escalation (issue #5237).
|
|
66
|
+
*
|
|
67
|
+
* @param {number} storyId
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
function buildRerunBackstopCommand(storyId) {
|
|
71
|
+
return `node .agents/scripts/deliver-light.js --backstop --story ${storyId}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
57
74
|
/**
|
|
58
75
|
* Coerce an `--amends` argument (`#123` or `123`) into a positive integer issue
|
|
59
76
|
* number, or `null` when absent/malformed.
|
|
@@ -127,10 +144,12 @@ export async function handleBlockedBackstop({
|
|
|
127
144
|
emitFn,
|
|
128
145
|
recordFrictionFn = recordScopeFriction,
|
|
129
146
|
} = {}) {
|
|
147
|
+
const refusalClass = result?.refusalClass ?? null;
|
|
130
148
|
await recordFrictionFn({
|
|
131
149
|
emitFn,
|
|
132
150
|
storyId,
|
|
133
151
|
surface: 'diff-backstop',
|
|
152
|
+
category: lightScopeRejectedCategory(refusalClass),
|
|
134
153
|
reasons: result?.reasons ?? [],
|
|
135
154
|
details: {
|
|
136
155
|
fileCount: result?.fileCount ?? null,
|
|
@@ -142,9 +161,12 @@ export async function handleBlockedBackstop({
|
|
|
142
161
|
// than one whose branch reached origin — the roll-up must be able to
|
|
143
162
|
// tell them apart.
|
|
144
163
|
preserved: preservation?.preserved ?? null,
|
|
164
|
+
refusalClass,
|
|
145
165
|
},
|
|
146
166
|
});
|
|
147
|
-
return
|
|
167
|
+
return refusalClass === LIGHT_REFUSAL_CLASSES.UNCOMMITTED_WORK
|
|
168
|
+
? buildRerunBackstopCommand(storyId)
|
|
169
|
+
: buildRecycleCommand(storyId);
|
|
148
170
|
}
|
|
149
171
|
|
|
150
172
|
/**
|
|
@@ -221,15 +243,19 @@ export function preserveRefusedWork({
|
|
|
221
243
|
* @param {{
|
|
222
244
|
* storyId?: number|null,
|
|
223
245
|
* surface: string,
|
|
246
|
+
* category?: string,
|
|
224
247
|
* reasons?: string[],
|
|
225
248
|
* details?: object,
|
|
226
249
|
* emitFn?: typeof emitRuntimeFriction,
|
|
227
|
-
* }} args
|
|
250
|
+
* }} args `category` defaults to the unclassified light-refusal bucket, which
|
|
251
|
+
* is what the suitability gate emits: it refuses a prompt before any diff
|
|
252
|
+
* exists, so it carries no refusal class to encode.
|
|
228
253
|
* @returns {Promise<boolean>}
|
|
229
254
|
*/
|
|
230
255
|
async function recordScopeFriction({
|
|
231
256
|
storyId,
|
|
232
257
|
surface,
|
|
258
|
+
category = RUNTIME_FRICTION_CATEGORIES.LIGHT_SCOPE_REJECTED,
|
|
233
259
|
reasons = [],
|
|
234
260
|
details = {},
|
|
235
261
|
emitFn,
|
|
@@ -238,7 +264,7 @@ async function recordScopeFriction({
|
|
|
238
264
|
try {
|
|
239
265
|
return await emit({
|
|
240
266
|
storyId,
|
|
241
|
-
category
|
|
267
|
+
category,
|
|
242
268
|
tool: 'deliver-light',
|
|
243
269
|
details: { surface, reasons, ...details },
|
|
244
270
|
});
|
|
@@ -524,6 +524,52 @@ export function resolveLightGateOutcome({
|
|
|
524
524
|
};
|
|
525
525
|
}
|
|
526
526
|
|
|
527
|
+
/**
|
|
528
|
+
* The refusal classes a blocked diff backstop can carry — the machine-readable
|
|
529
|
+
* half of a verdict whose `reasons[]` are prose (Story #5238).
|
|
530
|
+
*
|
|
531
|
+
* One value per blocked verdict, and the reason it exists is downstream: the
|
|
532
|
+
* refusal's friction category is derived from it
|
|
533
|
+
* ({@link module:lib/observability/runtime-friction.lightScopeRejectedCategory}),
|
|
534
|
+
* and the category is the ONLY key the retro composer separates buckets on.
|
|
535
|
+
* Under one bare category an empty-diff refusal and a `public-api` refusal
|
|
536
|
+
* aggregated into a single "recurred 2 times" follow-up with nothing in common
|
|
537
|
+
* (issue #5237) — the roll-up's shape fingerprint could not tell them apart
|
|
538
|
+
* either, because it hashes detail keys and every refusal carries the same set.
|
|
539
|
+
*
|
|
540
|
+
* Kept coarse on purpose: a class must be stable enough that N refusals of one
|
|
541
|
+
* cause still coalesce into the recurrence evidence the ceilings are
|
|
542
|
+
* recalibrated from.
|
|
543
|
+
*
|
|
544
|
+
* @typedef {{ reason: string, refusalClass: string }} Objection
|
|
545
|
+
*/
|
|
546
|
+
export const LIGHT_REFUSAL_CLASSES = Object.freeze({
|
|
547
|
+
/** The diff could not be enumerated, or enumerated to nothing. */
|
|
548
|
+
CHANGE_SET_UNKNOWN: 'change-set-unknown',
|
|
549
|
+
/** Enumerated-empty, but the worktree carries uncommitted changes. */
|
|
550
|
+
UNCOMMITTED_WORK: 'uncommitted-work',
|
|
551
|
+
/** The change set intersects a registered sensitive-path class. */
|
|
552
|
+
SENSITIVE_PATH: 'sensitive-path',
|
|
553
|
+
/** Sensitivity could not be classified, so non-sensitivity is unproven. */
|
|
554
|
+
SENSITIVITY_UNKNOWN: 'sensitivity-unknown',
|
|
555
|
+
/** The implementation magnitude could not be measured. */
|
|
556
|
+
MAGNITUDE_UNKNOWN: 'magnitude-unknown',
|
|
557
|
+
/** Measured magnitude exceeded a light ceiling. */
|
|
558
|
+
OVER_CEILING: 'over-ceiling',
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Name the branch a commit-first refusal tells the agent to commit on, with a
|
|
563
|
+
* generic stand-in when the caller supplied none. Pure.
|
|
564
|
+
*
|
|
565
|
+
* @param {unknown} storyBranch
|
|
566
|
+
* @returns {string}
|
|
567
|
+
*/
|
|
568
|
+
function describeStoryBranch(storyBranch) {
|
|
569
|
+
const name = typeof storyBranch === 'string' ? storyBranch.trim() : '';
|
|
570
|
+
return name === '' ? 'the Story branch' : name;
|
|
571
|
+
}
|
|
572
|
+
|
|
527
573
|
/**
|
|
528
574
|
* Diff-derived backstop (Story #4740 AC-4, re-based on magnitude by Story
|
|
529
575
|
* #4856): re-check the **actual** change set after implementation, because the
|
|
@@ -555,7 +601,14 @@ export function resolveLightGateOutcome({
|
|
|
555
601
|
* ceilings?: { maxImplLines?: number, maxImplFiles?: number },
|
|
556
602
|
* injectedRules?: object,
|
|
557
603
|
* selectSensitivePathClassesFn?: Function,
|
|
558
|
-
*
|
|
604
|
+
* storyBranch?: string,
|
|
605
|
+
* uncommittedWork?: boolean,
|
|
606
|
+
* }} [args] `uncommittedWork` is the caller's dirty-worktree probe result: the
|
|
607
|
+
* backstop reads COMMITTED state, so an implemented-but-uncommitted run
|
|
608
|
+
* measures an empty diff, and the door for that is `git commit` — not an
|
|
609
|
+
* escalation. It only ever refines an enumerated-empty verdict's guidance;
|
|
610
|
+
* the verdict itself still blocks. `storyBranch` names the branch that
|
|
611
|
+
* guidance points at.
|
|
559
612
|
* @returns {{
|
|
560
613
|
* blocked: boolean,
|
|
561
614
|
* level: 'low'|'high'|null,
|
|
@@ -563,8 +616,10 @@ export function resolveLightGateOutcome({
|
|
|
563
616
|
* fileCount: number|null,
|
|
564
617
|
* magnitude: { implFiles: number, implLines: number }|null,
|
|
565
618
|
* ceilings: { maxImplLines: number, maxImplFiles: number },
|
|
619
|
+
* refusalClass: string|null,
|
|
566
620
|
* reasons: string[],
|
|
567
|
-
* }}
|
|
621
|
+
* }} `refusalClass` is `null` on a clean verdict and exactly one
|
|
622
|
+
* {@link LIGHT_REFUSAL_CLASSES} value on every blocked one.
|
|
568
623
|
*/
|
|
569
624
|
export function checkLightDiffBackstop({
|
|
570
625
|
changedFiles,
|
|
@@ -572,6 +627,8 @@ export function checkLightDiffBackstop({
|
|
|
572
627
|
ceilings,
|
|
573
628
|
injectedRules,
|
|
574
629
|
selectSensitivePathClassesFn,
|
|
630
|
+
storyBranch,
|
|
631
|
+
uncommittedWork = false,
|
|
575
632
|
} = {}) {
|
|
576
633
|
const resolved = resolveDiffCeilings(ceilings);
|
|
577
634
|
const files = Array.isArray(changedFiles)
|
|
@@ -579,6 +636,12 @@ export function checkLightDiffBackstop({
|
|
|
579
636
|
: null;
|
|
580
637
|
|
|
581
638
|
if (files === null || files.length === 0) {
|
|
639
|
+
// An ENUMERATED-empty diff over a dirty worktree is a different event from
|
|
640
|
+
// an unverifiable one, and blocking is right for both — but only one of
|
|
641
|
+
// them is about scope. The caller's probe distinguishes them; `files ===
|
|
642
|
+
// null` never can, because a `git diff` that failed outright is exactly
|
|
643
|
+
// the case where nothing about the change is known.
|
|
644
|
+
const uncommitted = files !== null && uncommittedWork === true;
|
|
582
645
|
return {
|
|
583
646
|
blocked: true,
|
|
584
647
|
level: null,
|
|
@@ -586,8 +649,13 @@ export function checkLightDiffBackstop({
|
|
|
586
649
|
fileCount: files === null ? null : 0,
|
|
587
650
|
magnitude: null,
|
|
588
651
|
ceilings: resolved,
|
|
652
|
+
refusalClass: uncommitted
|
|
653
|
+
? LIGHT_REFUSAL_CLASSES.UNCOMMITTED_WORK
|
|
654
|
+
: LIGHT_REFUSAL_CLASSES.CHANGE_SET_UNKNOWN,
|
|
589
655
|
reasons: [
|
|
590
|
-
|
|
656
|
+
uncommitted
|
|
657
|
+
? `the change set is empty but the worktree has uncommitted changes — commit them on ${describeStoryBranch(storyBranch)}, then re-run the backstop; nothing here is over-scope, so do NOT escalate to /mandrel-plan`
|
|
658
|
+
: 'actual change set is unknown or empty — cannot verify the diff is light; escalate to /mandrel-plan',
|
|
591
659
|
],
|
|
592
660
|
};
|
|
593
661
|
}
|
|
@@ -599,12 +667,12 @@ export function checkLightDiffBackstop({
|
|
|
599
667
|
});
|
|
600
668
|
const measured = normalizeMagnitude(magnitude);
|
|
601
669
|
|
|
602
|
-
const
|
|
670
|
+
const objections = [
|
|
603
671
|
...describeSensitivity({ level, classes }),
|
|
604
672
|
...describeMagnitude(measured, resolved),
|
|
605
673
|
];
|
|
606
674
|
|
|
607
|
-
const blocked =
|
|
675
|
+
const blocked = objections.length > 0;
|
|
608
676
|
return {
|
|
609
677
|
blocked,
|
|
610
678
|
level,
|
|
@@ -612,8 +680,13 @@ export function checkLightDiffBackstop({
|
|
|
612
680
|
fileCount: files.length,
|
|
613
681
|
magnitude: measured,
|
|
614
682
|
ceilings: resolved,
|
|
683
|
+
// Objection ORDER is the class precedence: sensitivity is derived before
|
|
684
|
+
// magnitude, so a diff that is both sensitive and over-ceiling files as a
|
|
685
|
+
// sensitive-path refusal. That is the right way round — the ceiling is
|
|
686
|
+
// recalibratable, the sensitive path is not.
|
|
687
|
+
refusalClass: blocked ? objections[0].refusalClass : null,
|
|
615
688
|
reasons: blocked
|
|
616
|
-
?
|
|
689
|
+
? objections.map((objection) => objection.reason)
|
|
617
690
|
: [
|
|
618
691
|
`diff is light: ${measured.implLines} implementation line(s) ≤ ${resolved.maxImplLines} ` +
|
|
619
692
|
`across ${measured.implFiles} implementation file(s) ≤ ${resolved.maxImplFiles} ` +
|
|
@@ -641,17 +714,24 @@ function normalizeMagnitude(magnitude) {
|
|
|
641
714
|
* Sensitivity objections, over the **full** change set. Pure.
|
|
642
715
|
*
|
|
643
716
|
* @param {{ level: 'low'|'high'|null, classes: string[] }} derived
|
|
644
|
-
* @returns {
|
|
717
|
+
* @returns {Objection[]}
|
|
645
718
|
*/
|
|
646
719
|
function describeSensitivity({ level, classes }) {
|
|
647
720
|
if (classes.length > 0) {
|
|
648
721
|
return [
|
|
649
|
-
|
|
722
|
+
{
|
|
723
|
+
refusalClass: LIGHT_REFUSAL_CLASSES.SENSITIVE_PATH,
|
|
724
|
+
reason: `diff intersects sensitive-path class(es) ${classes.join(', ')} — escalate to /mandrel-plan (do not land light)`,
|
|
725
|
+
},
|
|
650
726
|
];
|
|
651
727
|
}
|
|
652
728
|
if (level !== 'low') {
|
|
653
729
|
return [
|
|
654
|
-
|
|
730
|
+
{
|
|
731
|
+
refusalClass: LIGHT_REFUSAL_CLASSES.SENSITIVITY_UNKNOWN,
|
|
732
|
+
reason:
|
|
733
|
+
'sensitive-path classification unavailable — cannot verify the diff is non-sensitive; escalate to /mandrel-plan',
|
|
734
|
+
},
|
|
655
735
|
];
|
|
656
736
|
}
|
|
657
737
|
return [];
|
|
@@ -662,26 +742,32 @@ function describeSensitivity({ level, classes }) {
|
|
|
662
742
|
*
|
|
663
743
|
* @param {{ implFiles: number, implLines: number }|null} measured
|
|
664
744
|
* @param {{ maxImplLines: number, maxImplFiles: number }} ceilings
|
|
665
|
-
* @returns {
|
|
745
|
+
* @returns {Objection[]}
|
|
666
746
|
*/
|
|
667
747
|
function describeMagnitude(measured, ceilings) {
|
|
668
748
|
if (measured === null) {
|
|
669
749
|
return [
|
|
670
|
-
|
|
750
|
+
{
|
|
751
|
+
refusalClass: LIGHT_REFUSAL_CLASSES.MAGNITUDE_UNKNOWN,
|
|
752
|
+
reason:
|
|
753
|
+
'change magnitude could not be measured (unreadable or unparseable numstat) — cannot verify the diff is light; escalate to /mandrel-plan',
|
|
754
|
+
},
|
|
671
755
|
];
|
|
672
756
|
}
|
|
673
|
-
const
|
|
757
|
+
const objections = [];
|
|
674
758
|
if (measured.implLines > ceilings.maxImplLines) {
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
759
|
+
objections.push({
|
|
760
|
+
refusalClass: LIGHT_REFUSAL_CLASSES.OVER_CEILING,
|
|
761
|
+
reason: `diff changes ${measured.implLines} implementation line(s) (> maxImplLines ${ceilings.maxImplLines}) — escalate to /mandrel-plan (do not land light)`,
|
|
762
|
+
});
|
|
678
763
|
}
|
|
679
764
|
if (measured.implFiles > ceilings.maxImplFiles) {
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
765
|
+
objections.push({
|
|
766
|
+
refusalClass: LIGHT_REFUSAL_CLASSES.OVER_CEILING,
|
|
767
|
+
reason: `diff spans ${measured.implFiles} implementation file(s) (> maxImplFiles ${ceilings.maxImplFiles}) — escalate to /mandrel-plan (do not land light)`,
|
|
768
|
+
});
|
|
683
769
|
}
|
|
684
|
-
return
|
|
770
|
+
return objections;
|
|
685
771
|
}
|
|
686
772
|
|
|
687
773
|
/** Cap on a receipt slug's length — keep the branch/id readable. */
|
|
@@ -305,6 +305,39 @@ function fingerprintBucket(category, tools, detailKeys) {
|
|
|
305
305
|
.slice(0, 8);
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Every reason text one signal's `details` carries, read from BOTH shapes the
|
|
310
|
+
* emitters actually write.
|
|
311
|
+
*
|
|
312
|
+
* Two keys because two emitter conventions, and the divergence was silent: the
|
|
313
|
+
* degradation emitters write a singular `details.reason` string, while the
|
|
314
|
+
* light path's refusal emitter (`light-escalation.recordScopeFriction`) writes
|
|
315
|
+
* `details.reasons` — an **array**, because one backstop verdict can object on
|
|
316
|
+
* sensitivity and magnitude in the same pass. Reading only the singular key is
|
|
317
|
+
* why every `light-scope-rejected` follow-up rendered with no `Reason:` line
|
|
318
|
+
* at all (issue #5237), which defeated Story #4837's whole intent for that
|
|
319
|
+
* emitter: the filed issue named a count and a category, and the refusal text
|
|
320
|
+
* that would have told a reader which ceiling fired stayed in the ledger.
|
|
321
|
+
*
|
|
322
|
+
* Non-string members are skipped rather than coerced — `String(value)` would
|
|
323
|
+
* put `[object Object]` in a live issue body.
|
|
324
|
+
*
|
|
325
|
+
* @param {object} details
|
|
326
|
+
* @returns {string[]}
|
|
327
|
+
*/
|
|
328
|
+
function collectReasons(details) {
|
|
329
|
+
const out = [];
|
|
330
|
+
const single = asString(details.reason);
|
|
331
|
+
if (single.length > 0) out.push(single);
|
|
332
|
+
if (Array.isArray(details.reasons)) {
|
|
333
|
+
for (const raw of details.reasons) {
|
|
334
|
+
const text = asString(raw);
|
|
335
|
+
if (text.length > 0) out.push(text);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
|
|
308
341
|
/**
|
|
309
342
|
* Widen a bucket's first-to-last window to include one instant. A row with no
|
|
310
343
|
* usable `ts` widens nothing — it is counted in `total` but cannot date the
|
|
@@ -391,8 +424,9 @@ function aggregateByCategory(signals) {
|
|
|
391
424
|
// over keys, deliberately) is untouched by them.
|
|
392
425
|
const surface = asString(sig.details.surface);
|
|
393
426
|
if (surface.length > 0) entry.surfaces.add(surface);
|
|
394
|
-
const reason
|
|
395
|
-
|
|
427
|
+
for (const reason of collectReasons(sig.details)) {
|
|
428
|
+
entry.reasons.add(reason);
|
|
429
|
+
}
|
|
396
430
|
}
|
|
397
431
|
// An id that cannot be resolved to a real issue is tracked separately
|
|
398
432
|
// rather than dropped: it must never be counted or printed as recurrence
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/worktree-dirty.js — "does this branch's checkout carry
|
|
3
|
+
* uncommitted changes?" (Story #5238).
|
|
4
|
+
*
|
|
5
|
+
* Its own module because the light path's diff backstop is otherwise a pure
|
|
6
|
+
* join over two committed-state git reads, and this is the one question there
|
|
7
|
+
* that needs a third read of a *working tree*. Keeping it here leaves
|
|
8
|
+
* {@link module:lib/orchestration/light-backstop} the thin join it claims to
|
|
9
|
+
* be, and gives the probe its own place to be tested against every way a git
|
|
10
|
+
* read can fail.
|
|
11
|
+
*
|
|
12
|
+
* @module lib/orchestration/worktree-dirty
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { gitSpawn } from '../git-utils.js';
|
|
16
|
+
import { parseWorktreePorcelain } from '../worktree/inspector.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The stdout of a successful git read, or `null` when it cannot be trusted.
|
|
20
|
+
*
|
|
21
|
+
* @param {{ status?: number, stdout?: unknown }|null|undefined} result
|
|
22
|
+
* @returns {string|null}
|
|
23
|
+
*/
|
|
24
|
+
function readableStdout(result) {
|
|
25
|
+
if (result?.status !== 0) return null;
|
|
26
|
+
return typeof result.stdout === 'string' ? result.stdout : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Locate the checkout that has `branch` checked out, or `null` when no
|
|
31
|
+
* checkout does.
|
|
32
|
+
*
|
|
33
|
+
* `git worktree list --porcelain` enumerates **every** checkout including the
|
|
34
|
+
* main one, so a repository working on the branch directly (no separate
|
|
35
|
+
* worktree) is found by this same lookup rather than by a second fallback path.
|
|
36
|
+
*
|
|
37
|
+
* @param {{ branch: string, cwd: string, gitFn: typeof gitSpawn }} args
|
|
38
|
+
* @returns {string|null}
|
|
39
|
+
*/
|
|
40
|
+
function resolveBranchCheckout({ branch, cwd, gitFn }) {
|
|
41
|
+
const listed = readableStdout(gitFn(cwd, 'worktree', 'list', '--porcelain'));
|
|
42
|
+
if (listed === null) return null;
|
|
43
|
+
const match = parseWorktreePorcelain(listed).find(
|
|
44
|
+
(record) => record.branch === branch,
|
|
45
|
+
);
|
|
46
|
+
return match ? match.path : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Does the checkout holding `branch` have uncommitted changes?
|
|
51
|
+
*
|
|
52
|
+
* **Why the light path asks.** Its diff backstop measures `base...head`, which
|
|
53
|
+
* is committed state, so a run that implemented and did not commit measures an
|
|
54
|
+
* empty change set — and the refusal then told the agent its scope was
|
|
55
|
+
* unverifiable and to escalate, when the actual fix was `git commit`. Measured
|
|
56
|
+
* in the consumer: the refusal signal is stamped 12:14:06Z and the branch's
|
|
57
|
+
* only commit 12:15:19Z (issue #5237).
|
|
58
|
+
*
|
|
59
|
+
* Total, and deliberately asymmetric: every unreadable surface — a failed
|
|
60
|
+
* `worktree list`, a branch no checkout holds, a failed `status`, a throwing
|
|
61
|
+
* git — answers `false`. A probe that cannot see the tree must not be able to
|
|
62
|
+
* talk a refusal into friendlier guidance than the evidence supports.
|
|
63
|
+
*
|
|
64
|
+
* @param {{ branch: string, cwd?: string, gitFn?: typeof gitSpawn }} args
|
|
65
|
+
* @returns {boolean}
|
|
66
|
+
*/
|
|
67
|
+
export function hasUncommittedWork({
|
|
68
|
+
branch,
|
|
69
|
+
cwd = process.cwd(),
|
|
70
|
+
gitFn = gitSpawn,
|
|
71
|
+
} = {}) {
|
|
72
|
+
try {
|
|
73
|
+
const checkout = resolveBranchCheckout({ branch, cwd, gitFn });
|
|
74
|
+
if (checkout === null) return false;
|
|
75
|
+
const status = readableStdout(gitFn(checkout, 'status', '--porcelain'));
|
|
76
|
+
return status !== null && status.trim() !== '';
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -31,9 +31,9 @@ on a project with no rendered frontend, since there are no components, templates
|
|
|
31
31
|
or routes to hold to WCAG. See the `target` key's schema description for how
|
|
32
32
|
applicability is probed from the consumer's checkout.
|
|
33
33
|
|
|
34
|
-
##
|
|
34
|
+
## Boundaries with the neighbouring web lenses
|
|
35
35
|
|
|
36
|
-
These
|
|
36
|
+
These lenses share a border with this one and must not double-report:
|
|
37
37
|
|
|
38
38
|
- **`audit-accessibility` (this lens)** owns **WCAG conformance** — the
|
|
39
39
|
standards question: does an assistive-technology user perceive, operate, and
|
|
@@ -41,11 +41,17 @@ These two web lenses share a border and must not double-report:
|
|
|
41
41
|
controls, text alternatives, and contrast against the WCAG ratio thresholds.
|
|
42
42
|
- **`audit-ux-ui`** owns **design-system adherence** — the consistency
|
|
43
43
|
question: do components and tokens match the project's own design system?
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
- **`audit-mobile`** owns **small-screen and touch behaviour** — layout at a
|
|
45
|
+
phone or tablet viewport, touch ergonomics, responsive assets, and mobile
|
|
46
|
+
test coverage. It may measure a control's rendered size as ergonomic
|
|
47
|
+
evidence, but the `2.5.8 Target Size (Minimum)` verdict on an undersized
|
|
48
|
+
target is reported here.
|
|
49
|
+
|
|
50
|
+
Contrast is the one axis accessibility and ux-ui both touch: **accessibility
|
|
51
|
+
owns the WCAG ratio verdict** (4.5:1 body / 3:1 large text / 3:1 non-text),
|
|
52
|
+
while ux-ui owns whether the colour came from a sanctioned token. When a
|
|
53
|
+
contrast defect is in scope for both, report the WCAG failure here and leave
|
|
54
|
+
the token-adherence note to ux-ui.
|
|
49
55
|
|
|
50
56
|
## Scope
|
|
51
57
|
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Audit mobile and tablet UX — layout and viewport correctness, touch ergonomics, responsive assets, and whether anything actually verifies them at a small viewport (static-first, with an optional runtime viewport pass)
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Mobile & Tablet UX Audit
|
|
6
|
+
|
|
7
|
+
You are a Senior Mobile Web Engineer holding the frontend to its **small-screen
|
|
8
|
+
and touch contract**: does every surface lay out, scroll, and answer a finger on
|
|
9
|
+
a phone and a tablet — and does anything in the suite actually verify that it
|
|
10
|
+
does? Default to **static** detection over source; escalate to a **runtime**
|
|
11
|
+
viewport pass only when a live target is configured. The shared lens machinery —
|
|
12
|
+
read-only constraint, scope interpretation, report envelope + finding-block
|
|
13
|
+
skeleton, severity scale, self-cross-check, and execution strategy — lives in
|
|
14
|
+
[`helpers/audit-lens-core.md`](helpers/audit-lens-core.md). Write the report to
|
|
15
|
+
`{{auditOutputDir}}/audit-mobile-results.md`. Dimension values:
|
|
16
|
+
`Layout & Viewport | Touch Ergonomics | Responsive Assets | Mobile Verification`.
|
|
17
|
+
Extra finding field: **Evidence:** (`measured | static` + the observable;
|
|
18
|
+
single-run runtime numbers are tagged `provisional`).
|
|
19
|
+
The report adds a **Runtime Viewport Pass** section.
|
|
20
|
+
|
|
21
|
+
> **An emulated viewport is not a device.** Viewport emulation resizes and
|
|
22
|
+
> re-flows the page; it does not reproduce a real device's browser engine,
|
|
23
|
+
> input latency, font rendering, or OS chrome. Report what the emulator
|
|
24
|
+
> observed, never "verified on iPhone".
|
|
25
|
+
|
|
26
|
+
## Applicability
|
|
27
|
+
|
|
28
|
+
**Web targets only.** Registered with `target: "web"` in
|
|
29
|
+
[`audit-rules.json`](../schemas/audit-rules.json): the selector skips this lens
|
|
30
|
+
on a project with no rendered frontend, since there is no layout to re-flow and
|
|
31
|
+
no control to touch. See the `target` key's schema description for how
|
|
32
|
+
applicability is probed from the consumer's checkout.
|
|
33
|
+
|
|
34
|
+
## Boundaries with the neighbouring lenses
|
|
35
|
+
|
|
36
|
+
Four lenses border this one. Report a finding **here** only when it is a
|
|
37
|
+
small-screen or touch defect; defer the rest so the suite never double-reports:
|
|
38
|
+
|
|
39
|
+
- [`/audit-accessibility`](audit-accessibility.md) owns every **WCAG
|
|
40
|
+
success-criterion verdict**, including `2.5.8 Target Size (Minimum)`. This
|
|
41
|
+
lens may **measure** a control's rendered size as ergonomic evidence, but the
|
|
42
|
+
conformance verdict on an undersized target belongs there.
|
|
43
|
+
- [`/audit-ux-ui`](audit-ux-ui.md) owns **design-system adherence** — whether a
|
|
44
|
+
value came from a sanctioned token and whether a raw element should have
|
|
45
|
+
deferred to a design-system component. This lens asks only whether the result
|
|
46
|
+
works at a small viewport, whatever its provenance.
|
|
47
|
+
- [`/audit-performance`](audit-performance.md) owns **Core Web Vitals, bundle
|
|
48
|
+
weight, and network cost**, mobile ones included. An oversized hero image is
|
|
49
|
+
reported here only as a missing `srcset`/`sizes` **contract**, never as a
|
|
50
|
+
payload-weight verdict.
|
|
51
|
+
- [`/audit-quality`](audit-quality.md) owns the **generic** test verdicts —
|
|
52
|
+
pyramid balance, flake, and coverage gaps. This lens owns exactly one test
|
|
53
|
+
question: is any of it exercised at a non-desktop viewport, and does that
|
|
54
|
+
exercise assert something mobile-specific (Step 2).
|
|
55
|
+
|
|
56
|
+
## Scope
|
|
57
|
+
|
|
58
|
+
Interpret this lens's change-set fence per the core's Scope interpretation:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
{{changedFiles}}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Execution strategy
|
|
65
|
+
|
|
66
|
+
Run this lens as a single `subagent_type: auditor` dispatch returning the report
|
|
67
|
+
path + Executive Summary; sequential inline execution is the fallback (see the
|
|
68
|
+
core's Execution strategy).
|
|
69
|
+
|
|
70
|
+
## Step 0: Discover the responsive baseline (run first)
|
|
71
|
+
|
|
72
|
+
**You cannot audit responsiveness against a generic ideal — a 640px fixed width
|
|
73
|
+
is a defect only relative to the breakpoints the project actually claims to
|
|
74
|
+
support.** Before any detection, locate this lens's own sources of truth (they
|
|
75
|
+
are *not* ux-ui's design system, though they often live beside it) and record
|
|
76
|
+
what they declare:
|
|
77
|
+
|
|
78
|
+
- **Breakpoint scale:** the `screens` map in `tailwind.config.{js,ts}`, CSS
|
|
79
|
+
custom media / container queries, a `breakpoints` token file, or a
|
|
80
|
+
CSS-in-JS theme's media helpers. Census the `@media` / `container` queries
|
|
81
|
+
actually used in the stylesheets and note the narrowest one — that is the
|
|
82
|
+
smallest width the project has any evidence of supporting.
|
|
83
|
+
- **Viewport contract:** the `<meta name="viewport">` tag (or the framework
|
|
84
|
+
`viewport` export) and whether it sets `width=device-width` and leaves user
|
|
85
|
+
scaling enabled.
|
|
86
|
+
- **Declared device matrix:** any non-desktop viewport already configured in the
|
|
87
|
+
consumer's test tooling — Playwright `projects[]` using `devices[...]` or an
|
|
88
|
+
explicit `viewport`, a Cypress `viewportWidth`/`viewportHeight`, a
|
|
89
|
+
visual-regression viewport list. This is the project's own statement of which
|
|
90
|
+
form factors it holds itself to.
|
|
91
|
+
- **Runtime target (optional):** the `qa.environments` map (see
|
|
92
|
+
[*Runtime viewport pass*](#step-3-runtime-viewport-pass-optional-corroboration))
|
|
93
|
+
and the navigability route SSOT.
|
|
94
|
+
|
|
95
|
+
Record the breakpoints, the viewport contract, and the device matrix. Every
|
|
96
|
+
finding downstream is measured against *this discovered baseline*. If the
|
|
97
|
+
project declares **no** breakpoint scale and no device matrix, say so and
|
|
98
|
+
downgrade findings to "no responsive baseline declared — recommend establishing
|
|
99
|
+
a breakpoint scale and a phone/tablet test viewport first" rather than scoring
|
|
100
|
+
the tree against an invented one.
|
|
101
|
+
|
|
102
|
+
## Step 1: Static detection, then triage
|
|
103
|
+
|
|
104
|
+
Run the **mechanical detectors first** (cheap, deterministic greps over the
|
|
105
|
+
in-scope styles and components), then apply **LLM triage** to each candidate
|
|
106
|
+
against the Step 0 baseline — a mechanical hit is a *candidate*, not
|
|
107
|
+
automatically a finding.
|
|
108
|
+
|
|
109
|
+
### Layout & Viewport
|
|
110
|
+
|
|
111
|
+
- **Viewport meta:** absent `<meta name="viewport">`, a missing
|
|
112
|
+
`width=device-width`, or `user-scalable=no` / `maximum-scale=1` pinning the
|
|
113
|
+
page against pinch-zoom.
|
|
114
|
+
- **Fixed dimensions:** `width`/`min-width`/`height` px literals wider than the
|
|
115
|
+
narrowest declared breakpoint, outside token and container-query files — the
|
|
116
|
+
classic source of a page that cannot shrink.
|
|
117
|
+
- **Viewport-height units:** `100vh` (or `vh` arithmetic) with no `dvh`/`svh`
|
|
118
|
+
fallback, which cuts content off under a mobile browser's collapsing toolbar.
|
|
119
|
+
- **Horizontal overflow:** unconstrained wide content — tables, `<pre>` blocks,
|
|
120
|
+
code fences, flex rows with no `min-width: 0`, absolutely-positioned elements
|
|
121
|
+
extending past the viewport — and any `overflow-x: visible` on a container
|
|
122
|
+
holding them. A page whose body scrolls sideways on a phone is a defect
|
|
123
|
+
regardless of its cause.
|
|
124
|
+
- **Safe-area insets:** `position: fixed`/`sticky` elements pinned to a screen
|
|
125
|
+
edge (bottom bars, floating actions, drawers, modals) with no
|
|
126
|
+
`env(safe-area-inset-*)` allowance, so a notch or home indicator overlaps
|
|
127
|
+
them.
|
|
128
|
+
|
|
129
|
+
### Touch Ergonomics
|
|
130
|
+
|
|
131
|
+
- **Hover-only interaction:** a `:hover`/`hover:` state that reveals content or
|
|
132
|
+
is the only affordance for an action, with no touch-reachable equivalent
|
|
133
|
+
(a click/tap handler, a focus state, or an always-visible control). On a
|
|
134
|
+
touch device that interaction does not exist.
|
|
135
|
+
- **Control size and spacing:** interactive controls whose rendered box is
|
|
136
|
+
visibly under ~44×44 CSS px, or adjacent tap targets with no separating
|
|
137
|
+
spacing. Report the **measurement** as ergonomic evidence and leave the
|
|
138
|
+
WCAG `2.5.8` verdict to the accessibility lens.
|
|
139
|
+
- **Input ergonomics:** form inputs with a font-size under 16px (iOS Safari
|
|
140
|
+
zooms the whole page on focus), a missing or wrong `inputmode`/`type` for the
|
|
141
|
+
expected keyboard (numeric, email, tel), and `autocomplete` omitted on
|
|
142
|
+
identity or address fields where a small-screen user most needs it.
|
|
143
|
+
- **Gesture conflicts:** custom swipe/drag handlers that call
|
|
144
|
+
`preventDefault()` on `touchstart`/`touchmove` across a scrollable region, or
|
|
145
|
+
scroll containers nested inside a horizontal pager, which strand the user's
|
|
146
|
+
scroll.
|
|
147
|
+
|
|
148
|
+
### Responsive Assets
|
|
149
|
+
|
|
150
|
+
- **Images:** `<img>` with no `srcset`/`sizes` (or a framework image component
|
|
151
|
+
bypassed for a raw tag) where the same file serves every width; a missing
|
|
152
|
+
intrinsic `width`/`height` or `aspect-ratio`, which shifts the layout as
|
|
153
|
+
images land.
|
|
154
|
+
- **Media and embeds:** `<video>`, `<iframe>`, and map/chart embeds with fixed
|
|
155
|
+
pixel dimensions or no responsive container.
|
|
156
|
+
- **Typography and spacing:** a type or spacing scale with no small-viewport
|
|
157
|
+
step, so a desktop-tuned heading dominates a phone screen.
|
|
158
|
+
|
|
159
|
+
> **Detector output is candidates.** Triage each against the Step 0 baseline
|
|
160
|
+
> before promoting it to a finding — a fixed width inside a design-system
|
|
161
|
+
> primitive that its container query already re-flows, a `100vh` on a
|
|
162
|
+
> deliberately desktop-only admin surface, or a raw `<img>` for a fixed-size
|
|
163
|
+
> icon, is expected, not a defect.
|
|
164
|
+
|
|
165
|
+
## Step 2: Mobile verification coverage & effectiveness
|
|
166
|
+
|
|
167
|
+
A responsive surface with nothing holding it responsive regresses on the next
|
|
168
|
+
change. This lens owns that one test question, in two parts — report them as
|
|
169
|
+
separate findings, because the fixes differ:
|
|
170
|
+
|
|
171
|
+
1. **Coverage — is anything exercised at a non-desktop viewport?** Reconcile the
|
|
172
|
+
Step 0 device matrix against the suites that exist: an e2e config with only a
|
|
173
|
+
desktop project, a visual-regression suite with a single wide snapshot width,
|
|
174
|
+
or Gherkin features with no phone/tablet variant all mean the responsive
|
|
175
|
+
behaviour is unverified. Name the specific surfaces in scope that no
|
|
176
|
+
non-desktop run touches.
|
|
177
|
+
2. **Effectiveness — does that exercise assert anything mobile-specific?** A
|
|
178
|
+
suite that merely replays its desktop assertions at 390px wide proves the
|
|
179
|
+
page renders, not that it works. Look for assertions that could only pass on
|
|
180
|
+
a small viewport: the drawer or hamburger nav opening in place of the desktop
|
|
181
|
+
bar, the absence of horizontal document scroll, a bottom bar clearing the
|
|
182
|
+
safe area, an orientation change, a swipe or long-press gesture, a
|
|
183
|
+
viewport-conditional element being hidden or shown. A mobile project whose
|
|
184
|
+
assertions are viewport-agnostic is a **false-confidence** finding: it is
|
|
185
|
+
reported even though the suite is green, and it is usually more valuable than
|
|
186
|
+
a missing-coverage finding, because the project believes it is covered.
|
|
187
|
+
|
|
188
|
+
Keep the generic test verdicts out of this step — pyramid balance, flake, and
|
|
189
|
+
overall coverage gaps belong to [`/audit-quality`](audit-quality.md). Where a
|
|
190
|
+
fix is a new test, name the viewport and the assertion it should make, not just
|
|
191
|
+
"add mobile tests".
|
|
192
|
+
|
|
193
|
+
## Step 3: Runtime viewport pass (optional corroboration)
|
|
194
|
+
|
|
195
|
+
Static detection is the default and always runs. The runtime pass is
|
|
196
|
+
**conditional** — it runs only when a live target is configured; its absence
|
|
197
|
+
never blocks the static report.
|
|
198
|
+
|
|
199
|
+
1. **Resolve the target from config — never a hardcoded URL.** Resolve the
|
|
200
|
+
target through the consumer's `qa.environments.<env>.baseUrl` (via
|
|
201
|
+
[`resolveQaEnvironment`](../scripts/lib/qa/resolve-qa-contract.js), the same
|
|
202
|
+
resolver `/qa-run` uses): an `<env>` argument resolves by exact name or
|
|
203
|
+
origin match; with no argument, enumerate `name → baseUrl` and let the
|
|
204
|
+
operator pick. If **no** `qa.environments` target is configured, **skip this
|
|
205
|
+
step** and note in the report that runtime corroboration was unavailable —
|
|
206
|
+
do not invent a URL and do not start an arbitrary dev server.
|
|
207
|
+
2. **Sample routes from the navigability SSOT.** Draw the routes to exercise
|
|
208
|
+
from the consumer's route/nav registry (`planning.navigation.navRegistry` /
|
|
209
|
+
`routeGlobs` — the same SSOT [`/audit-navigability`](audit-navigability.md)
|
|
210
|
+
reads), sampling a representative set (key personas' landing routes plus any
|
|
211
|
+
route in the change-set scope) rather than a single hardcoded page.
|
|
212
|
+
3. **Drive two form factors per route.** Emulate a **phone** and a **tablet**
|
|
213
|
+
viewport — `mcp__chrome-devtools__emulate` for a device profile, or
|
|
214
|
+
`resize_page` for an explicit width/height — then, per route and viewport:
|
|
215
|
+
take a screenshot, and evaluate the two observables static analysis cannot
|
|
216
|
+
resolve — whether `document.scrollingElement.scrollWidth` exceeds the
|
|
217
|
+
viewport width (horizontal overflow), and the rendered box of the
|
|
218
|
+
interactive controls Step 1 flagged as candidates. Reload after switching
|
|
219
|
+
form factor so load-time device gates re-run.
|
|
220
|
+
4. **Median-of-3 or provisional.** Any runtime measurement is subject to
|
|
221
|
+
run-to-run variance: capture a **median-of-3** (three runs per route, report
|
|
222
|
+
the median) before treating a number as authoritative. A single-run value is
|
|
223
|
+
reported **provisional** and never drives a Critical/High verdict on its own.
|
|
224
|
+
5. **Leave the viewport as you found it.** Reset the emulation before finishing
|
|
225
|
+
so a following lens or QA run does not inherit a phone viewport.
|
|
226
|
+
|
|
227
|
+
Corroborate static findings against the runtime observations (a statically
|
|
228
|
+
flagged fixed width confirmed by a real horizontal overflow graduates from
|
|
229
|
+
provisional to confirmed), and surface runtime-only defects the static pass
|
|
230
|
+
could not see — an element clipped only once the toolbar collapses, a drawer
|
|
231
|
+
that opens off-screen.
|
|
232
|
+
|
|
233
|
+
## Report additions
|
|
234
|
+
|
|
235
|
+
Beyond the shared skeleton, the Executive Summary states the runtime mode's
|
|
236
|
+
status (ran against `<env>` / skipped — no target configured) and names the
|
|
237
|
+
narrowest breakpoint the project declares, so a reader can tell what "mobile"
|
|
238
|
+
meant for this run. The report ends with a **Runtime Viewport Pass** section:
|
|
239
|
+
per-route, per-form-factor observations when the runtime mode ran, or
|
|
240
|
+
"*Runtime corroboration unavailable — no `qa.environments` target configured.*"
|
|
241
|
+
Drop every claimed finding that names no concrete element, style rule, or test
|
|
242
|
+
file.
|
|
@@ -89,12 +89,17 @@ baseline — a mechanical hit is a *candidate*, not automatically a finding.
|
|
|
89
89
|
2. **Error States:** Are form errors clear and helpful, or generic and
|
|
90
90
|
frustrating?
|
|
91
91
|
3. **Loading States:** Are there skeletons or spinners for async operations?
|
|
92
|
-
4. **
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
contrast-ratio verdict are owned by [`/audit-accessibility`](audit-accessibility.md).
|
|
92
|
+
4. **Accessibility (UX-focused):** Focus on tab order and whether interaction
|
|
93
|
+
colours come from a sanctioned token. **WCAG conformance is out of scope
|
|
94
|
+
here** — semantic structure, ARIA correctness, keyboard/focus operability,
|
|
95
|
+
form labelling, media alternatives, and the WCAG contrast-ratio verdict are
|
|
96
|
+
owned by [`/audit-accessibility`](audit-accessibility.md).
|
|
98
97
|
This lens keeps token/component design-system adherence; defer every WCAG
|
|
99
98
|
success-criterion judgement to the accessibility lens so the two never
|
|
100
99
|
double-report.
|
|
100
|
+
|
|
101
|
+
> **Small-screen behaviour is out of scope here too.** Layout at a phone or
|
|
102
|
+
> tablet viewport, touch-target ergonomics, responsive assets, and whether any
|
|
103
|
+
> suite exercises a non-desktop viewport belong to [`/audit-mobile`](audit-mobile.md).
|
|
104
|
+
> This lens keeps the token/component question at whatever viewport the surface
|
|
105
|
+
> renders.
|
|
@@ -153,13 +153,19 @@ answer).
|
|
|
153
153
|
number that then rejects the change. They are **not** exempt from
|
|
154
154
|
sensitive-path matching, which runs over the full change set.
|
|
155
155
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
the
|
|
156
|
+
**Commit before you run it.** The backstop measures **committed** state, so
|
|
157
|
+
a run that implemented but has not committed measures an empty diff and is
|
|
158
|
+
refused for a scope it never had. That refusal names the real fix — commit
|
|
159
|
+
on `story-<id>`, then re-run the backstop — and its `nextCommand` is that
|
|
160
|
+
re-run, not an escalation.
|
|
161
|
+
|
|
162
|
+
Exit `3` (`blocked: true`) otherwise means the diff exceeds a light ceiling or
|
|
163
|
+
touches a sensitive-path class. STOP, flip `agent::blocked`, and **recycle the
|
|
164
|
+
receipt** through the envelope's `nextCommand` (`/mandrel-plan <storyId>`) —
|
|
165
|
+
tickets mode rewrites it into properly-planned Stories and closes it as
|
|
166
|
+
superseded. Do not land, and do not leave the receipt open with no successor:
|
|
167
|
+
it already carries the branch, the worktree, and the implementation, all of
|
|
168
|
+
which are evidence the plan should read.
|
|
163
169
|
|
|
164
170
|
5. **Close and land (same engine).** Exactly [`/mandrel-deliver`](../mandrel-deliver.md)'s close:
|
|
165
171
|
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,20 @@ All notable changes to this project will be documented in this file.
|
|
|
15
15
|
-->
|
|
16
16
|
<!-- markdownlint-disable-file MD004 MD012 MD037 -->
|
|
17
17
|
|
|
18
|
+
## [2.52.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.51.0...mandrel-v2.52.0) (2026-09-08)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
* render deliver-light refusal reasons in retro follow-ups and file each backstop refusal class as its own friction category ([#5238](https://github.com/dsj1984/mandrel/issues/5238)) ([#5239](https://github.com/dsj1984/mandrel/issues/5239)) ([65ff0ff](https://github.com/dsj1984/mandrel/commit/65ff0ff4252ddc4fe34d4d8453159a80afae6223))
|
|
24
|
+
|
|
25
|
+
## [2.51.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.50.0...mandrel-v2.51.0) (2026-09-08)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
* **audit:** add the /audit-mobile lens for mobile and tablet UX (refs [#5233](https://github.com/dsj1984/mandrel/issues/5233)) ([#5234](https://github.com/dsj1984/mandrel/issues/5234)) ([eccd505](https://github.com/dsj1984/mandrel/commit/eccd50527f04a25e02d50f57e1c6d760345bc9e8))
|
|
31
|
+
|
|
18
32
|
## [2.50.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.49.0...mandrel-v2.50.0) (2026-09-08)
|
|
19
33
|
|
|
20
34
|
|
package/package.json
CHANGED