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 forColorScheme.fromSeed) and fixed semanticsuccess/warning/infocolors (light + dark variants of each)KhTypography— the Material 3 type scale (displayLarge…labelSmall), set in Inter (bundled local asset), plusKhTypography.allKhSpacing— 4px-based spacing scale (xxs…xxxl)KhRadius— corner-radius scale (none…xl, plusfullfor 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>forsuccess/warning/info, with.light/.darkinstancesKhShapeTokens— per-component corner radii (standardandroundedpresets,KhShapeTokens.of(context)); see ShapesKhGradients—primary/hero/surfacebrand gradients (KhGradients.of(context)); see GradientsKhTheme.light/KhTheme.dark/KhTheme.fromColorScheme— assemble everything above into completeThemeDatainstances
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:
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 whatKhThemehas 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):
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.