From e6c9af81e9ac3000d87d9168d3ef87ba2979258b Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 14 Aug 2026 22:51:06 +0200 Subject: [PATCH] Add design_system: the shared Flutter component layer unified-design-system/ froze the token layer as prose -- 1220 lines of README, tokens.md and annotated HTML, and zero importable code. Every Flutter repo transcribed the token table by hand, so the "shared" palette was enforced only by discipline, and had already drifted: 10 divergent theme.dart copies (todo and untools byte-identical, the other 8 each unique), ~318 raw color literals, 4 hand-rolled AppStatusColors extensions with inconsistent field sets, ~35 inlined AlertDialogs, ~38 raw SnackBar sites with no helper anywhere. This promotes the token layer into an importable component layer, so the design system is enforced by the compiler and the package manager instead. The components own the tokens: a consumer has nothing local left to edit. Scales are the six-repo consensus, which is also what tokens.md specifies; drifted repos get corrected to this rather than the reverse. Themes are built from explicit ColorSchemes, never fromSeed -- the palette is hand-picked for contrast, and secondary/tertiary are set explicitly so SegmentedButton cannot fall back to stock Material teal. Widgets are extracted only where two or more repos already had a structurally similar implementation; SectionHeader reconciles untools' trailing-action variant with billsplit's padded one as an opt-in union, so either caller can adopt it with no visual change. 63 tests, 102/102 lines covered, gated at 100% on pre-push. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL --- design_system/.gitignore | 6 + design_system/.pre-commit-config.yaml | 56 ++++++++ design_system/README.md | 81 ++++++++++++ design_system/analysis_options.yaml | 38 ++++++ design_system/lib/design_system.dart | 20 +++ design_system/lib/src/confirm.dart | 83 ++++++++++++ design_system/lib/src/empty_state.dart | 69 ++++++++++ design_system/lib/src/feedback.dart | 77 +++++++++++ design_system/lib/src/section_header.dart | 61 +++++++++ design_system/lib/src/status_colors.dart | 93 +++++++++++++ design_system/lib/src/theme.dart | 105 +++++++++++++++ design_system/lib/src/tokens.dart | 136 ++++++++++++++++++++ design_system/pubspec.yaml | 22 ++++ design_system/scripts/check_coverage.sh | 63 +++++++++ design_system/test/confirm_test.dart | 113 ++++++++++++++++ design_system/test/empty_state_test.dart | 71 ++++++++++ design_system/test/feedback_test.dart | 113 ++++++++++++++++ design_system/test/section_header_test.dart | 85 ++++++++++++ design_system/test/status_colors_test.dart | 127 ++++++++++++++++++ design_system/test/theme_test.dart | 124 ++++++++++++++++++ design_system/test/tokens_test.dart | 84 ++++++++++++ 21 files changed, 1627 insertions(+) create mode 100644 design_system/.gitignore create mode 100644 design_system/.pre-commit-config.yaml create mode 100644 design_system/README.md create mode 100644 design_system/analysis_options.yaml create mode 100644 design_system/lib/design_system.dart create mode 100644 design_system/lib/src/confirm.dart create mode 100644 design_system/lib/src/empty_state.dart create mode 100644 design_system/lib/src/feedback.dart create mode 100644 design_system/lib/src/section_header.dart create mode 100644 design_system/lib/src/status_colors.dart create mode 100644 design_system/lib/src/theme.dart create mode 100644 design_system/lib/src/tokens.dart create mode 100644 design_system/pubspec.yaml create mode 100755 design_system/scripts/check_coverage.sh create mode 100644 design_system/test/confirm_test.dart create mode 100644 design_system/test/empty_state_test.dart create mode 100644 design_system/test/feedback_test.dart create mode 100644 design_system/test/section_header_test.dart create mode 100644 design_system/test/status_colors_test.dart create mode 100644 design_system/test/theme_test.dart create mode 100644 design_system/test/tokens_test.dart 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); + }); +}