@tangle-network/browser-agent-driver 0.28.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/dist/brain/index.d.ts +15 -1
- package/dist/brain/index.d.ts.map +1 -1
- package/dist/brain/index.js +155 -14
- package/dist/brain/index.js.map +1 -1
- package/dist/cli-design-audit.d.ts +4 -0
- package/dist/cli-design-audit.d.ts.map +1 -1
- package/dist/cli-design-audit.js +144 -7
- package/dist/cli-design-audit.js.map +1 -1
- package/dist/cli-snapshot.d.ts +44 -0
- package/dist/cli-snapshot.d.ts.map +1 -0
- package/dist/cli-snapshot.js +122 -0
- package/dist/cli-snapshot.js.map +1 -0
- package/dist/cli-ui.d.ts.map +1 -1
- package/dist/cli-ui.js +15 -1
- package/dist/cli-ui.js.map +1 -1
- package/dist/cli.js +49 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +4 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/design/audit/evaluate.d.ts +69 -0
- package/dist/design/audit/evaluate.d.ts.map +1 -1
- package/dist/design/audit/evaluate.js +414 -55
- package/dist/design/audit/evaluate.js.map +1 -1
- package/dist/design/audit/pipeline.d.ts +14 -0
- package/dist/design/audit/pipeline.d.ts.map +1 -1
- package/dist/design/audit/pipeline.js +67 -2
- package/dist/design/audit/pipeline.js.map +1 -1
- package/dist/design/audit/rubric/fragments/universal-product-intent.md +48 -0
- package/dist/design/audit/rubric/loader.d.ts.map +1 -1
- package/dist/design/audit/rubric/loader.js +32 -1
- package/dist/design/audit/rubric/loader.js.map +1 -1
- package/dist/design/page-interaction.d.ts +1 -0
- package/dist/design/page-interaction.d.ts.map +1 -1
- package/dist/design/page-interaction.js +1 -1
- package/dist/design/page-interaction.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/provider-defaults.d.ts +1 -1
- package/dist/provider-defaults.d.ts.map +1 -1
- package/dist/provider-defaults.js +8 -0
- package/dist/provider-defaults.js.map +1 -1
- package/dist/runner/fan-out.d.ts +11 -0
- package/dist/runner/fan-out.d.ts.map +1 -1
- package/dist/runner/fan-out.js +27 -2
- package/dist/runner/fan-out.js.map +1 -1
- package/dist/supervisor/critic.d.ts +1 -1
- package/dist/supervisor/critic.d.ts.map +1 -1
- package/dist/supervisor/critic.js +50 -5
- package/dist/supervisor/critic.js.map +1 -1
- package/dist/supervisor/policy.js +8 -5
- package/dist/supervisor/policy.js.map +1 -1
- package/dist/telemetry/client.d.ts +42 -0
- package/dist/telemetry/client.d.ts.map +1 -0
- package/dist/telemetry/client.js +203 -0
- package/dist/telemetry/client.js.map +1 -0
- package/dist/telemetry/hash.d.ts +4 -0
- package/dist/telemetry/hash.d.ts.map +1 -0
- package/dist/telemetry/hash.js +7 -0
- package/dist/telemetry/hash.js.map +1 -0
- package/dist/telemetry/index.d.ts +6 -0
- package/dist/telemetry/index.d.ts.map +1 -0
- package/dist/telemetry/index.js +5 -0
- package/dist/telemetry/index.js.map +1 -0
- package/dist/telemetry/schema.d.ts +85 -0
- package/dist/telemetry/schema.d.ts.map +1 -0
- package/dist/telemetry/schema.js +13 -0
- package/dist/telemetry/schema.js.map +1 -0
- package/dist/telemetry/sink.d.ts +46 -0
- package/dist/telemetry/sink.d.ts.map +1 -0
- package/dist/telemetry/sink.js +97 -0
- package/dist/telemetry/sink.js.map +1 -0
- package/dist/test-runner.d.ts.map +1 -1
- package/dist/test-runner.js +2 -0
- package/dist/test-runner.js.map +1 -1
- package/dist/types.d.ts +53 -10
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +9 -1
- package/dist/types.js.map +1 -1
- package/package.json +4 -1
|
@@ -23,7 +23,72 @@ export interface EvaluateInput {
|
|
|
23
23
|
rubric: ComposedRubric;
|
|
24
24
|
measurements: MeasurementBundle;
|
|
25
25
|
screenshotPath?: string;
|
|
26
|
+
auditPasses?: AuditPassId[];
|
|
27
|
+
/**
|
|
28
|
+
* Evolve-aware overrides. When present, these supersede the hard-coded
|
|
29
|
+
* defaults below — used by the GEPA harness to A/B candidate prompts
|
|
30
|
+
* without forking the audit pipeline.
|
|
31
|
+
*/
|
|
32
|
+
overrides?: AuditOverrides;
|
|
26
33
|
}
|
|
34
|
+
export type AuditPassId = 'standard' | 'product' | 'visual' | 'trust' | 'workflow' | 'content';
|
|
35
|
+
export interface AuditPass {
|
|
36
|
+
id: AuditPassId;
|
|
37
|
+
title: string;
|
|
38
|
+
goal: string;
|
|
39
|
+
instructions: string;
|
|
40
|
+
/**
|
|
41
|
+
* Per-pass opening sentence for the system prompt. Different passes need
|
|
42
|
+
* different framings: a `trust` pass should NOT open with "your job is the
|
|
43
|
+
* subjective visual layer only" — that sets the model to ignore the very
|
|
44
|
+
* findings it's supposed to surface.
|
|
45
|
+
*/
|
|
46
|
+
systemOpener: string;
|
|
47
|
+
/**
|
|
48
|
+
* Suggested categories the LLM is most likely to emit for this pass. Used
|
|
49
|
+
* inside the few-shot example so the example matches the pass's focus.
|
|
50
|
+
* The category enum itself is shared (see brain/index.ts:VALID_CATEGORIES).
|
|
51
|
+
*/
|
|
52
|
+
primaryCategory: 'visual-bug' | 'layout' | 'spacing' | 'typography' | 'ux';
|
|
53
|
+
}
|
|
54
|
+
export interface AuditOverrides {
|
|
55
|
+
/** Override one or more pass definitions. Missing keys fall through to defaults. */
|
|
56
|
+
passDefinitions?: Partial<Record<AuditPassId, AuditPass>>;
|
|
57
|
+
/** Override the classification → audit-mode mapping. */
|
|
58
|
+
inferAuditMode?: (classification: PageClassification) => string;
|
|
59
|
+
/** Override the embedded few-shot finding. The example is the single most
|
|
60
|
+
* influential prompt knob — see `DEFAULT_FEW_SHOT_EXAMPLES` for shape. */
|
|
61
|
+
fewShotExamples?: Partial<Record<AuditPassId, string>>;
|
|
62
|
+
/** Override the NO-BS rules block (one rule per array element). */
|
|
63
|
+
noBsRules?: string[];
|
|
64
|
+
/** Override the (min,mean) weights for `conservativeScore`. */
|
|
65
|
+
conservativeWeights?: {
|
|
66
|
+
min: number;
|
|
67
|
+
mean: number;
|
|
68
|
+
};
|
|
69
|
+
/** Override the deep-mode pass bundle per page type. Keyed by classification.type. */
|
|
70
|
+
deepPassesByPageType?: Partial<Record<PageClassification['type'] | 'default', AuditPassId[]>>;
|
|
71
|
+
}
|
|
72
|
+
export declare const DEFAULT_NO_BS_RULES: string[];
|
|
73
|
+
export declare const DEFAULT_CONSERVATIVE_WEIGHTS: {
|
|
74
|
+
min: number;
|
|
75
|
+
mean: number;
|
|
76
|
+
};
|
|
77
|
+
export declare const DEFAULT_DEEP_PASSES_BY_TYPE: Record<PageClassification['type'] | 'default', AuditPassId[]>;
|
|
78
|
+
export declare const PASS_DEFINITIONS: Record<AuditPassId, AuditPass>;
|
|
79
|
+
/**
|
|
80
|
+
* Pass-specific few-shot examples. Each example uses a CONCRETE finding shape
|
|
81
|
+
* for that pass's focus — no fake CSS, no vague meta-findings. The example
|
|
82
|
+
* sets the *shape* of every output the model produces, so we keep them
|
|
83
|
+
* realistic and category-appropriate.
|
|
84
|
+
*/
|
|
85
|
+
export declare const DEFAULT_FEW_SHOT_EXAMPLES: Record<AuditPassId, string>;
|
|
86
|
+
export declare function resolveAuditPasses(value?: string | number | AuditPassId[], options?: {
|
|
87
|
+
classification?: PageClassification;
|
|
88
|
+
overrides?: AuditOverrides;
|
|
89
|
+
}): AuditPassId[];
|
|
90
|
+
export declare function inferAuditMode(classification: PageClassification): string;
|
|
91
|
+
export declare function buildEvalPrompt(input: EvaluateInput, pass: AuditPass): string;
|
|
27
92
|
/**
|
|
28
93
|
* Convert deterministic measurements into findings.
|
|
29
94
|
*
|
|
@@ -46,4 +111,8 @@ export declare function measurementsToFindings(measurements: MeasurementBundle):
|
|
|
46
111
|
* Returns a PageAuditResult with merged findings (deterministic + LLM visual).
|
|
47
112
|
*/
|
|
48
113
|
export declare function evaluatePage(brain: Brain, input: EvaluateInput): Promise<PageAuditResult>;
|
|
114
|
+
export declare function conservativeScore(scores: number[], weights?: {
|
|
115
|
+
min: number;
|
|
116
|
+
mean: number;
|
|
117
|
+
}): number;
|
|
49
118
|
//# sourceMappingURL=evaluate.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"evaluate.d.ts","sourceRoot":"","sources":["../../../src/design/audit/evaluate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,sBAAsB,CAAA;AACjD,OAAO,KAAK,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAC9D,OAAO,KAAK,EACV,kBAAkB,EAClB,cAAc,EACd,iBAAiB,EACjB,eAAe,EAChB,MAAM,YAAY,CAAA;AAInB,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,SAAS,CAAA;IAChB,cAAc,EAAE,kBAAkB,CAAA;IAClC,MAAM,EAAE,cAAc,CAAA;IACtB,YAAY,EAAE,iBAAiB,CAAA;IAC/B,cAAc,CAAC,EAAE,MAAM,CAAA;
|
|
1
|
+
{"version":3,"file":"evaluate.d.ts","sourceRoot":"","sources":["../../../src/design/audit/evaluate.ts"],"names":[],"mappings":"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"}
|
|
@@ -15,6 +15,196 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { impactToSeverity } from './measure/index.js';
|
|
17
17
|
import { annotateRoi } from './roi.js';
|
|
18
|
+
export const DEFAULT_NO_BS_RULES = [
|
|
19
|
+
'If the page is pretty but the product purpose or primary action is unclear, say that and cap the score at 6.',
|
|
20
|
+
'If the page is mostly empty states, skeletons, generic cards, or copy explaining the UI, judge it as unfinished unless it previews the real product state clearly.',
|
|
21
|
+
'If multiple actions have equal visual weight but unequal user importance, flag action hierarchy.',
|
|
22
|
+
'If important trust details are missing before a user commits money, data, identity, deployment, or medical/legal decisions, flag it as major or critical.',
|
|
23
|
+
'If the page could belong to any startup after swapping nouns, call out lack of product specificity.',
|
|
24
|
+
'Do not write soft feedback like "could benefit from." State the defect and the fix.',
|
|
25
|
+
];
|
|
26
|
+
export const DEFAULT_CONSERVATIVE_WEIGHTS = { min: 0.65, mean: 0.35 };
|
|
27
|
+
export const DEFAULT_DEEP_PASSES_BY_TYPE = {
|
|
28
|
+
marketing: ['product', 'visual', 'content'],
|
|
29
|
+
ecommerce: ['product', 'visual', 'trust'],
|
|
30
|
+
'saas-app': ['product', 'visual', 'workflow'],
|
|
31
|
+
dashboard: ['product', 'visual', 'workflow'],
|
|
32
|
+
docs: ['product', 'content', 'visual'],
|
|
33
|
+
social: ['product', 'visual', 'workflow'],
|
|
34
|
+
tool: ['product', 'workflow', 'visual'],
|
|
35
|
+
blog: ['content', 'visual', 'product'],
|
|
36
|
+
utility: ['workflow', 'visual', 'product'],
|
|
37
|
+
unknown: ['product', 'visual', 'trust'],
|
|
38
|
+
default: ['product', 'visual', 'trust'],
|
|
39
|
+
};
|
|
40
|
+
export const PASS_DEFINITIONS = {
|
|
41
|
+
standard: {
|
|
42
|
+
id: 'standard',
|
|
43
|
+
title: 'Integrated product and design audit',
|
|
44
|
+
goal: 'Audit the product clarity, workflow quality, and visual design quality of this page',
|
|
45
|
+
instructions: 'Run the full integrated review. Balance product intent, primary action, trust, IA, visual craft, and interaction quality.',
|
|
46
|
+
systemOpener: 'You are a principal product-design auditor. You review with the precision of a typographer and the ruthlessness of a design director, balanced against whether the screen actually helps the real audience finish the real job.',
|
|
47
|
+
primaryCategory: 'ux',
|
|
48
|
+
},
|
|
49
|
+
product: {
|
|
50
|
+
id: 'product',
|
|
51
|
+
title: 'Product intent and usefulness audit',
|
|
52
|
+
goal: 'Audit whether the page makes the product, audience, job, and next action obvious',
|
|
53
|
+
instructions: 'Focus on product specificity, audience, job-to-be-done, primary action, useful state, real domain objects, and whether the screen would help a human complete the actual product task. Penalize generic dashboard filler, vague empty states, and copy that explains itself instead of showing the product.',
|
|
54
|
+
systemOpener: 'You are a head of product reviewing whether this screen is doing its job. You do not care if the page is pretty. You care whether the audience, primary action, and product state are obvious within five seconds. Decorative cleanliness without product specificity is a defect.',
|
|
55
|
+
primaryCategory: 'ux',
|
|
56
|
+
},
|
|
57
|
+
visual: {
|
|
58
|
+
id: 'visual',
|
|
59
|
+
title: 'Visual system and craft audit',
|
|
60
|
+
goal: 'Audit the visual design system, layout, typography, spacing, color, and component craft',
|
|
61
|
+
instructions: 'Focus on hierarchy, density, rhythm, typography, color tokens, surfaces, component coherence, visual bugs, and whether the page has a deliberate design language rather than default UI kit assembly. Do not spend findings on product strategy unless it directly damages visual hierarchy.',
|
|
62
|
+
systemOpener: 'You are a principal design engineer who has shipped design systems at Linear, Stripe, and Vercel. You review with the precision of a typographer: count the type sizes, measure the rhythm, name the radii. Generic component-library assembly without intentional decisions is a defect.',
|
|
63
|
+
primaryCategory: 'spacing',
|
|
64
|
+
},
|
|
65
|
+
trust: {
|
|
66
|
+
id: 'trust',
|
|
67
|
+
title: 'Trust, risk, and commitment audit',
|
|
68
|
+
goal: 'Audit whether users can trust the page before committing money, identity, data, deployment, or operational changes',
|
|
69
|
+
instructions: 'Focus on risk communication, provenance, verification, permissions, fees, irreversible actions, security posture, operator/source identity, and confidence before commitment. Treat missing trust details as major when the UI asks users to pay, connect a wallet, deploy, register, run jobs, or share sensitive data.',
|
|
70
|
+
systemOpener: 'You are a senior trust-and-safety reviewer. The job is to catch what users would lose money, data, identity, or operational reputation over. Missing fees, missing provenance, ambiguous destructive actions, and unverifiable parties are the defects you look for first.',
|
|
71
|
+
primaryCategory: 'ux',
|
|
72
|
+
},
|
|
73
|
+
workflow: {
|
|
74
|
+
id: 'workflow',
|
|
75
|
+
title: 'Workflow and interaction audit',
|
|
76
|
+
goal: 'Audit whether the page supports the end-to-end workflow without confusion or dead ends',
|
|
77
|
+
instructions: 'Focus on sequencing, state transitions, disabled/loading/error/empty states, navigation, progressive disclosure, action hierarchy, and whether the user can recover from failure. Flag buttons, tabs, dropdowns, and CTAs that appear interactive but do not clarify outcome.',
|
|
78
|
+
systemOpener: 'You are a senior interaction designer. You evaluate sequences, not snapshots: what happens after click, what state recovery looks like, where the user gets stuck. Dead-end controls and unrecoverable error paths are the defects you call out first.',
|
|
79
|
+
primaryCategory: 'ux',
|
|
80
|
+
},
|
|
81
|
+
content: {
|
|
82
|
+
id: 'content',
|
|
83
|
+
title: 'Content and information architecture audit',
|
|
84
|
+
goal: 'Audit copy, labels, information architecture, and screen-level communication',
|
|
85
|
+
instructions: 'Focus on plain language, labels, headings, table/card structure, data naming, unnecessary meta-copy, vague marketing language, and whether the page says exactly what users need at the moment they need it.',
|
|
86
|
+
systemOpener: 'You are a senior content designer. You read the page like a user under time pressure. Meta-copy that explains the UI, vague marketing language, and labels that hide their meaning are the defects you call out first.',
|
|
87
|
+
primaryCategory: 'typography',
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Pass-specific few-shot examples. Each example uses a CONCRETE finding shape
|
|
92
|
+
* for that pass's focus — no fake CSS, no vague meta-findings. The example
|
|
93
|
+
* sets the *shape* of every output the model produces, so we keep them
|
|
94
|
+
* realistic and category-appropriate.
|
|
95
|
+
*/
|
|
96
|
+
export const DEFAULT_FEW_SHOT_EXAMPLES = {
|
|
97
|
+
standard: `{
|
|
98
|
+
"category": "spacing",
|
|
99
|
+
"severity": "major",
|
|
100
|
+
"description": "Hero section has 64px top padding but 16px bottom — inconsistent vertical rhythm breaks the 8px grid",
|
|
101
|
+
"location": "Hero → first feature row",
|
|
102
|
+
"suggestion": "Use 48px or 64px consistently for major section transitions",
|
|
103
|
+
"cssSelector": "main > section:first-child",
|
|
104
|
+
"cssFix": "padding-bottom: 48px",
|
|
105
|
+
"impact": 6,
|
|
106
|
+
"effort": 1,
|
|
107
|
+
"blast": "page"
|
|
108
|
+
}`,
|
|
109
|
+
product: `{
|
|
110
|
+
"category": "ux",
|
|
111
|
+
"severity": "major",
|
|
112
|
+
"description": "First viewport shows six equal-weight outline buttons (Create project, Invite member, Edit billing, Change password, Configure SSO, Browse templates) with no dominant primary; nothing tells a returning user what to do next",
|
|
113
|
+
"location": "Workspace home, action grid below the welcome heading",
|
|
114
|
+
"suggestion": "Pick one primary action for this page state (likely Create project) and render it as a filled button; demote the rest to a secondary toolbar or settings section",
|
|
115
|
+
"cssSelector": "main .actions",
|
|
116
|
+
"cssFix": "/* structural: keep one filled primary, demote the rest to text/icon-only */",
|
|
117
|
+
"impact": 8,
|
|
118
|
+
"effort": 3,
|
|
119
|
+
"blast": "page"
|
|
120
|
+
}`,
|
|
121
|
+
visual: `{
|
|
122
|
+
"category": "typography",
|
|
123
|
+
"severity": "major",
|
|
124
|
+
"description": "Five distinct font sizes visible (12, 13, 14, 15, 22) with no apparent scale — body sizes drift by 1px between cards instead of stepping on a defined ramp",
|
|
125
|
+
"location": "Card grid in the Overview section",
|
|
126
|
+
"suggestion": "Collapse to a 3-step type scale (12, 14, 22) and pin card body to 14px",
|
|
127
|
+
"cssSelector": ".card .label, .card .value, .card .delta",
|
|
128
|
+
"cssFix": "font-size: 14px",
|
|
129
|
+
"impact": 5,
|
|
130
|
+
"effort": 2,
|
|
131
|
+
"blast": "system"
|
|
132
|
+
}`,
|
|
133
|
+
trust: `{
|
|
134
|
+
"category": "ux",
|
|
135
|
+
"severity": "critical",
|
|
136
|
+
"description": "Order summary shows a single $120.00 line and a Pay now button — no fees, taxes, or merchant identity are surfaced before the user commits payment",
|
|
137
|
+
"location": "Confirm payment card, summary rows above the Pay now button",
|
|
138
|
+
"suggestion": "Add fee, tax, and total breakdown rows; show merchant logo + verified domain near the heading; require payment method confirmation before enabling Pay now",
|
|
139
|
+
"cssSelector": ".wrap .row.total",
|
|
140
|
+
"cssFix": "/* structural: insert fee + tax rows above .total; render merchant identity + payment method block */",
|
|
141
|
+
"impact": 9,
|
|
142
|
+
"effort": 4,
|
|
143
|
+
"blast": "page"
|
|
144
|
+
}`,
|
|
145
|
+
workflow: `{
|
|
146
|
+
"category": "ux",
|
|
147
|
+
"severity": "major",
|
|
148
|
+
"description": "Empty inbox shows a Got it button as the only call to action; clicking it doesn't move the user toward any next step in the product",
|
|
149
|
+
"location": "Empty state, action below the illustration",
|
|
150
|
+
"suggestion": "Replace Got it with a concrete next step (Compose first message / Connect inbox / Invite a teammate) and preview a sample row above it",
|
|
151
|
+
"cssSelector": "main .empty button",
|
|
152
|
+
"cssFix": "/* structural: replace generic ack button with a real next-step CTA + sample preview */",
|
|
153
|
+
"impact": 7,
|
|
154
|
+
"effort": 3,
|
|
155
|
+
"blast": "page"
|
|
156
|
+
}`,
|
|
157
|
+
content: `{
|
|
158
|
+
"category": "typography",
|
|
159
|
+
"severity": "major",
|
|
160
|
+
"description": "Body copy describes what the Reports page is supposed to do (\\"From here you can view reports about your data\\") instead of helping the user act; this is meta-copy, not product copy",
|
|
161
|
+
"location": "Reports page intro paragraph",
|
|
162
|
+
"suggestion": "Replace the descriptive paragraph with a real first row (most-recent report, sample report, or a one-line filter helper) and a single CTA",
|
|
163
|
+
"cssSelector": "main p",
|
|
164
|
+
"cssFix": "/* structural: remove descriptive paragraphs, render real or starter content */",
|
|
165
|
+
"impact": 6,
|
|
166
|
+
"effort": 2,
|
|
167
|
+
"blast": "page"
|
|
168
|
+
}`,
|
|
169
|
+
};
|
|
170
|
+
export function resolveAuditPasses(value, options) {
|
|
171
|
+
if (Array.isArray(value))
|
|
172
|
+
return normalizeAuditPasses(value);
|
|
173
|
+
if (typeof value === 'number')
|
|
174
|
+
return auditPassesForCount(value);
|
|
175
|
+
const raw = value?.trim().toLowerCase();
|
|
176
|
+
if (!raw || raw === 'standard' || raw === 'single' || raw === 'default')
|
|
177
|
+
return ['standard'];
|
|
178
|
+
if (raw === 'deep' || raw === 'parallel' || raw === 'full') {
|
|
179
|
+
return deepPassesForClassification(options?.classification, options?.overrides);
|
|
180
|
+
}
|
|
181
|
+
if (raw === 'max' || raw === 'exhaustive')
|
|
182
|
+
return ['product', 'visual', 'trust', 'workflow', 'content'];
|
|
183
|
+
if (/^\d+$/.test(raw))
|
|
184
|
+
return auditPassesForCount(Number(raw));
|
|
185
|
+
return normalizeAuditPasses(raw.split(',').map(part => part.trim()));
|
|
186
|
+
}
|
|
187
|
+
/** Pick the deep-mode bundle that fits the page's classification. */
|
|
188
|
+
function deepPassesForClassification(classification, overrides) {
|
|
189
|
+
const map = { ...DEFAULT_DEEP_PASSES_BY_TYPE, ...(overrides?.deepPassesByPageType ?? {}) };
|
|
190
|
+
if (!classification)
|
|
191
|
+
return map.default ?? DEFAULT_DEEP_PASSES_BY_TYPE.default;
|
|
192
|
+
return map[classification.type] ?? map.default ?? DEFAULT_DEEP_PASSES_BY_TYPE.default;
|
|
193
|
+
}
|
|
194
|
+
function normalizeAuditPasses(passes) {
|
|
195
|
+
const valid = new Set(['standard', 'product', 'visual', 'trust', 'workflow', 'content']);
|
|
196
|
+
const normalized = passes.filter((pass) => valid.has(pass));
|
|
197
|
+
return Array.from(new Set(normalized.length > 0 ? normalized : ['standard']));
|
|
198
|
+
}
|
|
199
|
+
function auditPassesForCount(count) {
|
|
200
|
+
if (count <= 1)
|
|
201
|
+
return ['standard'];
|
|
202
|
+
const ordered = ['product', 'visual', 'trust', 'workflow', 'content'];
|
|
203
|
+
return ordered.slice(0, Math.min(Math.max(count, 1), ordered.length));
|
|
204
|
+
}
|
|
205
|
+
function resolvePassDefinition(passId, overrides) {
|
|
206
|
+
return overrides?.passDefinitions?.[passId] ?? PASS_DEFINITIONS[passId];
|
|
207
|
+
}
|
|
18
208
|
/**
|
|
19
209
|
* Build the LLM prompt for visual evaluation. Includes:
|
|
20
210
|
* - The composed rubric (only fragments matching the classification)
|
|
@@ -32,8 +222,33 @@ const UNIVERSAL_DIMENSIONS = [
|
|
|
32
222
|
'accessibility',
|
|
33
223
|
'polish',
|
|
34
224
|
];
|
|
35
|
-
function
|
|
36
|
-
const
|
|
225
|
+
export function inferAuditMode(classification) {
|
|
226
|
+
const domain = classification.domain.toLowerCase();
|
|
227
|
+
if (/(crypto|defi|web3|wallet|payments?|finance|fintech|banking)/.test(domain)) {
|
|
228
|
+
return 'High-trust transactional product. Judge transaction clarity, trust, risk, provenance, verification, and whether users understand what they are committing to before they act.';
|
|
229
|
+
}
|
|
230
|
+
if (/(devtools?|developer|infrastructure|api|sdk|cloud|hosting|deploy|database|observability)/.test(domain)) {
|
|
231
|
+
return 'Developer/operator product. Judge whether the UI exposes real operational objects, status, logs, source, commands, deploy paths, and debugging affordances instead of generic dashboard filler.';
|
|
232
|
+
}
|
|
233
|
+
if (/(ai|ml|llm|agent|model|inference|training)/.test(domain)) {
|
|
234
|
+
return 'AI/ML product. Judge whether model capability, latency/cost, job state, inputs/outputs, safety limits, and failure recovery are concrete and usable.';
|
|
235
|
+
}
|
|
236
|
+
if (/(health|medical|clinical|legal|insurance)/.test(domain)) {
|
|
237
|
+
return 'High-stakes professional product. Judge clarity, safety, auditability, error prevention, and whether the UI avoids ambiguous or decorative communication.';
|
|
238
|
+
}
|
|
239
|
+
if (classification.type === 'ecommerce') {
|
|
240
|
+
return 'Commerce product. Judge product comprehension, comparison, price/fees, checkout confidence, inventory/delivery signals, and purchase path clarity.';
|
|
241
|
+
}
|
|
242
|
+
if (classification.type === 'docs') {
|
|
243
|
+
return 'Documentation product. Judge information scent, quickstart path, examples, API/reference scanability, versioning, and whether readers can get unstuck quickly.';
|
|
244
|
+
}
|
|
245
|
+
if (classification.type === 'marketing') {
|
|
246
|
+
return 'Marketing/conversion product. Judge whether the page makes the offer, audience, proof, differentiation, and next step obvious without vague hype.';
|
|
247
|
+
}
|
|
248
|
+
return 'General product surface. Judge whether the page makes its audience, purpose, state, and next action obvious, then evaluate visual craft in service of that job.';
|
|
249
|
+
}
|
|
250
|
+
export function buildEvalPrompt(input, pass) {
|
|
251
|
+
const { classification, rubric, measurements, overrides } = input;
|
|
37
252
|
const measurementSummary = [
|
|
38
253
|
`CONTRAST (already measured by deterministic math, do not re-evaluate):`,
|
|
39
254
|
` - ${measurements.contrast.totalChecked} text elements checked`,
|
|
@@ -65,9 +280,12 @@ function buildEvalPrompt(input) {
|
|
|
65
280
|
const dimensionExample = allDimensions
|
|
66
281
|
.map(d => ` "${d}": 7`)
|
|
67
282
|
.join(',\n');
|
|
68
|
-
|
|
283
|
+
const auditMode = (overrides?.inferAuditMode ?? inferAuditMode)(classification);
|
|
284
|
+
const fewShot = overrides?.fewShotExamples?.[pass.id] ?? DEFAULT_FEW_SHOT_EXAMPLES[pass.id];
|
|
285
|
+
const noBsRules = overrides?.noBsRules ?? DEFAULT_NO_BS_RULES;
|
|
286
|
+
return `${pass.systemOpener}
|
|
69
287
|
|
|
70
|
-
You are evaluating a page that has been pre-classified and pre-measured.
|
|
288
|
+
You are evaluating a page that has been pre-classified and pre-measured. Contrast and axe-core accessibility findings have already been counted deterministically — DO NOT invent them. They will be merged with your output. Everything else (product clarity, action hierarchy, trust, IA, visual craft, interaction quality) is yours to judge.
|
|
71
289
|
|
|
72
290
|
PAGE CLASSIFICATION:
|
|
73
291
|
- Type: ${classification.type}
|
|
@@ -78,6 +296,15 @@ PAGE CLASSIFICATION:
|
|
|
78
296
|
- Intent: ${classification.intent}
|
|
79
297
|
- Classifier confidence: ${classification.confidence}
|
|
80
298
|
|
|
299
|
+
AUDIT MODE:
|
|
300
|
+
${auditMode}
|
|
301
|
+
|
|
302
|
+
AUDIT PASS:
|
|
303
|
+
${pass.title}
|
|
304
|
+
|
|
305
|
+
PASS-SPECIFIC FOCUS:
|
|
306
|
+
${pass.instructions}
|
|
307
|
+
|
|
81
308
|
DETERMINISTIC MEASUREMENTS:
|
|
82
309
|
${measurementSummary}
|
|
83
310
|
|
|
@@ -86,13 +313,18 @@ EVALUATION RUBRIC (composed from fragments matching this page):
|
|
|
86
313
|
${rubric.body}
|
|
87
314
|
|
|
88
315
|
YOUR JOB:
|
|
89
|
-
1.
|
|
90
|
-
2.
|
|
91
|
-
3.
|
|
92
|
-
4. Do NOT produce
|
|
93
|
-
5.
|
|
94
|
-
6.
|
|
95
|
-
7.
|
|
316
|
+
1. First infer the page's product job-to-be-done from screenshot + classification. Judge the screen against that job before judging surface polish.
|
|
317
|
+
2. Apply the pass-specific focus above, then score this page 1-10 against the rubric. Use the calibration anchors strictly. Do not grade on a curve.
|
|
318
|
+
3. Produce findings ONLY for things you can SEE in the screenshot: product clarity, action hierarchy, trust/risk communication, information architecture, visual hierarchy, typography choices, spacing rhythm, component coherence, polish details.
|
|
319
|
+
4. Do NOT produce contrast findings — they've been measured.
|
|
320
|
+
5. Do NOT produce accessibility findings — axe has been run.
|
|
321
|
+
6. Be specific. Reference exact visible elements, wording, layout relationships, and interaction surfaces.
|
|
322
|
+
7. Prefer findings that would actually move the product outcome, not tiny decorative nits.
|
|
323
|
+
8. For each finding include a concrete CSS fix in the cssFix field when CSS can help. If the real fix is content/IA/component structure, put the smallest honest structural hint in cssFix as a comment.
|
|
324
|
+
9. For each finding ALSO include impact, effort, and blast — these drive the ROI ranking.
|
|
325
|
+
|
|
326
|
+
NO-BS REVIEW RULES:
|
|
327
|
+
${noBsRules.map((r) => `- ${r}`).join('\n')}
|
|
96
328
|
|
|
97
329
|
ROI FIELDS — score each finding on:
|
|
98
330
|
- impact (1-10): how much this hurts the user. 1 = nitpick, 10 = breaks the experience.
|
|
@@ -108,24 +340,13 @@ A high-blast / low-effort fix has massive ROI. Use this scale honestly — the u
|
|
|
108
340
|
RESPOND WITH ONLY a JSON object:
|
|
109
341
|
{
|
|
110
342
|
"score": 7,
|
|
111
|
-
"summary": "One-sentence assessment of the
|
|
343
|
+
"summary": "One-sentence assessment of whether the page helps the inferred audience complete the inferred job, plus design-system quality",
|
|
112
344
|
"strengths": [
|
|
113
345
|
"Specific evidence-based strength",
|
|
114
346
|
"Another measured strength"
|
|
115
347
|
],
|
|
116
348
|
"findings": [
|
|
117
|
-
{
|
|
118
|
-
"category": "spacing",
|
|
119
|
-
"severity": "major",
|
|
120
|
-
"description": "Hero section has 64px top padding but only 16px bottom — inconsistent vertical rhythm breaks the 8px grid",
|
|
121
|
-
"location": "Hero section → features grid transition",
|
|
122
|
-
"suggestion": "Use 48px or 64px consistently for all major section transitions",
|
|
123
|
-
"cssSelector": "main > section:first-child",
|
|
124
|
-
"cssFix": "padding-bottom: 48px",
|
|
125
|
-
"impact": 6,
|
|
126
|
-
"effort": 1,
|
|
127
|
-
"blast": "page"
|
|
128
|
-
}
|
|
349
|
+
${fewShot}
|
|
129
350
|
],
|
|
130
351
|
"designSystemScore": {
|
|
131
352
|
${dimensionExample}
|
|
@@ -243,35 +464,33 @@ export function measurementsToFindings(measurements) {
|
|
|
243
464
|
* Returns a PageAuditResult with merged findings (deterministic + LLM visual).
|
|
244
465
|
*/
|
|
245
466
|
export async function evaluatePage(brain, input) {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if (parsed.designSystemScore && typeof parsed.designSystemScore === 'object') {
|
|
264
|
-
designSystemScore = {};
|
|
265
|
-
for (const [k, v] of Object.entries(parsed.designSystemScore)) {
|
|
266
|
-
if (typeof v === 'number')
|
|
267
|
-
designSystemScore[k] = v;
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
catch {
|
|
273
|
-
// fall through with defaults
|
|
467
|
+
const passIds = resolveAuditPasses(input.auditPasses, {
|
|
468
|
+
classification: input.classification,
|
|
469
|
+
overrides: input.overrides,
|
|
470
|
+
});
|
|
471
|
+
const passes = passIds.map(id => resolvePassDefinition(id, input.overrides));
|
|
472
|
+
// Run independent subjective passes concurrently. Deterministic findings are
|
|
473
|
+
// still merged once below; this only broadens the LLM review surface.
|
|
474
|
+
const passResults = await Promise.allSettled(passes.map(async (pass) => ({
|
|
475
|
+
pass,
|
|
476
|
+
result: await brain.auditDesign(input.state, pass.goal, [], buildEvalPrompt(input, pass)),
|
|
477
|
+
})));
|
|
478
|
+
const fulfilled = passResults
|
|
479
|
+
.filter((r) => r.status === 'fulfilled')
|
|
480
|
+
.map(r => r.value);
|
|
481
|
+
if (fulfilled.length === 0) {
|
|
482
|
+
const reason = passResults.find((r) => r.status === 'rejected')?.reason;
|
|
483
|
+
throw reason instanceof Error ? reason : new Error(String(reason ?? 'All audit passes failed'));
|
|
274
484
|
}
|
|
485
|
+
// Parse summary/strengths/designSystemScore from raw LLM response
|
|
486
|
+
const parsedPasses = fulfilled.map(({ pass, result }) => ({
|
|
487
|
+
pass,
|
|
488
|
+
result,
|
|
489
|
+
parsed: parseAuditResponse(result.raw),
|
|
490
|
+
}));
|
|
491
|
+
const summary = buildMergedSummary(parsedPasses);
|
|
492
|
+
const strengths = mergeStrengths(parsedPasses.flatMap(p => p.parsed.strengths));
|
|
493
|
+
let designSystemScore = mergeDesignSystemScores(parsedPasses.map(p => p.parsed.designSystemScore));
|
|
275
494
|
// Filter LLM findings to only the categories we expect from the visual layer
|
|
276
495
|
// (the LLM should not be producing contrast/accessibility findings, but be defensive)
|
|
277
496
|
const visualCategories = new Set([
|
|
@@ -282,7 +501,7 @@ export async function evaluatePage(brain, input) {
|
|
|
282
501
|
'typography',
|
|
283
502
|
'ux',
|
|
284
503
|
]);
|
|
285
|
-
const visualFindings = result.findings.filter(f => visualCategories.has(f.category));
|
|
504
|
+
const visualFindings = dedupeFindings(fulfilled.flatMap(({ result }) => result.findings.filter(f => visualCategories.has(f.category))));
|
|
286
505
|
// Merge: deterministic measurements first (they're ground truth), then visual
|
|
287
506
|
const measurementFindings = measurementsToFindings(input.measurements);
|
|
288
507
|
const mergedFindings = [...measurementFindings, ...visualFindings];
|
|
@@ -299,7 +518,7 @@ export async function evaluatePage(brain, input) {
|
|
|
299
518
|
// Only hard-cap the overall score in catastrophic cases (broken contrast on
|
|
300
519
|
// most text). Otherwise trust the LLM's visual judgment and let the
|
|
301
520
|
// accessibility dimension carry the measurement story.
|
|
302
|
-
let finalScore = result.score;
|
|
521
|
+
let finalScore = conservativeScore(fulfilled.map(p => p.result.score), input.overrides?.conservativeWeights);
|
|
303
522
|
const contrastFailRate = 1 - input.measurements.contrast.summary.aaPassRate;
|
|
304
523
|
const trueCriticalA11y = input.measurements.a11y.violations.filter(v => v.impact === 'critical').length;
|
|
305
524
|
if (contrastFailRate > 0.5 && trueCriticalA11y >= 3) {
|
|
@@ -316,9 +535,149 @@ export async function evaluatePage(brain, input) {
|
|
|
316
535
|
measurements: input.measurements,
|
|
317
536
|
designSystemScore: designSystemScore,
|
|
318
537
|
screenshotPath: input.screenshotPath,
|
|
319
|
-
tokensUsed: result.tokensUsed,
|
|
538
|
+
tokensUsed: fulfilled.reduce((sum, p) => sum + (p.result.tokensUsed ?? 0), 0),
|
|
320
539
|
};
|
|
321
540
|
}
|
|
541
|
+
function parseAuditResponse(raw) {
|
|
542
|
+
try {
|
|
543
|
+
let text = raw.trim();
|
|
544
|
+
if (text.startsWith('```')) {
|
|
545
|
+
text = text.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
|
|
546
|
+
}
|
|
547
|
+
const start = text.indexOf('{');
|
|
548
|
+
const end = text.lastIndexOf('}');
|
|
549
|
+
if (start < 0 || end <= start)
|
|
550
|
+
return { summary: '', strengths: [] };
|
|
551
|
+
const parsed = JSON.parse(text.slice(start, end + 1));
|
|
552
|
+
const designSystemScore = {};
|
|
553
|
+
if (parsed.designSystemScore && typeof parsed.designSystemScore === 'object') {
|
|
554
|
+
for (const [k, v] of Object.entries(parsed.designSystemScore)) {
|
|
555
|
+
if (typeof v === 'number')
|
|
556
|
+
designSystemScore[k] = v;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return {
|
|
560
|
+
summary: typeof parsed.summary === 'string' ? parsed.summary : '',
|
|
561
|
+
strengths: Array.isArray(parsed.strengths)
|
|
562
|
+
? parsed.strengths.filter((s) => typeof s === 'string')
|
|
563
|
+
: [],
|
|
564
|
+
designSystemScore: Object.keys(designSystemScore).length > 0 ? designSystemScore : undefined,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
return { summary: '', strengths: [] };
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
function buildMergedSummary(passes) {
|
|
572
|
+
if (passes.length === 1)
|
|
573
|
+
return passes[0]?.parsed.summary ?? '';
|
|
574
|
+
const summaries = passes
|
|
575
|
+
.filter(p => p.parsed.summary)
|
|
576
|
+
.map(p => `${p.pass.id}: ${p.parsed.summary}`);
|
|
577
|
+
return summaries.slice(0, 3).join(' ');
|
|
578
|
+
}
|
|
579
|
+
function mergeStrengths(strengths) {
|
|
580
|
+
const seen = new Set();
|
|
581
|
+
const merged = [];
|
|
582
|
+
for (const strength of strengths) {
|
|
583
|
+
const key = strength.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
584
|
+
if (!key || seen.has(key))
|
|
585
|
+
continue;
|
|
586
|
+
seen.add(key);
|
|
587
|
+
merged.push(strength);
|
|
588
|
+
if (merged.length >= 6)
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
return merged;
|
|
592
|
+
}
|
|
593
|
+
function mergeDesignSystemScores(scores) {
|
|
594
|
+
const buckets = new Map();
|
|
595
|
+
for (const score of scores) {
|
|
596
|
+
if (!score)
|
|
597
|
+
continue;
|
|
598
|
+
for (const [dimension, value] of Object.entries(score)) {
|
|
599
|
+
const bucket = buckets.get(dimension) ?? [];
|
|
600
|
+
bucket.push(value);
|
|
601
|
+
buckets.set(dimension, bucket);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (buckets.size === 0)
|
|
605
|
+
return undefined;
|
|
606
|
+
const merged = {};
|
|
607
|
+
for (const [dimension, values] of buckets) {
|
|
608
|
+
const average = values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
609
|
+
merged[dimension] = Math.round(average * 10) / 10;
|
|
610
|
+
}
|
|
611
|
+
return merged;
|
|
612
|
+
}
|
|
613
|
+
function dedupeFindings(findings) {
|
|
614
|
+
const byKey = new Map();
|
|
615
|
+
for (const finding of findings) {
|
|
616
|
+
const key = `${finding.category}|${canonicalFindingTopic(finding)}`;
|
|
617
|
+
const existing = byKey.get(key);
|
|
618
|
+
if (!existing) {
|
|
619
|
+
byKey.set(key, finding);
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
if (shouldReplaceFinding(existing, finding)) {
|
|
623
|
+
byKey.set(key, finding);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return [...byKey.values()].slice(0, 18);
|
|
627
|
+
}
|
|
628
|
+
function canonicalFindingTopic(finding) {
|
|
629
|
+
const text = `${finding.description} ${finding.location} ${finding.suggestion}`
|
|
630
|
+
.toLowerCase()
|
|
631
|
+
.replace(/[“”"']/g, '')
|
|
632
|
+
.replace(/\s+/g, ' ')
|
|
633
|
+
.trim();
|
|
634
|
+
const topicRules = [
|
|
635
|
+
[/\b(primary|product)\s+(job|purpose)|next action|first viewport|what.+(doing|for)|product model|5 seconds/, 'product-clarity'],
|
|
636
|
+
[/equal[- ]weight|action hierarchy|button|cta|control set|peer controls|dominant primary|visually compete/, 'action-hierarchy'],
|
|
637
|
+
[/wallet|trust|pricing|price|cost|commit|authorize|approval|payment|identity|permission|network|chain|funds|fees/, 'trust-commitment'],
|
|
638
|
+
[/blueprint root|internal.+term|jargon|implementation term|leaked.+system|object name/, 'jargon-leak'],
|
|
639
|
+
[/what you buy|what you need|what you monitor|domain object|generic heading|abstract heading|abstract bucket|actual product/, 'domain-content'],
|
|
640
|
+
[/typography|type hierarchy|heading system|title|subtitle|information hierarchy/, 'type-hierarchy'],
|
|
641
|
+
[/spacing|rhythm|dead air|whitespace|vertical flow|gaps/, 'spacing-rhythm'],
|
|
642
|
+
[/layout|content frame|two-column|grid|alignment|grouped information|reviewable transaction/, 'layout-structure'],
|
|
643
|
+
[/label|grammatical|naming|button labels|language hierarchy|copy hierarchy/, 'label-consistency'],
|
|
644
|
+
[/console|marketing\/setup stub|operational product|component library|visual language|assembled ui/, 'product-surface-maturity'],
|
|
645
|
+
];
|
|
646
|
+
for (const [pattern, topic] of topicRules) {
|
|
647
|
+
if (pattern.test(text))
|
|
648
|
+
return topic;
|
|
649
|
+
}
|
|
650
|
+
return text.slice(0, 140);
|
|
651
|
+
}
|
|
652
|
+
function shouldReplaceFinding(existing, candidate) {
|
|
653
|
+
const severityDiff = severityRank(candidate.severity) - severityRank(existing.severity);
|
|
654
|
+
if (severityDiff < 0)
|
|
655
|
+
return true;
|
|
656
|
+
if (severityDiff > 0)
|
|
657
|
+
return false;
|
|
658
|
+
const candidateImpact = candidate.impact ?? 0;
|
|
659
|
+
const existingImpact = existing.impact ?? 0;
|
|
660
|
+
if (candidateImpact !== existingImpact)
|
|
661
|
+
return candidateImpact > existingImpact;
|
|
662
|
+
const candidateText = `${candidate.description} ${candidate.suggestion}`;
|
|
663
|
+
const existingText = `${existing.description} ${existing.suggestion}`;
|
|
664
|
+
return candidateText.length > existingText.length;
|
|
665
|
+
}
|
|
666
|
+
export function conservativeScore(scores, weights = DEFAULT_CONSERVATIVE_WEIGHTS) {
|
|
667
|
+
if (scores.length === 0)
|
|
668
|
+
return 5;
|
|
669
|
+
if (scores.length === 1)
|
|
670
|
+
return scores[0];
|
|
671
|
+
const min = Math.min(...scores);
|
|
672
|
+
const average = scores.reduce((sum, score) => sum + score, 0) / scores.length;
|
|
673
|
+
const total = weights.min + weights.mean;
|
|
674
|
+
const wMin = total > 0 ? weights.min / total : 0.65;
|
|
675
|
+
const wMean = total > 0 ? weights.mean / total : 0.35;
|
|
676
|
+
return Math.round((min * wMin + average * wMean) * 10) / 10;
|
|
677
|
+
}
|
|
678
|
+
function severityRank(severity) {
|
|
679
|
+
return severity === 'critical' ? 0 : severity === 'major' ? 1 : 2;
|
|
680
|
+
}
|
|
322
681
|
/**
|
|
323
682
|
* Translate raw measurement data into a 1-10 accessibility score.
|
|
324
683
|
*
|