utils/design_system/test/tokens_test.dart
Krzysztof kuhy Rudnicki f7418969cf Freeze the motion, haptic and sound vocabularies, and ship them as tokens
dopamine-ux prompt 01. The token layer froze colour, spacing, radius and type,
but every animation in every app picked its own duration and curve out of the
air -- so "200ms ease-in-out" and "180ms ease-out" both existed and neither was
wrong, because nothing said what right was.

unified-design-system/motion.md is the freeze: four durations, three curves,
a haptic vocabulary mapped onto Flutter's HapticFeedback, and three sound cues,
each with its one-line rationale. Shipped as AppDuration/AppCurve in Dart and
--duration-*/--ease-* in CSS, so a consumer imports the scale instead of
transcribing it.

Three things worth stating, because they are the parts easy to get wrong:

- Reduced motion collapses the *tokens themselves* (CSS @media, Flutter
  disableAnimations), so no consumer has to remember to branch.
- Haptics and sound are NOT motion and are deliberately left alone by that
  preference. Suppressing animation is not a request to lose confirmation that
  a tap landed; the sound opt-out is a separate, user-facing setting.
- Reward the recording, never the number recorded. The haptic fires on logging
  a meal, not on hitting a calorie target -- the latter rewards eating less
  rather than recording honestly, and corrupts the app's own data.

gatelock stays untouched: Tkinter has no animation framework, so motion there
is marked `n/a (tk)` rather than shipping a dead constant that reads as a
supported feature.

Tests assert the frozen values on both sides, and that the reduced-motion block
collapses every non-zero step -- verified by mutation: deleting one collapse
line fails the suite. flutter analyze / tsc / eslint clean, 68 Dart and 88 TS
tests pass, palette_check and the 250-line cap still exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 17:28:02 +02:00

145 lines
4.5 KiB
Dart

import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('AppSpacing', () {
test('is a 4px-based scale in ascending order', () {
const scale = [
AppSpacing.xs,
AppSpacing.sm,
AppSpacing.md,
AppSpacing.lg,
AppSpacing.xl,
AppSpacing.xxl,
];
expect(scale, [4.0, 8.0, 16.0, 24.0, 32.0, 48.0]);
for (final step in scale) {
expect(step % 4, 0, reason: '$step is off the 4px grid');
}
});
});
group('AppRadius', () {
test('matches the frozen scale', () {
expect([AppRadius.sm, AppRadius.md, AppRadius.lg], [8.0, 12.0, 16.0]);
});
});
group('AppTextSize', () {
test('matches the frozen scale', () {
expect(
[
AppTextSize.caption,
AppTextSize.label,
AppTextSize.body,
AppTextSize.subtitle,
AppTextSize.title,
AppTextSize.display,
],
[12.0, 14.0, 16.0, 20.0, 24.0, 32.0],
);
});
test('body is the reading floor', () {
expect(AppTextSize.caption, lessThan(AppTextSize.body));
expect(AppTextSize.label, lessThan(AppTextSize.body));
});
});
group('AppPalette', () {
test('matches the frozen token table', () {
expect(AppPalette.ink, const Color(0xFF211D1B));
expect(AppPalette.inkRaised1, const Color(0xFF2B2624));
expect(AppPalette.inkRaised2, const Color(0xFF38312E));
expect(AppPalette.lineDark, const Color(0xFF463E3A));
expect(AppPalette.textOnDark, const Color(0xFFECEAE9));
expect(AppPalette.mutedOnDark, const Color(0xFFAAA09A));
expect(AppPalette.paper, const Color(0xFFF6F4F3));
expect(AppPalette.paperRaised, const Color(0xFFFCFBFB));
expect(AppPalette.lineLight, const Color(0xFFE0DAD7));
expect(AppPalette.mutedOnLight, const Color(0xFF70625B));
expect(AppPalette.accent, const Color(0xFFB8862E));
expect(AppPalette.success, const Color(0xFF8A9A3C));
expect(AppPalette.warning, const Color(0xFFE0A63C));
expect(AppPalette.danger, const Color(0xFFE2585F));
});
test(
'reuses ink for text-on-light and on-fill, as the table specifies',
() {
expect(AppPalette.textOnLight, AppPalette.ink);
expect(AppPalette.onFill, AppPalette.ink);
},
);
test('has one accent hue: info mirrors it', () {
expect(AppPalette.info, AppPalette.accent);
});
});
test('kProseMaxWidth caps line length', () {
expect(kProseMaxWidth, 640.0);
});
group('AppDuration', () {
test('matches the frozen scale, ascending', () {
const scale = [
AppDuration.instant,
AppDuration.fast,
AppDuration.base,
AppDuration.slow,
];
expect(scale.map((d) => d.inMilliseconds), [0, 120, 200, 320]);
for (var i = 1; i < scale.length; i++) {
expect(
scale[i] > scale[i - 1],
isTrue,
reason: 'step $i is not longer than the one before it',
);
}
});
test('instant is the zero the reduced-motion contract collapses to', () {
expect(AppDuration.instant, Duration.zero);
});
test(
'stays within the perceptible band: nothing over the 320ms ceiling',
() {
expect(AppDuration.slow.inMilliseconds, lessThanOrEqualTo(350));
expect(AppDuration.fast.inMilliseconds, greaterThanOrEqualTo(100));
},
);
});
group('AppCurve', () {
const curves = {
'standard': AppCurve.standard,
'decelerate': AppCurve.decelerate,
'accelerate': AppCurve.accelerate,
};
test('every curve is anchored at both ends', () {
curves.forEach((name, curve) {
expect(curve.transform(0), closeTo(0, 1e-6), reason: '$name at t=0');
expect(curve.transform(1), closeTo(1, 1e-6), reason: '$name at t=1');
});
});
test('matches the frozen control points', () {
expect(AppCurve.standard, const Cubic(0.2, 0, 0, 1));
expect(AppCurve.decelerate, const Cubic(0, 0, 0, 1));
expect(AppCurve.accelerate, const Cubic(0.3, 0, 1, 1));
});
test('decelerate leads and accelerate trails, as their names promise', () {
// Named for what they do at the END: decelerate has covered most of the
// distance by the midpoint (it is settling), accelerate has covered
// little (it is still winding up).
expect(AppCurve.decelerate.transform(0.5), greaterThan(0.5));
expect(AppCurve.accelerate.transform(0.5), lessThan(0.5));
});
});
}