Run Semantics Lens from your own code
Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page scans the Dart - every widget call matched to its closing bracket, its named arguments read at their own depth - for missing labels and roles, small tap targets, switched-off text scaling, clipping heights, failing literal contrast, split rows and web semantics, then hands it all to the run as facts. The run writes the audit or the patch; the page then holds it to its own facts and re-scans a patched file with the same scanner (semreply.js).
Base URL and the response shape
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }
The token is minted for this app (the guest call takes {"slug":"semantics-lens"}), so no slug header is needed afterwards. Send it as Authorization: Bearer YOUR_TOKEN. The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required key (task, screen, code, facts) is missing. The page always sends all five of task, screen, code, carry, facts; carry is an empty string for audit. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: both lanes are metered and need a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field comes first
Two lanes, chosen by task, in the order the work runs - inspect, then produce:
audit- each of the page's findings confirmed (with the barrier, the fix and a short code fix) or cleared (with why), up to five barriers a scan cannot see on real lines, and the stops a screen reader makes as the code stands.patch- the whole patched file with labels, roles, targets, scaling, contrast, grouping and web semantics fixed, what changed and what was kept, andtest/a11y_test.dart.
A missing or unknown task is answered as audit. Both lanes send the same five fields. The facts are the page's scan, one finding per line with its id, and a line starting "must:" is a rule the reply has to hold. The page's handoff carries the audit's decisions into carry for the patch lane. The audit sends at most 24,000 characters of code (the middle of a longer file is cut on whole lines and marked; every finding is still in the facts); the patch lane takes at most 16,000.
The input
| Field | Required | What |
|---|---|---|
task | yes | audit or patch. If it is missing or neither, do audit and set "task": "audit". |
screen | yes | the screen's name, the platforms it targets, and the widget class the test pumps. |
code | yes | the Flutter code of one screen. A line starting "// [... N lines cut by the page" marks code the page scanned but did not send. |
carry | send it (empty for audit) | patch task only: the audit's decisions, one per line as id | confirmed or cleared or extra | widget | fix or reason; may be empty. |
facts | yes | the page's scan, one finding per line as id | area | severity | line | widget | evidence | source, the contrast pairs it measured and what is already present. A line starting "must:" applies to your reply. |
retry_note | no | optional; if present, your previous reply was unusable - follow it. |
The rules the model applies
From the source skills, @flutter/flutter-improving-accessibility and @flutter/flutter-accessibility-audit - the only standard the run applies:
- Text must have room at the largest OS font size: no fixed heights around text, and never switch text scaling off (TextScaler.noScaling, textScaleFactor: 1) or cap it low.
- Contrast: at least 4.5:1 for small text, 3:1 for large text (18 and up, or 14 and up bold) and for icons.
- Every interactive element gets a tap target of at least 48x48 (44x44 on iOS); a small visual can keep its size inside a larger target.
- A screen reader must be able to say what every interactive element is and does: an IconButton carries a tooltip, a custom tappable (GestureDetector, InkWell) is wrapped in Semantics with a label and a button role (button: true, or role: SemanticsRole.button), and standard Material widgets are preferred because they bring their roles.
- MergeSemantics groups a composite tappable row into one stop; ExcludeSemantics (or excludeFromSemantics: true on an image) hides what is purely decorative; a meaningful image carries a semanticLabel that says what it shows.
- Flutter web builds no semantics tree by default: main() calls SemanticsBinding.instance.ensureSemantics() when kIsWeb.
- The proof is a widget test: tester.ensureSemantics(), pump the screen, expectLater(tester, meetsGuideline(...)) for the four guidelines, then dispose the handle.
House rules
- Use only the input. Never invent a widget, a screen, a product name or a string the code does not hold. When a label needs words the code does not give, build them from the text already on the screen, or say in a caveat what the team must supply.
- The page's ids, lines and contrast ratios are measured facts: never renumber an id, never restate a ratio as a different number, and never compute a new contrast ratio - the page re-measures any colour you change.
- No web links (http or https) in prose. A URL that is already in the code stays as it is.
- Write in English. Keep the JSON keys and the ids exactly as given.
- Never use backticks. Name widgets and parameters plainly (IconButton, tooltip:). Words from the rules above are free to use; what you never do is paste a whole sentence from this prompt.
- Never write a gendered pronoun for a user; write they, the user or the person.
- Reply with ONLY one JSON object - no prose before or after, no code fences.
The replies
audit
Confirm or clear each of the page's findings, add what a scan cannot see, and give the order a screen reader meets the screen.
summary- one sentence, at most 40 words: what blocks a screen-reader or large-text user most on this screen, and the first fix.findings- one object per page finding you confirm, in id order:id(the page's id),widget(the widget, with what it is on screen, e.g. "IconButton - the back arrow"),barrier(one sentence: what a TalkBack, VoiceOver, large-text or low-dexterity user meets here),fix(one sentence: the change; for a contrast finding, the passing colour the facts give),code(the corrected Dart for that widget only, at most 6 lines; "" for a colour or layout change you describe in fix).cleared- one object per page finding that is not a barrier in this code, withidandwhy(one sentence tied to the code - e.g. the image is decorative and sits beside a Text that says the same). Every page id appears exactly once, in findings or in cleared.extra- at most 5 barriers the scan cannot see, each tied to a line of the code:id(X1, X2, ...),area(label, role, target, scaling, contrast, grouping or web),line(a number),widget,barrier,fix. Examples: a state change a screen reader is never told about, a Switch or Checkbox with no label of its own, a reading order that does not match the visual order, a price split across several Text widgets.[]when there are none.reading_order- 3 to 12 short strings: the stops a screen reader makes on this screen as the code stands, top to bottom, each as what it announces (e.g. "\"button\" - the back arrow, unlabelled").caveats- at most 4 short strings;[]when there is nothing to say.
{
"task": "audit",
"summary": "one sentence",
"findings": [ { "id": "S1", "widget": "IconButton - the back arrow", "barrier": "one sentence", "fix": "one sentence", "code": "IconButton(\n tooltip: 'Back',\n ...\n)" } ],
"cleared": [ { "id": "S4", "why": "one sentence" } ],
"extra": [ { "id": "X1", "area": "label", "line": 30, "widget": "Switch", "barrier": "one sentence", "fix": "one sentence" } ],
"reading_order": [ "what the first stop announces" ],
"caveats": [ ]
}
The page holds it to its own facts (check() in semreply.js; any warning makes the reply flagged): the reply answers the task it was sent; every page finding id appears exactly once across findings and cleared, and no id the page never found; a cleared id has a reason (a cleared blocker is noted); a confirmed id has a barrier and a fix; at most five extra barriers, each on a line inside the paste; a reading order of 3 to 12 stops; a summary; no web link in the prose.
patch
Rewrite the screen with the fixes applied, and write the widget test.
summary- one sentence, at most 40 words: what the patch changes and what it leaves for the team.code- the complete patched file. Keep every import, class, method, callback and behaviour; change only what the findings name, plus the imports a change needs (package:flutter/semantics.dart for SemanticsRole or ensureSemantics, package:flutter/foundation.dart for kIsWeb). Never shorten the file with "..." or "rest unchanged" - write every line. If the code holds a line starting "// [... N lines cut by the page", keep that line exactly where it is. Semantics always goes OUTSIDE the tappable, never inside it: Semantics(button: true, label: ..., child: GestureDetector(onTap: ..., child: the original child)). A Semantics placed inside the GestureDetector or InkWell does not give the tappable its role. A missing label: add tooltip: to an IconButton; wrap a custom tappable in Semantics(button: true, label: ...) as above, or turn an icon-only GestureDetector into an IconButton with a tooltip. A missing role: wrap the tappable in Semantics(button: true), with the tappable as its child, unchanged. A tap target: remove the fixed size or the empty constraints and padding: EdgeInsets.zero, or set MaterialTapTargetSize.padded - the icon may stay small. Text scaling: delete the override; replace a fixed height around text with constraints: BoxConstraints(minHeight: ...) or padding. Contrast: replace the failing colour with the passing colour the facts give for that finding ("Use Color(0x...)"), exactly as written, and name the old and the new colour in changes. Never pick a colour of your own - you cannot measure it; the page can. Grouping: wrap the tappable row in MergeSemantics. A decorative image: excludeFromSemantics: true. A meaningful image: semanticLabel: built from the text the screen shows for it. Flutter web: in main(), call SemanticsBinding.instance.ensureSemantics() when kIsWeb.changes- one object per page finding you fixed, in id order:id,change(one sentence: what changed, naming the widget). Extra ids (X1, ...) from the carried audit may appear too.kept- one object per page finding you did not change:id,why(one sentence - the audit cleared it, or it needs a decision the code cannot make). Every page id appears exactly once, in changes or in kept; an id the carried audit cleared belongs in kept.test- the complete test/a11y_test.dart: import flutter/material.dart and flutter_test, one testWidgets that calls tester.ensureSemantics(), pumps MaterialApp(home: the widget class named in the screen field), awaits expectLater(tester, meetsGuideline(...)) for androidTapTargetGuideline, iOSTapTargetGuideline, labeledTapTargetGuideline and textContrastGuideline, and ends with handle.dispose(). The input does not give the screen's file path: write the import of the screen as a commented placeholder (// import 'package:your_app/<file>.dart';) and say in a caveat that the team fills it in. Pass placeholder values for required constructor arguments.caveats- at most 4 short strings;[]when there is nothing to say.
{
"task": "patch",
"summary": "one sentence",
"code": "the complete patched Dart file",
"changes": [ { "id": "S1", "change": "one sentence" } ],
"kept": [ { "id": "S4", "why": "one sentence" } ],
"test": "the complete test file",
"caveats": [ ]
}
The page requires: the reply answers the task it was sent; every page finding id appears exactly once across changes and kept; every widget class of the paste survives; the file is not shortened (at least 70% of its lines) and elides nothing; the imports a change needs are present; the patched code is scanned again - a rule the changes claim fixed that still fires, or a rule the paste never had, is flagged; the test asserts androidTapTargetGuideline, iOSTapTargetGuideline, labeledTapTargetGuideline and textContrastGuideline, calls ensureSemantics, disposes the handle and pumps the screen's widget class.
Worked examples
Three runs from the page's free samples (invented screens): the audit of a checkout screen a screen reader cannot use, the patch of the same screen carrying that audit, and the audit of a podcast player built for the web too. Each input is exactly what the page sends; each reply is the saved reply the sample replays, shipped unedited - but the code, the facts and long strings are shortened in this display only, and every cut is listed under the example. The last line is the page's own check of the full reply.
audit - A checkout screen a screen reader cannot use
The page read it as: Screen-reader blockers - 4 blocker, 4 major, 0 minor; findings S1 no-scaling, S2 unlabeled-button, S3 image-label, S4 small-target, S5 unlabeled-button, S6 fixed-height, S7 low-contrast, S8 unlabeled-tap.
Input (code is 2013 characters; the first 1382 are shown; facts is 2211 characters; the first 1384 are shown):
{
"task": "audit",
"screen": "Screen: Checkout\nTargets: Android and iOS\nWidget the test pumps: CheckoutScreen",
"code": "import 'package:flutter/material.dart';\n\nclass CheckoutScreen extends StatelessWidget {\n const CheckoutScreen({super.key});\n\n @override\n Widget build(BuildContext context) {\n return MediaQuery(\n // Design asked for pixel-exact totals.\n data: MediaQuery.of(context).copyWith(textScaler: TextScaler.noScaling),\n child: Scaffold(\n backgroundColor: Colors.white,\n appBar: AppBar(\n leading: IconButton(\n icon: const Icon(Icons.arrow_back),\n onPressed: () => Navigator.pop(context),\n ),\n title: const Text('Checkout'),\n ),\n body: ListView(\n padding: const EdgeInsets.all(16),\n children: [\n Row(\n children: [\n Image.network('https://cdn.example.com/p/123.jpg', width: 64, height: 64),\n const SizedBox(width: 12),\n const Expanded(child: Text('Trail runner, size 42')),\n SizedBox(\n width: 32,\n height: 32,\n child: IconButton(\n padding: EdgeInsets.zero,\n icon: const Icon(Icons.delete_outline, size: 18),\n onPressed: () {},\n ),\n ),\n ],\n ),\n const SizedBox(height: 16),\n Container(\n height: 48,\n\n[... 631 more characters cut from this display ...]",
"carry": "",
"facts": "The page's scan of 64 lines (29 widget calls); verdict: screen-reader blockers - 4 blocker, 4 major, 0 minor.\nFindings (id | area | severity | line | widget | evidence | source):\n- S1 | scaling | blocker | line 10 | MediaQuery | textScaler: TextScaler.noScaling turns off the system font size for everything below it. | source: data: MediaQuery.of(context).copyWith(textScaler: TextScaler.noScaling),\n- S2 | label | blocker | line 14 | IconButton | IconButton has no tooltip, so a screen reader announces only \"button\". | source: leading: IconButton(\n- S3 | label | major | line 25 | Image.network | Image.network has no semanticLabel and is not excluded from semantics. | source: Image.network('https://cdn.example.com/p/123.jpg', width: 64, height: 64),\n- S4 | target | major | line 28 | IconButton | IconButton sits in a 32 x 32 box - under the 48 x 48 Android minimum and the 44 x 44 iOS minimum. | source: SizedBox(\n- S5 | label | blocker | line 31 | IconButton | IconButton has no tooltip, so a screen reader announces only \"button\". | source: child: IconButton(\n- S6 | scaling | major | line 41 | Container | Container fixes height: 48 around text, which clips when the system font is enlarged. | source: height: 48,\n- S7 | contrast | major | line 45 | TextStyle | Text colour Colors.grey[400] on Colors.white (Container) is 1.88:1 - text under 18 (or 14 bold) needs 4.5:1. Use\n\n[... 827 more characters cut from this display ...]"
}
Reply:
{
"task": "audit",
"summary": "Three icon-only controls (the back arrow, the delete icon and the offer tag) announce no purpose, and TextScaler.noScaling locks every line at one size; remove the MediaQuery override first, then add tooltips and a Semantics label.",
"findings": [
{
"id": "S1",
"widget": "MediaQuery - wraps the whole Checkout Scaffold",
"barrier": "A low-vision user who has raised the system font size still gets every line, including the item name and the Pay £129.00 total, at the default size.",
"fix": "Delete the MediaQuery wrapper and return the Scaffold directly, so the system text scaler reaches every Text on the screen.",
"code": "return Scaffold(\n backgroundColor: Colors.white,\n appBar: AppBar(\n ...\n ),\n);"
},
{
"id": "S2",
"widget": "IconButton - the back arrow in the AppBar",
"barrier": "TalkBack and VoiceOver announce only \"button\" for the first control on the screen, so the user cannot tell it leaves Checkout.",
"fix": "Add tooltip: 'Back' to the leading IconButton, which gives it both a spoken label and a long-press hint.",
"code": "leading: IconButton(\n tooltip: 'Back',\n icon: const Icon(Icons.arrow_back),\n onPressed: () => Navigator.pop(context),\n),"
},
{
"id": "S3",
"widget": "Image.network - the product photo in the item row",
"barrier": "A screen reader stops on an unlabelled image before the item name, announcing \"image\" with nothing to say what it shows.",
"fix": "Mark the photo excludeFromSemantics: true, because the Text beside it already says Trail runner, size 42.",
"code": "Image.network(\n 'https://cdn.example.com/p/123.jpg',\n width: 64,\n height: 64,\n excludeFromSemantics: true,\n),"
},
{
"id": "S4",
"widget": "SizedBox 32 x 32 - holds the delete IconButton in the item row",
"barrier": "A user with limited dexterity has a 32 x 32 target, with padding: EdgeInsets.zero, to hit right next to the item text, so taps miss or land on the row.",
"fix": "Remove the 32 x 32 SizedBox and the padding: EdgeInsets.zero so the IconButton gets its default 48 x 48 target while the icon stays at size 18.",
"code": "IconButton(\n tooltip: 'Remove Trail runner, size 42',\n icon: const Icon(Icons.delete_outline, size: 18),\n onPressed: () {},\n),"
},
{
"id": "S5",
"widget": "IconButton - the delete icon after Trail runner, size 42",
"barrier": "TalkBack and VoiceOver announce only \"button\", so the user hears nothing that warns the control removes the item from the order.",
"fix": "Add a tooltip: built from the item text on screen, so the spoken label names the action and the item.",
"code": "child: IconButton(\n tooltip: 'Remove Trail runner, size 42',\n icon: const Icon(Icons.delete_outline, size: 18),\n onPressed: () {},\n),"
},
{
"id": "S6",
"widget": "Container - the 48-high box around the returns and duties note",
"barrier": "At a large font size the note wraps to more lines than 48 pixels hold, so the end of the sentence about duties is clipped once scaling is back on.",
"fix": "Replace height: 48 with constraints: BoxConstraints(minHeight: 48) so the box grows with the text.",
"code": "Container(\n constraints: const BoxConstraints(minHeight: 48),\n color: Colors.white,\n child: Text(\n 'Free returns within 30 days. Duties are included at checkout.',\n style: TextStyle(color: Colors.grey[700], fontSize: 13),"
},
{
"id": "S7",
"widget": "TextStyle - the grey returns and duties note",
"barrier": "A low-vision user can barely read the 13-point note in Colors.grey[400] on white, which the page measured at 1.88:1 against the 4.5:1 that small text needs.",
"fix": "Change Colors.grey[400] to a darker shade of the same family such as Colors.grey[700], and let the page re-measure the pair.",
"code": ""
},
{
"id": "S8",
"widget": "GestureDetector - the offer tag icon above the Pay button",
"barrier": "A screen reader either skips the tag or reads nothing for it, so the user is never told there is a tappable control here or what it does.",
"fix": "Turn the GestureDetector into an IconButton with a tooltip:, which brings the button role, a label and a 48 x 48 target together.",
"code": "IconButton(\n tooltip: 'Offer',\n icon: const Icon(Icons.local_offer_outlined),\n onPressed: () {},\n),"
}
],
"cleared": [],
"extra": [
{
"id": "X1",
"area": "target",
"line": 51,
"widget": "Icon - the offer tag inside the GestureDetector",
"barrier": "The GestureDetector is only as big as its 24-pixel Icon, so the tappable area is half the 48 x 48 minimum on Android and under 44 x 44 on iOS.",
"fix": "Use an IconButton in place of the GestureDetector, as in S8, so the icon keeps its size inside a 48 x 48 target."
},
{
"id": "X2",
"area": "grouping",
"line": 23,
"widget": "Row - the item row with the photo, the item name and the delete button",
"barrier": "A screen-reader user meets the item as separate stops with no link between them, so the delete button is heard apart from the item it removes.",
"fix": "Exclude the photo and name the item inside the delete tooltip: (Remove Trail runner, size 42) so the action stays tied to the item without merging the button into the text."
},
{
"id": "X3",
"area": "label",
"line": 34,
"widget": "IconButton - the delete icon, onPressed",
"barrier": "When the item is removed nothing tells a screen-reader user it happened, so they cannot tell if the tap worked.",
"fix": "Once removal is wired up, announce the result, for example with a SnackBar that says the item was removed, which TalkBack and VoiceOver read out."
}
],
"reading_order": [
"\"button\" - the back arrow, unlabelled",
"\"Checkout\" - the AppBar title",
"\"image\" - the product photo, unlabelled",
"\"Trail runner, size 42\" - the item name",
"\"button\" - the delete icon, unlabelled",
"\"Free returns within 30 days. Duties are included at checkout.\" - the grey note",
"nothing - the offer tag GestureDetector, skipped or silent",
"\"Pay £129.00, button\" - the ElevatedButton"
],
"caveats": [
"The code holds no text for the offer tag; 'Offer' comes from the icon name, and the team must supply the words that say what tapping it does.",
"The delete and offer callbacks are empty here, so the wording of any removal announcement is for the team to decide."
]
}
What step 5 prints for it: S1: Delete the MediaQuery wrapper and return the Scaffold directly, so the system text scaler reaches every Text on the screen. | S2: Add tooltip: 'Back' to the leading IconButton, which gives it both a spoken label and a long-press hint. | S3: Mark the photo excludeFromSemantics: true, because the Text beside it already says Trail runner, size 42. | S4: Remove the 32 x 32 SizedBox and the padding: EdgeInsets.zero so the IconButton gets its default 48 x 48 target while the icon stays at size 18. | S5: Add a tooltip: built from the item text on screen, so the spoken label names the action and the item. | S6: Replace height: 48 with constraints: BoxConstraints(minHeight: 48) so the box grows with the text. | S7: Change Colors.grey[400] to a darker shade of the same family such as Colors.grey[700], and let the page re-measure the pair. | S8: Turn the GestureDetector into an IconButton with a tooltip:, which brings the button role, a label and a 48 x 48 target together.
The page's check: kept - nothing to flag
patch - A checkout screen a screen reader cannot use
The input carries the saved audit (R.laneForm fills carry from the audit reply), as the page's handoff does. The page re-scanned the patched code: 8 findings before, 0 after.
Input (code is 2013 characters; the first 1382 are shown; facts is 2211 characters; the first 1384 are shown; carry is 2177 characters; the first 1390 are shown):
{
"task": "patch",
"screen": "Screen: Checkout\nTargets: Android and iOS\nWidget the test pumps: CheckoutScreen",
"code": "import 'package:flutter/material.dart';\n\nclass CheckoutScreen extends StatelessWidget {\n const CheckoutScreen({super.key});\n\n @override\n Widget build(BuildContext context) {\n return MediaQuery(\n // Design asked for pixel-exact totals.\n data: MediaQuery.of(context).copyWith(textScaler: TextScaler.noScaling),\n child: Scaffold(\n backgroundColor: Colors.white,\n appBar: AppBar(\n leading: IconButton(\n icon: const Icon(Icons.arrow_back),\n onPressed: () => Navigator.pop(context),\n ),\n title: const Text('Checkout'),\n ),\n body: ListView(\n padding: const EdgeInsets.all(16),\n children: [\n Row(\n children: [\n Image.network('https://cdn.example.com/p/123.jpg', width: 64, height: 64),\n const SizedBox(width: 12),\n const Expanded(child: Text('Trail runner, size 42')),\n SizedBox(\n width: 32,\n height: 32,\n child: IconButton(\n padding: EdgeInsets.zero,\n icon: const Icon(Icons.delete_outline, size: 18),\n onPressed: () {},\n ),\n ),\n ],\n ),\n const SizedBox(height: 16),\n Container(\n height: 48,\n\n[... 631 more characters cut from this display ...]",
"carry": "S1 | confirmed | MediaQuery - wraps the whole Checkout Scaffold | Delete the MediaQuery wrapper and return the Scaffold directly, so the system text scaler reaches every Text on the screen.\nS2 | confirmed | IconButton - the back arrow in the AppBar | Add tooltip: 'Back' to the leading IconButton, which gives it both a spoken label and a long-press hint.\nS3 | confirmed | Image.network - the product photo in the item row | Mark the photo excludeFromSemantics: true, because the Text beside it already says Trail runner, size 42.\nS4 | confirmed | SizedBox 32 x 32 - holds the delete IconButton in the item row | Remove the 32 x 32 SizedBox and the padding: EdgeInsets.zero so the IconButton gets its default 48 x 48 target while the icon stays at size 18.\nS5 | confirmed | IconButton - the delete icon after Trail runner, size 42 | Add a tooltip: built from the item text on screen, so the spoken label names the action and the item.\nS6 | confirmed | Container - the 48-high box around the returns and duties note | Replace height: 48 with constraints: BoxConstraints(minHeight: 48) so the box grows with the text.\nS7 | confirmed | TextStyle - the grey returns and duties note | Change Colors.grey[400] to a darker shade of the same family such as Colors.grey[700], and let the page re-measure the pair.\nS8 | confirmed | GestureDetector - the offer tag icon above the Pay button | Turn the\n\n[... 787 more characters cut from this display ...]",
"facts": "The page's scan of 64 lines (29 widget calls); verdict: screen-reader blockers - 4 blocker, 4 major, 0 minor.\nFindings (id | area | severity | line | widget | evidence | source):\n- S1 | scaling | blocker | line 10 | MediaQuery | textScaler: TextScaler.noScaling turns off the system font size for everything below it. | source: data: MediaQuery.of(context).copyWith(textScaler: TextScaler.noScaling),\n- S2 | label | blocker | line 14 | IconButton | IconButton has no tooltip, so a screen reader announces only \"button\". | source: leading: IconButton(\n- S3 | label | major | line 25 | Image.network | Image.network has no semanticLabel and is not excluded from semantics. | source: Image.network('https://cdn.example.com/p/123.jpg', width: 64, height: 64),\n- S4 | target | major | line 28 | IconButton | IconButton sits in a 32 x 32 box - under the 48 x 48 Android minimum and the 44 x 44 iOS minimum. | source: SizedBox(\n- S5 | label | blocker | line 31 | IconButton | IconButton has no tooltip, so a screen reader announces only \"button\". | source: child: IconButton(\n- S6 | scaling | major | line 41 | Container | Container fixes height: 48 around text, which clips when the system font is enlarged. | source: height: 48,\n- S7 | contrast | major | line 45 | TextStyle | Text colour Colors.grey[400] on Colors.white (Container) is 1.88:1 - text under 18 (or 14 bold) needs 4.5:1. Use\n\n[... 827 more characters cut from this display ...]"
}
Reply (long strings shortened for display - see the cuts below):
{
"task": "patch",
"summary": "Restores system text scaling, labels the back, delete and offer buttons, gives the delete button a full target, lets the returns note grow and darkens its grey; the team still words the offer label and wires removal feedback.",
"code": "import 'package:flutter/material.dart';\n\nclass CheckoutScreen extends StatelessWidget {\n const CheckoutScreen({super.key});\n\n @override\n Widget build(BuildContext context) {\n return Scaffold(\n backgroundColor: Colors.white,\n appBar: AppBar(\n leading: IconButton(\n tooltip: 'Back', [... 1642 more characters cut from this display]",
"changes": [
{
"id": "S1",
"change": "Deleted the MediaQuery wrapper with TextScaler.noScaling so build returns the Scaffold and every Text follows the system font size."
},
{
"id": "S2",
"change": "Added tooltip: 'Back' to the leading IconButton in the AppBar."
},
{
"id": "S3",
"change": "Set excludeFromSemantics: true on the product Image.network, since the Text beside it names the item."
},
{
"id": "S4",
"change": "Removed the 32 x 32 SizedBox and padding: EdgeInsets.zero around the delete IconButton so it gets its default 48 x 48 target while the icon stays at size 18."
},
{
"id": "S5",
"change": "Added tooltip: 'Remove Trail runner, size 42' to the delete IconButton, built from the item text on screen."
},
{
"id": "S6",
"change": "Replaced height: 48 on the returns note Container with constraints: BoxConstraints(minHeight: 48) so the box grows with enlarged text."
},
{
"id": "S7",
"change": "Changed the returns note TextStyle colour from Colors.grey[400] to Colors.grey[700]; the page re-measures the pair."
},
{
"id": "S8",
"change": "Turned the offer GestureDetector into an IconButton with tooltip: 'Offer' and the same onPressed, which brings the button role and a label."
},
{
"id": "X1",
"change": "The offer icon now sits inside that IconButton, keeping its size inside a 48 x 48 target, aligned left as before."
},
{
"id": "X2",
"change": "The photo is excluded and the delete IconButton tooltip names the item, so the action stays tied to Trail runner, size 42 without merging the button into the text."
}
],
"kept": [],
"test": "import 'package:flutter/material.dart';\nimport 'package:flutter_test/flutter_test.dart';\n\nimport 'package:app/checkout_screen.dart';\n\nvoid main() {\n testWidgets('CheckoutScreen meets accessibility guidelines', (WidgetTester tester) async {\n final SemanticsHandle handle = tester.ensureSemantics();\n await [... 383 more characters cut from this display]",
"caveats": [
"The offer button label Offer comes from the icon name; the team should supply the final wording for what the button opens.",
"X3 is left open: the delete onPressed does nothing yet, so once removal is wired up, announce the result, for example with a SnackBar.",
"The test imports the screen from package:app/checkout_screen.dart; change that path to where CheckoutScreen lives.",
"Image.network loads from the network, so the widget test may need a mocked HTTP client or a placeholder image to pump cleanly."
]
}
Cut from this display only (the saved reply and the check use the full text): code (1954 characters, 312 shown); test (694 characters, 311 shown).
What step 5 prints for it: S1: Deleted the MediaQuery wrapper with TextScaler.noScaling so build returns the Scaffold and every Text follows the system font size. | S2: Added tooltip: 'Back' to the leading IconButton in the AppBar. | S3: Set excludeFromSemantics: true on the product Image.network, since the Text beside it names the item. | S4: Removed the 32 x 32 SizedBox and padding: EdgeInsets.zero around the delete IconButton so it gets its default 48 x 48 target while the icon stays at size 18. | S5: Added tooltip: 'Remove Trail runner, size 42' to the delete IconButton, built from the item text on screen. | S6: Replaced height: 48 on the returns note Container with constraints: BoxConstraints(minHeight: 48) so the box grows with enlarged text. | S7: Changed the returns note TextStyle colour from Colors.grey[400] to Colors.grey[700]; the page re-measures the pair. | S8: Turned the offer GestureDetector into an IconButton with tooltip: 'Offer' and the same onPressed, which brings the button role and a label. | X1: The offer icon now sits inside that IconButton, keeping its size inside a 48 x 48 target, aligned left as before. | X2: The photo is excluded and the delete IconButton tooltip names the item, so the action stays tied to Trail runner, size 42 without merging the button into the text.
The page's check: kept - nothing to flag
audit - A podcast player built for the web too
The page read it as: Fix before release - 0 blocker, 4 major, 1 minor; findings S1 web-semantics, S2 low-contrast, S3 no-role, S4 split-reading, S5 shrink-target.
Input (code is 1717 characters; the first 1391 are shown; facts is 1788 characters; the first 1396 are shown):
{
"task": "audit",
"screen": "Screen: Episode player\nTargets: Android, iOS and the web\nWidget the test pumps: EpisodePage",
"code": "import 'package:flutter/foundation.dart';\nimport 'package:flutter/material.dart';\n\nvoid main() {\n runApp(const MaterialApp(home: EpisodePage()));\n}\n\nclass EpisodePage extends StatefulWidget {\n const EpisodePage({super.key});\n @override\n State<EpisodePage> createState() => _EpisodePageState();\n}\n\nclass _EpisodePageState extends State<EpisodePage> {\n bool playing = false;\n\n @override\n Widget build(BuildContext context) {\n return Scaffold(\n body: Container(\n color: Colors.amber,\n padding: const EdgeInsets.all(20),\n child: Column(\n crossAxisAlignment: CrossAxisAlignment.start,\n children: [\n Image.asset('assets/wave.png', excludeFromSemantics: true),\n const Text('Episode 41', style: TextStyle(color: Colors.white70, fontSize: 14)),\n const Text('Shipping on Fridays', style: TextStyle(color: Color(0xFF1B1B1B), fontSize: 22, fontWeight: FontWeight.bold)),\n IconButton(\n tooltip: playing ? 'Pause' : 'Play',\n iconSize: 48,\n icon: Icon(playing ? Icons.pause_circle : Icons.play_circle),\n onPressed: () => setState(() => playing = !playing),\n ),\n InkWell(\n onTap: () {},\n child: const Row(\n children: [\n Text('Show notes'),\n SizedBox(width: 8),\n\n[... 326 more characters cut from this display ...]",
"carry": "",
"facts": "The page's scan of 56 lines (22 widget calls); verdict: fix before release - 0 blocker, 4 major, 1 minor.\nFindings (id | area | severity | line | widget | evidence | source):\n- S1 | web | major | line 4 | main | Flutter web builds no semantics tree until it is enabled, and main() never calls SemanticsBinding.instance.ensureSemantics(). | source: void main() {\n- S2 | contrast | major | line 27 | TextStyle | Text colour Colors.white70 on Colors.amber (Container) is 1.39:1 - text under 18 (or 14 bold) needs 4.5:1. Use Color(0xFF59533F) for the text - the page measures it at 4.71:1 on Colors.amber. | source: const Text('Episode 41', style: TextStyle(color: Colors.white70, fontSize: 14)),\n- S3 | role | major | line 35 | InkWell | InkWell (onTap) is tappable but has no button role, so it is not announced as a button. | source: InkWell(\n- S4 | grouping | minor | line 35 | InkWell | InkWell holds 2 Text widgets that a screen reader reads as separate stops. | source: InkWell(\n- S5 | target | major | line 46 | ActionChip | ActionChip sets materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, shrinking the tap target below 48 x 48. | source: materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,\nContrast pairs the page could resolve (line | kind | colour on background | ratio | needs):\n- line 27 | text | Colors.white70 on Colors.amber | 1.39:1 | 4.5:1 FAIL | a colour that passes:\n\n[... 392 more characters cut from this display ...]"
}
Reply:
{
"task": "audit",
"summary": "On the web build a screen reader finds nothing at all, because main() never turns the semantics tree on; the first fix is calling SemanticsBinding.instance.ensureSemantics() when kIsWeb, then giving the Show notes row a button role.",
"findings": [
{
"id": "S1",
"widget": "main - the app entry point",
"barrier": "On the web build the browser screen reader meets an empty page, since Flutter web builds no semantics tree until main() asks for one, so the title, the play button and the chip are all silent.",
"fix": "Import package:flutter/semantics.dart and call SemanticsBinding.instance.ensureSemantics() inside main() when kIsWeb, before runApp.",
"code": "void main() {\n if (kIsWeb) SemanticsBinding.instance.ensureSemantics();\n runApp(const MaterialApp(home: EpisodePage()));\n}"
},
{
"id": "S2",
"widget": "Text - the Episode 41 label",
"barrier": "A low-vision user can barely see the Episode 41 label, because Colors.white70 on the Colors.amber background measures 1.39:1 where 14-point regular text needs 4.5:1.",
"fix": "Change the Episode 41 text colour from Colors.white70 to a dark shade such as Color(0xFF1B1B1B), the colour the title already uses, and let the page re-measure it.",
"code": ""
},
{
"id": "S3",
"widget": "InkWell - the Show notes row",
"barrier": "TalkBack and VoiceOver read Show notes as plain text with no button role, so the user is never told the row can be activated to open the notes.",
"fix": "Wrap the InkWell in Semantics(button: true) so the row is announced as a button, keeping its Show notes text as the label.",
"code": "Semantics(\n button: true,\n child: InkWell(onTap: () {},\n child: const Row(children: [Text('Show notes'), SizedBox(width: 8), Text('12 min read')]),\n ),\n)"
},
{
"id": "S4",
"widget": "InkWell - the Show notes row with its 12 min read text",
"barrier": "The two Text widgets inside the row are read as separate stops, so the user hears Show notes and then 12 min read apart and cannot tell the reading time belongs to the same tappable row.",
"fix": "Wrap the InkWell in MergeSemantics so both Text widgets and the tap action form a single focus stop.",
"code": "MergeSemantics(\n child: InkWell(\n onTap: () {},\n child: const Row(children: [Text('Show notes'), SizedBox(width: 8), Text('12 min read')]),\n ),\n)"
},
{
"id": "S5",
"widget": "ActionChip - the Speed 1.5x chip",
"barrier": "With materialTapTargetSize set to MaterialTapTargetSize.shrinkWrap the chip's touch area falls under 48 x 48, so a user with limited dexterity often misses it.",
"fix": "Set materialTapTargetSize to MaterialTapTargetSize.padded (or delete the line) so the chip keeps its look inside a 48 x 48 target.",
"code": "ActionChip(\n materialTapTargetSize: MaterialTapTargetSize.padded,\n label: const Text('Speed 1.5x'),\n onPressed: () {},\n)"
}
],
"cleared": [],
"extra": [
{
"id": "X1",
"area": "role",
"line": 33,
"widget": "IconButton - the play and pause button",
"barrier": "When the user activates the button, setState flips playing and only the tooltip text changes from Play to Pause, so a screen-reader user is never told that playback started or stopped.",
"fix": "In onPressed, after setState, call SemanticsService.announce(playing ? 'Playing' : 'Paused', Directionality.of(context)) from package:flutter/semantics.dart, or expose the state with isSelected: playing and selectedIcon: Icon(Icons.pause_circle)."
},
{
"id": "X2",
"area": "scaling",
"line": 39,
"widget": "Row - the Show notes and 12 min read texts",
"barrier": "The Row holds two Text widgets side by side with no Flexible or Wrap, so at the largest OS font size the row can overflow the screen width and clip the reading time.",
"fix": "Replace the Row with a Wrap (spacing: 8) or wrap the second Text in Flexible so the texts move to a new line when they grow."
}
],
"reading_order": [
"\"Episode 41\" - small label, hard to see at 1.39:1",
"\"Shipping on Fridays\" - the episode title, read as plain text",
"\"Play, button\" - the play button, labelled by its tooltip",
"\"Show notes\" - read as text, no button role",
"\"12 min read\" - a separate stop for the same row",
"\"Speed 1.5x, button\" - the speed chip, with a shrunken tap target",
"On the web build: no stops at all until ensureSemantics() is called"
],
"caveats": [
"The wave image is already excluded from semantics with excludeFromSemantics: true, which suits a decorative graphic.",
"Show notes and the Speed 1.5x chip have empty onPressed and onTap handlers, so the team must supply what they open or change.",
"The replacement colour for Episode 41 is a suggestion; the page re-measures it against Colors.amber."
]
}
What step 5 prints for it: S1: Import package:flutter/semantics.dart and call SemanticsBinding.instance.ensureSemantics() inside main() when kIsWeb, before runApp. | S2: Change the Episode 41 text colour from Colors.white70 to a dark shade such as Color(0xFF1B1B1B), the colour the title already uses, and let the page re-measure it. | S3: Wrap the InkWell in Semantics(button: true) so the row is announced as a button, keeping its Show notes text as the label. | S4: Wrap the InkWell in MergeSemantics so both Text widgets and the tap action form a single focus stop. | S5: Set materialTapTargetSize to MaterialTapTargetSize.padded (or delete the line) so the chip keeps its look inside a 48 x 48 target.
The page's check: kept - nothing to flag
Build the input yourself
Load the engine in node (global.window = global; require("./semkit.js"); require("./semreply.js")), then:
const K = SemKit, R = SemReply;
const f = K.form({ name: "Profile", targets: "mobile", carry: "",
code: "class ProfileScreen extends StatelessWidget {\n Widget build(BuildContext c) => Row(children: [\n IconButton(icon: Icon(Icons.edit), onPressed: () {}),\n Text('Ada', style: TextStyle(color: Colors.grey[400])),\n ]);\n}" });
// f.findings (id, area, sev, line, widget, evidence, fix), f.pairs (contrast), f.checklist, f.test
// K.findingsCsv(f), K.checklistMd(f) - the free exports
const b = K.buildInput(f, "audit"); // or "patch" (R.laneForm(form, "patch", audit) fills carry)
const wire = K.toWire(b.input); // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "audit");
// R.check(reply, b) // {status: kept or flags, notes}
Get a token for the steps below from /tokens.html.
Step 1 — a tiny client helper
One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN" # from https://semantics-lens.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://semantics-lens.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// semantics-lens.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://semantics-lens.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "YOUR_TOKEN" // from https://semantics-lens.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// Semantics Lens.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class Semantics Lens {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://semantics-lens.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://semantics-lens.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN"; // from https://semantics-lens.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "YOUR_TOKEN"; // from https://semantics-lens.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"semantics-lens"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "semantics-lens"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "semantics-lens" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "semantics-lens"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "semantics-lens"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "semantics-lens" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "semantics-lens"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "semantics-lens" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.
# input.json: the run input object - {"task": "audit", "screen": "Screen: Checkout
Targets: Android and iOS
Widget the test pumps: CheckoutScreen", "code": "import 'package:flutter/material.dart';
...", "carry": "", "facts": "The page's scan of 64 lines ...
Findings (id | area | severity | line | widget | evidence | source):
- S1 | scaling | blocker | line 10 | ..."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "audit", "screen": "Screen: Checkout
Targets: Android and iOS
Widget the test pumps: CheckoutScreen", "code": "import 'package:flutter/material.dart';
...", "carry": "", "facts": "The page's scan of 64 lines ...
Findings (id | area | severity | line | widget | evidence | source):
- S1 | scaling | blocker | line 10 | ..."}
# - see the input table
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "audit" or "patch", screen, code, carry, facts} (see the input table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses semantics-lens:<task>:<inputHash>:a<attempt> - the lane is part of the key, retry_note is left out of the hash - and its one automatic reformat retry is a new attempt number, named in the page as one extra run. An audit reply carries summary, findings, cleared, extra, reading_order and caveats; a patch reply carries summary, code, changes, kept, test and caveats. The code below prints the gist of either: each confirmed finding and its fix, or each change.
KEY="semantics-lens:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r 'if .task == "patch" then (.changes[] | "\(.id): \(.change)")
else (.findings[] | "\(.id): \(.fix)") end' reply.json
import hashlib, time
key = "semantics-lens:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply["task"])
if reply["task"] == "patch":
for x in reply["changes"]:
print(x["id"], x["change"])
else:
for x in reply["findings"]:
print(x["id"], x["fix"])
import { createHash } from "node:crypto";
const key = `semantics-lens:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.task);
if (reply.task === "patch") { for (const x of reply.changes) console.log(x.id, x.change); }
else { for (const x of reply.findings) console.log(x.id, x.fix); }
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "semantics-lens:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
var reply map[string]any
json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &reply)
fmt.Println(j["charged_credits"], j["truncated"], reply["task"])
key, b := "findings", "fix"
if reply["task"] == "patch" {
key, b = "changes", "change"
}
xs, _ := reply[key].([]any)
for _, x := range xs {
xx := x.(map[string]any)
fmt.Println(xx["id"], xx[b])
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "semantics-lens:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply.path("task").asText());
if (reply.path("task").asText().equals("patch")) {
for (JsonNode x : reply.path("changes")) System.out.println(x.path("id").asText() + " " + x.path("change").asText());
} else {
for (JsonNode x : reply.path("findings")) System.out.println(x.path("id").asText() + " " + x.path("fix").asText());
}
require "digest"
key = "semantics-lens:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["task"]].inspect
if reply["task"] == "patch"
reply["changes"].each { |x| puts "#{x["id"]} #{x["change"]}" }
else
reply["findings"].each { |x| puts "#{x["id"]} #{x["fix"]}" }
end
$key = "semantics-lens:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["task"], "\n";
if ($reply["task"] === "patch") { foreach ($reply["changes"] as $x) echo $x["id"], " ", $x["change"], "\n"; }
else { foreach ($reply["findings"] as $x) echo $x["id"], " ", $x["fix"], "\n"; }
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"semantics-lens:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>())!;
var task = (string?)reply["task"];
Console.WriteLine($"{j["charged_credits"]} {task}");
if (task == "patch") { foreach (var x in reply["changes"]!.AsArray()) Console.WriteLine($"{x!["id"]} {x["change"]}"); }
else { foreach (var x in reply["findings"]!.AsArray()) Console.WriteLine($"{x!["id"]} {x["fix"]}"); }
Step 6 — POST /run-stream
The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.
# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: semantics-lens:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "audit", "screen": "Screen: Checkout
Targets: Android and iOS
Widget the test pumps: CheckoutScreen", "code": "import 'package:flutter/material.dart';
...", "carry": "", "facts": "The page's scan of 64 lines ...
Findings (id | area | severity | line | widget | evidence | source):
- S1 | scaling | blocker | line 10 | ..."}
# - see the input table
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "semantics-lens:" + rev["task"] + ":" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `semantics-lens:${rev.task}:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "semantics-lens:"+input["task"].(string)+":"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "semantics-lens:" + input.get("task") + ":" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "semantics-lens:#{JSON.parse(rev)["task"]}:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: semantics-lens:" . json_decode($rev, true)["task"] . ":" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"semantics-lens:{input["task"]}:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);