From 37445ea741f32f8cffb5d3fe7acf2776be90f83b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 10:14:00 +0000 Subject: [PATCH 1/6] i18n: English for the strings the instrument prints, and the tests that read them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The level projection's path-to-next-level sentences, the plan's blocked reasons, the measurement outcome, the packet's blocked note and the retained-fixture reasons were Vietnamese while every other card line is English. Translated, with the four pair suites that match on them updated to the same words. Test inputs (alert texts, a prose mustNotMatch, the context quote pair) translated so each pair still tests what it named. Left as is: "Tiếng Việt" in the focus-visible fixtures and the run clip, which is a language picker naming the language in its own words. Not touched: uxcli.context.json and test/fixtures/crm/ — signed quotes and a fixture product, a decision for the owner. level, model, report and run pairs still cannot run here: they read .claude/specs/design/uxcli-data-v0.1/, which is gitignored. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011TUCUpbzmTpBE6ND7eHfzx --- src/core/level/projection.js | 26 +++++++++++++------------- src/core/promise/parse.js | 2 +- src/core/run/measure.js | 6 +++--- src/core/run/packet.js | 4 ++-- src/core/run/plan.js | 12 ++++++------ src/journey.js | 4 ++-- test/context-pairs.mjs | 8 ++++---- test/level-pairs.mjs | 4 ++-- test/measure-pairs.mjs | 2 +- test/model-pairs.mjs | 4 ++-- test/predicate-pairs.mjs | 6 +++--- test/run-pairs.mjs | 10 +++++----- 12 files changed, 44 insertions(+), 44 deletions(-) diff --git a/src/core/level/projection.js b/src/core/level/projection.js index 3496517..f47384e 100644 --- a/src/core/level/projection.js +++ b/src/core/level/projection.js @@ -16,9 +16,9 @@ export function insightStanding(i) { if (i.demotedAt || check?.fired === true) { return { confidence: 'hypothesis', demoted: day(i.demotedAt || check?.at), propagated: (i.propagatedTo || []).map(idOf) }; } - if (!evidence) return { confidence: 'hypothesis', why: 'không evidence' }; - if (!i.wouldChangeIf) return { confidence: 'low', why: 'không wouldChangeIf' }; - if (!check) return { confidence: 'medium', why: 'wouldChangeIf chưa từng kiểm' }; + if (!evidence) return { confidence: 'hypothesis', why: 'no evidence' }; + if (!i.wouldChangeIf) return { confidence: 'low', why: 'no wouldChangeIf' }; + if (!check) return { confidence: 'medium', why: 'wouldChangeIf never checked' }; return { confidence: 'high', lastCheck: day(check.at), fired: false }; } @@ -68,24 +68,24 @@ export function projection({ insights: insightList = [], journeys = [], commitme const corpusN = corpusLabels.length; const failedProbes = new Set(completed.flatMap(r => (r.probes || []).filter(p => ['fail', 'finding'].includes(p.value)).map(p => p.id))); for (const id of failedProbes) { - if (!fpMeasured(probes.find(p => p.id === id), corpusLabels, id)) toGate.push(`probe ${id}: fpRate chưa đo (${corpusN} corpus case, cần ≥ N) → chưa được fail`); + if (!fpMeasured(probes.find(p => p.id === id), corpusLabels, id)) toGate.push(`probe ${id}: fpRate not measured (${corpusN} corpus case${corpusN === 1 ? '' : 's'}, needs ≥ N) → may not fail yet`); } for (const c of commitments) { const n = unproven(c), total = (c.measurements || []).length; if (c.status === 'ACTIVE' && n) { - const label = n === total ? (total === 1 ? 'measurement' : total === 2 ? 'cả hai measurement' : `cả ${total} measurement`) : `${n}/${total} measurement`; - toGate.push(`${c.id}: ${label} method-unproven → chưa gate được`); + const label = n === total ? (total === 1 ? 'measurement' : total === 2 ? 'both measurements' : `all ${total} measurements`) : `${n}/${total} measurements`; + toGate.push(`${c.id}: ${label} method-unproven → cannot gate yet`); } // Blocking power is earned against ground truth: a commitment nobody has labeled a run for has no // false-positive rate, and a verdict with no measured error rate cannot gate. const labeled = corpusLabels.some(l => l.journey === c.scope?.journey && (!c.scope?.step || !l.step || l.step === c.scope.step)); - if (c.status === 'ACTIVE' && !n && !labeled) toGate.push(`${c.id}: chưa có nhãn corpus nào cho ${c.scope?.journey || '?'}${c.scope?.step ? '/' + c.scope.step : ''} → FP rate chưa đo, chưa gate được`); - if (c.status === 'RETIREMENT_PROPOSED' && !c.retirement?.decidedBy) toGate.push(`${c.id} RETIREMENT_PROPOSED từ ${day(c.retirement?.at)}, chưa ai quyết`); - if (standingC[c.id].reviewOverdue) toGate.push(`${c.id}: quá reviewAfter ${c.reviewAfter}, chưa ai xem → chỉ được finding`); - if (standingC[c.id].traceDemoted) toGate.push(`${c.id}: trace tới insight đã demote (${standingC[c.id].traceDemoted.join(', ')}) → chờ quyết`); + if (c.status === 'ACTIVE' && !n && !labeled) toGate.push(`${c.id}: no corpus label yet for ${c.scope?.journey || '?'}${c.scope?.step ? '/' + c.scope.step : ''} → FP rate not measured, cannot gate yet`); + if (c.status === 'RETIREMENT_PROPOSED' && !c.retirement?.decidedBy) toGate.push(`${c.id} RETIREMENT_PROPOSED since ${day(c.retirement?.at)}, nobody has decided`); + if (standingC[c.id].reviewOverdue) toGate.push(`${c.id}: past reviewAfter ${c.reviewAfter}, nobody has reviewed it → finding only`); + if (standingC[c.id].traceDemoted) toGate.push(`${c.id}: traces to a demoted insight (${standingC[c.id].traceDemoted.join(', ')}) → awaiting a decision`); } const trust = verified === 0 ? 'observe' : toGate.length ? 'verify' : 'gate'; - if (trust === 'observe') toGate.unshift('chưa có ACTIVE commitment nào với measurement method-validated được đo trên một run completed'); + if (trust === 'observe') toGate.unshift('no ACTIVE commitment yet with a method-validated measurement evaluated on a completed run'); // Reach axis: the most any completed run achieved, and what keeps the next environment shut. const reach = completed.map(r => r.reach?.effective).filter(Boolean).sort((a, b) => rankReach(b) - rankReach(a))[0] || 'observe'; @@ -93,10 +93,10 @@ export function projection({ insights: insightList = [], journeys = [], commitme const fixtureRuns = current.flatMap(r => (r.scenario?.fixtures || []).map(f => ({ ...f, ranAt: r.ranAt }))); for (const p of new Set(fixtureRuns.map(f => f.profile))) { const cleanups = fixtureRuns.filter(f => f.profile === p).map(f => f.cleanup); - if (!cleanups.includes('deleted')) toInject.push(`recoverability của ${p} unverified: cleanup chưa từng chạy sạch trên run fail`); + if (!cleanups.includes('deleted')) toInject.push(`recoverability of ${p} unverified: cleanup has never run clean on a failed run`); } for (const [env, e] of Object.entries(policy.environments || {})) { - if (rankReach(e.reachMax) > rankReach(reach) && !current.some(r => r.environment === env)) toInject.push(`environment ${env} chưa có run nào`); + if (rankReach(e.reachMax) > rankReach(reach) && !current.some(r => r.environment === env)) toInject.push(`environment ${env} has no run yet`); } const L = { observe: 'L1 — Observe', verify: 'L2 — Verify', gate: reach === ORDER[ORDER.length - 1] ? 'L4 — Gate + inject' : 'L3 — Gate' }[trust]; diff --git a/src/core/promise/parse.js b/src/core/promise/parse.js index 2dfd359..7e4a294 100644 --- a/src/core/promise/parse.js +++ b/src/core/promise/parse.js @@ -53,7 +53,7 @@ function parse0(file, J) { } // schema 1 — the signed promise of 2026-09-17-business-definitions.md §4. -// Two different failures, kept apart on purpose: a missing **buộc** key means the file is not a +// Two different failures, kept apart on purpose: a missing **required** key means the file is not a // promise at all and nothing may be measured from it; a missing signature means it is a proposal — // still a valid file, simply not one anyone may run. const REQUIRED = ['id', 'task', 'actor', 'entry', 'steps', 'done', 'owner', 'source']; diff --git a/src/core/run/measure.js b/src/core/run/measure.js index fa54e9c..2750c7d 100644 --- a/src/core/run/measure.js +++ b/src/core/run/measure.js @@ -36,13 +36,13 @@ export function outcomeOf(c, m, index, steps, ctx = {}) { const v = step.timing?.[p.field]; if (v == null) return { ...out, outcome: 'unmeasurable', cause: `${p.field} was not measured at ${step.id}` }; if (v >= CAP) return { ...out, outcome: 'unmeasurable', cause: `${p.field} never stabilized (≥ ${CAP}ms)` }; - if (!(v > p.gt)) return { ...out, outcome: 'condition-not-met', cause: `${p.field} ${v}ms ≤ ${p.gt}ms — điều kiện không xảy ra` }; + if (!(v > p.gt)) return { ...out, outcome: 'condition-not-met', cause: `${p.field} ${v}ms ≤ ${p.gt}ms — the condition did not occur` }; sig = p.then; } const r = holds({ signals: [sig] }, obs, ctx); if (r.held === true) return { ...out, outcome: 'held' }; if (r.held === null) return { ...out, outcome: 'unmeasurable', cause: r.why.join('; ') }; const d = sig.selector ? obs.dom?.[sig.selector] : null; - const scroll = d && sig.inViewportWithoutScroll && d.scrollsNeeded ? ` — cần ${d.scrollsNeeded} lần cuộn${ctx.viewport ? ` ở ${ctx.viewport}` : ''}` : ''; - return { ...out, outcome: 'not-held', what: `${r.why.join('; ')}${scroll} tại ${m.target}` }; + const scroll = d && sig.inViewportWithoutScroll && d.scrollsNeeded ? ` — needs ${d.scrollsNeeded} scroll${d.scrollsNeeded === 1 ? '' : 's'}${ctx.viewport ? ` at ${ctx.viewport}` : ''}` : ''; + return { ...out, outcome: 'not-held', what: `${r.why.join('; ')}${scroll} at ${m.target}` }; } diff --git a/src/core/run/packet.js b/src/core/run/packet.js index 6ed18a1..f1e938a 100644 --- a/src/core/run/packet.js +++ b/src/core/run/packet.js @@ -49,7 +49,7 @@ export function lineageOf(journey, steps) { for (const [k, h] of Object.entries(produced)) { const by = consumers[k] || []; if (by.length) out.push({ path: `${s.id}.${k}`, sha1_8: bare(h), consumedBy: by }); - else if (!declared[s.id]?.has(k)) out.push({ path: `${s.id}.${k}`, kept: false, why: 'không step nào consume' }); + else if (!declared[s.id]?.has(k)) out.push({ path: `${s.id}.${k}`, kept: false, why: 'no step consumes it' }); } } return out; @@ -100,7 +100,7 @@ export function packet({ id, journey, definitionHash, env, viewport, scenario, r breaches, status, exit: status === 'completed' ? 0 : 1, - ...(blocked && { blocked: { ...blocked, note: 'không probe nào chạy; không có verdict; không tính vào FP rate hay level' } }), + ...(blocked && { blocked: { ...blocked, note: 'no probe ran; no verdict; not counted toward the FP rate or the level' } }), steps: keep(steps, lineage), lineage, ...(ruleSnapshots && { ruleSnapshots }), diff --git a/src/core/run/plan.js b/src/core/run/plan.js index b0f8d09..7bb336d 100644 --- a/src/core/run/plan.js +++ b/src/core/run/plan.js @@ -30,22 +30,22 @@ export function plan(journey, policy, env, scenario, { prerequisites = [], workf if (identity) { const bad = Object.entries(identity.verified || {}).filter(([, ok]) => ok === false).map(([k]) => k); if (!identity.expiresAt) bad.push('expiresAt'); - if (bad.length) which.identity_rejected.push(`identity ${identity.profile || identity.mode}: ${bad.join(', ')} không xác minh được`); + if (bad.length) which.identity_rejected.push(`identity ${identity.profile || identity.mode}: ${bad.join(', ')} could not be verified`); } for (const step of wf.steps || []) { if (step.kind === 'fixture') { - if (!atLeast(eff, 'mutate')) fixtures.push(`${step.id} ${step.profile}: fixture cần mutate, effective reach là ${eff}`); + if (!atLeast(eff, 'mutate')) fixtures.push(`${step.id} ${step.profile}: fixture needs mutate, effective reach is ${eff}`); continue; } if (step.before && prerequisites.includes(step.before)) { if (journey.requires?.identityProfile && !identity) { - const why = policy?.testIdentity && !atLeast(eff, 'mutate') ? `${env} reach ${eff}, testIdentity không áp dụng` : 'không có identity để tạo state'; - which.prerequisite_not_satisfied.push(`${step.id}.before ${step.before}: không identity — ${why}`); + const why = policy?.testIdentity && !atLeast(eff, 'mutate') ? `${env} reach ${eff}, testIdentity does not apply` : 'no identity to produce the state'; + which.prerequisite_not_satisfied.push(`${step.id}.before ${step.before}: no identity — ${why}`); } else unverified.push(step.before); } const need = stepNeeds(wf, step); - if (!atLeast(eff, need)) which.reach_insufficient.push(`${wf.id}/${step.id}: cần ${need}, effective reach là ${eff}`); + if (!atLeast(eff, need)) which.reach_insufficient.push(`${wf.id}/${step.id}: needs ${need}, effective reach is ${eff}`); } which.prerequisite_not_satisfied.push(...fixtures); @@ -60,7 +60,7 @@ export function plan(journey, policy, env, scenario, { prerequisites = [], workf export function blockedAt({ prerequisites = [], identity = null, fixtures = [] } = {}) { const which = { prerequisite_not_satisfied: [ - ...prerequisites.filter(p => p.held === false).map(p => `${p.step}.before ${p.state}: không giữ${p.why?.length ? ' — ' + p.why.join('; ') : ''}`), + ...prerequisites.filter(p => p.held === false).map(p => `${p.step}.before ${p.state}: not held${p.why?.length ? ' — ' + p.why.join('; ') : ''}`), ...fixtures.filter(f => f.problems?.length).map(f => `${f.step} ${f.profile}: ${f.problems.join('; ')}`), ], identity_rejected: (identity?.rejected || []).map(r => `${r.field}: ${r.why}`), diff --git a/src/journey.js b/src/journey.js index eb2b60e..d0d099e 100644 --- a/src/journey.js +++ b/src/journey.js @@ -277,11 +277,11 @@ export async function runJourney(file, { env, origin, viewport = '390x844', out return finish(run, async r => { const retain = r.exit === 2; const expires = new Date(Date.now() + 48 * 3600e3).toISOString(); for (const f of [...fixtures].reverse()) { - if (retain) Object.assign(f.record, { cleanup: 'retained', retainedBecause: 'run có fail — fixture là bằng chứng', expiresAt: expires }); + if (retain) Object.assign(f.record, { cleanup: 'retained', retainedBecause: 'the run has a fail — the fixture is evidence', expiresAt: expires }); else { const c = cleanup(f.prof, f.values, { cwd: root, env: shell }); Object.assign(f.record, { cleanup: c.status, cleanupVerified: c.verified }); } } if (identity && idValues) { - if (retain) Object.assign(identity, { cleanupVerified: false, retained: true, retainedBecause: 'run có fail' }); + if (retain) Object.assign(identity, { cleanupVerified: false, retained: true, retainedBecause: 'the run has a fail' }); else identity.cleanupVerified = cleanup(idProfile.value, idValues, { cwd: root, env: shell }).verified; } return { ...r, scenario: { identity, fixtures: fixtures.map(f => f.record) } }; diff --git a/test/context-pairs.mjs b/test/context-pairs.mjs index b3059af..352cae4 100644 --- a/test/context-pairs.mjs +++ b/test/context-pairs.mjs @@ -2,7 +2,7 @@ import { readContext, standingOf, citable, citationOf, STANDINGS, FIELDS } from export const OPERATOR = 'a field quoting words that are really in the document, against the same field once the document no longer says them — which is what happened to this project\'s own declaration and which the old rule, asking only whether `source` was a non-empty string, reported as known; and the three honest ways a citation says less than that, none of which may be read as a mismatch: a source that is only a name, a document that could not be read, and a field nobody wrote.'; -const DOC = 'Trước khi nó được phép nói "xong", sản phẩm đang chạy\nphải được đối chiếu với những gì đã được hứa.'; +const DOC = 'Before it may say "done", the running product\nmust be checked against what was promised.'; const at = (text, tracked = true) => ({ 'spec.md': { found: true, text, tracked } }); const field = (source, value = 'x') => ({ value, source }); @@ -11,14 +11,14 @@ export function pair() { let checks = 0; const is = (got, want, what) => { checks++; if (got !== want) problems.push(`${what}: ${JSON.stringify(got)}, wanted ${JSON.stringify(want)}`); }; - const cited = field({ doc: 'spec.md', quote: 'phải được đối chiếu với những gì đã được hứa' }); + const cited = field({ doc: 'spec.md', quote: 'must be checked against what was promised' }); // The pair. Same declaration, same quote; only the document moves. is(standingOf(cited, at(DOC)).standing, 'quoted', 'a quote that is in the document'); - is(standingOf(cited, at('Tài liệu này đã được viết lại.')).standing, 'drifted', 'the document no longer says it'); + is(standingOf(cited, at('This document has been rewritten.')).standing, 'drifted', 'the document no longer says it'); // A quote is words, not bytes: a sentence re-wrapped when the file was edited is the same sentence. - is(standingOf(field({ doc: 'spec.md', quote: 'sản phẩm đang chạy phải được đối chiếu' }), at(DOC)).standing, + is(standingOf(field({ doc: 'spec.md', quote: 'the running product must be checked' }), at(DOC)).standing, 'quoted', 'a quote that spans a line break in the document'); // The three ways of saying less, each its own word. None of them is `drifted`: a citation that was diff --git a/test/level-pairs.mjs b/test/level-pairs.mjs index 8c1e2b2..1ab8d9e 100644 --- a/test/level-pairs.mjs +++ b/test/level-pairs.mjs @@ -46,12 +46,12 @@ export function pair() { must('a blocked run counted somewhere as a pass', onlyBlocked.level.trust === 'observe' && onlyBlocked.level.reach === 'observe' && onlyBlocked.rows.length === 1 && onlyBlocked.rows[0].status === 'blocked' && !('fails' in onlyBlocked.rows[0])); const validatedIn = edit('C-002', c => ({ ...c, measurements: c.measurements.map(m => ({ ...m, method: 'method-validated' })) })); const validated = projection(validatedIn); - must('validating C-002 did not turn its method sentence into a corpus sentence', !validated.level.toNext['trust → gate'].some(s => s.startsWith('C-002') && s.includes('method-unproven')) && validated.level.toNext['trust → gate'].some(s => s.startsWith('C-002') && s.includes('nhãn corpus')) && validated.level.trust === 'verify'); + must('validating C-002 did not turn its method sentence into a corpus sentence', !validated.level.toNext['trust → gate'].some(s => s.startsWith('C-002') && s.includes('method-unproven')) && validated.level.toNext['trust → gate'].some(s => s.startsWith('C-002') && s.includes('no corpus label')) && validated.level.trust === 'verify'); // Gate is earned against ground truth: only a corpus label on this commitment's step clears the last sentence. const labeled = projection({ ...validatedIn, corpusLabels: [...input.corpusLabels, { id: 'L-test', journey: 'handle-inbound-lead', step: 's1', groundTruth: 'pass', blind: true, labeledBy: [{ who: 'test' }] }] }); must('a corpus label on C-002\'s step did not clear its sentence', !labeled.level.toNext['trust → gate'].some(s => s.startsWith('C-002'))); const unlabeled = projection({ ...input, corpusLabels: [] }); - must('with no corpus label at all, C-001 (validated, measured) still had no corpus sentence', unlabeled.level.toNext['trust → gate'].some(s => s.startsWith('C-001') && s.includes('nhãn corpus'))); + must('with no corpus label at all, C-001 (validated, measured) still had no corpus sentence', unlabeled.level.toNext['trust → gate'].some(s => s.startsWith('C-001') && s.includes('no corpus label'))); const cleaned = projection({ ...input, runs: input.runs.map(r => r.run.id !== 'j-handle-inbound-lead' ? r : { ...r, run: { ...r.run, scenario: { ...r.run.scenario, fixtures: r.run.scenario.fixtures.map(f => ({ ...f, cleanup: 'deleted' })) } } }) }); must('a verified cleanup did not remove the recoverability sentence', !cleaned.level.toNext['reach → inject'].some(s => s.startsWith('recoverability'))); diff --git a/test/measure-pairs.mjs b/test/measure-pairs.mjs index 4261b05..4232439 100644 --- a/test/measure-pairs.mjs +++ b/test/measure-pairs.mjs @@ -23,7 +23,7 @@ export function pair() { must('a state met at two steps was not read at the scope step', o.where === 's2'); must('the before side was not preferred at the scope step', o.shot === 's2-before.png'); must('a button two scrolls down was not not-held', o.outcome === 'not-held'); - must('how far the button sits was not in the outcome', /2 lần cuộn ở 390x844/.test(o.what || '')); + must('how far the button sits was not in the outcome', /2 scrolls at 390x844/.test(o.what || '')); const held = outcomeOf({ ...c1, scope: { step: 's1' } }, c1.measurements[0], 0, steps, {}); must('a button in view at s1 was not held', held.outcome === 'held' && held.where === 's1'); must('prose predicate was not unmeasurable', outcomeOf(c1, { target: 'agent.lead_detail', predicate: 'in viewport' }, 0, steps).outcome === 'unmeasurable'); diff --git a/test/model-pairs.mjs b/test/model-pairs.mjs index 5216214..5fd4c32 100644 --- a/test/model-pairs.mjs +++ b/test/model-pairs.mjs @@ -83,7 +83,7 @@ export function pair() { must('discrimination check missed a mustNotMatch entry that is not even a sentence', discriminationProblems(j).filter(p => p.includes('is not a state name')).length === 2); j = clone(auth); j.states['anon.login_page'].mustNotMatch = ['anon.login_page']; must('discrimination check let a state exclude itself', discriminationProblems(j).some(p => p.includes('cannot be what it must not match'))); - j = clone(auth); j.states['anon.login_page'].mustNotMatch = ['khi đang tải']; + j = clone(auth); j.states['anon.login_page'].mustNotMatch = ['while loading']; must('discrimination check refused a prose entry, which the runtime leaves unchecked', discriminationProblems(j).length === 0); must('discrimination check objected to the example', discriminationProblems(auth).length === 0 && discriminationProblems(lead).length === 0); const pre = prerequisitesOf(lead); @@ -97,7 +97,7 @@ export function pair() { // Commitment — one owner, and a quote the document still says. let c = clone(c001); c.owner = [c.owner, { type: 'role', ref: 'engineering-lead' }]; must('commitment parser accepted two owners', says(parseCommitment(c, { docs }), 'owner: one signer, not a list')); - c = clone(c001); c.source.quote = 'hành động nằm rất xa dữ liệu đang xem'; + c = clone(c001); c.source.quote = 'the action sits far from the data being viewed'; must('commitment parser accepted a quote the document does not say', says(parseCommitment(c, { docs }), 'source.quote: understanding/actors/real-estate-agent.json no longer says')); c = clone(c001); delete c.source.quote; must('commitment parser accepted a document named without its words', says(parseCommitment(c, { docs }), 'source.quote: required')); diff --git a/test/predicate-pairs.mjs b/test/predicate-pairs.mjs index 6f7f174..0bc048b 100644 --- a/test/predicate-pairs.mjs +++ b/test/predicate-pairs.mjs @@ -25,7 +25,7 @@ const STATES = { 'agent.workspace_ready': { signals: [{ observer: 'url', path: '/workspace/{workspaceId}' }, { observer: 'network', request: 'GET /api/me', status: 200 }, { observer: 'dom', selector: '[data-uxcli=lead-list]', visible: true }], - strength: 'strong', mustMatch: ['/workspace/*'], mustNotMatch: ['/login', '/onboarding', '/workspace/* khi GET /api/me → 401'], + strength: 'strong', mustMatch: ['/workspace/*'], mustNotMatch: ['/login', '/onboarding', '/workspace/* when GET /api/me → 401'], }, 'anon.login_failed_retryable': { signals: [{ observer: 'a11y', role: 'alert', visible: true }, { observer: 'dom', selector: '[data-uxcli=login-submit]', enabled: true }, @@ -134,7 +134,7 @@ export function pair() { const ALERT = { signals: [{ observer: 'a11y', role: 'alert', visible: true }] }; is(holds(ALERT, { a11y: { alerts: [{ role: 'alert', text: '', visible: true }] } }).held, false, 'a mounted, empty alert region does not satisfy role=alert visible'); is(holds(ALERT, { a11y: { alerts: [{ role: 'alert', text: ' ', visible: true }] } }).held, false, 'whitespace is not an alert either'); - is(holds(ALERT, { a11y: { alerts: [{ role: 'alert', text: '', visible: true }, { role: 'alert', text: 'Mật khẩu không đúng', visible: true }] } }).held, true, 'the same page with a region that says something holds'); + is(holds(ALERT, { a11y: { alerts: [{ role: 'alert', text: '', visible: true }, { role: 'alert', text: 'Incorrect password', visible: true }] } }).held, true, 'the same page with a region that says something holds'); // Caps lower fail to finding, and touch nothing else. is(capFor({ method: 'method-validated', strength: 'strong' }), [], 'a validated method on a strong state is uncapped'); @@ -148,7 +148,7 @@ export function pair() { // The example run: runs/j-authenticate server-error/r1. After the intercepted 500 the alert shows, the // button is enabled, and the email field is empty — the state is not held and the email signal says why. const before = { url: { path: '/login' }, dom: { 'input[name=email]': { present: true, value: sha8('agent@mail.sink.local') } } }; - const after = emailValue => ({ url: { path: '/login' }, a11y: { alerts: [{ role: 'alert', text: 'Lỗi máy chủ, thử lại', visible: true }] }, + const after = emailValue => ({ url: { path: '/login' }, a11y: { alerts: [{ role: 'alert', text: 'Server error, try again', visible: true }] }, dom: { '[data-uxcli=login-submit]': { present: true, visible: true, enabled: true }, 'input[name=email]': { present: true, value: emailValue } }, network: [] }); const r1 = holds(STATES['anon.login_failed_retryable'], after(''), { before, hash }); is([r1.held, r1.strength], [false, 'medium'], 'server-error/r1: the state is not held'); diff --git a/test/run-pairs.mjs b/test/run-pairs.mjs index 35cb387..2383690 100644 --- a/test/run-pairs.mjs +++ b/test/run-pairs.mjs @@ -43,7 +43,7 @@ export function pair() { const okPlan = plan(lead, policy, 'staging', exLead.scenario, { prerequisites: ['agent.workspace_ready'] }); must('staging plan with identity did not come back ok', okPlan.status === 'ok' && eq(okPlan.unverified, ['agent.workspace_ready'])); const noIdPlan = plan(lead, policy, 'staging', { identity: null, fixtures: [] }, { prerequisites: ['agent.workspace_ready'] }); - must('staging without identity did not block on the prerequisite', noIdPlan.status === 'blocked' && noIdPlan.reason === 'prerequisite_not_satisfied' && noIdPlan.which.some(w => w.startsWith('f0 lead-new-unassigned: fixture cần mutate, effective reach là interact'))); + must('staging without identity did not block on the prerequisite', noIdPlan.status === 'blocked' && noIdPlan.reason === 'prerequisite_not_satisfied' && noIdPlan.which.some(w => w.startsWith('f0 lead-new-unassigned: fixture needs mutate, effective reach is interact'))); const rejected = plan(lead, policy, 'staging', { identity: { ...exLead.scenario.identity, verified: { ...exLead.scenario.identity.verified, tenant: false } }, fixtures: [] }, { prerequisites: [] }); must('an identity that failed tenant verification was not rejected', rejected.status === 'blocked' && rejected.reason === 'identity_rejected'); must('an intercept workflow under observe was not reach_insufficient', plan(auth, policy, 'production', { identity: null }, { workflow: 'server-error' }).reason === 'reach_insufficient'); @@ -98,16 +98,16 @@ export function pair() { // Constraints: false is a breach, null is recorded and nothing more. const cs = exLead.reach.constraints; - const broken = packet({ ...base, constraints: cs.map(c => c.id !== 'no-real-sms' ? c : { ...c, held: false, observed: '1 egress tới sms-provider' }) }); + const broken = packet({ ...base, constraints: cs.map(c => c.id !== 'no-real-sms' ? c : { ...c, held: false, observed: '1 egress to sms-provider' }) }); must('a constraint measured false did not abort with a breach', broken.status === 'aborted' && broken.breaches.some(b => b.constraint === 'no-real-sms' && b.rule === 'constraint-not-held')); const unknown = packet({ ...base, constraints: cs.map(c => c.id !== 'no-real-sms' ? c : { ...c, held: null }) }); must('a constraint that could not be observed was treated as a breach', unknown.status === 'completed' && unknown.breaches.length === 0 && unknown.reach.constraints.some(c => c.held === null)); // Runtime blocks, from the observer's own results. - must('a prerequisite measured false did not block', eq(blockedAt({ prerequisites: [{ step: 's1', state: 'agent.workspace_ready', held: false, why: ['GET /api/me → 401'] }] }), { reason: 'prerequisite_not_satisfied', which: ['s1.before agent.workspace_ready: không giữ — GET /api/me → 401'], fix: FIX.prerequisite_not_satisfied })); + must('a prerequisite measured false did not block', eq(blockedAt({ prerequisites: [{ step: 's1', state: 'agent.workspace_ready', held: false, why: ['GET /api/me → 401'] }] }), { reason: 'prerequisite_not_satisfied', which: ['s1.before agent.workspace_ready: not held — GET /api/me → 401'], fix: FIX.prerequisite_not_satisfied })); must('a prerequisite measured null blocked', blockedAt({ prerequisites: [{ step: 's1', state: 'agent.workspace_ready', held: null }] }) === null); - must('a rejected identity did not block as identity_rejected', eq(blockedAt({ identity: { rejected: [{ field: 'tenantId', why: 'không thuộc syntheticTenants' }] } }), { reason: 'identity_rejected', which: ['tenantId: không thuộc syntheticTenants'], fix: FIX.identity_rejected })); - must('a fixture derive() could not value did not block', eq(blockedAt({ fixtures: [{ step: 'f0', profile: 'lead-new-unassigned', problems: ['leads.responseSlaMinutes không có giá trị'] }] }), { reason: 'prerequisite_not_satisfied', which: ['f0 lead-new-unassigned: leads.responseSlaMinutes không có giá trị'], fix: FIX.prerequisite_not_satisfied })); + must('a rejected identity did not block as identity_rejected', eq(blockedAt({ identity: { rejected: [{ field: 'tenantId', why: 'not in syntheticTenants' }] } }), { reason: 'identity_rejected', which: ['tenantId: not in syntheticTenants'], fix: FIX.identity_rejected })); + must('a fixture derive() could not value did not block', eq(blockedAt({ fixtures: [{ step: 'f0', profile: 'lead-new-unassigned', problems: ['leads.responseSlaMinutes has no value'] }] }), { reason: 'prerequisite_not_satisfied', which: ['f0 lead-new-unassigned: leads.responseSlaMinutes has no value'], fix: FIX.prerequisite_not_satisfied })); // Effects from the observer: the class, never a blocked request. const net = [{ method: 'POST', path: '/api/calls', effectClass: 'database_write' }, { method: 'POST', path: '/api/login', effectClass: 'account_mutation', blocked: true, intercepted: { status: 500 } }, { method: 'GET', path: '/api/me' }]; From 2198ce718722f51c0573f9cdadbfa6351d957787 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:32:50 +0000 Subject: [PATCH 2/6] map: the muted ink reads at 4.5:1 on every surface; examples say what the code now says The gate's own-pages check failed on main: the map's `na` flag set --dim #6b7380 on --well #eef1f5, 4.22:1. --dim is now #5f6774, which keeps the hue and holds 4.8:1 or better on every light surface the page paints (white, well, bg, canvas, accent-soft). Dark theme untouched. The computed sentences in examples/crm/ and test/fixtures/runs/ (blocked notes, plan reasons, level path, measurement outcomes, retained-fixture reasons) are rewritten with the same English the code prints since the previous commit, so run and level pairs reproduce the example again. Authored text (journeys, insights, actors) is untouched. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011TUCUpbzmTpBE6ND7eHfzx --- examples/crm/.uxcli/index.json | 12 ++++++------ examples/crm/.uxcli/proposals/P-0003.json | 2 +- .../runs/R-2026-09-25T08-30-02Z-uzdc8z/run.json | 6 +++--- .../runs/R-2026-09-25T08-41-12Z-zs8fns/run.json | 8 ++++---- src/core/map.js | 2 +- test/fixtures/runs/crm-authenticate/run.json | 4 ++-- test/fixtures/runs/crm-handle-inbound-lead/run.json | 10 +++++----- 7 files changed, 22 insertions(+), 22 deletions(-) diff --git a/examples/crm/.uxcli/index.json b/examples/crm/.uxcli/index.json index cef5da2..d850fe5 100644 --- a/examples/crm/.uxcli/index.json +++ b/examples/crm/.uxcli/index.json @@ -7,13 +7,13 @@ "story": "L2 — Verify", "toNext": { "trust → gate": [ - "probe lead-phone-visible: fpRate chưa đo (1 corpus case, cần ≥ N) → chưa được fail", - "C-002: cả hai measurement method-unproven → chưa gate được", - "C-003 RETIREMENT_PROPOSED từ 2026-09-24, chưa ai quyết" + "probe lead-phone-visible: fpRate not measured (1 corpus case, needs ≥ N) → may not fail yet", + "C-002: both measurements method-unproven → cannot gate yet", + "C-003 RETIREMENT_PROPOSED since 2026-09-24, nobody has decided" ], "reach → inject": [ - "recoverability của lead-new-unassigned unverified: cleanup chưa từng chạy sạch trên run fail", - "environment local chưa có run nào" + "recoverability of lead-new-unassigned unverified: cleanup has never run clean on a failed run", + "environment local has no run yet" ] } }, @@ -33,7 +33,7 @@ }, "I-003": { "confidence": "hypothesis", - "why": "không evidence" + "why": "no evidence" } }, "commitments": { diff --git a/examples/crm/.uxcli/proposals/P-0003.json b/examples/crm/.uxcli/proposals/P-0003.json index 73c4b03..176ade1 100644 --- a/examples/crm/.uxcli/proposals/P-0003.json +++ b/examples/crm/.uxcli/proposals/P-0003.json @@ -6,7 +6,7 @@ "ref": "claude", "onBehalfOf": "chuongld" }, - "statement": "Cam kết: hành động Gọi khách trong viewport không cuộn tại agent.lead_detail", + "statement": "Cam kết: hành động Gọi khách trong viewport không cuộn at agent.lead_detail", "trace": [ "understanding/insights/I-001.json", "journeys/handle-inbound-lead.json#open-and-call/s2" diff --git a/examples/crm/.uxcli/runs/R-2026-09-25T08-30-02Z-uzdc8z/run.json b/examples/crm/.uxcli/runs/R-2026-09-25T08-30-02Z-uzdc8z/run.json index 3e797d2..82dd4a6 100644 --- a/examples/crm/.uxcli/runs/R-2026-09-25T08-30-02Z-uzdc8z/run.json +++ b/examples/crm/.uxcli/runs/R-2026-09-25T08-30-02Z-uzdc8z/run.json @@ -24,10 +24,10 @@ "blocked": { "reason": "prerequisite_not_satisfied", "which": [ - "s1.before agent.workspace_ready: không identity — production reach observe, testIdentity không áp dụng", - "f0 lead-new-unassigned: fixture cần mutate, effective reach là observe" + "s1.before agent.workspace_ready: no identity — production reach observe, testIdentity does not apply", + "f0 lead-new-unassigned: fixture needs mutate, effective reach is observe" ], - "note": "không probe nào chạy; không có verdict; không tính vào FP rate hay level" + "note": "no probe ran; no verdict; not counted toward the FP rate or the level" }, "steps": [], "verdicts": [] diff --git a/examples/crm/.uxcli/runs/R-2026-09-25T08-41-12Z-zs8fns/run.json b/examples/crm/.uxcli/runs/R-2026-09-25T08-41-12Z-zs8fns/run.json index a58d931..b75665f 100644 --- a/examples/crm/.uxcli/runs/R-2026-09-25T08-41-12Z-zs8fns/run.json +++ b/examples/crm/.uxcli/runs/R-2026-09-25T08-41-12Z-zs8fns/run.json @@ -41,7 +41,7 @@ "tenant": true }, "cleanup": "retained", - "retainedBecause": "run có fail — fixture là bằng chứng", + "retainedBecause": "the run has a fail — the fixture is evidence", "expiresAt": "2026-09-27T08:41:12Z" } ] @@ -238,7 +238,7 @@ { "path": "s1.response.internalScore", "kept": false, - "why": "không step nào consume" + "why": "no step consumes it" } ], "ruleSnapshots": { @@ -279,7 +279,7 @@ "value": "fail", "caps": [], "cause": "probe-said", - "what": "[data-uxcli=call-action] cần 2 lần cuộn ở 390×844 tại agent.lead_detail", + "what": "[data-uxcli=call-action] cần 2 lần cuộn ở 390×844 at agent.lead_detail", "where": "s2", "shot": "s2-before.png" }, @@ -288,7 +288,7 @@ "measurement": 0, "value": "not-applicable", "caps": [], - "cause": "toStable 611ms < 1000ms — điều kiện không xảy ra" + "cause": "toStable 611ms < 1000ms — the condition did not occur" }, { "commitment": "C-002", diff --git a/src/core/map.js b/src/core/map.js index cebb355..5de8876 100644 --- a/src/core/map.js +++ b/src/core/map.js @@ -82,7 +82,7 @@ export function mapPage(m) { ${esc(m.project.name)} · journey map

Experience

What the person goes through on each journey, from the trace of its last walk in Chrome. Every finding is method-unproven: reported with its source, never a fail and never a pass.

${sections || '

No journey run with steps on disk.

'}
Estimate: keystroke-level model (Card, Moran & Newell) — K ${KLM.K} s, P ${KLM.P} s, BB ${2 * KLM.B} s, H ${KLM.H} s, M ${KLM.M} s, each scroll one P, plus the measured settle time. Response bands after Nielsen (1993): ${LIMITS.instant} ms, ${LIMITS.flow} ms, ${LIMITS.attention} ms.
+`; + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + fs.writeFileSync(outFile, html); + return outFile; +} + +export const pageFile = root => path.join(path.resolve(root), UXCLI, 'experience', 'index.html'); diff --git a/src/journey.js b/src/journey.js index d0d099e..7d84131 100644 --- a/src/journey.js +++ b/src/journey.js @@ -6,7 +6,7 @@ // .uxcli/index.json recomputed from disk import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { launch } from './browser.js'; -import { observe, intercept, requestMatches, getPath } from './adapters/chrome/index.js'; +import { observe, intercept, requestMatches, getPath, sha1_8 } from './adapters/chrome/index.js'; import { provision, verifyIdentity, allowedPermissions, cleanup } from './adapters/provision/index.js'; import { parseJourney, prerequisitesOf } from './core/model/journey.js'; import { parseCommitment } from './core/model/commitment.js'; @@ -208,7 +208,9 @@ export async function runJourney(file, { env, origin, viewport = '390x844', out } if (!record) return true; const interactions = (step.interactions || []).map(x => { - if (x.type === 'ui') { const d = obsB.dom?.[fill(x.target, params)]; return { type: 'ui', target: x.target, ...(d && { inViewportWithoutScroll: d.inViewportWithoutScroll, scrollsNeeded: d.scrollsNeeded, viewport, ...(d.rect && { rect: d.rect }) }), ...(x.consumes && { consumed: x.consumes }) }; } + // A typed value is kept as its length and its hash, never as itself: enough to estimate the typing + // time and to see the same answer asked for twice, and nothing a reader of the packet could use. + if (x.type === 'ui') { const d = obsB.dom?.[fill(x.target, params)]; const v = x.fill !== undefined ? String(fill(x.fill, params)) : null; return { type: 'ui', target: x.target, ...(d && { inViewportWithoutScroll: d.inViewportWithoutScroll, scrollsNeeded: d.scrollsNeeded, viewport, ...(d.rect && { rect: d.rect }) }), ...(v !== null && { typed: { chars: v.length, value: sha1_8(v) } }), ...(x.consumes && { consumed: x.consumes }) }; } if (x.type === 'api') { const n = (obsA?.network || []).find(e => requestMatches(fill(x.request, params), e.method, e.path)); return { type: 'api', request: x.request, ...(n && { status: n.status, ms: n.ms, ...(n.effectClass && { effect: n.effectClass }), ...(n.requestHeaders?.['x-tenant-id'] && { tenantHeader: n.requestHeaders['x-tenant-id'] }), ...(n.blocked && { blocked: true, intercepted: n.intercepted }) }), ...(byInteraction.has(x) && { produced: byInteraction.get(x) }), ...(x.consumes && { consumed: x.consumes }) }; } return { type: x.type, ...(x.to && { to: x.to }), ...(x.expr && { expr: x.expr }), ...(byInteraction.has(x) && { produced: byInteraction.get(x) }), ...(x.consumes && { consumed: x.consumes }) }; }); diff --git a/src/suites.js b/src/suites.js index 586df6d..f026d4e 100644 --- a/src/suites.js +++ b/src/suites.js @@ -36,4 +36,5 @@ export const SUITES = [ { tag: 'page', title: 'the instrument measures its own pages', file: 'page-pairs.mjs', miss: 'seen' }, { tag: 'lens', title: 'a viewpoint has a name, a review answers all', file: 'lens-pairs.mjs', miss: 'refused' }, { tag: 'tmpl', title: 'a template asks, and apply only creates', file: 'template-pairs.mjs', miss: 'refused' }, + { tag: 'exp', title: 'what the person goes through, from the trace', file: 'experience-pairs.mjs', miss: 'seen' }, ]; diff --git a/test/experience-pairs.mjs b/test/experience-pairs.mjs new file mode 100644 index 0000000..72a052f --- /dev/null +++ b/test/experience-pairs.mjs @@ -0,0 +1,93 @@ +// Experience metrics read the trace of a walk. Each is a pair: a clean walk where it must stay silent, +// and the same walk with one thing a person would feel planted — a wait, a control below the fold, an +// answer asked for twice, a step that does not arrive, an extra step — where it must speak. The example +// project's last walk must surface the defect its own commitment C-001 is about. +import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { experience, compareExperience, KLM } from '../src/core/experience.js'; +import { experiences, experiencePage } from '../src/experience.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const clone = o => JSON.parse(JSON.stringify(o)); + +export const OPERATOR = 'a clean three-step walk with no finding, against the same walk made to settle in 2.4 s, to need its button two scrolls down, to ask for an email typed one step earlier, to miss the state a step must reach, and to take one more step; the estimate grows by the operators of the planted step and nothing else; a comparison of the two walks names what moved and which findings came and went; a fixture step is not a step the person takes; and the example project\'s last walk, whose call button C-001 says is reachable without scrolling, must report it two scrolls away'; + +const ui = (target, extra = {}) => ({ type: 'ui', target, inViewportWithoutScroll: true, scrollsNeeded: 0, viewport: '390x844', rect: { x: 20, y: 300, w: 350, h: 48, viewport: { w: 390, h: 844 } }, ...extra }); +const typed = (chars, value) => ({ typed: { chars, value } }); +const CLEAN = { + journey: { ref: 'journeys/checkout.json' }, ranAt: '2026-10-03T00:00:00Z', + steps: [ + { id: 'f0', kind: 'fixture', ms: 400 }, + { id: 's1', workflow: 'buy', action: 'type the email and continue', after: { state: 'details', held: true }, interactions: [ui('input[name=email]', typed(17, 'sha1_8:aaaa1111')), ui('[data-uxcli=continue]')], timing: { toStable: 80 }, shots: ['buy-s1-before.png', 'buy-s1-after.png'] }, + { id: 's2', workflow: 'buy', action: 'type the address and continue', after: { state: 'review', held: true }, interactions: [ui('input[name=address]', typed(24, 'sha1_8:bbbb2222')), ui('[data-uxcli=continue]')], timing: { toStable: 300 }, shots: ['buy-s2-before.png', 'buy-s2-after.png'] }, + { id: 's3', workflow: 'buy', action: 'place the order', after: { state: 'done', held: true }, interactions: [ui('[data-uxcli=place-order]')], timing: { toStable: 600 }, shots: ['buy-s3-before.png', 'buy-s3-after.png'] }, + ], +}; + +export async function pair() { + const problems = []; let checks = 0; + const must = (what, cond) => { checks++; if (!cond) problems.push(what); }; + const metrics = r => r.findings.map(f => f.metric); + + // must-pass: the clean walk + const clean = experience(CLEAN); + must(`the clean walk reported ${JSON.stringify(clean.findings)}`, clean.findings.length === 0); + must('a fixture step was counted as a step the person takes', clean.totals.steps === 3); + must('the clean totals are wrong', clean.totals.clicks === 3 && clean.totals.fields === 2 && clean.totals.chars === 41 && clean.totals.scrolls === 0 && clean.totals.reached === 3); + const click = KLM.M + KLM.P + 2 * KLM.B; const field = n => click + 2 * KLM.H + n * KLM.K; + const want = Math.round((field(17) + click + 0.08 + field(24) + click + 0.3 + click + 0.6) * 10) / 10; + must(`the estimate ${clean.totals.klmSeconds} s is not the operators' sum ${want} s`, Math.abs(clean.totals.klmSeconds - want) < 0.21); + must('a response band is wrong', clean.steps.map(s => s.response).join(' ') === 'instant flow flow'); + + // must-fail: each thing a person would feel, planted once + const slow = clone(CLEAN); slow.steps[3].timing.toStable = 2400; + must('a 2.4 s wait was not reported', metrics(experience(slow)).includes('response')); + const lost = clone(CLEAN); lost.steps[3].timing.toStable = 12000; + must('a 12 s wait was not reported as lost attention', experience(lost).findings.some(f => f.metric === 'response' && /10 s/.test(f.what))); + const far = clone(CLEAN); far.steps[3].interactions[0] = ui('[data-uxcli=place-order]', { inViewportWithoutScroll: false, scrollsNeeded: 2 }); + const farR = experience(far); + must('a button two scrolls down was not reported', farR.findings.some(f => f.metric === 'reach' && f.step === 's3' && /2 scrolls/.test(f.what) && f.rect)); + must('scrolls did not add to the estimate', farR.totals.klmSeconds > clean.totals.klmSeconds && farR.totals.scrolls === 2); + const twice = clone(CLEAN); twice.steps[2].interactions[0] = ui('input[name=confirm-email]', typed(17, 'sha1_8:aaaa1111')); + must('an answer typed twice was not reported', experience(twice).findings.some(f => f.metric === 'asked-twice' && f.step === 's2' && /s1/.test(f.what))); + const other = clone(twice); other.steps[2].workflow = 'another'; + must('the same value in another workflow was reported as asked twice', !metrics(experience(other)).includes('asked-twice')); + const miss = clone(CLEAN); miss.steps[3].after = { state: 'done', held: false, what: '[data-uxcli=order-number] visible: false' }; + must('a step that did not arrive was not reported', experience(miss).findings.some(f => f.metric === 'not-reached' && /order-number/.test(f.what))); + const longer = clone(CLEAN); longer.steps.splice(3, 0, { id: 's2b', workflow: 'buy', action: 'confirm the address', after: { state: 'confirmed', held: true }, interactions: [ui('[data-uxcli=confirm]')], timing: { toStable: 100 }, shots: [] }); + const longR = experience(longer); + must('an extra step did not add one step and one click', longR.totals.steps === 4 && longR.totals.clicks === 4); + must('an extra step did not add its operators to the estimate', Math.abs(longR.totals.klmSeconds - clean.totals.klmSeconds - (click + 0.1)) < 0.21); + + // alternatives are not a longer journey: a second workflow is totalled on its own + const alt = clone(CLEAN); alt.steps.push({ id: 'r1', workflow: 'card-declined', action: 'place the order with a declined card', after: { state: 'declined', held: true }, interactions: [ui('[data-uxcli=place-order]')], timing: { toStable: 200 }, shots: [] }); + const altR = experience(alt); + must('an alternative workflow was added to the happy path\'s totals', altR.totals.steps === 3 && altR.workflows.length === 2 && altR.workflows[1].totals.steps === 1); + // versions: the comparison names what moved + const cmp = compareExperience(clean, farR); + must('the comparison did not name the scrolls that appeared', cmp.totals.scrolls.delta === 2 && cmp.introduced.length === 1 && cmp.fixed.length === 0); + const back = compareExperience(farR, clean); + must('the comparison did not name the finding that went away', back.fixed.length === 1 && back.introduced.length === 0); + + // the example project: its last walk carries the defect C-001 is signed against + const ex = experiences(path.join(ROOT, 'examples', 'crm')); + const lead = ex.find(x => x.report.journey === 'handle-inbound-lead'); + must('the example\'s handle-inbound-lead walk was not found', !!lead); + must('the example\'s call button was not reported two scrolls away at 390x844', !!lead && lead.report.findings.some(f => f.metric === 'reach' && /call-action/.test(f.what) && /2 scrolls/.test(f.what) && /390x844/.test(f.what))); + + // the page: written where asked, one card per step, the pinned finding drawn as a box + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'uxcli-exp-')); + fs.mkdirSync(path.join(tmp, 'artifacts')); fs.writeFileSync(path.join(tmp, 'artifacts', 'buy-s3-before.png'), Buffer.from('89504e470d0a1a0a', 'hex')); + const out = experiencePage([{ dir: tmp, report: farR }], path.join(tmp, 'experience', 'index.html')); + const html = fs.readFileSync(out, 'utf8'); + must('the page does not carry one card per step', (html.match(/
Date: Sat, 3 Oct 2026 15:56:42 +0000 Subject: [PATCH 6/6] experience: recovery and consistency; versions: name a walk, compare two, propose a redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recovery. After a step the server answers with an error (4xx, 5xx or an intercepted failure), `uxcli experience` reports a typed answer the page no longer holds — a password excepted, the security convention — and an error nothing on the page announced in text. Consistency: within a workflow, a navigation landmark whose shared items change order (3.2.3). The runner records, per typed field, whether it still held the value afterwards and whether it is a password (never the value), the visible announcement roles after each action, and each navigation landmark's items; the observer reads input type and landmarks. Walked live on the CRM fixture it now reports exactly the planted defect on the server-error path (the email is cleared) and nothing on the wrong-password path. Versions. `uxcli version save [--note --run --proposal]` names one walk: .uxcli/versions//.json, one file per version, created and never rewritten, schema version.schema.json; the run it names is pinned against pruning. `uxcli experience --journey= --from= --to= [--page]` compares two versions step by step, with both pictures of each step side by side. Proposals take `kind: redesign`, citing the walk in evidence[]; references/versions.md is the loop. The CRM example carries v1 and v2. Gate: experience-pairs (30 checks) gains recovery and consistency pairs; version-pairs (22) — refused shapes, create-only save, a walk of another journey or of none refused, pruning keeps only what a version names, the example's v1→v2 comparison and its side-by-side page. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011TUCUpbzmTpBE6ND7eHfzx --- .claude/skills/uxcli/SKILL.md | 1 + .claude/skills/uxcli/references/versions.md | 43 +++++++++ CHANGELOG.md | 13 ++- README.md | 1 + ROADMAP.md | 11 +++ bin/uxcli.js | 19 +++- .../versions/handle-inbound-lead/v1.json | 9 ++ .../versions/handle-inbound-lead/v2.json | 9 ++ schemas/proposal.schema.json | 3 +- schemas/version.schema.json | 20 +++++ skills/uxcli/SKILL.md | 1 + skills/uxcli/references/versions.md | 43 +++++++++ src/adapters/chrome/observer.js | 13 ++- src/core/experience.js | 25 ++++++ src/core/model/version.js | 23 +++++ src/experience.js | 26 ++++-- src/journey.js | 11 ++- src/suites.js | 1 + src/version.js | 67 ++++++++++++++ test/experience-pairs.mjs | 23 ++++- test/version-pairs.mjs | 89 +++++++++++++++++++ 21 files changed, 435 insertions(+), 16 deletions(-) create mode 100644 .claude/skills/uxcli/references/versions.md create mode 100644 examples/crm/.uxcli/versions/handle-inbound-lead/v1.json create mode 100644 examples/crm/.uxcli/versions/handle-inbound-lead/v2.json create mode 100644 schemas/version.schema.json create mode 100644 skills/uxcli/references/versions.md create mode 100644 src/core/model/version.js create mode 100644 src/version.js create mode 100644 test/version-pairs.mjs diff --git a/.claude/skills/uxcli/SKILL.md b/.claude/skills/uxcli/SKILL.md index 418a047..d3e5577 100644 --- a/.claude/skills/uxcli/SKILL.md +++ b/.claude/skills/uxcli/SKILL.md @@ -101,6 +101,7 @@ Before you design or change a screen, you read `uxcli context show` for the jour | `references/journey.md` | the card says no journey, or a flow has changed shape: states as signals, hooks, workflows | | `references/mockups.md` | a screen the journey names has no mockup or no pick: variants, hooks, `pick.json` | | `references/understand.md` | the card says no understanding, or an insight the screen leans on is missing or a hypothesis | +| `references/versions.md` | a walk showed something worth changing: naming versions, a redesign proposal with evidence, comparing two versions | | `references/research.md` | you do not know the product's field from sources yet: where to look, what to come back with, how to write it down | | `templates/.md` | the product is a kind uxcli has a template for — `workspace`, `shop`, `landing`: screens and their lens, journeys to walk, what to research | | `lenses/.md` | before you draw or change a screen: the checklist for its kind of UI — `marketing`, `content`, `data`, `workspace`, `shop`, `transaction` | diff --git a/.claude/skills/uxcli/references/versions.md b/.claude/skills/uxcli/references/versions.md new file mode 100644 index 0000000..27e2f7a --- /dev/null +++ b/.claude/skills/uxcli/references/versions.md @@ -0,0 +1,43 @@ +# versions — proposing a better version, and showing it is better + +Read when: a walk of a journey showed something worth changing, or you are about to change a screen +that a journey goes through. A version names one walk so it is kept; a redesign proposal says what to +change and points at the evidence; the next version is compared with the last, step by step. + +## The loop + +```bash +npx -y @junixlabs/uxcli run .uxcli/journeys/.json # walk it +npx -y @junixlabs/uxcli experience --page # what the person goes through, pinned on screenshots +npx -y @junixlabs/uxcli version save v1 --note="as it is" # name that walk; it is kept against pruning +# write the redesign proposal (below); a person approves it; draw and build the change +npx -y @junixlabs/uxcli run .uxcli/journeys/.json +npx -y @junixlabs/uxcli version save v2 --note="call button above the fold" --proposal=proposals/P-0012.json +npx -y @junixlabs/uxcli experience --journey= --from=v1 --to=v2 --page # both pictures of every step, what moved +``` + +## A redesign proposal + +`.uxcli/proposals/P-xxxx.json` with `"kind": "redesign"`, `target` naming the journey and step +(`journeys/.json#`), `statement` saying what changes for the person, and `evidence[]` citing +what the walk showed — one entry per finding it answers: + +```json +{ "run": "runs/R-…", "check": "experience.reach", "observed": "[data-uxcli=call-action] 2 scrolls away at 390x844", "what": "the call button is below the fold on a phone" } +``` + +A proposal with no evidence is an opinion; cite the walk, the lens viewpoint, or the insight it rests +on. A person approves it before it is built. Name the version that answers it with `--proposal`. + +## Saying it is better + +Report the comparison in its own words: the steps, typing, scrolls and estimate that moved, the +findings that went away and the ones that appeared. A version with fewer findings and a higher +estimate is not simply better; say both. The metrics are findings, not verdicts, and nobody was +watched: a better walk is evidence for the change, not proof that people prefer it. + +## What you do not do + +- Rewrite or delete a version. A new walk gets a new name. +- Save a version of a walk you did not make on the final files. +- Call a change better without the comparison, or show only the numbers that improved. diff --git a/CHANGELOG.md b/CHANGELOG.md index b4bed49..adc2656 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,19 @@ Entries are written per release. The log is the source material, not the text. twice in one workflow, and a step that did not reach its state — every one a finding with the source of its threshold, never a fail. With an older walk on disk it says what moved since. `--page` writes `.uxcli/experience/index.html`: each step's screenshot with its findings pinned where the browser - found the element (a dashed bar at the bottom edge when it was below the fold). The runner now records + found the element (a dashed bar at the bottom edge when it was below the fold). + After an error answer (4xx, 5xx or an intercepted failure) it reports an answer the page cleared — + a password excepted, the security convention — and an error nothing announced in text; within a + workflow it reports a navigation landmark whose items change order (3.2.3). The runner records, per + step, whether each typed field still held the value afterwards, which announcement roles were + visible, and each navigation landmark's items; the observer reads the input type and the landmarks. The runner now records a typed value as its length and its hash, never the value. Gate: `experience-pairs`. +- `uxcli version [save ]` and `uxcli experience --journey= --from= --to=`: + a name for one walk of a journey, kept against pruning and comparable with another, step by step, + with both pictures side by side on the page. `.uxcli/versions//.json`, one file per + version, created and never rewritten (`schemas/version.schema.json`). Proposals take + `kind: redesign`, citing the walk in `evidence[]`; `references/versions.md` is the loop. Gate: + `version-pairs`. - `uxcli template [show | apply [dir]]`: templates for three kinds of product — `workspace`, `shop`, `landing` — with the screens such products have and the lens each is read against, the journeys to walk first and what to watch on them, the research questions and where research starts. diff --git a/README.md b/README.md index 743870d..00319c1 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,7 @@ uxcli lens [show ] # the shipped lenses: named designers' vie uxcli template [show | apply ] # where to start for a kind of product: screens, lenses, journeys, research questions uxcli review / --lens= --write # an empty review beside a drawing (a URL with --name reviews a screen) uxcli review check # every review complete, fresh, and not contradicted by a probe +uxcli version [save ] # name a walk so it is kept; experience --journey= --from= --to= compares two uxcli experience [dir] [--page] # what the person goes through: steps, typing, scrolls, waits, an estimate in seconds; findings pinned on screenshots uxcli map [dir] # the journey map page: MODEL · RUN · DIFF · IMPACT, step by step, with the evidence uxcli run # measure a journey --env --origin --viewport --json --out diff --git a/ROADMAP.md b/ROADMAP.md index f23298a..ec6f2f3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -49,6 +49,12 @@ Measured from the trace of a real walk in Chrome, each with a must-fail and a mu - **Consistency**: navigation, terms and the primary action in the same place across the journey's screens (3.2.3 generalised). +- Done 2026-10-03: `uxcli experience` reports steps, typing, scrolls, settle time in Nielsen's bands + and a keystroke-level estimate per workflow; a wait past 1 s, a control below the fold, an answer + typed twice, a step that did not arrive; after an error answer, a typed answer the page cleared + (passwords excepted) and an error nothing announced; a navigation landmark whose items change order + within a workflow. `--page` pins each on the step's screenshot. All findings; none validated yet. + Every finding is pinned on the step's screenshot. Exit: each metric has its pair in the gate, and on the CRM example a planted extra step, a missing progress signal and a lost value are each caught. @@ -59,6 +65,11 @@ the CRM example a planted extra step, a missing progress signal and a lost value - A version of a journey: its screens, metrics and findings at one build. A proposal for a new version names the evidence that caused it. - `map` compares two versions step by step: what changed on screen, and what the metrics did. +- Done 2026-10-03: `uxcli version save ` names a walk (one file per version, never + rewritten; its run kept against pruning); `experience --journey --from --to [--page]` compares two + versions step by step with both pictures side by side; proposals take `kind: redesign`, with + `evidence[]` citing the walk; `references/versions.md` is the loop. The trace and evidence shapes are + the run packet and the experience report; a schema of their own is next. - Exit: on the CRM example, v1 and v2 side by side, with the metrics that moved. ## 4. Walkthrough and personas diff --git a/bin/uxcli.js b/bin/uxcli.js index 7c2382b..eb93fc3 100755 --- a/bin/uxcli.js +++ b/bin/uxcli.js @@ -44,6 +44,7 @@ const usage = `usage: uxcli gate run every probe's falsification pair; exit 1 unless all hold uxcli lens [show ] the shipped lenses — named designers' viewpoints by kind of UI (marketing, content, data, workspace, shop, transaction) uxcli experience [dir|run] [--page] [--json] what the person goes through on each journey's last walk: steps, clicks, typing, scrolls, waits, a keystroke-level estimate, and what changed since the walk before; findings only, never a fail + uxcli version [save [--note=…] [--run=DIR] [--proposal=proposals/P-x.json]] name a walk of a journey so it is kept and can be compared: uxcli experience --journey= --from= --to= [--page] uxcli template [show | apply [dir]] where to start for a kind of product (workspace, shop, landing): screens and their lens, journeys to walk, what to research; apply writes the actor questions as unknowns, creating only uxcli review / --lens= --write an empty review beside the drawing; a URL with --name= reviews a screen uxcli review check every review complete, fresh against its drawing, and not contradicted by a probe @@ -140,11 +141,27 @@ try { process.exit(lib.problems.length || proj.problems.length ? 1 : 0); } else if (cmd === 'experience') { // What the person goes through on each journey's last walk: steps, typing, scrolls, waits, an estimate in seconds. - const E = await import('../src/experience.js'); const list = E.experiences(args[0] || '.'); + const E = await import('../src/experience.js'); let list; + if (opt('from') && opt('to')) { if (!opt('journey')) { console.error('--from and --to compare two versions of one journey: add --journey='); process.exit(1); } const c = await E.versionComparison(args[0] || '.', opt('journey'), opt('from'), opt('to')); if (c.problems) { console.error('uxcli: ' + c.problems.join('\n ')); process.exit(1); } list = [c.entry]; } + else list = E.experiences(args[0] || '.'); if (flags.has('--json')) console.log(JSON.stringify(list.map(x => ({ dir: x.dir, ...x.report, ...(x.change && { change: x.change }) })), null, 1)); else console.log(E.experienceCard(list)); if (flags.has('--page')) { const f = E.experiencePage(list, opt('out') || E.pageFile(args[0] && fs.existsSync(path.join(args[0], '.uxcli')) ? args[0] : '.')); console.log(` page ${path.relative(process.cwd(), f)}`); } process.exit(0); + } else if (cmd === 'version') { + // Named walks of a journey: kept against pruning, compared with experience --from --to. + const V = await import('../src/version.js'); const root = path.resolve(opt('src') || '.'); + if (args[0] === 'save' && args[1] && args[2]) { + let by = { type: 'person', ref: 'unknown' }; + try { const { execFileSync } = await import('node:child_process'); const n = execFileSync('git', ['config', 'user.name'], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); if (n) by = { type: 'person', ref: n }; } catch {} + if (opt('as')) by = { type: 'agent', ref: `${opt('as')}${opt('for') ? ` for ${opt('for')}` : ''}` }; + const r = V.saveVersion(root, args[1], args[2], { run: opt('run'), note: opt('note'), by, proposal: opt('proposal') }); + if (r.problems) { console.error('uxcli: ' + r.problems.join('\n ')); process.exit(1); } + console.log(`wrote ${path.relative(process.cwd(), r.file)} · ${r.value.journey}/${r.value.name} is ${r.value.run}, kept against pruning`); + process.exit(0); + } + console.log(flags.has('--json') ? JSON.stringify(V.versions(root), null, 1) : V.versionListCard(root)); + process.exit(V.versions(root).some(v => !v.value) ? 1 : 0); } else if (cmd === 'template') { // Where to start for a kind of product: screens and their lens, journeys to walk, what to research. const T = await import('../src/template.js'); const lib = T.templates(); diff --git a/examples/crm/.uxcli/versions/handle-inbound-lead/v1.json b/examples/crm/.uxcli/versions/handle-inbound-lead/v1.json new file mode 100644 index 0000000..39d3b05 --- /dev/null +++ b/examples/crm/.uxcli/versions/handle-inbound-lead/v1.json @@ -0,0 +1,9 @@ +{ + "schema_version": 1, + "journey": "handle-inbound-lead", + "name": "v1", + "run": "runs/R-2026-09-24T09-12-03Z-dujexe", + "at": "2026-09-24T09:30:00Z", + "by": { "type": "person", "ref": "example-owner" }, + "note": "First walk: open the lead from the list." +} diff --git a/examples/crm/.uxcli/versions/handle-inbound-lead/v2.json b/examples/crm/.uxcli/versions/handle-inbound-lead/v2.json new file mode 100644 index 0000000..86bf27f --- /dev/null +++ b/examples/crm/.uxcli/versions/handle-inbound-lead/v2.json @@ -0,0 +1,9 @@ +{ + "schema_version": 1, + "journey": "handle-inbound-lead", + "name": "v2", + "run": "runs/R-2026-09-25T08-41-12Z-zs8fns", + "at": "2026-09-25T09:00:00Z", + "by": { "type": "person", "ref": "example-owner" }, + "note": "The walk now seeds its own lead and goes on to the call." +} diff --git a/schemas/proposal.schema.json b/schemas/proposal.schema.json index 01ba81e..7498aab 100644 --- a/schemas/proposal.schema.json +++ b/schemas/proposal.schema.json @@ -24,7 +24,8 @@ "retirement", "profile", "journey", - "insight" + "insight", + "redesign" ] }, "proposedBy": { diff --git a/schemas/version.schema.json b/schemas/version.schema.json new file mode 100644 index 0000000..6e46cb1 --- /dev/null +++ b/schemas/version.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/junixlabs/uxcli/schemas/version.schema.json", + "title": "uxcli version", + "description": "A version of a journey — .uxcli/versions//.json: a name given to one walk of it, so it is kept against pruning and can be compared with another (uxcli experience --from --to). Created by uxcli version save, never edited.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "journey", "name", "run", "at", "by"], + "properties": { + "schema_version": { "const": 1 }, + "journey": { "type": "string", "minLength": 1 }, + "name": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" }, + "run": { "type": "string", "pattern": "^runs/R-[^/]+$", "description": "the walk this version is" }, + "at": { "type": "string", "minLength": 1 }, + "by": { "type": "object", "additionalProperties": false, "required": ["type", "ref"], "properties": { "type": { "type": "string", "minLength": 1 }, "ref": { "type": "string", "minLength": 1 } } }, + "note": { "type": "string", "minLength": 1, "description": "what changed in this version, in words" }, + "build": { "type": "string", "minLength": 1, "description": "the commit the walk ran against" }, + "proposal": { "type": "string", "minLength": 1, "description": "the redesign proposal this version answers" } + } +} diff --git a/skills/uxcli/SKILL.md b/skills/uxcli/SKILL.md index 418a047..d3e5577 100644 --- a/skills/uxcli/SKILL.md +++ b/skills/uxcli/SKILL.md @@ -101,6 +101,7 @@ Before you design or change a screen, you read `uxcli context show` for the jour | `references/journey.md` | the card says no journey, or a flow has changed shape: states as signals, hooks, workflows | | `references/mockups.md` | a screen the journey names has no mockup or no pick: variants, hooks, `pick.json` | | `references/understand.md` | the card says no understanding, or an insight the screen leans on is missing or a hypothesis | +| `references/versions.md` | a walk showed something worth changing: naming versions, a redesign proposal with evidence, comparing two versions | | `references/research.md` | you do not know the product's field from sources yet: where to look, what to come back with, how to write it down | | `templates/.md` | the product is a kind uxcli has a template for — `workspace`, `shop`, `landing`: screens and their lens, journeys to walk, what to research | | `lenses/.md` | before you draw or change a screen: the checklist for its kind of UI — `marketing`, `content`, `data`, `workspace`, `shop`, `transaction` | diff --git a/skills/uxcli/references/versions.md b/skills/uxcli/references/versions.md new file mode 100644 index 0000000..27e2f7a --- /dev/null +++ b/skills/uxcli/references/versions.md @@ -0,0 +1,43 @@ +# versions — proposing a better version, and showing it is better + +Read when: a walk of a journey showed something worth changing, or you are about to change a screen +that a journey goes through. A version names one walk so it is kept; a redesign proposal says what to +change and points at the evidence; the next version is compared with the last, step by step. + +## The loop + +```bash +npx -y @junixlabs/uxcli run .uxcli/journeys/.json # walk it +npx -y @junixlabs/uxcli experience --page # what the person goes through, pinned on screenshots +npx -y @junixlabs/uxcli version save v1 --note="as it is" # name that walk; it is kept against pruning +# write the redesign proposal (below); a person approves it; draw and build the change +npx -y @junixlabs/uxcli run .uxcli/journeys/.json +npx -y @junixlabs/uxcli version save v2 --note="call button above the fold" --proposal=proposals/P-0012.json +npx -y @junixlabs/uxcli experience --journey= --from=v1 --to=v2 --page # both pictures of every step, what moved +``` + +## A redesign proposal + +`.uxcli/proposals/P-xxxx.json` with `"kind": "redesign"`, `target` naming the journey and step +(`journeys/.json#`), `statement` saying what changes for the person, and `evidence[]` citing +what the walk showed — one entry per finding it answers: + +```json +{ "run": "runs/R-…", "check": "experience.reach", "observed": "[data-uxcli=call-action] 2 scrolls away at 390x844", "what": "the call button is below the fold on a phone" } +``` + +A proposal with no evidence is an opinion; cite the walk, the lens viewpoint, or the insight it rests +on. A person approves it before it is built. Name the version that answers it with `--proposal`. + +## Saying it is better + +Report the comparison in its own words: the steps, typing, scrolls and estimate that moved, the +findings that went away and the ones that appeared. A version with fewer findings and a higher +estimate is not simply better; say both. The metrics are findings, not verdicts, and nobody was +watched: a better walk is evidence for the change, not proof that people prefer it. + +## What you do not do + +- Rewrite or delete a version. A new walk gets a new name. +- Save a version of a walk you did not make on the final files. +- Call a change better without the comparison, or show only the numbers that improved. diff --git a/src/adapters/chrome/observer.js b/src/adapters/chrome/observer.js index c28c0d9..fc80997 100644 --- a/src/adapters/chrome/observer.js +++ b/src/adapters/chrome/observer.js @@ -78,7 +78,7 @@ const DOM = `(sels) => { const out = {}; for (const sel of sels) { let el = null; try { el = document.querySelector(sel); } catch {} - if (!el) { out[sel] = { present: false, visible: false, enabled: false, inViewportWithoutScroll: false, scrollsNeeded: null, text: null, value: null }; continue; } + if (!el) { out[sel] = { present: false, visible: false, enabled: false, inViewportWithoutScroll: false, scrollsNeeded: null, text: null, value: null, inputType: null }; continue; } const r = el.getBoundingClientRect(); const H = window.innerHeight, W = window.innerWidth; const inView = r.top >= 0 && r.left >= 0 && r.bottom <= H && r.right <= W; const scrollsNeeded = inView ? 0 : Math.ceil(Math.max(r.bottom - H, -r.top, 0) / H) || (r.right > W || r.left < 0 ? 1 : 0); @@ -87,7 +87,7 @@ const DOM = `(sels) => { // Where the element sits in the viewport at this moment, in CSS pixels: the same frame the // screenshot shows, so a picture can point at the hotspot — or at where it is, below the fold. rect: { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height), viewport: { w: W, h: H } }, - value: 'value' in el && el.tagName !== 'LI' ? String(el.value ?? '') : null }; + value: 'value' in el && el.tagName !== 'LI' ? String(el.value ?? '') : null, inputType: el.tagName === 'INPUT' ? (el.type || 'text') : null }; } return out; }`; @@ -96,6 +96,12 @@ const ALERTS = `() => [...document.querySelectorAll('[role=alert],[role=status], return { role: el.getAttribute('role') || 'live:' + el.getAttribute('aria-live'), text: (el.innerText ?? el.textContent ?? '').trim(), visible: !!el.getClientRects().length && cs.visibility !== 'hidden' && cs.display !== 'none' && parseFloat(cs.opacity) > 0 }; })`; +// Navigation landmarks as a person reads them: each nav's label and the text of its links and buttons, +// in order. Read so a journey's screens can be compared with each other, never to judge one alone. +const NAV = `() => [...document.querySelectorAll('nav,[role=navigation]')].filter(n => n.getClientRects().length).map(n => ({ + label: n.getAttribute('aria-label') || (n.getAttribute('aria-labelledby') && document.getElementById(n.getAttribute('aria-labelledby'))?.textContent?.trim()) || null, + items: [...n.querySelectorAll('a,button,[role=link],[role=menuitem]')].filter(x => x.getClientRects().length).map(x => (x.innerText ?? x.textContent ?? '').trim()).filter(Boolean), +}))`; const STORAGE = `(keys) => { const c = Object.fromEntries(document.cookie.split(';').map(x => x.trim().split('=')).filter(x => x[0]).map(([k, ...v]) => [k, v.join('=')])); const out = {}; for (const k of keys) { const v = localStorage.getItem(k) ?? sessionStorage.getItem(k) ?? c[k] ?? null; out[k] = v === null ? { present: false } : { present: true, value: v }; } @@ -130,6 +136,7 @@ export async function observe(page, opts = {}) { const href = page.url(); const dom = await page.evaluate(new Function('return ' + DOM)(), selectors).catch(() => ({})); const alerts = await page.evaluate(new Function('return ' + ALERTS)()).catch(() => []); + const nav = await page.evaluate(new Function('return ' + NAV)()).catch(() => []); const raw = await page.evaluate(new Function('return ' + STORAGE)(), storageKeys).catch(() => ({})); const storage = {}; for (const [k, v] of Object.entries(raw)) storage[k] = v.present ? { present: true, hash: sha1_8(v.value) } : { present: false }; @@ -155,5 +162,5 @@ export async function observe(page, opts = {}) { const file = `${shotName}.png`; await page.screenshot({ path: path.join(shotDir, file), timeout: 5000 }).then(() => shots.push(file)).catch(() => {}); } - return { at, url: { href, path: pathOf(href) }, dom, a11y: { alerts }, network, storage, timing: { toStable }, shots }; + return { at, url: { href, path: pathOf(href) }, dom, a11y: { alerts }, nav, network, storage, timing: { toStable }, shots }; } diff --git a/src/core/experience.js b/src/core/experience.js index 0faeaf9..c5918a8 100644 --- a/src/core/experience.js +++ b/src/core/experience.js @@ -20,6 +20,9 @@ const SRC = { reach: 'Steve Krug, Don\'t Make Me Think; NN/g on content below the fold — what a step needs is where the person already is', twice: 'WCAG 2.2 SC 3.3.7 Redundant Entry — information entered before is not asked for again in the same process', reached: 'the project\'s own journey: the state this step must reach', + kept: 'GOV.UK Design System, Error message — "Do not clear any form fields … Keep both passing and failing answers." (forms.keep-answers-after-an-error)', + announced: 'WCAG 2.2 SC 3.3.1 Error Identification and 4.1.3 Status Messages — an error is described to the person in text and announced', + consistent: 'WCAG 2.2 SC 3.2.3 Consistent Navigation — navigation repeated on several pages occurs in the same relative order', }; export const LIMITS = { instant: 100, flow: 1000, attention: 10000 }; @@ -46,6 +49,7 @@ export function experience(run) { const steps = []; const findings = []; const typedAt = new Map(); // hash of a typed value → the first step it was typed at const visited = new Map(); // a path navigated to → the first step that went there + const navAt = new Map(); // a navigation landmark → the first step it was seen at, and its items // A fixture step sets data up before the person arrives; it is not something the person goes through. for (const st of (run?.steps || []).filter(x => x.kind !== 'fixture')) { const ui = (st.interactions || []).filter(x => x.type === 'ui'); @@ -79,6 +83,27 @@ export function experience(run) { } if (st.after?.held === false) findings.push({ metric: 'not-reached', step: st.id, workflow: st.workflow || null, what: `${st.action || st.id} did not reach ${st.after.state}${st.after.what ? `: ${st.after.what}` : ''}`, source: SRC.reached, shot: shotAfter }); + // Recovery: a step the server answered with an error is where answers get lost and messages go missing. + const errored = (st.interactions || []).some(i => i.type === 'api' && (i.status >= 400 || i.blocked)); + if (errored) { + row.error = true; + // A password is cleared after a failed sign-in on purpose: the security exception GOV.UK and the + // platforms make, so it is not counted. + for (const x of typed) if (x.typed.keptAfter === false && !x.typed.secret) findings.push({ metric: 'recovery', step: st.id, workflow: st.workflow || null, + what: `after the error, ${x.target} no longer holds what the person typed — they have to type it again`, source: SRC.kept, shot: shotAfter, ...(x.rect && { rect: x.rect }) }); + if (Array.isArray(st.announced) && !st.announced.length) findings.push({ metric: 'recovery', step: st.id, workflow: st.workflow || null, + what: `${st.action || st.id}: the server answered with an error and nothing on the page announced it (no visible role=alert, role=status or live region with text)`, source: SRC.announced, shot: shotAfter }); + } + // Consistency: the same navigation, in the same order, on every screen of the workflow that shows it. + for (const [i, n] of (st.nav || []).entries()) { + const key = `${st.workflow}|${n.label || `nav ${i + 1}`}`; + const seen = navAt.get(key); + if (!seen) { navAt.set(key, { step: st.id, items: n.items }); continue; } + const common = n.items.filter(t => seen.items.includes(t)); + const order = seen.items.filter(t => common.includes(t)); + if (common.length > 1 && common.join('\u0000') !== order.join('\u0000')) findings.push({ metric: 'consistency', step: st.id, workflow: st.workflow || null, + what: `${n.label ? `the "${n.label}" navigation` : `navigation ${i + 1}`} reads ${common.join(' · ')} here and ${order.join(' · ')} at ${seen.step}`, source: SRC.consistent, shot: shotBefore }); + } for (const x of (st.interactions || []).filter(i => i.type === 'navigation' && i.to)) { const was = visited.get(`${st.workflow}|${x.to}`); if (was && was !== st.id) row.revisits = [...(row.revisits || []), { to: x.to, first: was }]; diff --git a/src/core/model/version.js b/src/core/model/version.js new file mode 100644 index 0000000..6cb4515 --- /dev/null +++ b/src/core/model/version.js @@ -0,0 +1,23 @@ +// A version of a journey: a name somebody gave to one walk of it, so that walk can be compared with +// another later and is never pruned. The walk is the evidence; the version is the label and why it +// was given. Pure: a parser in, { value, problems } out. +import { isStr, isObj } from './common.js'; + +const NAME = /^[a-z0-9][a-z0-9._-]*$/i; +const RUN = /^runs\/R-[^/]+$/; + +export function parseVersion(doc) { + const problems = []; + if (!isObj(doc)) return { value: null, problems: ['not an object'] }; + if (doc.schema_version !== 1) problems.push('schema_version must be 1'); + if (!isStr(doc.journey)) problems.push('journey: the journey id this is a version of'); + if (!isStr(doc.name) || !NAME.test(doc.name)) problems.push('name: letters, digits, dot, dash, underscore'); + if (!isStr(doc.run) || !RUN.test(doc.run)) problems.push('run: runs/R-… — the walk this version is'); + if (!isStr(doc.at)) problems.push('at: when it was named'); + if (!isObj(doc.by) || !isStr(doc.by.type) || !isStr(doc.by.ref)) problems.push('by: { type, ref } — who named it'); + if (doc.note !== undefined && !isStr(doc.note)) problems.push('note: what changed in this version, in words'); + if (doc.build !== undefined && !isStr(doc.build)) problems.push('build: the commit the walk ran against'); + if (doc.proposal !== undefined && !isStr(doc.proposal)) problems.push('proposal: the proposal this version answers'); + for (const k of Object.keys(doc)) if (!['schema_version', 'journey', 'name', 'run', 'at', 'by', 'note', 'build', 'proposal'].includes(k)) problems.push(`unknown key "${k}"`); + return { value: problems.length ? null : { journey: doc.journey, name: doc.name, run: doc.run, at: doc.at, by: doc.by, note: doc.note || null, build: doc.build || null, proposal: doc.proposal || null }, problems }; +} diff --git a/src/experience.js b/src/experience.js index 6272333..9d74ad9 100644 --- a/src/experience.js +++ b/src/experience.js @@ -24,6 +24,17 @@ export function experiences(target = '.') { }); } +// Two named versions of one journey, as one entry: the later walk, with the earlier as `previous`. +export async function versionComparison(root, journey, from, to) { + const { versionRun } = await import('./version.js'); + const a = versionRun(path.resolve(root), journey, from); const b = versionRun(path.resolve(root), journey, to); + const problems = []; + for (const [n, v] of [[from, a], [to, b]]) if (!v) problems.push(`no version ${journey}/${n} under .uxcli/versions/`); else if (!v.run) problems.push(`version ${journey}/${n} names ${v.version.run}, which is no longer on disk`); + if (problems.length) return { problems }; + const before = experience(a.run); const now = experience(b.run); + return { entry: { dir: b.dir, report: now, labels: { from, to }, previous: { dir: a.dir, report: before }, change: compareExperience(before, now) } }; +} + const secs = n => `${n.toFixed(1)} s`; const signed = n => (n > 0 ? '+' : '') + (Number.isInteger(n) ? n : n.toFixed(1)); @@ -40,7 +51,7 @@ export function experienceCard(list) { } if (x.change) { const c = x.change.totals; - L.push(` since the walk before: steps ${signed(c.steps.delta)} · clicks ${signed(c.clicks.delta)} · characters ${signed(c.chars.delta)} · scrolls ${signed(c.scrolls.delta)} · estimate ${signed(c.klmSeconds.delta)} s`); + L.push(` ${x.labels ? `${x.labels.from} → ${x.labels.to}` : 'since the walk before'}: steps ${signed(c.steps.delta)} · clicks ${signed(c.clicks.delta)} · characters ${signed(c.chars.delta)} · scrolls ${signed(c.scrolls.delta)} · estimate ${signed(c.klmSeconds.delta)} s`); for (const f of x.change.fixed) L.push(` gone ${f.metric} at ${f.step}: ${f.what}`); for (const f of x.change.introduced) L.push(` new ${f.metric} at ${f.step}: ${f.what}`); } @@ -61,17 +72,20 @@ export function experiencePage(list, outFile) { const rel = (dir, shot) => { if (!shot) return null; const a = path.join(artifactsDir(dir), shot); const f = fs.existsSync(a) ? a : path.join(dir, shot); return fs.existsSync(f) ? path.relative(path.dirname(outFile), f).split(path.sep).join('/') : null; }; const sections = list.map(x => { const r = x.report; const t = r.totals; - const change = x.change ? `

Since the walk before: ${['steps', 'clicks', 'chars', 'scrolls'].map(k => `${k} ${signed(x.change.totals[k].delta)}`).join(' · ')} · estimate ${signed(x.change.totals.klmSeconds.delta)} s${x.change.fixed.length ? ` · ${x.change.fixed.length} finding${x.change.fixed.length === 1 ? '' : 's'} gone` : ''}${x.change.introduced.length ? ` · ${x.change.introduced.length} new` : ''}

` : ''; + const change = x.change ? `

${x.labels ? `${esc(x.labels.from)} → ${esc(x.labels.to)}` : 'Since the walk before'}: ${['steps', 'clicks', 'chars', 'scrolls'].map(k => `${k} ${signed(x.change.totals[k].delta)}`).join(' · ')} · estimate ${signed(x.change.totals.klmSeconds.delta)} s${x.change.fixed.length ? ` · ${x.change.fixed.length} finding${x.change.fixed.length === 1 ? '' : 's'} gone` : ''}${x.change.introduced.length ? ` · ${x.change.introduced.length} new` : ''}

` : ''; const cards = r.steps.map(s => { const fs_ = r.findings.filter(f => f.step === s.id && f.workflow === s.workflow); const pinned = fs_.filter(f => f.rect); const shot = rel(x.dir, pinned.length ? s.shots.before : (s.shots.after || s.shots.before)); const boxes = pinned.map((f, i) => `${i + 1}`).join(''); const pic = shot ? `
${esc(s.action || s.id)}, ${pinned.length ? 'before the action' : 'after the action'}${boxes}
` : '
no screenshot for this step
'; + const prevStep = x.labels && x.previous ? x.previous.report.steps.find(p => p.id === s.id && p.workflow === s.workflow) : null; + const prevShot = prevStep ? rel(x.previous.dir, prevStep.shots.after || prevStep.shots.before) : null; + const pair = x.labels ? `
${esc(x.labels.from)}${prevStep ? ` · ${prevStep.klmSeconds} s` : ' · not in this version'}
${prevShot ? `
${esc(s.action || s.id)} in ${esc(x.labels.from)}
` : '
—
'}
${esc(x.labels.to)} · ${s.klmSeconds} s
${pic}
` : pic; const list_ = fs_.length ? `
    ${fs_.map(f => `
  1. ${esc(f.metric)} ${esc(f.what)}${esc(f.source)}
  2. `).join('')}
` : '

No finding on this step.

'; - return `
${esc(r.workflows.length > 1 ? `${s.workflow}/${s.id}` : s.id)}

${esc(s.action || s.id)}

${pic}
Estimate
${s.klmSeconds} s
Settled
${s.settleMs ?? '–'} ms · ${esc(s.response || '–')}
Clicks
${s.clicks}
Typed
${s.fields} fields · ${s.chars} chars
Scrolls
${s.scrolls}
${list_}
`; + return `
${esc(r.workflows.length > 1 ? `${s.workflow}/${s.id}` : s.id)}

${esc(s.action || s.id)}

${pair}
Estimate
${s.klmSeconds} s
Settled
${s.settleMs ?? '–'} ms · ${esc(s.response || '–')}
Clicks
${s.clicks}
Typed
${s.fields} fields · ${s.chars} chars
Scrolls
${s.scrolls}
${list_}
`; }).join(''); - return `

${esc(r.journey || 'journey')}

${esc(r.viewport || '')} · ${esc(r.ranAt || '')} · ${t.steps} step${t.steps === 1 ? '' : 's'} · ${t.clicks} click${t.clicks === 1 ? '' : 's'} · ${t.chars} characters typed · ${t.scrolls} scroll${t.scrolls === 1 ? '' : 's'} · about ${t.klmSeconds} s for a practised user${r.workflows.length > 1 ? ` on ${esc(r.workflows[0].id)}, ${r.workflows.length - 1} other workflow${r.workflows.length === 2 ? '' : 's'} below` : ''} · ${r.findings.length} finding${r.findings.length === 1 ? '' : 's'}

${change}
${cards}
`; + return `

${esc(r.journey || 'journey')}${x.labels ? ` · ${esc(x.labels.from)} → ${esc(x.labels.to)}` : ''}

${esc(r.viewport || '')} · ${esc(r.ranAt || '')} · ${t.steps} step${t.steps === 1 ? '' : 's'} · ${t.clicks} click${t.clicks === 1 ? '' : 's'} · ${t.chars} characters typed · ${t.scrolls} scroll${t.scrolls === 1 ? '' : 's'} · about ${t.klmSeconds} s for a practised user${r.workflows.length > 1 ? ` on ${esc(r.workflows[0].id)}, ${r.workflows.length - 1} other workflow${r.workflows.length === 2 ? '' : 's'} below` : ''} · ${r.findings.length} finding${r.findings.length === 1 ? '' : 's'}

${change}
${cards}
`; }).join(''); const html = `uxcli experience