Skip to content

kh_theme

Design tokens (colors, typography, spacing) and assembled light/dark ThemeData for khflutterlib-based apps.

The first package in the workspace with a Flutter dependency — see PHASE_0_ARCHITECTURE.md §8 (dependency rules) and §16 (design rationale).

Status

Available. Version 0.1.2+2, shipped in release train khflutterlib-v0.4.3. Exports:

  • KhColors — brandSeed (the default seed color for ColorScheme.fromSeed) and fixed semantic success/warning/info colors (light + dark variants of each)
  • KhTypography — the Material 3 type scale (displayLarge … labelSmall), set in Inter (bundled local asset), plus KhTypography.all
  • KhSpacing — 4px-based spacing scale (xxs … xxxl)
  • KhRadius — corner-radius scale (none … xl, plus full for pill/stadium shapes)
  • KhElevation — Material 3's six elevation levels (level0 … level5)
  • KhSizes — component sizing: button heights (buttonHeightSmall/buttonHeight/buttonHeightLarge) and icon sizes (iconXs … iconXl)
  • KhThemeExtension — ThemeExtension<KhThemeExtension> for success/warning/info, with .light/.dark instances
  • KhShapeTokens — per-component corner radii (standard and rounded presets, KhShapeTokens.of(context)); see Shapes
  • KhGradients — primary/hero/surface brand gradients (KhGradients.of(context)); see Gradients
  • KhTheme.light/KhTheme.dark/KhTheme.fromColorScheme — assemble everything above into complete ThemeData instances

KhTheme themes all four standard Material button types (elevatedButtonTheme/filledButtonTheme/outlinedButtonTheme/textButtonTheme), plus cards, dialogs, bottom sheets, snackbars, chips and inputs, from the same shape tokens.

Note on customization: kh_theme does not hand-pick every color as a fixed constant. KhTheme.light/KhTheme.dark generate the full ColorScheme from a single seed color via Material 3's ColorScheme.fromSeed — each consuming app can supply its own brand color, or an exact ColorScheme. Component corner radii are configurable through KhShapeTokens; spacing, elevation and the type scale stay fixed across all apps. A consuming app can also override the font family (Inter by default) via KhTheme.light(fontFamily: ...) without forking the package — sizes, weights, letter spacing, and line heights stay as KhTypography defines them; only the family changes. See PHASE_0_ARCHITECTURE.md §16 for the full rationale.

Usage

dependencies:
  kh_theme:
    git:
      url: https://github.com/msflib/flutter.git
      path: packages/kh_theme
      ref: khflutterlib-v0.4.3 # use the same release tag for every kh_* package
import 'package:kh_theme/kh_theme.dart';

final defaultSeed = KhColors.brandSeed;
final success = KhColors.successLight;

final headline = KhTypography.headlineSmall;
final body = KhTypography.bodyMedium;

KhTypography sets everything in Inter, bundled as a local font asset in this package (assets/fonts/, declared in pubspec.yaml) — no network dependency at runtime, unlike an earlier google_fonts-based draft (reverted; see this package's CHANGELOG.md and PHASE_0_ARCHITECTURE.md §16 for why). The .ttf files (weights 400, 500, 600, 700 and 800) are committed in assets/fonts/, so a git dependency gets them automatically; assets/fonts/README.md records where each one came from.

Padding(
  padding: const EdgeInsets.all(KhSpacing.md),
  child: Container(
    decoration: BoxDecoration(
      borderRadius: BorderRadius.circular(KhRadius.sm),
    ),
    child: const Text('...'),
  ),
);

Card(elevation: KhElevation.level1, child: ...);

KhThemeExtension carries semantic colors ColorScheme has no slot for. KhTheme.light/KhTheme.dark register .light/.dark via ThemeData(extensions: [...]); widget code reads them through the theme, not by importing KhColors directly:

final success = Theme.of(context).extension<KhThemeExtension>()!.success;

KhTheme.light/KhTheme.dark assemble everything above — this is the only class most apps touch directly:

MaterialApp(
  theme: KhTheme.light(),       // or KhTheme.light(seedColor: myBrandColor)
  darkTheme: KhTheme.dark(),
);

A seed generates every color role for you, so the palette is always harmonious and correctly contrasted, but it can only approximate a brand color. When a brand has exact hex values, pass a full ColorScheme instead. Start from a seed and pin only the roles the brand specifies, so everything else stays generated:

final light = ColorScheme.fromSeed(seedColor: brandBlue).copyWith(
  primary: brandBlue,
  secondary: brandNavy,
);
final dark = ColorScheme.fromSeed(
  seedColor: brandBlue,
  brightness: Brightness.dark,
).copyWith(primary: brandBlue, secondary: brandNavy);

MaterialApp(
  theme: KhTheme.light(colorScheme: light),
  darkTheme: KhTheme.dark(colorScheme: dark),
);

With an exact scheme, your app owns contrast for every role it sets. Pass seedColor or colorScheme, not both, and the scheme's brightness must match the method you call. KhTheme.fromColorScheme(scheme) picks light or dark from the scheme itself.

An app that already ships its own brand typeface can override Inter without forking kh_theme:

MaterialApp(
  theme: KhTheme.light(fontFamily: 'Roboto'),
  darkTheme: KhTheme.dark(fontFamily: 'Roboto'),
);

fontFamily only threads the family name through TextTheme.apply — the named font must already be resolvable by the Flutter engine (bundled as an asset and declared in your app's own pubspec.yaml, or a platform default). kh_theme doesn't load arbitrary fonts on your app's behalf.

Using Inter in your own styles

Because Inter is bundled by kh_theme, Flutter registers it as packages/kh_theme/Inter, not Inter. A hand-written TextStyle(fontFamily: 'Inter') won't find it and silently falls back to the platform font. Use the constant instead, or start from a KhTypography style:

const TextStyle(fontFamily: KhTypography.fontFamily, fontSize: 40);
KhTypography.displaySmall.copyWith(fontWeight: FontWeight.w800);

(Before 0.4.2, kh_theme itself made this mistake, so apps rendered the platform font instead of Inter.)

Overriding text styles

textTheme overrides individual KhTypography styles. Each style is merged field by field, so you only set what changes; size, letter spacing and line height carry over:

MaterialApp(
  theme: KhTheme.light(
    textTheme: const TextTheme(
      displaySmall: TextStyle(fontWeight: FontWeight.w800),
      headlineMedium: TextStyle(fontWeight: FontWeight.w700),
    ),
  ),
);

Inter is bundled at weights 400, 500, 600, 700 and 800, so heavy headings render with a real face. KhTypography's own styles still use only 400 and 500. textTheme is applied after fontFamily, so a family set inside it wins for that style. KhTypography.textTheme is the base scale, if you want to build from it yourself.

Shapes

KhShapeTokens sets the corner radius of buttons, text inputs, cards, dialogs, bottom sheets, chips and snackbars. There are two presets:

  • KhShapeTokens.standard (the default): compact radii for dense, data-heavy apps. It's what KhTheme has always rendered.
  • KhShapeTokens.rounded: pill buttons and chips, with generously rounded cards, inputs and modal surfaces.

Use a preset as is, or adjust individual tokens:

final shapes = KhShapeTokens.rounded.copyWith(
  card: BorderRadius.circular(20),
);

MaterialApp(
  theme: KhTheme.light(shapes: shapes),
  darkTheme: KhTheme.dark(shapes: shapes),
);

Every token is a BorderRadius. For a pill, use KhRadius.full. The tokens are applied to the matching Material component themes, so plain Material widgets pick them up too. Widgets that draw their own shaped surfaces read them with KhShapeTokens.of(context). The navigation drawer's shape stays fixed.

Gradients

KhGradients holds three brand gradients for surfaces ColorScheme has no slot for. Each is meant to carry content in a specific colour role:

Gradient Use Content colour
primary calls to action, featured banners onPrimary
hero hero headers, feature cards onPrimaryContainer
surface subtle background washes onSurface

By default they're derived from the theme's ColorScheme, so they follow a custom seed or exact scheme automatically. To use a brand's own, pass them to each theme (they can be linear, radial or sweep):

MaterialApp(
  theme: KhTheme.light(
    colorScheme: brandLight,
    gradients: const KhGradients(
      primary: LinearGradient(colors: [brandBlue, brandNavy]),
      hero: LinearGradient(colors: [Color(0xFFDCE6FF), Color(0xFFB4C8FF)]),
      surface: LinearGradient(colors: [Colors.white, Color(0xFFEAF0FF)]),
    ),
  ),
);

Widgets read them with KhGradients.of(context):

DecoratedBox(
  decoration: BoxDecoration(gradient: KhGradients.of(context).hero),
  child: ...,
);

If you pass custom gradients, pass a separate set to KhTheme.dark that suits a dark background. Otherwise the dark theme uses gradients derived from its own scheme.