mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
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>
145 lines
4.5 KiB
Dart
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));
|
|
});
|
|
});
|
|
}
|