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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL
This commit is contained in:
Krzysztof Rudnicki 2026-08-14 22:51:06 +02:00
parent ca81e091a1
commit e6c9af81e9
21 changed files with 1627 additions and 0 deletions

6
design_system/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.dart_tool/
.packages
build/
pubspec.lock
coverage/
*.iml

View File

@ -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]

81
design_system/README.md Normal file
View File

@ -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.

View File

@ -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

View File

@ -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';

View File

@ -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<bool> confirmDestructive(
BuildContext context, {
required String title,
required String message,
String confirmLabel = 'Delete',
String cancelLabel = 'Cancel',
}) async {
final confirmed = await showDialog<bool>(
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),
),
],
),
);
}
}

View File

@ -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,
],
],
),
),
);
}
}

View File

@ -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<SnackBar, SnackBarClosedReason>? 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<SnackBar, SnackBarClosedReason>? showError(
BuildContext context,
String message,
) {
final colors = context.statusColors;
return _show(
context,
message,
background: colors.danger,
foreground: colors.onStatus,
);
}
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? _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),
),
),
);
}

View File

@ -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);
}
}

View File

@ -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<AppStatusColors> {
/// 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<AppStatusColors>() ??
const AppStatusColors.standard();
}

View File

@ -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,
),
);
}

View File

@ -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;

View File

@ -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:

View File

@ -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 <lcov.info> <min_percent>
# ============================================================================
set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
usage() {
echo "Usage: $SCRIPT_NAME <lcov.info> <min_percent>"
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

View File

@ -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<List<bool?>> _pumpConfirm(
WidgetTester tester, {
String confirmLabel = 'Delete',
String cancelLabel = 'Cancel',
}) async {
final results = <bool?>[];
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<TextButton>(
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]);
});
}

View File

@ -0,0 +1,71 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
Future<void> _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<Icon>(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<Text>(
find.text('Add your first item to get started.'),
);
expect(message.style?.color, scheme.onSurfaceVariant);
expect(message.textAlign, TextAlign.center);
final title = tester.widget<Text>(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<Column>(find.byType(Column));
expect(column.mainAxisSize, MainAxisSize.min);
});
}

View File

@ -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<void> _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<SnackBar>(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<SnackBar>(find.byType(SnackBar));
expect(snack.backgroundColor, AppPalette.danger);
expect(snack.duration, const Duration(seconds: 5));
final text = tester.widget<Text>(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<SnackBar>(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<SnackBar, SnackBarClosedReason>? toast;
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? 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);
});
}

View File

@ -0,0 +1,85 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
Future<void> _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<Text>(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<Padding>(
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);
});
}

View File

@ -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);
});
});
}

View File

@ -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<AppStatusColors>();
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);
});
});
}
}

View File

@ -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);
});
}