Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions lib/config/view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, dynamic> 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',
},
},
};
41 changes: 41 additions & 0 deletions lib/config/wind_theme.dart
Original file line number Diff line number Diff line change
@@ -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: <String, String>{...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<String, String> supplementAliases = <String, String>{
// 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]',
};
7 changes: 5 additions & 2 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
164 changes: 164 additions & 0 deletions test/config/wind_token_resolution_test.dart
Original file line number Diff line number Diff line change
@@ -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<String> rawPalette = <String>{
'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<String> colourless = <String>{'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<String, List<String>> tokensInLib() {
final Map<String, List<String>> found = <String, List<String>>{};
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<String> 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)!, () => <String>[])
.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<String> 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<String> dead = <String>[];

tokensInLib().forEach((String token, List<String> 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<String> raw = <String>[];

tokensInLib().forEach((String token, List<String> 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')}',
);
});
}