← Semantics Lens
API
Tokens

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.

StatusCodeMeaning
400validation_errorThe 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.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: both lanes are metered and need a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. 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:

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

FieldRequiredWhat
taskyesaudit or patch. If it is missing or neither, do audit and set "task": "audit".
screenyesthe screen's name, the platforms it targets, and the widget class the test pumps.
codeyesthe Flutter code of one screen. A line starting "// [... N lines cut by the page" marks code the page scanned but did not send.
carrysend 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.
factsyesthe 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_notenooptional; 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:

House rules

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.

  1. summary - one sentence, at most 40 words: what blocks a screen-reader or large-text user most on this screen, and the first fix.
  2. 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).
  3. cleared - one object per page finding that is not a barrier in this code, with id and why (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.
  4. 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.
  5. 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").
  6. 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.

  1. summary - one sentence, at most 40 words: what the patch changes and what it leaves for the team.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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"}
}

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')

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}' 

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}' 

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

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