diff --git a/design_system/.gitignore b/design_system/.gitignore new file mode 100644 index 0000000..08e2419 --- /dev/null +++ b/design_system/.gitignore @@ -0,0 +1,6 @@ +.dart_tool/ +.packages +build/ +pubspec.lock +coverage/ +*.iml diff --git a/design_system/.pre-commit-config.yaml b/design_system/.pre-commit-config.yaml new file mode 100644 index 0000000..5cab55b --- /dev/null +++ b/design_system/.pre-commit-config.yaml @@ -0,0 +1,56 @@ +# Pre-commit Configuration for design_system +# Install: pre-commit install && pre-commit install --hook-type pre-push +# Run: pre-commit run --all-files + +# NOTE: this subproject lives inside the ~/utils monorepo, whose git root is +# one level up. pre-commit always runs hooks from the git root, so every path +# below is written relative to THAT, not to this directory, and the top-level +# `files` key keeps these hooks off the sibling subprojects. CI invokes this +# config exactly the same way: +# pre-commit run --config design_system/.pre-commit-config.yaml --all-files +files: ^design_system/ + +default_stages: [pre-commit] +fail_fast: false + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - id: end-of-file-fixer + - id: check-yaml + - id: check-merge-conflict + - id: mixed-line-ending + args: [--fix=lf] + + - repo: local + hooks: + - id: subproject-scope + name: subproject scope regex still matches + entry: scripts/check_subproject_scope.sh design_system + language: script + # always_run so the top-level `files` filter cannot skip the one hook + # whose job is to notice that filter has stopped matching anything. + always_run: true + pass_filenames: false + - id: dart-format + name: dart format --set-exit-if-changed + entry: bash -c 'cd design_system && dart format --set-exit-if-changed lib test' + language: system + pass_filenames: false + - id: flutter-analyze + name: flutter analyze + entry: bash -c 'cd design_system && flutter analyze' + language: system + pass_filenames: false + - id: flutter-test-coverage + name: flutter test --coverage with 100% line-coverage gate + entry: >- + bash -c 'cd design_system && flutter test --coverage && + scripts/check_coverage.sh coverage/lcov.info 100' + language: system + pass_filenames: false + require_serial: true + stages: [pre-push] diff --git a/design_system/README.md b/design_system/README.md new file mode 100644 index 0000000..d1ef8e1 --- /dev/null +++ b/design_system/README.md @@ -0,0 +1,81 @@ +# design_system + +The shared Flutter component layer for kuhy's apps. + +`~/utils/unified-design-system/` froze the **token** layer — one palette, one +spacing scale, one type scale — but it is documentation only: 1220 lines of +prose and annotated HTML, zero importable code. Every repo transcribed the +token table by hand, and then drifted from it. + +This package is that same token set as Dart, plus the handful of widgets that +had each been reimplemented in three or more repos. **The components own the +tokens**: a consumer that depends on this package has nothing local left to +edit, so the palette is enforced by the compiler and the package manager +instead of by prose plus discipline. + +## Consuming it + +Tag-pinned git dependency, matching the convention `sync_settings_ui` and +`crdt_sync_dart` already use: + +```yaml +dependencies: + design_system: + git: + url: https://github.com/kuhyx/utils + ref: design_system-v0.1.0 + path: design_system +``` + +Then delete the repo's local `lib/ui/theme.dart` and import the package: + +```dart +import 'package:design_system/design_system.dart'; + +MaterialApp( + theme: buildLightTheme(), + darkTheme: buildDarkTheme(), + // ... +); +``` + +## What's in it + +| Export | Replaces | +|---|---| +| `AppPalette` | ~318 raw `Color(0x…)` literals, almost all inside hand-copied theme files | +| `AppSpacing` / `AppRadius` / `AppTextSize` | the six-repo consensus scales, transcribed per repo | +| `buildLightTheme()` / `buildDarkTheme()` | 10 divergent `theme.dart` copies (2 byte-identical, 8 drifted) | +| `AppStatusColors` | 4 hand-rolled `ThemeExtension`s with inconsistent field sets | +| `confirmDestructive()` | ~35 inlined `AlertDialog`s | +| `showToast()` / `showError()` | ~38 raw `SnackBar` sites and zero helpers | +| `EmptyState` | 4 variants, only one of them reusable | +| `SectionHeader` | 3 near-identical private widgets | + +### Why the scales are what they are + +The values are the six-repo consensus — todo, home_inventory, dufs-cloud, +habit_stack, and untools all shipped byte-identical copies — which is also +exactly what `unified-design-system/tokens.md` specifies. Repos that had +drifted (diet-guard, macro-cam) are corrected *to* this package, not the +other way round. + +### Why explicit `ColorScheme`s, never `fromSeed` + +The palette is hand-picked to hit specific contrast ratios. A seeded scheme +silently replaces those values with algorithmically derived ones. The +`secondary`/`tertiary` slots are set explicitly for the same reason: without +them, widgets that reach for `secondaryContainer` (e.g. `SegmentedButton`'s +selected segment) fall back to stock Material teal — confirmed on-device. + +## Development + +```bash +flutter analyze # must be clean +dart format lib test +flutter test --coverage +scripts/check_coverage.sh coverage/lcov.info 100 # 100% line coverage, gated +``` + +Coverage is a **gate, not a convention**: this ships as a 100%-covered shared +package by requirement. The gate runs on pre-push. diff --git a/design_system/analysis_options.yaml b/design_system/analysis_options.yaml new file mode 100644 index 0000000..286e24c --- /dev/null +++ b/design_system/analysis_options.yaml @@ -0,0 +1,38 @@ +include: package:very_good_analysis/analysis_options.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + errors: + # Promote key lints to errors so they fail CI, not just warn. + missing_required_param: error + unnecessary_null_comparison: error + dead_code: error + invalid_annotation_target: error + exclude: + - build/** + - "**/*.g.dart" + - "**/*.freezed.dart" + - "test/**" + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** + +linter: + rules: + # very_good_analysis enables most rules; add extras it doesn't include. + always_use_package_imports: true + avoid_print: true + avoid_relative_lib_imports: true + cancel_subscriptions: true + close_sinks: true + comment_references: false # conflicts with typical Flutter API docs style + directives_ordering: true + lines_longer_than_80_chars: true + public_member_api_docs: true + unawaited_futures: true diff --git a/design_system/lib/design_system.dart b/design_system/lib/design_system.dart new file mode 100644 index 0000000..a09a08f --- /dev/null +++ b/design_system/lib/design_system.dart @@ -0,0 +1,20 @@ +/// The shared component layer for kuhy's Flutter apps. +/// +/// `~/utils/unified-design-system/` froze the *token* layer as prose — one +/// palette, one spacing scale, one type scale — but shipped no importable +/// code, so every repo transcribed the table by hand and drifted. This +/// package is that same token set as Dart, plus the handful of widgets and +/// helpers that had each been reimplemented in three or more repos. +/// +/// The tokens live *here*, not in a doc: a consumer that imports this package +/// cannot silently fall off the palette, because there is nothing local left +/// to edit. +library; + +export 'src/confirm.dart'; +export 'src/empty_state.dart'; +export 'src/feedback.dart'; +export 'src/section_header.dart'; +export 'src/status_colors.dart'; +export 'src/theme.dart'; +export 'src/tokens.dart'; diff --git a/design_system/lib/src/confirm.dart b/design_system/lib/src/confirm.dart new file mode 100644 index 0000000..39d5f14 --- /dev/null +++ b/design_system/lib/src/confirm.dart @@ -0,0 +1,83 @@ +/// Confirmation for destructive actions. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Asks the user to confirm a destructive action, returning true if they do. +/// +/// Exists because a delete with no confirmation and no undo is only *risky* +/// with a pointer — you have to travel to the control and press it. With a +/// keyboard it is one Tab away from a Return you were about to press anyway, +/// and the record is gone with nothing to recover it. +/// +/// Defaults are chosen so the *safe* option is what a reflexive keypress hits: +/// Cancel is autofocused, and Escape dismisses. The destructive button is +/// styled with the error color but is deliberately not the default action. +/// +/// Returns false when dismissed by Escape, by the barrier, or by Cancel. +Future confirmDestructive( + BuildContext context, { + required String title, + required String message, + String confirmLabel = 'Delete', + String cancelLabel = 'Cancel', +}) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => _ConfirmDialog( + title: title, + message: message, + confirmLabel: confirmLabel, + cancelLabel: cancelLabel, + ), + ); + return confirmed ?? false; +} + +class _ConfirmDialog extends StatelessWidget { + const _ConfirmDialog({ + required this.title, + required this.message, + required this.confirmLabel, + required this.cancelLabel, + }); + + final String title; + final String message; + final String confirmLabel; + final String cancelLabel; + + @override + Widget build(BuildContext context) { + return CallbackShortcuts( + // Escape dismisses as "no". Flutter's own barrier handles this on most + // platforms, but the desktop surface for these apps is a Chrome `--app` + // window where that cannot be assumed, and a confirmation the keyboard + // cannot back out of is worse than no confirmation at all. + bindings: { + const SingleActivator(LogicalKeyboardKey.escape): () => + Navigator.of(context).pop(false), + }, + child: AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + // Autofocused so the reflexive Return presses Cancel, not Delete. + autofocus: true, + onPressed: () => Navigator.of(context).pop(false), + child: Text(cancelLabel), + ), + TextButton( + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + onPressed: () => Navigator.of(context).pop(true), + child: Text(confirmLabel), + ), + ], + ), + ); + } +} diff --git a/design_system/lib/src/empty_state.dart b/design_system/lib/src/empty_state.dart new file mode 100644 index 0000000..f1df8f4 --- /dev/null +++ b/design_system/lib/src/empty_state.dart @@ -0,0 +1,69 @@ +/// The placeholder shown when a list has nothing in it. +library; + +import 'package:design_system/src/tokens.dart'; +import 'package:flutter/material.dart'; + +/// A centred icon, headline and explanation for an empty list. +/// +/// Promoted from `home_inventory` — the only one of four fleet variants that +/// was already a reusable widget rather than an inlined `Column`. +class EmptyState extends StatelessWidget { + /// Creates an empty-state placeholder. + const EmptyState({ + required this.icon, + required this.title, + required this.message, + this.action, + super.key, + }); + + /// Glyph shown above the text. + final IconData icon; + + /// Short headline, e.g. "Nothing here yet". + final String title; + + /// One line explaining what to do about it. + final String message; + + /// Optional call to action below the message — usually the button that + /// creates the first item. Omit it where the empty state is a *filter* + /// result rather than an empty collection: there is nothing to create. + final Widget? action; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final action = this.action; + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: AppSpacing.xxl, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: AppSpacing.md), + Text(title, style: theme.textTheme.titleMedium), + const SizedBox(height: AppSpacing.sm), + Text( + message, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (action != null) ...[ + const SizedBox(height: AppSpacing.lg), + action, + ], + ], + ), + ), + ); + } +} diff --git a/design_system/lib/src/feedback.dart b/design_system/lib/src/feedback.dart new file mode 100644 index 0000000..d1c72a1 --- /dev/null +++ b/design_system/lib/src/feedback.dart @@ -0,0 +1,77 @@ +/// Transient feedback (`SnackBar`) helpers. +/// +/// There were ~38 raw `ScaffoldMessenger.of(context).showSnackBar(...)` call +/// sites across the fleet and zero helpers, so every one of them re-decided +/// duration, styling, and whether to clear the previous snack bar. These two +/// functions make that one decision. +library; + +import 'package:design_system/src/status_colors.dart'; +import 'package:design_system/src/tokens.dart'; +import 'package:flutter/material.dart'; + +/// How long a neutral confirmation stays up. +const Duration _toastDuration = Duration(seconds: 3); + +/// How long an error stays up. Longer than a toast: an error the user missed +/// is an error they will hit again. +const Duration _errorDuration = Duration(seconds: 5); + +/// Shows a neutral, transient confirmation ("Saved", "Copied"). +/// +/// Returns null when [context] has no `ScaffoldMessenger` above it — which +/// happens routinely when an async callback completes after its screen was +/// popped, and should not throw. +ScaffoldFeatureController? showToast( + BuildContext context, + String message, +) { + return _show(context, message, background: null, foreground: null); +} + +/// Shows a transient error, in the danger hue. +/// +/// Use for something that *failed*, not for something merely noteworthy — +/// the color is the signal, so spending it on routine confirmations makes a +/// real failure invisible. +ScaffoldFeatureController? showError( + BuildContext context, + String message, +) { + final colors = context.statusColors; + return _show( + context, + message, + background: colors.danger, + foreground: colors.onStatus, + ); +} + +ScaffoldFeatureController? _show( + BuildContext context, + String message, { + required Color? background, + required Color? foreground, +}) { + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) { + return null; + } + // Replace rather than queue: a queued snack bar describes an action the + // user has already moved on from, and shows up seconds late. + messenger.clearSnackBars(); + return messenger.showSnackBar( + SnackBar( + content: Text( + message, + style: foreground == null ? null : TextStyle(color: foreground), + ), + backgroundColor: background, + duration: background == null ? _toastDuration : _errorDuration, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + ), + ); +} diff --git a/design_system/lib/src/section_header.dart b/design_system/lib/src/section_header.dart new file mode 100644 index 0000000..1ceec61 --- /dev/null +++ b/design_system/lib/src/section_header.dart @@ -0,0 +1,61 @@ +/// A labelled divider between groups of controls in a long screen. +library; + +import 'package:design_system/src/tokens.dart'; +import 'package:flutter/material.dart'; + +/// A section title, optionally with a trailing action. +/// +/// Reconciles the fleet's variants, which agreed on `titleMedium` and +/// differed only in what they wrapped around it: `untools`' `_SectionHeading` +/// supported a trailing action and no padding; `billsplit`'s `_SectionHeader` +/// had padding and no action. This is the union — both are opt-in, so either +/// caller can adopt it without a visual change. +class SectionHeader extends StatelessWidget { + /// Creates a section header. + const SectionHeader( + this.title, { + this.action, + this.padding, + super.key, + }); + + /// The section's title. + final String title; + + /// Optional trailing control, right-aligned on the same baseline — + /// typically an "Add" or "Clear" button scoped to this section. + final Widget? action; + + /// Outer padding. Defaults to none, so the header inherits whatever the + /// surrounding list already applies; pass [defaultPadding] for a header + /// sitting directly in an unpadded `ListView`. + final EdgeInsetsGeometry? padding; + + /// Padding for a header placed directly in an unpadded scroll view: full + /// gutters, generous above, tight below so the title groups with the + /// content it labels rather than floating between two sections. + static const EdgeInsets defaultPadding = EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.md, + AppSpacing.xs, + ); + + @override + Widget build(BuildContext context) { + final row = Row( + children: [ + Expanded( + child: Text(title, style: Theme.of(context).textTheme.titleMedium), + ), + ?action, + ], + ); + final padding = this.padding; + if (padding == null) { + return row; + } + return Padding(padding: padding, child: row); + } +} diff --git a/design_system/lib/src/status_colors.dart b/design_system/lib/src/status_colors.dart new file mode 100644 index 0000000..339270d --- /dev/null +++ b/design_system/lib/src/status_colors.dart @@ -0,0 +1,93 @@ +/// Status hues (success/warning/danger/info) as a `ThemeExtension`. +/// +/// `ColorScheme` has a slot for `error` but none for success, warning or a +/// separate info hue, which is why five repos each grew their own copy of +/// this. Reading them off the theme — rather than off a raw constant — +/// keeps a widget correct if a surface ever renders under a different +/// brightness than the app default. +library; + +import 'package:design_system/src/tokens.dart'; +import 'package:flutter/material.dart'; + +/// The four status colors, plus the text color to draw on top of them. +@immutable +class AppStatusColors extends ThemeExtension { + /// Creates a status-color set. Prefer [AppStatusColors.standard] unless a + /// surface genuinely needs different hues. + const AppStatusColors({ + required this.success, + required this.warning, + required this.danger, + required this.info, + required this.onStatus, + }); + + /// The frozen palette's status hues. Identical in light and dark: these + /// are always used as *fills* with [onStatus] text on top, never as text + /// on the page background, so they do not need a per-brightness variant. + const AppStatusColors.standard() + : success = AppPalette.success, + warning = AppPalette.warning, + danger = AppPalette.danger, + info = AppPalette.info, + onStatus = AppPalette.onFill; + + /// Completed / healthy / within-budget. + final Color success; + + /// Needs attention but is not yet a failure. + final Color warning; + + /// Failed, over-budget, or destructive. + final Color danger; + + /// Neutral emphasis. The accent hue, reused. + final Color info; + + /// Text/icon color to draw on top of any of the four fills above. + final Color onStatus; + + @override + AppStatusColors copyWith({ + Color? success, + Color? warning, + Color? danger, + Color? info, + Color? onStatus, + }) { + return AppStatusColors( + success: success ?? this.success, + warning: warning ?? this.warning, + danger: danger ?? this.danger, + info: info ?? this.info, + onStatus: onStatus ?? this.onStatus, + ); + } + + @override + AppStatusColors lerp(covariant AppStatusColors? other, double t) { + if (other == null) { + return this; + } + return AppStatusColors( + success: Color.lerp(success, other.success, t)!, + warning: Color.lerp(warning, other.warning, t)!, + danger: Color.lerp(danger, other.danger, t)!, + info: Color.lerp(info, other.info, t)!, + onStatus: Color.lerp(onStatus, other.onStatus, t)!, + ); + } +} + +/// Convenience access to [AppStatusColors] from a `BuildContext`. +extension AppStatusColorsContext on BuildContext { + /// The status colors for the nearest theme. + /// + /// Falls back to [AppStatusColors.standard] when the ambient theme was not + /// built by `buildAppTheme` — a widget test that pumps a bare + /// `MaterialApp` should still render, not crash on a null extension. + AppStatusColors get statusColors => + Theme.of(this).extension() ?? + const AppStatusColors.standard(); +} diff --git a/design_system/lib/src/theme.dart b/design_system/lib/src/theme.dart new file mode 100644 index 0000000..c75eb1d --- /dev/null +++ b/design_system/lib/src/theme.dart @@ -0,0 +1,105 @@ +/// The shared `ThemeData` builders. +/// +/// Built from explicit `ColorScheme`s, never `ColorScheme.fromSeed`: the +/// shared palette is hand-picked to hit specific contrast ratios, and a seeded +/// scheme silently replaces those values with algorithmically derived ones. +library; + +import 'package:design_system/src/status_colors.dart'; +import 'package:design_system/src/tokens.dart'; +import 'package:flutter/material.dart'; + +/// Builds the shared light [ThemeData]. +ThemeData buildLightTheme() { + const colorScheme = ColorScheme.light( + surface: AppPalette.paper, + surfaceContainerHighest: AppPalette.paperRaised, + onSurface: AppPalette.textOnLight, + onSurfaceVariant: AppPalette.mutedOnLight, + outline: AppPalette.lineLight, + primary: AppPalette.accent, + // Filled surfaces use dark text: the accent is light enough that white + // on it fails contrast. + onPrimary: AppPalette.onFill, + // secondary/tertiary — the shared palette has one accent, not a + // separate secondary hue, so these mirror primary. Without an explicit + // value here, widgets that reach for secondaryContainer (e.g. + // SegmentedButton's selected segment) silently fall back to Flutter's + // stock Material teal — confirmed live on-device, not just in theory. + secondary: AppPalette.accent, + onSecondary: AppPalette.onFill, + secondaryContainer: AppPalette.lineLight, + onSecondaryContainer: AppPalette.accent, + tertiary: AppPalette.accent, + onTertiary: AppPalette.onFill, + tertiaryContainer: AppPalette.lineLight, + onTertiaryContainer: AppPalette.accent, + error: AppPalette.danger, + onError: AppPalette.onFill, + ); + return _buildTheme(colorScheme); +} + +/// Builds the shared dark [ThemeData]. +ThemeData buildDarkTheme() { + const colorScheme = ColorScheme.dark( + surface: AppPalette.ink, + surfaceContainerHighest: AppPalette.inkRaised2, + surfaceContainerHigh: AppPalette.inkRaised1, + onSurface: AppPalette.textOnDark, + onSurfaceVariant: AppPalette.mutedOnDark, + outline: AppPalette.lineDark, + primary: AppPalette.accent, + onPrimary: AppPalette.onFill, + secondary: AppPalette.accent, + onSecondary: AppPalette.onFill, + secondaryContainer: AppPalette.lineDark, + onSecondaryContainer: AppPalette.accent, + tertiary: AppPalette.accent, + onTertiary: AppPalette.onFill, + tertiaryContainer: AppPalette.lineDark, + onTertiaryContainer: AppPalette.accent, + error: AppPalette.danger, + onError: AppPalette.onFill, + ); + return _buildTheme(colorScheme); +} + +ThemeData _buildTheme(ColorScheme colorScheme) { + return ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + scaffoldBackgroundColor: colorScheme.surface, + extensions: const [AppStatusColors.standard()], + textTheme: const TextTheme( + bodyLarge: TextStyle(fontSize: AppTextSize.body), + bodyMedium: TextStyle(fontSize: AppTextSize.body), + titleLarge: TextStyle(fontSize: AppTextSize.title), + titleMedium: TextStyle(fontSize: AppTextSize.subtitle), + labelMedium: TextStyle(fontSize: AppTextSize.label), + labelSmall: TextStyle(fontSize: AppTextSize.caption), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + borderSide: BorderSide(color: colorScheme.outline), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + borderSide: BorderSide(color: colorScheme.outline), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + borderSide: BorderSide(color: colorScheme.primary, width: 2), + ), + labelStyle: TextStyle(color: colorScheme.onSurfaceVariant), + ), + dividerTheme: DividerThemeData( + color: colorScheme.outline, + thickness: 1, + space: AppSpacing.md, + ), + ); +} diff --git a/design_system/lib/src/tokens.dart b/design_system/lib/src/tokens.dart new file mode 100644 index 0000000..478d6d2 --- /dev/null +++ b/design_system/lib/src/tokens.dart @@ -0,0 +1,136 @@ +/// The frozen design tokens, as importable Dart instead of a token table +/// every repo transcribes by hand. +/// +/// Values are the six-repo consensus (todo, home_inventory, dufs-cloud, +/// habit_stack, workout_app, untools all shipped a byte-identical copy of +/// these scales), which is also exactly what `unified-design-system/tokens.md` +/// specifies. Repos that had drifted are corrected *to* this file, not the +/// other way round. +library; + +import 'package:flutter/widgets.dart'; + +/// The raw palette, as named constants. +/// +/// Prefer reading colors off `Theme.of(context).colorScheme` (or +/// [AppStatusColors] for the status hues) — this class exists so the theme +/// builders and the very few places that genuinely need a literal have one +/// shared source, rather than 343 scattered `Color(0x…)` values. +abstract final class AppPalette { + /// Dark background. Same value as the shared launcher-icon charcoal. + static const Color ink = Color(0xFF211D1B); + + /// Dark elevated surface, step 1. + static const Color inkRaised1 = Color(0xFF2B2624); + + /// Dark elevated surface, step 2 (kept within 12% luma of the background). + static const Color inkRaised2 = Color(0xFF38312E); + + /// Border on dark surfaces. + static const Color lineDark = Color(0xFF463E3A); + + /// Primary text on dark. Near-white, deliberately not pure white. + static const Color textOnDark = Color(0xFFECEAE9); + + /// Secondary/caption text on dark. + static const Color mutedOnDark = Color(0xFFAAA09A); + + /// Light background. + static const Color paper = Color(0xFFF6F4F3); + + /// Light elevated surface — lighter than the background, per rule 15. + static const Color paperRaised = Color(0xFFFCFBFB); + + /// Border on light surfaces. + static const Color lineLight = Color(0xFFE0DAD7); + + /// Primary text on light. Reuses [ink] — a deliberate symmetry. + static const Color textOnLight = ink; + + /// Secondary/caption text on light. + static const Color mutedOnLight = Color(0xFF70625B); + + /// The single accent hue. The palette has one accent, not a second + /// secondary hue. + static const Color accent = Color(0xFFB8862E); + + /// Informational status — the accent, reused. + static const Color info = accent; + + /// Success status. + static const Color success = Color(0xFF8A9A3C); + + /// Warning status. + static const Color warning = Color(0xFFE0A63C); + + /// Danger/error status. + static const Color danger = Color(0xFFE2585F); + + /// Text drawn on top of a filled accent/status surface. Dark, not white: + /// the accent is light enough that white text fails contrast on it. + static const Color onFill = ink; +} + +/// Shared spacing scale (4px base) — round any new value to one of these +/// instead of introducing an off-scale literal. +abstract final class AppSpacing { + /// 4px. + static const double xs = 4; + + /// 8px. + static const double sm = 8; + + /// 16px. + static const double md = 16; + + /// 24px. + static const double lg = 24; + + /// 32px. + static const double xl = 32; + + /// 48px. + static const double xxl = 48; +} + +/// Shared corner-radius scale. Nested radii should be `outer - gap`, not a +/// fixed constant — compute per instance per safe-design-rules rule 24. +abstract final class AppRadius { + /// Buttons, inputs, chips. + static const double sm = 8; + + /// Cards. + static const double md = 12; + + /// Sheets, dialogs. + static const double lg = 16; +} + +/// Shared type scale (px). `body` is the floor for anything a user reads; +/// `label`/`caption` are for UI chrome only (timestamps, badges, tags). +abstract final class AppTextSize { + /// 12px — chrome only. + static const double caption = 12; + + /// 14px — chrome only. + static const double label = 14; + + /// 16px — the floor for actual reading content. + static const double body = 16; + + /// 20px. + static const double subtitle = 20; + + /// 24px. + static const double title = 24; + + /// 32px. + static const double display = 32; +} + +/// Prose/paragraph line-length cap (rule 21). +/// +/// Desktop builds here are Chrome `--app` windows that can be arbitrarily +/// wide (potentially 4K), so rendered/edited body text needs an explicit +/// column width instead of filling the whole viewport. +const double kProseMaxWidth = 640; diff --git a/design_system/pubspec.yaml b/design_system/pubspec.yaml new file mode 100644 index 0000000..b2b5cf8 --- /dev/null +++ b/design_system/pubspec.yaml @@ -0,0 +1,22 @@ +name: design_system +description: >- + The shared component layer for kuhy's Flutter apps: the frozen palette, + spacing/radius/type scales, theme builders, and the small set of widgets and + helpers that were each reimplemented in three or more repos. Owns the tokens + -- consumers import them rather than transcribing the token table by hand. +publish_to: 'none' +version: 0.1.0 + +environment: + sdk: ^3.12.2 + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + very_good_analysis: ^10.2.0 + +flutter: diff --git a/design_system/scripts/check_coverage.sh b/design_system/scripts/check_coverage.sh new file mode 100755 index 0000000..2c0ee6f --- /dev/null +++ b/design_system/scripts/check_coverage.sh @@ -0,0 +1,63 @@ +#!/bin/bash + +# ============================================================================ +# Fail if lcov line coverage drops below a threshold. +# +# No Dart package in this repo has coverage enforced in CI yet -- crdt_sync_dart +# sits at 100% by discipline, not a gate. This script is that gate for +# sync_settings_ui, which ships as a 100%-covered shared UI package by +# requirement, not by convention. +# +# Usage: check_coverage.sh +# ============================================================================ + +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" +readonly SCRIPT_NAME + +usage() { + echo "Usage: $SCRIPT_NAME " + echo " Fails when line coverage in lcov.info is below min_percent." + exit 0 +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage +fi + +if [[ $# -ne 2 ]]; then + usage +fi + +lcov_file="$1" +min_percent="$2" + +if [[ ! -f "$lcov_file" ]]; then + echo "Error: $lcov_file not found. Run 'flutter test --coverage' first." >&2 + exit 1 +fi + +# Sum LH (lines hit) and LF (lines found) across every SF (source file) +# block rather than trusting a single top-level total -- lcov.info has no +# such total line, only per-file LH/LF pairs. +read -r hit found <<< "$(awk ' + /^LH:/ { hit += substr($0, 4) } + /^LF:/ { found += substr($0, 4) } + END { print hit, found } +' "$lcov_file")" + +if [[ "$found" -eq 0 ]]; then + echo "Error: $lcov_file reports zero coverable lines." >&2 + exit 1 +fi + +percent=$(awk -v h="$hit" -v f="$found" 'BEGIN { printf "%.2f", (h / f) * 100 }') +meets_threshold=$(awk -v p="$percent" -v m="$min_percent" 'BEGIN { print (p + 0 >= m + 0) ? 1 : 0 }') + +echo "Line coverage: $hit/$found ($percent%), threshold: $min_percent%" + +if [[ "$meets_threshold" -ne 1 ]]; then + echo "Error: coverage $percent% is below the required $min_percent%." >&2 + exit 1 +fi diff --git a/design_system/test/confirm_test.dart b/design_system/test/confirm_test.dart new file mode 100644 index 0000000..4d1c915 --- /dev/null +++ b/design_system/test/confirm_test.dart @@ -0,0 +1,113 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Pumps a screen with one button that opens the confirmation and records +/// whatever it returned. +Future> _pumpConfirm( + WidgetTester tester, { + String confirmLabel = 'Delete', + String cancelLabel = 'Cancel', +}) async { + final results = []; + await tester.pumpWidget( + MaterialApp( + theme: buildDarkTheme(), + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () async { + results.add( + await confirmDestructive( + context, + title: 'Delete note?', + message: 'This cannot be undone.', + confirmLabel: confirmLabel, + cancelLabel: cancelLabel, + ), + ); + }, + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + return results; +} + +void main() { + testWidgets('shows the title and message', (tester) async { + await _pumpConfirm(tester); + expect(find.text('Delete note?'), findsOneWidget); + expect(find.text('This cannot be undone.'), findsOneWidget); + }); + + testWidgets('returns true when the destructive action is chosen', ( + tester, + ) async { + final results = await _pumpConfirm(tester); + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + expect(results, [true]); + }); + + testWidgets('returns false when cancelled', (tester) async { + final results = await _pumpConfirm(tester); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(results, [false]); + }); + + testWidgets('Escape dismisses as a refusal', (tester) async { + final results = await _pumpConfirm(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(results, [false]); + }); + + testWidgets('a barrier dismissal counts as a refusal, not a null', ( + tester, + ) async { + final results = await _pumpConfirm(tester); + // Tap the barrier, well outside the dialog. + await tester.tapAt(const Offset(10, 10)); + await tester.pumpAndSettle(); + expect(results, [false]); + }); + + testWidgets('autofocuses Cancel so a reflexive Return is safe', ( + tester, + ) async { + final results = await _pumpConfirm(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(results, [false], reason: 'Return must not delete'); + }); + + testWidgets('styles the destructive action with the error color', ( + tester, + ) async { + await _pumpConfirm(tester); + final button = tester.widget( + find.ancestor(of: find.text('Delete'), matching: find.byType(TextButton)), + ); + final color = button.style?.foregroundColor?.resolve({}); + expect(color, buildDarkTheme().colorScheme.error); + }); + + testWidgets('honours custom labels', (tester) async { + final results = await _pumpConfirm( + tester, + confirmLabel: 'Discard', + cancelLabel: 'Keep', + ); + expect(find.text('Discard'), findsOneWidget); + await tester.tap(find.text('Keep')); + await tester.pumpAndSettle(); + expect(results, [false]); + }); +} diff --git a/design_system/test/empty_state_test.dart b/design_system/test/empty_state_test.dart new file mode 100644 index 0000000..5b6f2a8 --- /dev/null +++ b/design_system/test/empty_state_test.dart @@ -0,0 +1,71 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future _pumpEmptyState(WidgetTester tester, {Widget? action}) { + return tester.pumpWidget( + MaterialApp( + theme: buildDarkTheme(), + home: Scaffold( + body: EmptyState( + icon: Icons.inbox_outlined, + title: 'Nothing here yet', + message: 'Add your first item to get started.', + action: action, + ), + ), + ), + ); +} + +void main() { + testWidgets('shows the icon, title and message', (tester) async { + await _pumpEmptyState(tester); + expect(find.byIcon(Icons.inbox_outlined), findsOneWidget); + expect(find.text('Nothing here yet'), findsOneWidget); + expect(find.text('Add your first item to get started.'), findsOneWidget); + }); + + testWidgets('draws the icon at the shared size in the muted color', ( + tester, + ) async { + await _pumpEmptyState(tester); + final icon = tester.widget(find.byType(Icon)); + expect(icon.size, AppSpacing.xxl); + expect(icon.color, buildDarkTheme().colorScheme.onSurfaceVariant); + }); + + testWidgets('mutes the message but not the title', (tester) async { + await _pumpEmptyState(tester); + final scheme = buildDarkTheme().colorScheme; + + final message = tester.widget( + find.text('Add your first item to get started.'), + ); + expect(message.style?.color, scheme.onSurfaceVariant); + expect(message.textAlign, TextAlign.center); + + final title = tester.widget(find.text('Nothing here yet')); + expect(title.style?.color, isNot(scheme.onSurfaceVariant)); + }); + + testWidgets('omits the action slot by default', (tester) async { + await _pumpEmptyState(tester); + expect(find.byType(ElevatedButton), findsNothing); + }); + + testWidgets('renders an action when one is given', (tester) async { + await _pumpEmptyState( + tester, + action: ElevatedButton(onPressed: () {}, child: const Text('Add item')), + ); + expect(find.text('Add item'), findsOneWidget); + }); + + testWidgets('stays centred and shrink-wrapped', (tester) async { + await _pumpEmptyState(tester); + expect(find.byType(Center), findsWidgets); + final column = tester.widget(find.byType(Column)); + expect(column.mainAxisSize, MainAxisSize.min); + }); +} diff --git a/design_system/test/feedback_test.dart b/design_system/test/feedback_test.dart new file mode 100644 index 0000000..297d775 --- /dev/null +++ b/design_system/test/feedback_test.dart @@ -0,0 +1,113 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Pumps a screen whose button calls [onPressed] with a context that has a +/// `ScaffoldMessenger` above it. +Future _pumpMessengerScreen( + WidgetTester tester, + void Function(BuildContext context) onPressed, +) async { + await tester.pumpWidget( + MaterialApp( + theme: buildDarkTheme(), + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => onPressed(context), + child: const Text('go'), + ), + ), + ), + ), + ); +} + +void main() { + testWidgets('showToast displays the message', (tester) async { + await _pumpMessengerScreen(tester, (context) { + showToast(context, 'Saved'); + }); + await tester.tap(find.text('go')); + await tester.pump(); + expect(find.text('Saved'), findsOneWidget); + }); + + testWidgets('showToast leaves the background unstyled', (tester) async { + await _pumpMessengerScreen(tester, (context) { + showToast(context, 'Saved'); + }); + await tester.tap(find.text('go')); + await tester.pump(); + final snack = tester.widget(find.byType(SnackBar)); + expect(snack.backgroundColor, isNull); + expect(snack.duration, const Duration(seconds: 3)); + }); + + testWidgets('showError uses the danger hue and stays up longer', ( + tester, + ) async { + await _pumpMessengerScreen(tester, (context) { + showError(context, 'Sync failed'); + }); + await tester.tap(find.text('go')); + await tester.pump(); + + final snack = tester.widget(find.byType(SnackBar)); + expect(snack.backgroundColor, AppPalette.danger); + expect(snack.duration, const Duration(seconds: 5)); + + final text = tester.widget(find.text('Sync failed')); + expect(text.style?.color, AppPalette.onFill); + }); + + testWidgets('both float and use the shared radius', (tester) async { + await _pumpMessengerScreen(tester, (context) { + showToast(context, 'Saved'); + }); + await tester.tap(find.text('go')); + await tester.pump(); + + final snack = tester.widget(find.byType(SnackBar)); + expect(snack.behavior, SnackBarBehavior.floating); + expect( + (snack.shape! as RoundedRectangleBorder).borderRadius, + BorderRadius.circular(AppRadius.sm), + ); + }); + + testWidgets('a second message replaces the first rather than queueing', ( + tester, + ) async { + await _pumpMessengerScreen(tester, (context) { + showToast(context, 'First'); + showToast(context, 'Second'); + }); + await tester.tap(find.text('go')); + await tester.pump(); + + expect(find.text('First'), findsNothing); + expect(find.text('Second'), findsOneWidget); + }); + + testWidgets('returns null instead of throwing with no messenger above', ( + tester, + ) async { + ScaffoldFeatureController? toast; + ScaffoldFeatureController? error; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (context) { + toast = showToast(context, 'Saved'); + error = showError(context, 'Broke'); + return const SizedBox.shrink(); + }, + ), + ), + ); + expect(toast, isNull); + expect(error, isNull); + }); +} diff --git a/design_system/test/section_header_test.dart b/design_system/test/section_header_test.dart new file mode 100644 index 0000000..762e6a1 --- /dev/null +++ b/design_system/test/section_header_test.dart @@ -0,0 +1,85 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future _pumpHeader( + WidgetTester tester, { + Widget? action, + EdgeInsetsGeometry? padding, +}) { + return tester.pumpWidget( + MaterialApp( + theme: buildDarkTheme(), + home: Scaffold( + body: SectionHeader('Filters', action: action, padding: padding), + ), + ), + ); +} + +void main() { + testWidgets('shows the title at titleMedium', (tester) async { + await _pumpHeader(tester); + expect(find.text('Filters'), findsOneWidget); + // Compare the resolved size, not the whole TextStyle: the localization + // delegate merges geometry (weight, letter spacing, line height) into + // the theme's style before it reaches the widget, so the two objects are + // never `==` even when the widget is doing exactly the right thing. + final text = tester.widget(find.text('Filters')); + expect(text.style?.fontSize, AppTextSize.subtitle); + expect( + text.style?.fontSize, + buildDarkTheme().textTheme.titleMedium?.fontSize, + ); + }); + + testWidgets('has no padding of its own by default', (tester) async { + await _pumpHeader(tester); + expect( + find.descendant( + of: find.byType(SectionHeader), + matching: find.byType(Padding), + ), + findsNothing, + ); + }); + + testWidgets('applies padding when asked', (tester) async { + await _pumpHeader(tester, padding: SectionHeader.defaultPadding); + final padding = tester.widget( + find + .descendant( + of: find.byType(SectionHeader), + matching: find.byType(Padding), + ) + .first, + ); + expect(padding.padding, SectionHeader.defaultPadding); + }); + + testWidgets('omits the action slot by default', (tester) async { + await _pumpHeader(tester); + expect(find.byType(IconButton), findsNothing); + }); + + testWidgets('renders a trailing action beside the title', (tester) async { + await _pumpHeader( + tester, + action: IconButton(onPressed: () {}, icon: const Icon(Icons.add)), + ); + expect(find.byIcon(Icons.add), findsOneWidget); + // The action sits to the right of the title, not below it. + final titleRight = tester.getTopRight(find.text('Filters')).dx; + final actionLeft = tester.getTopLeft(find.byIcon(Icons.add)).dx; + expect(actionLeft, greaterThanOrEqualTo(titleRight)); + }); + + test('defaultPadding groups the title with the content below it', () { + // Tighter below than above, so the header reads as belonging to the + // section it labels rather than floating between two of them. + expect(SectionHeader.defaultPadding.bottom, AppSpacing.xs); + expect(SectionHeader.defaultPadding.top, AppSpacing.md); + expect(SectionHeader.defaultPadding.left, AppSpacing.md); + expect(SectionHeader.defaultPadding.right, AppSpacing.md); + }); +} diff --git a/design_system/test/status_colors_test.dart b/design_system/test/status_colors_test.dart new file mode 100644 index 0000000..bb007a5 --- /dev/null +++ b/design_system/test/status_colors_test.dart @@ -0,0 +1,127 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('AppStatusColors.standard', () { + const colors = AppStatusColors.standard(); + + test('uses the frozen status hues', () { + expect(colors.success, AppPalette.success); + expect(colors.warning, AppPalette.warning); + expect(colors.danger, AppPalette.danger); + expect(colors.info, AppPalette.info); + expect(colors.onStatus, AppPalette.onFill); + }); + }); + + group('copyWith', () { + const base = AppStatusColors.standard(); + + test('replaces only the named field', () { + const green = Color(0xFF00FF00); + final copy = base.copyWith(success: green); + expect(copy.success, green); + expect(copy.warning, base.warning); + expect(copy.danger, base.danger); + expect(copy.info, base.info); + expect(copy.onStatus, base.onStatus); + }); + + test('replaces every field when all are given', () { + const other = AppStatusColors( + success: Color(0xFF111111), + warning: Color(0xFF222222), + danger: Color(0xFF333333), + info: Color(0xFF444444), + onStatus: Color(0xFF555555), + ); + final copy = base.copyWith( + success: other.success, + warning: other.warning, + danger: other.danger, + info: other.info, + onStatus: other.onStatus, + ); + expect(copy.success, other.success); + expect(copy.warning, other.warning); + expect(copy.danger, other.danger); + expect(copy.info, other.info); + expect(copy.onStatus, other.onStatus); + }); + + test('returns an equivalent set when given nothing', () { + final copy = base.copyWith(); + expect(copy.success, base.success); + expect(copy.onStatus, base.onStatus); + }); + }); + + group('lerp', () { + const base = AppStatusColors.standard(); + const other = AppStatusColors( + success: Color(0xFF000000), + warning: Color(0xFF000000), + danger: Color(0xFF000000), + info: Color(0xFF000000), + onStatus: Color(0xFF000000), + ); + + test('returns this when the other set is null', () { + expect(base.lerp(null, 0.5), same(base)); + }); + + test('returns the endpoints at t=0 and t=1', () { + expect(base.lerp(other, 0).success, base.success); + expect(base.lerp(other, 1).success, other.success); + }); + + test('interpolates every field', () { + final mid = base.lerp(other, 0.5); + expect(mid.success, Color.lerp(base.success, other.success, 0.5)); + expect(mid.warning, Color.lerp(base.warning, other.warning, 0.5)); + expect(mid.danger, Color.lerp(base.danger, other.danger, 0.5)); + expect(mid.info, Color.lerp(base.info, other.info, 0.5)); + expect(mid.onStatus, Color.lerp(base.onStatus, other.onStatus, 0.5)); + }); + }); + + group('context.statusColors', () { + testWidgets('reads the extension off a theme built by buildAppTheme', ( + tester, + ) async { + late AppStatusColors found; + await tester.pumpWidget( + MaterialApp( + theme: buildDarkTheme(), + home: Builder( + builder: (context) { + found = context.statusColors; + return const SizedBox.shrink(); + }, + ), + ), + ); + expect(found.success, AppPalette.success); + }); + + testWidgets('falls back to the standard set on a bare theme', ( + tester, + ) async { + late AppStatusColors found; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(), + home: Builder( + builder: (context) { + found = context.statusColors; + return const SizedBox.shrink(); + }, + ), + ), + ); + expect(found.success, AppPalette.success); + expect(found.danger, AppPalette.danger); + }); + }); +} diff --git a/design_system/test/theme_test.dart b/design_system/test/theme_test.dart new file mode 100644 index 0000000..b31b1af --- /dev/null +++ b/design_system/test/theme_test.dart @@ -0,0 +1,124 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('buildDarkTheme', () { + final theme = buildDarkTheme(); + + test('uses the frozen dark palette', () { + final scheme = theme.colorScheme; + expect(scheme.brightness, Brightness.dark); + expect(scheme.surface, AppPalette.ink); + expect(scheme.surfaceContainerHigh, AppPalette.inkRaised1); + expect(scheme.surfaceContainerHighest, AppPalette.inkRaised2); + expect(scheme.onSurface, AppPalette.textOnDark); + expect(scheme.onSurfaceVariant, AppPalette.mutedOnDark); + expect(scheme.outline, AppPalette.lineDark); + expect(scheme.error, AppPalette.danger); + }); + + test('scaffold background matches the surface', () { + expect(theme.scaffoldBackgroundColor, theme.colorScheme.surface); + }); + }); + + group('buildLightTheme', () { + final theme = buildLightTheme(); + + test('uses the frozen light palette', () { + final scheme = theme.colorScheme; + expect(scheme.brightness, Brightness.light); + expect(scheme.surface, AppPalette.paper); + expect(scheme.surfaceContainerHighest, AppPalette.paperRaised); + expect(scheme.onSurface, AppPalette.textOnLight); + expect(scheme.onSurfaceVariant, AppPalette.mutedOnLight); + expect(scheme.outline, AppPalette.lineLight); + expect(scheme.error, AppPalette.danger); + }); + + test('scaffold background matches the surface', () { + expect(theme.scaffoldBackgroundColor, theme.colorScheme.surface); + }); + }); + + for (final entry in { + 'dark': buildDarkTheme(), + 'light': buildLightTheme(), + }.entries) { + group('${entry.key} theme', () { + final theme = entry.value; + + test('is Material 3', () { + expect(theme.useMaterial3, isTrue); + }); + + test('has one accent: secondary and tertiary mirror primary', () { + final scheme = theme.colorScheme; + expect(scheme.primary, AppPalette.accent); + expect(scheme.secondary, AppPalette.accent); + expect(scheme.tertiary, AppPalette.accent); + }); + + test('draws dark text on every filled surface', () { + final scheme = theme.colorScheme; + expect(scheme.onPrimary, AppPalette.onFill); + expect(scheme.onSecondary, AppPalette.onFill); + expect(scheme.onTertiary, AppPalette.onFill); + expect(scheme.onError, AppPalette.onFill); + }); + + test('never leaks stock Material teal into container slots', () { + final scheme = theme.colorScheme; + expect(scheme.onSecondaryContainer, AppPalette.accent); + expect(scheme.onTertiaryContainer, AppPalette.accent); + expect(scheme.secondaryContainer, scheme.outline); + expect(scheme.tertiaryContainer, scheme.outline); + }); + + test('registers the status colors as a theme extension', () { + final status = theme.extension(); + expect(status, isNotNull); + expect(status!.success, AppPalette.success); + }); + + test('sizes text from the shared type scale', () { + final text = theme.textTheme; + expect(text.bodyLarge?.fontSize, AppTextSize.body); + expect(text.bodyMedium?.fontSize, AppTextSize.body); + expect(text.titleLarge?.fontSize, AppTextSize.title); + expect(text.titleMedium?.fontSize, AppTextSize.subtitle); + expect(text.labelMedium?.fontSize, AppTextSize.label); + expect(text.labelSmall?.fontSize, AppTextSize.caption); + }); + + test('inputs are filled, rounded, and show a 2px accent focus ring', () { + final input = theme.inputDecorationTheme; + expect(input.filled, isTrue); + expect(input.fillColor, theme.colorScheme.surfaceContainerHighest); + + final focused = input.focusedBorder! as OutlineInputBorder; + expect(focused.borderSide.color, AppPalette.accent); + expect(focused.borderSide.width, 2.0); + expect( + focused.borderRadius, + BorderRadius.circular(AppRadius.sm), + ); + + for (final border in [input.border!, input.enabledBorder!]) { + expect( + (border as OutlineInputBorder).borderSide.color, + theme.colorScheme.outline, + ); + } + expect(input.labelStyle?.color, theme.colorScheme.onSurfaceVariant); + }); + + test('dividers use the outline color at 1px', () { + expect(theme.dividerTheme.color, theme.colorScheme.outline); + expect(theme.dividerTheme.thickness, 1.0); + expect(theme.dividerTheme.space, AppSpacing.md); + }); + }); + } +} diff --git a/design_system/test/tokens_test.dart b/design_system/test/tokens_test.dart new file mode 100644 index 0000000..e471ff7 --- /dev/null +++ b/design_system/test/tokens_test.dart @@ -0,0 +1,84 @@ +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); + }); +}