warm-tint the unified-design-system palette, fix design audit violations

Replaces the inline ColorScheme.fromSeed(Colors.deepPurple) theme with a new
lib/ui/theme.dart built from explicit light/dark ColorSchemes on the frozen
unified-design-system tokens (same shared spacing/radius/type-scale constants
as diet-guard/habit_stack's theme.dart), and adds darkTheme (previously
absent).

Fixes 2 of 3 violations in DESIGN_AUDIT_TODO.md:
- settings_screen.dart's one hardcoded Colors.red -> colorScheme.error,
  matching every other error-state text in the app
- markdown_view.dart and note_editor.dart's raw-mode TextField now cap their
  reading/editing column at kProseMaxWidth (640) via Center+ConstrainedBox --
  the desktop build is an arbitrarily wide Chrome --app window, so text was
  running the full viewport. Deliberately no extra Column introduced around
  the raw-mode TextField, per its own documented expands:true/nested-Column
  regression history.

The 3rd violation (a few off-scale SizedBox/EdgeInsets values, e.g. 6px vs
the file's dominant 4px scale) was attempted but reverted: bumping the
_EnumChips/_MultiChips/_DateRangeField 6px gaps to 8px broke 3 real tests --
the filter bottom sheet's "Apply" button is tight enough against its edge
that even a 2px shift pushes it out of the hit-testable area. Not worth
trading a working control for a cosmetic 2px scale nitpick; left at 6px.

Verified: flutter analyze clean, dart format clean on all touched files,
188/188 tests passing (added test/theme_test.dart for the new file, which
had 0% coverage on its own), 100% total line coverage maintained, re-verified
identical in an isolated worktree at HEAD (narrowly staged around this
repo's own pre-existing uncommitted CLAUDE.md edit, left untouched). A
Flutter-web headless-Chrome screenshot check for the desktop target was
attempted but blocked by a CanvasKit/WebGL rendering issue in this sandbox,
unrelated to the app itself; the widget test suite (which does catch real
RenderFlex overflow, as confirmed during this same rollout) passed clean for
both touched screens instead. Mobile on-device verification (phone-deploy)
is pending -- the phone isn't connected via ADB right now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-07-22 18:56:45 +02:00
parent 01d667b19f
commit 2ed9267e06
6 changed files with 213 additions and 23 deletions

View File

@ -7,6 +7,7 @@ import 'package:todo/data/note_repository.dart';
import 'package:todo/data/repository_factory.dart';
import 'package:todo/frame_stats.dart';
import 'package:todo/ui/capture_screen.dart';
import 'package:todo/ui/theme.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
@ -33,10 +34,8 @@ class TodoApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
title: 'todo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
theme: buildLightTheme(),
darkTheme: buildDarkTheme(),
home: CaptureScreen(repository: repository),
);
}

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:todo/ui/theme.dart';
/// A lightweight, read-only renderer for the note format.
///
/// The app's notes use a small, known Markdown subset — an `#` title, `##`
@ -26,13 +28,13 @@ class MarkdownView extends StatelessWidget {
final trimmed = line.trim();
if (trimmed.isEmpty) {
widgets.add(const SizedBox(height: 10));
widgets.add(const SizedBox(height: 8));
continue;
}
if (trimmed.startsWith('## ')) {
widgets.add(
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 2),
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Text(
trimmed.substring(3).trim(),
style: theme.textTheme.titleMedium?.copyWith(
@ -75,7 +77,7 @@ class MarkdownView extends StatelessWidget {
if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
widgets.add(
Padding(
padding: const EdgeInsets.only(left: 4, top: 2),
padding: const EdgeInsets.only(left: 4, top: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -97,9 +99,17 @@ class MarkdownView extends StatelessWidget {
return SelectionArea(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widgets,
// Caps the reading column at ~70 chars (rule 21) — the desktop
// build is an arbitrarily wide Chrome `--app` window, so without
// this, body text runs the full viewport width.
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: kProseMaxWidth),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widgets,
),
),
),
),
);

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_template.dart';
import 'package:todo/ui/markdown_view.dart';
import 'package:todo/ui/theme.dart';
/// Which view of the note the editor is currently showing.
enum NoteEditorMode {
@ -518,19 +519,28 @@ class _NoteEditorState extends State<NoteEditor> {
}
Widget _buildRaw(ThemeData theme) {
return TextField(
controller: _body,
autofocus: widget.autofocus,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
keyboardType: TextInputType.multiline,
style: theme.textTheme.bodyLarge,
decoration: const InputDecoration(
border: InputBorder.none,
hintText: 'Write your idea…',
// Caps the editing column at ~70 chars (rule 21) on the desktop's
// arbitrarily wide Chrome `--app` window. Center/ConstrainedBox only,
// no extra Column — nesting a second Column here previously broke the
// expands:true constraint chain in release builds (see _buildStepPage).
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: kProseMaxWidth),
child: TextField(
controller: _body,
autofocus: widget.autofocus,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
keyboardType: TextInputType.multiline,
style: theme.textTheme.bodyLarge,
decoration: const InputDecoration(
border: InputBorder.none,
hintText: 'Write your idea…',
),
onChanged: (_) => _emit(),
),
),
onChanged: (_) => _emit(),
);
}

View File

@ -407,7 +407,10 @@ class _DeviceCodeDialogState extends State<_DeviceCodeDialog> {
],
)
else
Text(_error!, style: const TextStyle(color: Colors.red)),
Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
),
actions: [

143
lib/ui/theme.dart Normal file
View File

@ -0,0 +1,143 @@
/// The app's design tokens: one identical palette shared across every one
/// of kuhy's apps (Flutter, web, Python/Tkinter alike) — see the
/// `unified-design-system` skill (`~/.claude/skills/unified-design-system/`)
/// for the frozen token table this file implements. Built from explicit
/// `ColorScheme`s, not `ColorScheme.fromSeed`, because the shared palette is
/// hand-picked, not algorithmically derived from one seed color.
library;
import 'package:flutter/material.dart';
/// Builds the app's light `ThemeData` from the shared token set.
ThemeData buildLightTheme() {
const colorScheme = ColorScheme.light(
surface: Color(0xFFF6F4F3), // paper
surfaceContainerHighest: Color(0xFFFCFBFB), // paper-raised
onSurface: Color(0xFF211D1B), // text-on-light (= ink)
onSurfaceVariant: Color(0xFF70625B), // muted-on-light
outline: Color(0xFFE0DAD7), // line-light
primary: Color(0xFFB8862E), // accent
onPrimary: Color(0xFF211D1B), // on-fill — filled surfaces use dark text
error: Color(0xFFE2585F), // danger
onError: Color(0xFF211D1B), // on-fill
);
return _buildTheme(colorScheme);
}
/// Builds the app's dark `ThemeData` from the shared token set.
ThemeData buildDarkTheme() {
const colorScheme = ColorScheme.dark(
surface: Color(0xFF211D1B), // ink
surfaceContainerHighest: Color(0xFF38312E), // ink-raised-2
surfaceContainerHigh: Color(0xFF2B2624), // ink-raised-1
onSurface: Color(0xFFECEAE9), // text-on-dark
onSurfaceVariant: Color(0xFFAAA09A), // muted-on-dark
outline: Color(0xFF463E3A), // line-dark
primary: Color(0xFFB8862E), // accent
onPrimary: Color(0xFF211D1B), // on-fill
error: Color(0xFFE2585F), // danger
onError: Color(0xFF211D1B), // on-fill
);
return _buildTheme(colorScheme);
}
ThemeData _buildTheme(ColorScheme colorScheme) {
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: colorScheme.surface,
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,
),
);
}
/// 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) — this app's desktop build is a
/// Chrome `--app` window that can be arbitrarily wide (potentially 4K), so
/// rendered/edited note text needs an explicit column width instead of
/// filling the whole viewport.
const double kProseMaxWidth = 640;

25
test/theme_test.dart Normal file
View File

@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/ui/theme.dart';
void main() {
test('buildLightTheme uses the shared palette and 16px body floor', () {
final theme = buildLightTheme();
expect(theme.colorScheme.brightness, Brightness.light);
expect(theme.colorScheme.primary, const Color(0xFFB8862E));
expect(theme.colorScheme.onPrimary, const Color(0xFF211D1B));
expect(theme.textTheme.bodyLarge!.fontSize, AppTextSize.body);
expect(theme.textTheme.bodyMedium!.fontSize, AppTextSize.body);
});
test('buildDarkTheme uses the shared palette and 16px body floor', () {
final theme = buildDarkTheme();
expect(theme.colorScheme.brightness, Brightness.dark);
expect(theme.colorScheme.primary, const Color(0xFFB8862E));
expect(theme.colorScheme.onPrimary, const Color(0xFF211D1B));
expect(theme.textTheme.bodyLarge!.fontSize, AppTextSize.body);
expect(theme.textTheme.bodyMedium!.fontSize, AppTextSize.body);
});
}