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')}', + ); + }); +}