plinth_core 1.5.1
plinth_core: ^1.5.1 copied to clipboard
Design tokens for Plinth UI: seed a 10-shade colour ramp from any brand colour, name colours by role, resolve text against WCAG contrast floors.
plinth_core #
A design-token engine for Flutter. Colour that resolves against a
WCAG contrast floor at lookup time, ramps anchored to your own brand
colour, and a way to check your existing ThemeData against your
tokens instead of replacing it.
This is the foundation Plinth UI is built on — but it is not only for Plinth's widgets. Most apps adopting it already have a UI layer they are not going to rewrite. It themes your widgets just as well.
flutter pub add plinth_core
Four things it does that a colour scheme does not #
Colour resolved against a contrast floor, not a fixed shade #
Ask for a palette colour as text, and you get a shade that clears 4.5:1 against the background it actually sits on:
final theme = context.plinth;
Text('Overdue', style: TextStyle(color: theme.readableOn('cyan', theme.surface)))
A fixed shade cannot do this, because ramps differ in intrinsic
lightness. Of the thirteen built-in ramps, exactly one — violet, at
4.95:1 — clears 4.5:1 on white at shade 6, the shade components
default to. cyan is 2.79:1 and yellow is 1.86:1: colours you can
see but cannot read.
readableOn walks the ramp from the shade you asked for toward
whichever end contrasts, and returns the first one that clears the
floor — cyan becomes #157785 at 5.24:1, and violet comes back
untouched because it already passed.
The floor is named by what the colour is for —
PlinthContrast.body (4.5:1) is the default, because that is the table
cell most callers are actually painting, not the heading.
Ramps anchored to your colour #
Feed in your brand colour, ask for shade 6, get your brand colour back:
PlinthTheme.generateShades(const Color(0xFFFF3B30))[6] // 0xFFFF3B30
That sounds obvious and most generators do not do it. Normalising a
base colour onto a fixed lightness curve turned #FA5252 into
#E90707, and the best-matching index landed anywhere from 5 to 8
depending on hue — so there was no shade a caller could reliably ask
for. Both endpoints stay put, so shade 0 is still a usable tint.
Colours named by role, not by hue #
An app's palette is expense, income, brand — not red, green,
blue. Declare the mapping once, with a contrast floor per role:
PlinthTheme.defaultTheme.copyWith(
semanticColors: const {
'expense': PlinthSemanticColor('red'),
'income': PlinthSemanticColor('green'),
},
)
theme.semantic('expense') // the fill
theme.semanticText('expense') // dark enough to read as a label
theme.semanticWash('expense') // a tint for the row behind it
The three are deliberately different colours. A brand red that looks
right as a fill is routinely unreadable as a label — #FF9500 on white
is 2.20:1 — and semanticText is the hand-darkening every app ends up
doing, done for you and held to a threshold.
Keep your own ThemeData #
The question most teams actually have is not "how do I generate a theme", it is "are the two palettes I already have the same colours?"
test('the two palettes agree', () {
expect(myPlinthTheme.colorSchemeDisagreements(myScheme), isEmpty);
});
You get back a list of every field where your ColorScheme and your
tokens disagree, with both values. Assert it is empty in CI and the
drift cannot come back silently. Comparison is exact — a tolerance
would only decide for you how much drift is acceptable, which is the
judgement this exists to surface rather than make.
There is a toColorScheme() for apps willing to hand over the
decision. It is the less useful direction, and that is a finding from
migrating a real app: a wholesale toThemeData() was the
highest-priority item on this package's roadmap, and during the
migration it was never reached for once.
Registering it #
PlinthTheme is a ThemeExtension, so it rides along with the theme
you already have:
MaterialApp(
theme: ThemeData(extensions: [PlinthTheme.defaultTheme]),
darkTheme: ThemeData(extensions: [PlinthTheme.darkTheme]),
)
Then read it from any BuildContext with context.plinth.
Light and dark share the same ramps. A shade's role is mirrored for the active brightness rather than the palette being swapped, so a blue button stays the same blue and only the chrome around it changes.
Spacing does not need a context #
Colour genuinely varies by theme, so paying a BuildContext for it
buys something. Spacing does not, so the scale ships twice:
const SizedBox(height: PlinthSpacing.md) // still const
theme.spacing[PlinthSize.md] // when the size is chosen at runtime
The app this was validated against had 355 spacing literals. Routing
those through a theme lookup would have traded 355 const widgets for
355 runtime ones and bought nothing.
Adopting tokens
covers the rest of what that migration cost.
What's in the package #
PlinthTheme— theThemeExtensionholding colour ramps, semantic roles, spacing, radius and font-size scales, plus the lookups above (readableOn,semantic,wash,contrastingOn,series).PlinthSemanticColor— a role: a ramp, a shade, and the contrast floor it is held to.PlinthRole,PlinthSize(xs–xl),PlinthVariant,PlinthDensity— the shared vocabulary.PlinthMaterialBridge—colorSchemeDisagreements,toColorScheme,toTextTheme.context.plinth— the accessor.
The widgets #
If you want components rather than only tokens — buttons, inputs,
cards, and 114 others —
plinth_components is
built entirely on this package, and is the evidence that the token
layer holds up at scale.
🌐 Live demo · 📖 Full documentation