From c533a7cfc2e8b838a36eea3caf3c0b40d8c46898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Thu, 27 Aug 2026 23:25:09 +0300 Subject: [PATCH] feat(gate): catch the tokens that resolve to nothing Wind drops an unrecognised token SILENTLY: no warning, no throw, nothing at the call site. So a token that never existed, one that was renamed, and one that works look identical in the source, and the only symptom is a colour that never appears. That is the rare rule worth a test rather than a reviewer, because the failure is invisible exactly where a person reads. This guard mirrors one that first paid for itself in a product built on this boilerplate, where it found four dead tokens, two of them invisible to a full-tree review. It found two more here on its first run, and both are fixed in this commit rather than allowlisted: `text-destructive` resolved to nothing, so the dashboard's heart glyph rendered in the inherited colour. `design:sync` emits `bg-destructive` and `text-on-destructive` (the colour that sits ON the solid) but no `text-` peer, which is a shape worth naming: the role exists in DESIGN.md, the prefix does not exist in the generated map, and the near-miss reads perfectly. `config/view.dart` reached straight for `bg-white`, `text-gray-600` and `bg-red-500`. Those resolve, so the first check cannot see them, which is why the second one exists: a hardcoded colour cannot answer to a theme change, and regenerating `wind_theme.g.dart` from a different DESIGN.md restyled the whole app while every dialog kept the old palette. Those strings are as much a design surface as a widget's className. THE DIALOG TONES MOVE as a result, and deliberately: `bg-white dark:bg-gray-800` becomes `bg-surface-container`, the card tone, because a dialog sits above the page rather than on it. The `dark:` peers disappear from that file because each alias already carries its own. Two supporting changes. `buildWindTheme()` moves the assembly out of `main()` so a test can ask the same theme the app runs on; a second copy would drift and certify the wrong map. And `supplementAliases` gives hand-authored tokens a home beside the generated file rather than inside it, since `design:sync` overwrites that file wholesale and would drop them silently. Both halves mutation-tested: restoring one palette colour reddens the Token-Only check, dropping the supplement reddens the dead-token one. `bin/check` green. NOT VISUALLY VERIFIED. Driving this app locally fails on a CORS preflight between the web build and its API, which is an environment problem this change did not cause and does not fix, so the moved dialog tones have been reasoned about rather than looked at. --- lib/config/view.dart | 26 +++- lib/config/wind_theme.dart | 41 +++++ lib/main.dart | 7 +- test/config/wind_token_resolution_test.dart | 164 ++++++++++++++++++++ 4 files changed, 229 insertions(+), 9 deletions(-) create mode 100644 lib/config/wind_theme.dart create mode 100644 test/config/wind_token_resolution_test.dart diff --git a/lib/config/view.dart b/lib/config/view.dart index fa8abdd..0c0fdd0 100644 --- a/lib/config/view.dart +++ b/lib/config/view.dart @@ -3,19 +3,31 @@ /// Customizes the appearance of Magic UI components (dialogs, confirms, /// loading). These className values are read by MagicFeedback via /// `Config.get('view.*')`. +/// +/// EVERY COLOUR HERE IS A SEMANTIC TOKEN, never a palette one. These strings +/// are as much a design surface as a widget's `className`, and they used to be +/// the one place in this app that reached straight for `bg-white`, +/// `text-gray-600` and `bg-red-500`. A raw palette colour cannot answer to a +/// theme change: regenerating `wind_theme.g.dart` from a different DESIGN.md +/// restyled the whole app and left every dialog looking like the old one. +/// +/// It also means the `dark:` peers disappear from this file. They have not been +/// dropped: each alias carries its own, so `bg-surface-container` already +/// resolves to one hex in light and another in dark. Map get viewConfig => { 'view': { 'dialog': { - 'class': 'bg-white dark:bg-gray-800 rounded-xl p-6 shadow-2xl max-w-lg', + 'class': 'bg-surface-container rounded-xl p-6 shadow-2xl max-w-lg', }, 'confirm': { 'container_class': - 'bg-white dark:bg-gray-800 rounded-xl p-6 shadow-2xl w-80', - 'title_class': 'text-lg font-bold text-gray-900 dark:text-white', - 'message_class': 'text-gray-600 dark:text-gray-400 mt-2', - 'button_cancel_class': 'px-4 py-2 text-gray-600 dark:text-gray-300', - 'button_confirm_class': 'px-4 py-2 bg-primary text-white rounded-lg', - 'button_danger_class': 'px-4 py-2 bg-red-500 text-white rounded-lg', + 'bg-surface-container rounded-xl p-6 shadow-2xl w-80', + 'title_class': 'text-lg font-bold text-fg', + 'message_class': 'text-fg-muted mt-2', + 'button_cancel_class': 'px-4 py-2 text-fg-muted', + 'button_confirm_class': 'px-4 py-2 bg-primary text-on-primary rounded-lg', + 'button_danger_class': + 'px-4 py-2 bg-destructive text-on-destructive rounded-lg', }, }, }; diff --git a/lib/config/wind_theme.dart b/lib/config/wind_theme.dart new file mode 100644 index 0000000..e12c8ff --- /dev/null +++ b/lib/config/wind_theme.dart @@ -0,0 +1,41 @@ +import 'package:magic/magic.dart'; + +import 'wind_theme.g.dart'; + +/// The app's Wind theme, assembled in ONE place. +/// +/// A function rather than a `WindThemeData` built inline in `main()`, so that +/// anything which needs to ask the theme a question can get the same answer the +/// running app gets. The token guard in `test/config/` is the reason it exists: +/// a test cannot call `main()` to find out which aliases resolve, and a second +/// copy of the assembly would drift from this one and certify the wrong map. +/// +/// Everything here comes from `wind_theme.g.dart`, which `design:sync` +/// regenerates from DESIGN.md. Hand-authored supplements, when a project needs +/// tokens `design:sync` does not emit, belong in their own file and are merged +/// in here rather than in `main()`. +WindThemeData buildWindTheme() { + return WindThemeData( + colors: designColors, + aliases: {...designAliases, ...supplementAliases}, + ); +} + +/// Tokens `design:sync` does not emit, hand-authored and merged above. +/// +/// Kept beside the generated map rather than inside it, because `design:sync` +/// overwrites `wind_theme.g.dart` wholesale: anything added there is lost on +/// the next regeneration, silently, in a file nobody re-reads. +/// +/// Every entry needs a REASON, and the reason is always the same shape: a role +/// DESIGN.md defines, in a prefix the generator does not produce for it. Wind +/// resolves an unknown token to nothing without complaining, so the absence +/// shows up as a colour that never appears rather than as an error. +const Map supplementAliases = { + // text-destructive: the destructive role as a FOREGROUND. `design:sync` emits + // `bg-destructive` and `text-on-destructive` (the colour that sits ON the + // solid), but no `text-` peer, so a destructive-coloured glyph or line of + // text resolved to nothing and rendered in the inherited colour. Same hexes + // as `bg-destructive`, which is what the role means. + 'text-destructive': 'text-[#DC2626] dark:text-[#EF4444]', +}; diff --git a/lib/main.dart b/lib/main.dart index 5dd33ad..9515c45 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,7 +10,7 @@ import 'config/cache.dart'; import 'config/logging.dart'; import 'config/broadcasting.dart'; import 'config/deeplink.dart'; -import 'config/wind_theme.g.dart'; +import 'config/wind_theme.dart'; import 'package:flutter/foundation.dart' show kDebugMode; import 'package:magic_devtools/magic_devtools.dart'; import 'package:magic_starter/magic_starter.dart' show MagicStarter; @@ -47,7 +47,10 @@ void main() async { // Theme generated from DESIGN.md via `design:sync`. Regenerate with: // dart run bin/dispatcher.dart design:sync - final windTheme = WindThemeData(colors: designColors, aliases: designAliases); + // + // Assembled in `config/wind_theme.dart` rather than here, so the token guard + // in `test/config/` can ask the same theme this app runs on. + final WindThemeData windTheme = buildWindTheme(); // Adopt the wind theme across every magic_starter sub-theme in one call, so // the starter's navigation, form, auth, and layout surfaces derive from the diff --git a/test/config/wind_token_resolution_test.dart b/test/config/wind_token_resolution_test.dart new file mode 100644 index 0000000..dcfebe3 --- /dev/null +++ b/test/config/wind_token_resolution_test.dart @@ -0,0 +1,164 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; + +import 'package:magic_example/config/wind_theme.dart'; + +/// Every colour token written in `lib/` has to resolve to something. +/// +/// WIND DROPS AN UNRECOGNISED TOKEN SILENTLY: no warning, no throw, nothing at +/// the call site. So a token that never existed, one that was renamed, and one +/// that works look identical in the source, and the only symptom is a colour +/// that does not appear. That makes this the rare rule worth a test rather than +/// a reviewer: the failure is invisible precisely where a human reads. +/// +/// The shapes it catches, each of which has happened: +/// +/// - A `bg-` peer for a role `design:sync` emits only as `text-`. `fg-muted` is +/// a foreground colour, so `bg-fg-muted` reads perfectly and resolves to +/// nothing until an alias defines it. +/// - A near-miss on an alias that does exist: a map defining `border-ai-soft` +/// makes `border-ai` look like a sibling rather than an absence. +/// - A prefix the parser cannot read: `border-bg-destructive` is a border whose +/// body names a background, and the border parser answers nothing. +/// +/// The second test is the Token-Only rule, and it exists because the first one +/// CANNOT see its target: a raw palette token like `text-white` resolves +/// perfectly. It is a blocker all the same, because a hardcoded colour cannot +/// answer to dark mode or to a theme change. +/// +/// This is the boilerplate's copy of a guard that first paid for itself in a +/// product built on it. Keep the two in step. +void main() { + final WindThemeData theme = buildWindTheme(); + + /// Colour-family names Wind ships built in, which resolve without an alias. + /// + /// The Token-Only rule bans reaching for them by name in app code, so they are + /// listed here to be REJECTED rather than allowed: a token naming one of these + /// is a raw palette token, not a semantic one. + const Set rawPalette = { + 'white', 'black', + 'slate', 'gray', 'grey', 'zinc', 'neutral', 'stone', + 'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal', + 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', + 'rose', + }; + + /// Values that name the ABSENCE of a colour rather than a choice of one. + /// + /// `border-transparent` holds a border's width without painting it, which is + /// how a swatch picker keeps its layout stable between selected and + /// unselected. Banning it would push the call site to a hardcoded hex or to a + /// layout hack, both worse. + const Set colourless = {'transparent', 'current', 'inherit'}; + + /// Tokens whose `bg-`/`text-`/`border-` prefix is not a colour at all: sizes, + /// weights, alignment, border sides and widths, background sizing. + final RegExp nonColour = RegExp( + r'^(?:' + r'text-(?:xs|sm|base|lg|xl|\dxl|left|right|center|justify|wrap|nowrap|ellipsis|clip|balance)' + r'|border-(?:t|b|l|r|x|y|s|e|\d+|none|solid|dashed|dotted|double|hidden)' + r'|bg-(?:cover|contain|auto|fixed|local|scroll|center|top|bottom|left|right|repeat|no-repeat|clip-\w+|origin-\w+|gradient-[\w-]+|linear-[\w-]+)' + r')$', + ); + + /// Every colour-ish token written in `lib/`, with its file and line. + /// + /// Comment lines are skipped: a codebase names dead tokens in comments on + /// purpose (explaining why one was replaced), and flagging those would make + /// the guard argue with its own documentation. + Map> tokensInLib() { + final Map> found = >{}; + final RegExp token = RegExp(r'\b(?:bg|text|border)-[a-z][a-z0-9-]*'); + + for (final FileSystemEntity entity + in Directory('lib').listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) continue; + // Generated files are regenerated, never hand-edited, and the theme file + // legitimately contains the raw hex the aliases expand to. + if (entity.path.endsWith('.g.dart')) continue; + // `lib/preview/` is the token CATALOGUE: its job is to display token + // names, so it carries them as data (a row label, a `startsWith` test) + // rather than as classNames. Scanning it makes the guard argue with the + // one directory whose purpose is to name what the guard checks. + if (entity.path.startsWith('lib/preview/')) continue; + + final List lines = entity.readAsLinesSync(); + + for (int i = 0; i < lines.length; i++) { + final String line = lines[i]; + final String trimmed = line.trimLeft(); + if (trimmed.startsWith('//') || trimmed.startsWith('///')) continue; + + for (final RegExpMatch match in token.allMatches(line)) { + found + .putIfAbsent(match.group(0)!, () => []) + .add('${entity.path}:${i + 1}'); + } + } + } + + return found; + } + + /// One line per offending token: the token, where it first appears, and how + /// many other sites carry it. + String site(String token, List sites) { + final String extra = sites.length > 1 ? ' (+${sites.length - 1} more)' : ''; + + return '$token -> ${sites.first}$extra'; + } + + test('no token in lib/ resolves to nothing', () { + final List dead = []; + + tokensInLib().forEach((String token, List sites) { + if (nonColour.hasMatch(token)) return; + if (theme.aliases.containsKey(token)) return; + + // A token naming a real colour family resolves, whatever the rule says + // about reaching for one by name. That is the next test's business. + final String body = token.substring(token.indexOf('-') + 1); + final String family = body.split('-').first; + if (colourless.contains(family)) return; + if (theme.colors.containsKey(family) || rawPalette.contains(family)) { + return; + } + + dead.add(site(token, sites)); + }); + + expect( + dead, + isEmpty, + reason: + 'Wind drops an unrecognised token silently, so these paint nothing ' + 'and nothing at the call site says so:\n${dead.join('\n')}', + ); + }); + + test('no token in lib/ hardcodes a raw palette colour', () { + final List raw = []; + + tokensInLib().forEach((String token, List sites) { + if (nonColour.hasMatch(token)) return; + if (theme.aliases.containsKey(token)) return; + + final String body = token.substring(token.indexOf('-') + 1); + if (colourless.contains(body.split('-').first)) return; + if (rawPalette.contains(body.split('-').first)) raw.add(site(token, sites)); + }); + + expect( + raw, + isEmpty, + reason: + 'These resolve, so the dead-token check cannot see them, and they are ' + 'blockers all the same: a hardcoded colour cannot answer to dark mode ' + 'or to a theme change. Use a semantic alias from DESIGN.md, or derive ' + 'the colour when it sits on a value the user chose:\n${raw.join('\n')}', + ); + }); +}