← Theme Lens
API
Tokens

Run Theme 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 call matched to its closing bracket, its named arguments read at their own depth - for deprecated APIs, Material 2 leftovers, component theme classes, removed buttons, role contrast (with a measured passing colour), theme bypasses, dark mode and desktop idioms, then hands it all to the run as facts. The run writes the review or the migration; the page then holds it to its own facts and re-scans a migrated file with the same scanner (themereply.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":"theme-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, theme, code, facts) is missing. The page always sends all five of task, theme, code, carry, facts; carry is an empty string for review.
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 review. 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 review's decisions into carry for the migrate lane. The review 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 migrate lane takes at most 16,000.

The input

FieldRequiredWhat
taskyesreview or migrate. If it is missing or neither, do review and set "task": "review".
themeyesthe theme's name, the platforms it targets, and the seed colour the page found.
codeyesthe Flutter code: the ThemeData and the widgets that use it. A line starting "// [... N lines cut by the page" marks code the page scanned but did not send.
carrysend it (empty for review)migrate task only: the review's decisions, one per line as id | confirmed or cleared or extra | where | fix or reason; may be empty.
factsyesthe page's scan, one finding per line as id | area | severity | line | where | evidence | source, the role pairs it measured and what is 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 skill, @flutter/flutter-theming-apps - the only standard the run applies:

House rules

The replies

review

Confirm or clear each of the page's findings, add what a scan cannot see, and put the migration in order.

  1. summary - one sentence, at most 40 words: the state of this theme and the first change to make.
  2. findings - one object per page finding you confirm, in id order: id (the page's id), widget (where it is, e.g. "ThemeData - the app theme"), issue (one sentence: what goes wrong for this app - a compile error, a wrong colour in dark mode, a Material 2 look), fix (one sentence: the change; for a role-contrast finding, the passing colour the facts give), code (the corrected Dart for that piece only, at most 6 lines; "" when fix says it all).
  3. cleared - one object per page finding that is not a problem in this code, with id and why (one sentence tied to the code). Every page id appears exactly once, in findings or in cleared.
  4. extra - at most 5 issues the scan cannot see, each tied to a line of the code: id (X1, X2, ...), area (deprecated, material3, components, contrast, bypass, darkmode or adaptive), line (a number), widget, issue, fix. Examples: a colour held in a variable that bypasses the theme, a ButtonStyle that ignores the pressed or disabled state, an elevation that now shows a surface tint, a Theme override that should be a copyWith. [] when there are none.
  5. plan - 2 to 10 short strings, the migration in the order to do it (what will not compile first, then Material 3, then colours and dark mode, then widgets), each naming the ids it covers (e.g. "Replace RaisedButton with ElevatedButton (T8)"). Every confirmed id appears in a step. [] only when nothing is confirmed.
  6. caveats - at most 4 short strings; [] when there is nothing to say.
{
  "task": "review",
  "summary": "one sentence",
  "findings": [ { "id": "T1", "widget": "ThemeData - the app theme", "issue": "one sentence", "fix": "one sentence", "code": "ThemeData(\n  colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),\n)" } ],
  "cleared": [ { "id": "T3", "why": "one sentence" } ],
  "extra": [ { "id": "X1", "area": "bypass", "line": 30, "widget": "Container", "issue": "one sentence", "fix": "one sentence" } ],
  "plan": [ "Replace RaisedButton with ElevatedButton (T8)" ],
  "caveats": [ ]
}

The page holds it to its own facts (check() in themereply.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 confirmed id has an issue and a fix; a role-contrast fix uses the passing colour the page measured; at most five extra issues, each on a line inside the paste; a plan of 2 to 10 steps that names every confirmed id; a summary; no web link in the prose.

migrate

Rewrite the file with the fixes applied.

  1. summary - one sentence, at most 40 words: what the migration changes and what it leaves for the team.
  2. code - the complete migrated file. Keep every import, class, top-level declaration, method, callback and behaviour; change only what the findings name, plus the imports a change needs. Never shorten the file with "..." or "unchanged" - write every line. If the code holds a line starting "// [... N lines cut by the page", keep that line exactly where it is. useMaterial3: false - delete it. primarySwatch - replace with colorScheme: ColorScheme.fromSeed(seedColor: the same colour). accentColor - move it into the scheme: ColorScheme.fromSeed(seedColor: ..., secondary: the accent colour). AppBarTheme color: - becomes backgroundColor:. A *Theme class in ThemeData - the *ThemeData class with the same arguments. A removed button - its replacement (RaisedButton to ElevatedButton, FlatButton to TextButton, OutlineButton to OutlinedButton), with color: moved into style: ElevatedButton.styleFrom(backgroundColor: ...) (or the matching styleFrom). A Material 2 navigation component - its Material 3 replacement with the same destinations (BottomNavigationBarItem becomes NavigationDestination, items: becomes destinations:). A failing role pair - the on-colour becomes the passing colour the facts give, exactly. No dark theme - add darkTheme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: the same seed, brightness: Brightness.dark)) and themeMode: ThemeMode.system. When the light theme has no seed, seed both from the colour the facts name as the seed. A hard-coded colour or font size in a widget - read the matching role or text style from Theme.of(context); if no role fits, keep the literal and put the id in kept. A scrollbar - thumbVisibility: true. Dialog actions - textDirection: defaultTargetPlatform == TargetPlatform.windows ? TextDirection.rtl : TextDirection.ltr on the Row (material.dart already exports defaultTargetPlatform).
  3. changes - one object per page finding you fixed, in id order: id, change (one sentence: what changed, naming the class). Extra ids (X1, ...) from the carried review may appear too.
  4. kept - one object per page finding you did not change: id, why (one sentence - the review 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 review cleared belongs in kept.
  5. caveats - at most 4 short strings; [] when there is nothing to say.
{
  "task": "migrate",
  "summary": "one sentence",
  "code": "the complete migrated Dart file",
  "changes": [ { "id": "T1", "change": "one sentence" } ],
  "kept": [ { "id": "T3", "why": "one sentence" } ],
  "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; Platform comes with its dart:io import; a changed role-contrast finding uses the measured passing colour in the code; and the migrated code is scanned again - a rule the changes claim fixed that still fires, or a rule the paste never had, is flagged.

Worked examples

Three runs from the page's free samples (invented apps): the review of a 2021 shop theme that no longer compiles, the migration of the same file carrying that review, and the review of a hand-built brand scheme. 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.

review - A 2021 shop theme that no longer compiles

The page read it as: Does not compile - 1 blocker, 3 major, 5 minor; findings T1 no-dark, T2 m3-off, T3 swatch, T4 deprecated-prop, T5 theme-suffix, T6 appbar-color, T7 theme-suffix, T8 legacy-button, T9 legacy-nav.

Input (facts is 2224 characters; the first 1391 are shown):

{
 "task": "review",
 "theme": "Theme: Fenwickle Market\nTargets: Android and iOS\nSeed found: Colors.teal",
 "code": "import 'package:flutter/material.dart';\n\nvoid main() => runApp(const ShopApp());\n\nclass ShopApp extends StatelessWidget {\n  const ShopApp({super.key});\n\n  @override\n  Widget build(BuildContext context) {\n    return MaterialApp(\n      title: 'Fenwickle Market',\n      theme: ThemeData(\n        useMaterial3: false,\n        primarySwatch: Colors.teal,\n        accentColor: Colors.orangeAccent,\n        appBarTheme: const AppBarTheme(color: Colors.teal, elevation: 4),\n        cardTheme: const CardTheme(elevation: 2),\n      ),\n      home: const ShopHome(),\n    );\n  }\n}\n\nclass ShopHome extends StatelessWidget {\n  const ShopHome({super.key});\n\n  @override\n  Widget build(BuildContext context) {\n    return Scaffold(\n      appBar: AppBar(title: const Text('Fenwickle Market')),\n      body: Center(\n        child: RaisedButton(\n          color: Colors.orangeAccent,\n          onPressed: () {},\n          child: const Text('Check out'),\n        ),\n      ),\n      bottomNavigationBar: BottomNavigationBar(\n        items: const [\n          BottomNavigationBarItem(icon: Icon(Icons.store), label: 'Shop'),\n          BottomNavigationBarItem(icon: Icon(Icons.receipt), label: 'Orders'),\n        ],\n      ),\n    );\n  }\n}\n",
 "carry": "",
 "facts": "The page's scan of 47 lines (19 calls); verdict: Does not compile - 1 blocker, 3 major, 5 minor.\nFindings (id | area | severity | line | where | evidence | source):\n- T1 | darkmode | minor | line 10 | MaterialApp | MaterialApp sets theme: but no darkTheme:, so dark mode falls back to the light theme. | source: return MaterialApp(\n- T2 | material3 | major | line 13 | ThemeData | ThemeData sets useMaterial3: false - Material 3 is the default since Flutter 3.16. | source: useMaterial3: false,\n- T3 | material3 | minor | line 14 | ThemeData | ThemeData builds its colours from primarySwatch, the Material 2 path. | source: primarySwatch: Colors.teal,\n- T4 | deprecated | major | line 15 | ThemeData | ThemeData sets accentColor, which is deprecated. | source: accentColor: Colors.orangeAccent,\n- T5 | components | minor | line 16 | ThemeData | appBarTheme is set to AppBarTheme(...) - ThemeData's component themes take the normalized AppBarThemeData class. | source: appBarTheme: const AppBarTheme(color: Colors.teal, elevation: 4),\n- T6 | deprecated | major | line 16 | AppBarTheme | AppBarTheme sets color:, which is deprecated. | source: appBarTheme: const AppBarTheme(color: Colors.teal, elevation: 4),\n- T7 | components | minor | line 17 | ThemeData | cardTheme is set to CardTheme(...) - ThemeData's component themes take the normalized CardThemeData class. | source: cardTheme: const\n\n[... 833 more characters cut from this display ...]"
}

Reply:

{
 "task": "review",
 "summary": "Fenwickle Market is a Material 2 theme with no dark mode, and the removed RaisedButton stops it compiling, so replace that button with ElevatedButton first.",
 "findings": [
  {
   "id": "T1",
   "widget": "MaterialApp - ShopApp",
   "issue": "With no darkTheme:, a phone in dark mode still shows the light teal theme.",
   "fix": "Add a darkTheme: seeded from Colors.teal with Brightness.dark and set themeMode: ThemeMode.system.",
   "code": "darkTheme: ThemeData(\n  colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal, brightness: Brightness.dark),\n),\nthemeMode: ThemeMode.system,"
  },
  {
   "id": "T2",
   "widget": "ThemeData - the app theme",
   "issue": "useMaterial3: false pins the whole shop to the Material 2 look and opts out of the current default.",
   "fix": "Delete the useMaterial3: false line.",
   "code": ""
  },
  {
   "id": "T3",
   "widget": "ThemeData - the app theme",
   "issue": "primarySwatch: Colors.teal builds the colours on the Material 2 path, so no Material 3 colour roles are generated.",
   "fix": "Replace primarySwatch with colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal).",
   "code": "colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),"
  },
  {
   "id": "T4",
   "widget": "ThemeData - the app theme",
   "issue": "accentColor is deprecated, so the orange accent is not carried into the colour scheme the widgets read.",
   "fix": "Move Colors.orangeAccent into the scheme as secondary: and delete accentColor.",
   "code": "colorScheme: ColorScheme.fromSeed(\n  seedColor: Colors.teal,\n  secondary: Colors.orangeAccent,\n),"
  },
  {
   "id": "T5",
   "widget": "ThemeData - appBarTheme",
   "issue": "appBarTheme is given the AppBarTheme class instead of the normalized AppBarThemeData class.",
   "fix": "Pass AppBarThemeData with the same arguments.",
   "code": "appBarTheme: const AppBarThemeData(backgroundColor: Colors.teal, elevation: 4),"
  },
  {
   "id": "T6",
   "widget": "AppBarTheme - the app bar theme",
   "issue": "color: on the app bar theme is deprecated and is the old spelling of the bar colour.",
   "fix": "Rename color: to backgroundColor:, keeping Colors.teal.",
   "code": ""
  },
  {
   "id": "T7",
   "widget": "ThemeData - cardTheme",
   "issue": "cardTheme is given the CardTheme class instead of the normalized CardThemeData class.",
   "fix": "Pass CardThemeData with the same elevation.",
   "code": "cardTheme: const CardThemeData(elevation: 2),"
  },
  {
   "id": "T8",
   "widget": "RaisedButton - the Check out button",
   "issue": "RaisedButton was removed, so ShopHome does not compile on a current Flutter SDK.",
   "fix": "Use ElevatedButton and move color: into style: ElevatedButton.styleFrom(backgroundColor: ...).",
   "code": "child: ElevatedButton(\n  style: ElevatedButton.styleFrom(backgroundColor: Colors.orangeAccent),\n  onPressed: () {},\n  child: const Text('Check out'),\n),"
  },
  {
   "id": "T9",
   "widget": "BottomNavigationBar - ShopHome",
   "issue": "BottomNavigationBar keeps the Shop and Orders tabs in the Material 2 style.",
   "fix": "Use NavigationBar with destinations: built from NavigationDestination, keeping both tabs.",
   "code": "bottomNavigationBar: NavigationBar(\n  destinations: const [\n    NavigationDestination(icon: Icon(Icons.store), label: 'Shop'),\n    NavigationDestination(icon: Icon(Icons.receipt), label: 'Orders'),\n  ],\n),"
  }
 ],
 "cleared": [],
 "extra": [
  {
   "id": "X1",
   "area": "bypass",
   "line": 33,
   "widget": "RaisedButton - the Check out button",
   "issue": "The button hard-codes Colors.orangeAccent, so it ignores the theme and keeps the same orange in dark mode.",
   "fix": "Once the accent lives in the scheme, read Theme.of(context).colorScheme.secondary for the button background, or drop the override and let the theme colour it."
  },
  {
   "id": "X2",
   "area": "material3",
   "line": 16,
   "widget": "AppBarTheme - the app bar theme",
   "issue": "Under Material 3 the elevation of 4 shows as a surface tint rather than the Material 2 shadow the shop was designed with.",
   "fix": "Keep the tint as the Material 3 look, or set surfaceTint: Colors.transparent and a shadowColor to keep a shadow."
  },
  {
   "id": "X3",
   "area": "components",
   "line": 38,
   "widget": "BottomNavigationBar - ShopHome",
   "issue": "The bar has no currentIndex or onTap, so tapping Orders never changes the selected tab or the page.",
   "fix": "Hold the selected index in state and pass selectedIndex: and onDestinationSelected: to the NavigationBar."
  }
 ],
 "plan": [
  "Replace RaisedButton with ElevatedButton and move its colour into ElevatedButton.styleFrom(backgroundColor:) (T8)",
  "Rename AppBarTheme color: to backgroundColor: and switch to AppBarThemeData (T6, T5)",
  "Switch cardTheme to CardThemeData (T7)",
  "Delete useMaterial3: false (T2)",
  "Replace primarySwatch and accentColor with ColorScheme.fromSeed(seedColor: Colors.teal, secondary: Colors.orangeAccent) (T3, T4)",
  "Add a dark theme from the same teal seed with themeMode: ThemeMode.system (T1)",
  "Read the button colour from the colour scheme and decide on surface tint or shadows for the app bar (X1, X2)",
  "Replace BottomNavigationBar with NavigationBar and wire selectedIndex: and onDestinationSelected: (T9, X3)"
 ],
 "caveats": [
  "Setting secondary: by hand overrides the generated secondary role, so the page should re-measure text on that orange once the scheme is in place."
 ]
}

What step 5 prints for it: T1: Add a darkTheme: seeded from Colors.teal with Brightness.dark and set themeMode: ThemeMode.system. | T2: Delete the useMaterial3: false line. | T3: Replace primarySwatch with colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal). | T4: Move Colors.orangeAccent into the scheme as secondary: and delete accentColor. | T5: Pass AppBarThemeData with the same arguments. | T6: Rename color: to backgroundColor:, keeping Colors.teal. | T7: Pass CardThemeData with the same elevation. | T8: Use ElevatedButton and move color: into style: ElevatedButton.styleFrom(backgroundColor: ...). | T9: Use NavigationBar with destinations: built from NavigationDestination, keeping both tabs.

The page's check: kept - nothing to flag

migrate - A 2021 shop theme that no longer compiles

The input carries the saved review (R.laneForm fills carry from the review reply), as the page's handoff does. The page re-scanned the migrated code: 9 findings before, 0 after.

Input (facts is 2224 characters; the first 1391 are shown; carry is 1599 characters; the first 1382 are shown):

{
 "task": "migrate",
 "theme": "Theme: Fenwickle Market\nTargets: Android and iOS\nSeed found: Colors.teal",
 "code": "import 'package:flutter/material.dart';\n\nvoid main() => runApp(const ShopApp());\n\nclass ShopApp extends StatelessWidget {\n  const ShopApp({super.key});\n\n  @override\n  Widget build(BuildContext context) {\n    return MaterialApp(\n      title: 'Fenwickle Market',\n      theme: ThemeData(\n        useMaterial3: false,\n        primarySwatch: Colors.teal,\n        accentColor: Colors.orangeAccent,\n        appBarTheme: const AppBarTheme(color: Colors.teal, elevation: 4),\n        cardTheme: const CardTheme(elevation: 2),\n      ),\n      home: const ShopHome(),\n    );\n  }\n}\n\nclass ShopHome extends StatelessWidget {\n  const ShopHome({super.key});\n\n  @override\n  Widget build(BuildContext context) {\n    return Scaffold(\n      appBar: AppBar(title: const Text('Fenwickle Market')),\n      body: Center(\n        child: RaisedButton(\n          color: Colors.orangeAccent,\n          onPressed: () {},\n          child: const Text('Check out'),\n        ),\n      ),\n      bottomNavigationBar: BottomNavigationBar(\n        items: const [\n          BottomNavigationBarItem(icon: Icon(Icons.store), label: 'Shop'),\n          BottomNavigationBarItem(icon: Icon(Icons.receipt), label: 'Orders'),\n        ],\n      ),\n    );\n  }\n}\n",
 "carry": "T1 | confirmed | MaterialApp - ShopApp | Add a darkTheme: seeded from Colors.teal with Brightness.dark and set themeMode: ThemeMode.system.\nT2 | confirmed | ThemeData - the app theme | Delete the useMaterial3: false line.\nT3 | confirmed | ThemeData - the app theme | Replace primarySwatch with colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal).\nT4 | confirmed | ThemeData - the app theme | Move Colors.orangeAccent into the scheme as secondary: and delete accentColor.\nT5 | confirmed | ThemeData - appBarTheme | Pass AppBarThemeData with the same arguments.\nT6 | confirmed | AppBarTheme - the app bar theme | Rename color: to backgroundColor:, keeping Colors.teal.\nT7 | confirmed | ThemeData - cardTheme | Pass CardThemeData with the same elevation.\nT8 | confirmed | RaisedButton - the Check out button | Use ElevatedButton and move color: into style: ElevatedButton.styleFrom(backgroundColor: ...).\nT9 | confirmed | BottomNavigationBar - ShopHome | Use NavigationBar with destinations: built from NavigationDestination, keeping both tabs.\nX1 | extra | RaisedButton - the Check out button (line 33) | Once the accent lives in the scheme, read Theme.of(context).colorScheme.secondary for the button background, or drop the override and let the theme colour it.\nX2 | extra | AppBarTheme - the app bar theme (line 16) | Keep the tint as the Material 3 look, or set surfaceTint:\n\n[... 217 more characters cut from this display ...]",
 "facts": "The page's scan of 47 lines (19 calls); verdict: Does not compile - 1 blocker, 3 major, 5 minor.\nFindings (id | area | severity | line | where | evidence | source):\n- T1 | darkmode | minor | line 10 | MaterialApp | MaterialApp sets theme: but no darkTheme:, so dark mode falls back to the light theme. | source: return MaterialApp(\n- T2 | material3 | major | line 13 | ThemeData | ThemeData sets useMaterial3: false - Material 3 is the default since Flutter 3.16. | source: useMaterial3: false,\n- T3 | material3 | minor | line 14 | ThemeData | ThemeData builds its colours from primarySwatch, the Material 2 path. | source: primarySwatch: Colors.teal,\n- T4 | deprecated | major | line 15 | ThemeData | ThemeData sets accentColor, which is deprecated. | source: accentColor: Colors.orangeAccent,\n- T5 | components | minor | line 16 | ThemeData | appBarTheme is set to AppBarTheme(...) - ThemeData's component themes take the normalized AppBarThemeData class. | source: appBarTheme: const AppBarTheme(color: Colors.teal, elevation: 4),\n- T6 | deprecated | major | line 16 | AppBarTheme | AppBarTheme sets color:, which is deprecated. | source: appBarTheme: const AppBarTheme(color: Colors.teal, elevation: 4),\n- T7 | components | minor | line 17 | ThemeData | cardTheme is set to CardTheme(...) - ThemeData's component themes take the normalized CardThemeData class. | source: cardTheme: const\n\n[... 833 more characters cut from this display ...]"
}

Reply (long strings shortened for display - see the cuts below):

{
 "task": "migrate",
 "summary": "Moves Fenwickle Market onto Material 3 with a teal seed scheme, normalized component themes, ElevatedButton, NavigationBar and a system dark theme; the team still decides whether the app bar keeps its tint or a shadow.",
 "code": "import 'package:flutter/material.dart';\n\nvoid main() => runApp(const ShopApp());\n\nclass ShopApp extends StatelessWidget {\n  const ShopApp({super.key});\n\n  @override\n  Widget build(BuildContext context) {\n    return MaterialApp(\n      title: 'Fenwickle Market',\n      theme: ThemeData(\n        colorScheme: [... 1513 more characters cut from this display]",
 "changes": [
  {
   "id": "T1",
   "change": "MaterialApp now passes a darkTheme: ThemeData seeded from Colors.teal with Brightness.dark and sets themeMode: ThemeMode.system."
  },
  {
   "id": "T2",
   "change": "The useMaterial3: false line is deleted from ThemeData, so the app uses the Material 3 default."
  },
  {
   "id": "T3",
   "change": "ThemeData drops primarySwatch and builds colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal)."
  },
  {
   "id": "T4",
   "change": "ThemeData drops accentColor and Colors.orangeAccent moves into ColorScheme.fromSeed as secondary:."
  },
  {
   "id": "T5",
   "change": "appBarTheme now takes AppBarThemeData with the same elevation of 4."
  },
  {
   "id": "T6",
   "change": "The app bar theme sets backgroundColor: Colors.teal in place of the deprecated color: argument."
  },
  {
   "id": "T7",
   "change": "cardTheme now takes CardThemeData with the same elevation of 2."
  },
  {
   "id": "T8",
   "change": "The Check out RaisedButton becomes an ElevatedButton with its colour in style: ElevatedButton.styleFrom(backgroundColor: ...)."
  },
  {
   "id": "T9",
   "change": "BottomNavigationBar becomes NavigationBar, with both tabs rebuilt as NavigationDestination entries under destinations:."
  },
  {
   "id": "X1",
   "change": "The ElevatedButton background reads Theme.of(context).colorScheme.secondary instead of the literal Colors.orangeAccent, so it follows the scheme in both themes."
  },
  {
   "id": "X3",
   "change": "ShopHome becomes a StatefulWidget that holds the selected index and passes selectedIndex: and onDestinationSelected: to the NavigationBar."
  }
 ],
 "kept": [],
 "caveats": [
  "X2 is left as a decision: with elevation 4 the app bar now shows a Material 3 surface tint; set surfaceTint: Colors.transparent and a shadowColor if the team wants a shadow instead.",
  "The dark theme seeds from Colors.teal only, so the button reads the generated dark secondary rather than Colors.orangeAccent; add secondary: there too if the accent must match.",
  "The fixed backgroundColor: Colors.teal on the app bar applies to the light theme only; the dark theme uses the generated scheme."
 ]
}

Cut from this display only (the saved reply and the check use the full text): code (1813 characters, 300 shown).

What step 5 prints for it: T1: MaterialApp now passes a darkTheme: ThemeData seeded from Colors.teal with Brightness.dark and sets themeMode: ThemeMode.system. | T2: The useMaterial3: false line is deleted from ThemeData, so the app uses the Material 3 default. | T3: ThemeData drops primarySwatch and builds colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal). | T4: ThemeData drops accentColor and Colors.orangeAccent moves into ColorScheme.fromSeed as secondary:. | T5: appBarTheme now takes AppBarThemeData with the same elevation of 4. | T6: The app bar theme sets backgroundColor: Colors.teal in place of the deprecated color: argument. | T7: cardTheme now takes CardThemeData with the same elevation of 2. | T8: The Check out RaisedButton becomes an ElevatedButton with its colour in style: ElevatedButton.styleFrom(backgroundColor: ...). | T9: BottomNavigationBar becomes NavigationBar, with both tabs rebuilt as NavigationDestination entries under destinations:. | X1: The ElevatedButton background reads Theme.of(context).colorScheme.secondary instead of the literal Colors.orangeAccent, so it follows the scheme in both themes. | X3: ShopHome becomes a StatefulWidget that holds the selected index and passes selectedIndex: and onDestinationSelected: to the NavigationBar.

The page's check: kept - nothing to flag

review - A hand-built brand scheme

The page read it as: Migrate to Material 3 - 0 blocker, 1 major, 4 minor; findings T1 role-contrast, T2 no-dark, T3 bypass-color, T4 bypass-type, T5 bypass-color.

Input (facts is 1941 characters; the first 1397 are shown):

{
 "task": "review",
 "theme": "Theme: Brand theme\nTargets: Android and iOS\nSeed found: Color(0xFFFF7A59)",
 "code": "import 'package:flutter/material.dart';\n\nconst brandScheme = ColorScheme(\n  brightness: Brightness.light,\n  primary: Color(0xFFFF7A59),\n  onPrimary: Color(0xFFFFFFFF),\n  secondary: Color(0xFF2D3E50),\n  onSecondary: Color(0xFFFFFFFF),\n  error: Color(0xFFB3261E),\n  onError: Color(0xFFFFFFFF),\n  surface: Color(0xFFFFFBF7),\n  onSurface: Color(0xFF1C1B1A),\n);\n\nclass BrandApp extends StatelessWidget {\n  const BrandApp({super.key});\n\n  @override\n  Widget build(BuildContext context) {\n    return MaterialApp(\n      theme: ThemeData(\n        colorScheme: brandScheme,\n        appBarTheme: const AppBarThemeData(backgroundColor: Color(0xFFFF7A59)),\n      ),\n      home: Scaffold(\n        body: Column(\n          children: [\n            const Text('Welcome back', style: TextStyle(fontSize: 28, color: Color(0xFF2D3E50))),\n            Container(\n              color: Color(0xFFFFE8E0),\n              child: const Text('Your trial ends in 3 days'),\n            ),\n            FilledButton(onPressed: () {}, child: const Text('Upgrade')),\n          ],\n        ),\n      ),\n    );\n  }\n}\n",
 "carry": "",
 "facts": "The page's scan of 40 lines (24 calls); verdict: Migrate to Material 3 - 0 blocker, 1 major, 4 minor.\nFindings (id | area | severity | line | where | evidence | source):\n- T1 | contrast | major | line 6 | ColorScheme | onPrimary Color(0xFFFFFFFF) on primary Color(0xFFFF7A59) is 2.57:1 - text on a role colour needs 4.5:1. Use Color(0xFF333333) for onPrimary - the page measures it at 4.92:1 on Color(0xFFFF7A59). | source: onPrimary: Color(0xFFFFFFFF),\n- T2 | darkmode | minor | line 20 | MaterialApp | MaterialApp sets theme: but no darkTheme:, so dark mode falls back to the light theme. | source: return MaterialApp(\n- T3 | bypass | minor | line 28 | TextStyle | TextStyle hard-codes color: Color(0xFF2D3E50) instead of reading the theme. | source: const Text('Welcome back', style: TextStyle(fontSize: 28, color: Color(0xFF2D3E50))),\n- T4 | bypass | minor | line 28 | TextStyle | TextStyle hard-codes fontSize: 28 instead of a textTheme style. | source: const Text('Welcome back', style: TextStyle(fontSize: 28, color: Color(0xFF2D3E50))),\n- T5 | bypass | minor | line 30 | Container | Container hard-codes color: Color(0xFFFFE8E0) instead of reading the theme. | source: color: Color(0xFFFFE8E0),\nRole contrast the page could measure (line | on-colour on role | ratio | needs):\n- line 6 | onPrimary Color(0xFFFFFFFF) on primary Color(0xFFFF7A59) | 2.57:1 | 4.5:1 FAIL | a colour that passes:\n\n[... 544 more characters cut from this display ...]"
}

Reply:

{
 "task": "review",
 "summary": "A hand-built light ColorScheme with no dark theme, white onPrimary text that fails contrast on the orange primary, and widget colours and sizes typed as literals; change onPrimary to the passing colour first.",
 "findings": [
  {
   "id": "T1",
   "widget": "ColorScheme - brandScheme",
   "issue": "White onPrimary text on the Color(0xFFFF7A59) primary measures 2.57:1, so the Upgrade FilledButton label and anything else on primary is hard to read.",
   "fix": "Set onPrimary to Color(0xFF333333), which the page measures at 4.92:1 on the primary.",
   "code": "  primary: Color(0xFFFF7A59),\n  onPrimary: Color(0xFF333333),"
  },
  {
   "id": "T2",
   "widget": "MaterialApp - BrandApp",
   "issue": "MaterialApp has theme: but no darkTheme:, so a phone in dark mode still shows the light cream surface and orange scheme.",
   "fix": "Add a darkTheme seeded from the same Color(0xFFFF7A59) with Brightness.dark and set themeMode: ThemeMode.system.",
   "code": "darkTheme: ThemeData(\n  colorScheme: ColorScheme.fromSeed(\n    seedColor: Color(0xFFFF7A59),\n    brightness: Brightness.dark,\n  ),\n),\nthemeMode: ThemeMode.system,"
  },
  {
   "id": "T3",
   "widget": "Text - Welcome back",
   "issue": "The heading colour Color(0xFF2D3E50) is a copy of the scheme's secondary, so it will not follow a scheme change and stays dark navy on a dark background in dark mode.",
   "fix": "Read the colour from Theme.of(context).colorScheme.secondary instead of the literal.",
   "code": "Text('Welcome back',\n  style: Theme.of(context).textTheme.headlineMedium?.copyWith(\n    color: Theme.of(context).colorScheme.secondary,\n  ),\n),"
  },
  {
   "id": "T4",
   "widget": "Text - Welcome back",
   "issue": "fontSize: 28 is typed by hand, which is the size of the Material 3 headlineMedium style, so the heading ignores the app's textTheme and text scaling choices made there.",
   "fix": "Use Theme.of(context).textTheme.headlineMedium as the base style, as in the T3 code, and drop the fontSize literal.",
   "code": ""
  },
  {
   "id": "T5",
   "widget": "Container - trial notice",
   "issue": "The peach Color(0xFFFFE8E0) is not a role in brandScheme, so in dark mode the box stays light while the Text inside switches to the light dark-mode onSurface and becomes unreadable.",
   "fix": "Add primaryContainer: Color(0xFFFFE8E0) to brandScheme and read Theme.of(context).colorScheme.primaryContainer in the Container, so the dark scheme supplies its own container colour.",
   "code": "Container(\n  color: Theme.of(context).colorScheme.primaryContainer,\n  child: const Text('Your trial ends in 3 days'),\n),"
  }
 ],
 "cleared": [],
 "extra": [
  {
   "id": "X1",
   "area": "bypass",
   "line": 25,
   "widget": "Scaffold - home",
   "issue": "The Scaffold is built inline in BrandApp.build, whose context sits above MaterialApp, so Theme.of(context) there would read the default theme rather than brandScheme once T3 to T5 are fixed.",
   "fix": "Wrap the Scaffold in a Builder (or move it into its own widget) and call Theme.of on that inner context."
  },
  {
   "id": "X2",
   "area": "bypass",
   "line": 23,
   "widget": "AppBarThemeData - the app theme",
   "issue": "backgroundColor: Color(0xFFFF7A59) repeats the primary colour as a literal, so changing the scheme leaves the app bar on the old orange.",
   "fix": "Use AppBarThemeData(backgroundColor: brandScheme.primary) without const, or drop the override and let the scheme colour the app bar."
  }
 ],
 "plan": [
  "Change onPrimary to Color(0xFF333333) in brandScheme (T1)",
  "Add a dark theme seeded from Color(0xFFFF7A59) with themeMode: ThemeMode.system (T2)",
  "Wrap the home Scaffold in a Builder so Theme.of reads the app theme (X1)",
  "Replace the Welcome back TextStyle with textTheme.headlineMedium and colorScheme.secondary (T3, T4)",
  "Add primaryContainer: Color(0xFFFFE8E0) to brandScheme and read it in the trial Container (T5)",
  "Point the app bar background at the scheme primary instead of a literal (X2)"
 ],
 "caveats": [
  "brandScheme is built by hand, so the dark theme from the seed will not match the light brand colours exactly; check the dark secondary and primaryContainer on a device.",
  "The page measured onPrimary only against the light primary; the dark theme pairs come from fromSeed and were not measured here."
 ]
}

What step 5 prints for it: T1: Set onPrimary to Color(0xFF333333), which the page measures at 4.92:1 on the primary. | T2: Add a darkTheme seeded from the same Color(0xFFFF7A59) with Brightness.dark and set themeMode: ThemeMode.system. | T3: Read the colour from Theme.of(context).colorScheme.secondary instead of the literal. | T4: Use Theme.of(context).textTheme.headlineMedium as the base style, as in the T3 code, and drop the fontSize literal. | T5: Add primaryContainer: Color(0xFFFFE8E0) to brandScheme and read Theme.of(context).colorScheme.primaryContainer in the Container, so the dark scheme supplies its own container colour.

The page's check: kept - nothing to flag

Build the input yourself

Load the engine in node (global.window = global; require("./themekit.js"); require("./themereply.js")), then:

const K = ThemeKit, R = ThemeReply;
const f = K.form({ name: "Acme", targets: "mobile", carry: "",
  code: "MaterialApp(theme: ThemeData(useMaterial3: false, accentColor: Colors.pink), home: Home())" });
// f.findings (id, area, sev, line, widget, evidence, fix), f.pairs (role contrast), f.checklist, f.starter
// K.findingsCsv(f), K.checklistMd(f) - the free exports
const b = K.buildInput(f, "review");                         // or "migrate" (R.laneForm(form, "migrate", review) fills carry)
const wire = K.toWire(b.input);                              // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "review");
// 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://theme-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":"theme-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": "review", "theme": "Theme: Fenwickle Market
Targets: Android and iOS
Seed found: Colors.teal", "code": "import 'package:flutter/material.dart';
...", "carry": "", "facts": "The page's scan of 46 lines ...
Findings (id | area | severity | line | widget | evidence | source):
- T1 | darkmode | minor | 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 theme-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. A review reply carries summary, findings, cleared, extra, plan and caveats; a migrate reply carries summary, code, changes, kept and caveats. The code below prints the gist of either: each confirmed finding and its fix, or each change.

KEY="theme-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 == "migrate" 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: theme-lens:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json