plinth_core 1.2.0
plinth_core: ^1.2.0 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.
Changelog #
All notable changes to this package will be documented in this file.
The format follows Keep a Changelog and Semantic Versioning.
The three Plinth packages move in lockstep from 1.0.0 onward — see
PUBLISHING.md.
A release where this package itself did not change says so rather than
inventing one. Before 1.0.0, minor bumps could carry breaking
changes; from 1.0.0 they cannot.
1.2.0 #
No change in this package. Released in lockstep with plinth_components
1.2.0, which builds out F-3 - the announcement work - and fixes two
components that could not be reached by keyboard. See
B0C_FINDINGS.md.
1.1.0 #
No change in this package. Released in lockstep with
plinth_components 1.1.0, which carries the fixes from the
B0c screen-reader pass.
1.0.1 #
Fixed #
-
The pubspec description was 202 characters, and pub.dev caps it at 180. That cost 10 pub points —
Provide a valid pubspec.yamlscored 0/10 — dropping the package from 160 to 150.No code changed. The description says the same things more briefly.
1.0.0 #
The first stable release. Everything below shipped across the two betas and is collected here; the sections after this entry are the betas themselves, kept for anyone tracking what moved when.
What 1.0.0 promises: no breaking source change without a 2.0.0. Rendered output is not covered — a minor may correct a colour, a size or an announcement, which this library has already done several times and will do again. See PUBLISHING.md. Pin goldens to a version, not a range.
B0c, the manual screen-reader pass, had not run when this shipped.
Everything accessibility-related is verified by tests and simulated
semantics trees and has not been heard aloud. Its findings will land as
corrections in a 1.x.
Added #
-
PlinthDensity— a tap-target floor, so an app can say whether it is a desktop tool or a phone. (A1c)PlinthTheme.defaultTheme.copyWith(density: PlinthDensity.touch)Plinth sizes like the web library it is modelled on. Measured across eleven controls at default size: every one clears WCAG 2.2 AA's 24x24, and none clears iOS's 44 or Android's 48. That is the right answer for a dense admin table and the wrong one for a phone, and there was no way to say which.
floor standard(default)24 — WCAG 2.2 SC 2.5.8 comfortable44 — iOS HIG touch48 — Android Material standardis a no-op, asserted rather than assumed: every control already cleared 24, so the default density cannot restyle anything. -
lerpis real, so theme changes animate. (PR-11)It used to be
return t < 0.5 ? this : other;— a hard cut halfway through any transition. Chrome colours, ramp shades and the numeric scales now interpolate, driven by theAnimatedThemethatMaterialAppalready installs.brightness,primaryColor,defaultRadiusand the four lookup maps still change over at the midpoint, because there is no half-step between two brightnesses or two ramp names.Expect a light↔dark toggle to be a partial cross-fade.
defaultThemeanddarkThemeshare one ramp map, so a light versus dark palette colour differs only throughshadeFormirroring — which followsbrightnessand therefore snaps. The chrome fades; the accents change over. That is a limit of shade mirroring, not of the interpolation. -
PlinthRoleandroleRamps— the component library resolves its own colour roles through a mapping instead of reaching intocolorsfor'red','gray'and'green'. (PR-09)// Keep 'red' for your own meaning; the library still has an error colour. theme.copyWith(roleRamps: const {PlinthRole.error: 'brandDanger'});colorswas a namespace shared between the library and its consumer where neither knew. An app that repurposedredas its expense pole silently restyled every form field's error state; an app that did not ended up with two different reds on screen. Both happened in the same migration.Three roles, because three is what the library actually uses:
error(every field's border and message),neutral(descriptions, separators, empty states),success(the copy button's flash).Value-preserving.
kDefaultRoleRampsmaps them tored,grayandgreen— exactly what was hardcoded — and a test asserts equality across all three roles and all ten shades in both themes. 53 call sites moved; no golden did.A partial map falls back per role, so remapping one does not blank the other two.
-
A Material bridge —
PlinthMaterialBridge, an extension onPlinthThemefor reconciling it withThemeData. (PR-08)// Keep your own ThemeData, and assert the two agree. test('palettes agree', () { expect(myTheme.colorSchemeDisagreements(myScheme), isEmpty); }); // Or derive Material's types from Plinth. ThemeData(colorScheme: myTheme.toColorScheme(), textTheme: myTheme.toTextTheme());The need was agreement, not generation, and that reverses what was planned. A
toThemeData()was the roadmap's top-ranked task and during the migration it was never reached for — the app already had six working lines ofThemeData, and replacing that wholesale is riskier than the six lines it saves. What actually broke was reading colour from two systems at once: 58plinth.*lookups beside 31colorScheme.*and 80textTheme.*, with Material's seeded red sitting in the same tables as the app's red and nothing keeping them in agreement.What Plinth does not have an opinion about is named, not guessed.
toColorSchemetakessecondary,tertiary, the container roles and the inverse roles fromColorScheme.fromSeed, and the checker ignores them;ownedSchemeFieldslists the ten it decides. Reporting a disagreement about a field Plinth never had a view on would train people to ignore the list. LikewisetoTextThemefills the body, title and label roles and leavesheadline/displayas the base had them, becausefontSizesruns 12 to 20 and cannot answer what a display size is without inventing one.errormaps to theredramp becauseplinth_componentsalready hardcodesshaded('red', …)for destructive state in 12 places.Comparison is exact, deliberately: a tolerance would decide for you how much drift is acceptable, which is the judgement this exists to surface rather than make.
-
A categorical series palette, built to survive colour-vision deficiency. (PR-04, PR-18)
theme.series(0); // the nth series colour theme.seriesFor('groceries'); // by domain key36 of the subject app's 91 hardcoded colours were chart series — the largest single category of hardcoding the migration left behind, and a property neither a brand ramp nor a status colour has.
kDefaultSeriesColorsis scored across eight contexts: the light and dark themes, each under normal vision plus simulated protanopia, deuteranopia and tritanopia (Viénot–Brettel–Mollon 1999). Worst case across all eight: 13.5 ΔE between any two, 33.1 between neighbours.The shades vary rather than sitting at 6, and that is the whole mechanism. Dichromats lose hue discrimination but keep lightness discrimination, so a palette separated only by hue collapses for them and one that also moves through lightness does not.
kVividSeriesColorsis the hue-only alternative: ten ramps at shade 6 with the largest pairwise separation for normal vision (30.5). It is not the default because it scores 2.3 under tritanopia and 3.3 under protanopia — 2.3 is about the just-noticeable difference, so two of its series are the same colour to a reader with tritanopia. Use it when colour is decoration beside a label rather than the information itself.seriesFortakes a name, not aColor, and that is the point. The layer that knows a slice is'crypto'is usually pure Dart with noBuildContext; the layer that paints it has one. A name crosses that boundary, a colour cannot without dragging the theme with it.Register domain keys with
seriesKeysto pin them. Unregistered keys resolve deterministically — an explicit FNV-1a rather thanhashCode, which Dart does not promise to keep stable across runs — so a chart does not reshuffle on restart. The hash is a floor, not a solution: ten positions and an unbounded key space collide, and'groceries'and'transport'both land on 0. Register anything shown together.
1.0.0-beta.2 #
A second beta rather than 1.0.0, deliberately. Everything below is
breaking, and PR-17 — whether a heading on a tinted surface belongs at
the body floor or the large-text one — is still open and would move
colours again. 1.0.0 promises no breaking change without a 2.0.0, and
spending that promise the week it is made is worse than one more beta.
Everything here came from migrating one real app onto the packages and recording where it had to work around them — see ADOPTION_REQUIREMENTS.md, which numbers each gap, and APP_VALIDATION_PLAN.md for how the evidence was gathered.
Breaking #
-
generateShadesanchors the supplied colour at shade 6, which repaints every built-in ramp. (PR-03, PR-16)Feed a colour in, ask for shade 6 — the shade every component defaults to — and you now get that colour back. Before, the lightness stops were absolute, so a base was normalised onto the curve rather than anchored to it, and the best-matching index was not even consistent (anywhere from 5 to 8 depending on hue). There was no shade a caller could reliably ask for.
This is a visible restyle, not a refactor. The 13 built-in ramps are seeded with Mantine's own published
.6values and none of them survived the old generator:Ramp Seed Was Now red#FA5252#E90707#FA5252violet#7950F2#4511DF#7950F2blue#228BE6#187FD7#228BE6The distortion ran one way — darker and more saturated than Mantine — so the palette gets lighter and truer. Shades 0 and 9 do not move (both endpoints are held), so washes and the darkest shades render as before; shades 1–8 shift, most visibly on
redandviolet.Two knock-on effects worth expecting:
-
readableOnnow does more work. Mantine's realred.6is ~3.6:1 on white and does not clear the body floor, where the old over-darkened#E90707did. Text taking a palette colour will darken where it previously did not — which is the PR-06 floor working, not a regression. -
Anything pinned to a literal shade value will move. If you screenshot-test or hardcode a generated shade, re-baseline it.
-
Filled buttons in blue and red now carry a dark label, not a white one.
contrastingOnpicks whichever foreground contrasts better, and on the corrected fills the light one stops winning: white onblue.6goes from 4.15:1 to 3.56:1 while the dark foreground reaches 4.84:1. New for blue and red only — green, yellow and teal already had dark labels and violet keeps white. Sinceblueis the defaultprimaryColor, this changes the default button.Deliberate. White on Mantine's real
blue.6fails AA for body text, so Mantine's own filled buttons do not clear it either; the distorted palette had been hiding that. Plinth is now more accessible than the palette it copies, and looks less like it. OverrideonFilled/onFilledInverseif you want the old pairing back.
An app supplying its own brand colour can now delete any re-anchoring curve it wrote — which is what publishing
generateShadeswas supposed to achieve and could not. -
-
readableOnnow defaults to a body-text contrast floor (4.5:1) instead of 3.0:1. (PR-06) 3.0 is WCAG's large text threshold — right for a heading, wrong for the table cell most callers are actually painting. On the subject app, six of seven text tokens were sitting in "large text only" and nobody had noticed.The floor is now named rather than numeric:
PlinthContrast.body(4.5),.large(3.0),.nonText(3.0), passed aslevel:. An explicitminRatio:still overrides it, so existing callers that passed a number are unaffected.What moves: accent colours darken where they were between 3.0 and 4.5 — 19 of 26 ramp/background pairings in the light theme, 11 of 26 in dark.
plinth_componentspins itsPlinthVariant.lightpairings to.largeexplicitly, so that variant looks exactly as it did: a same-hue label on a same-hue tint cannot reach 4.5 and stay recognisably that colour, since in a dark theme walking to body contrast lands on near-white (cyan #90DFEA → #F0F8F9).Golden images need regenerating on Linux. The goldens are skipped on Windows and macOS by design, so this change could not be verified visually where it was made.
Added #
-
A semantic token tier — name a colour by the role it plays rather than by its hue. (PR-01, the largest gap the adoption exercise found)
PlinthTheme.defaultTheme.copyWith( colors: { ...PlinthTheme.defaultTheme.colors, 'expenseRamp': PlinthTheme.generateShades(const Color(0xFFFF3B30)), }, semanticColors: {'expense': const PlinthSemanticColor('expenseRamp')}, ); theme.semantic('expense'); // the fill theme.semanticText('expense'); // legible as a label on the surface theme.semanticWash('expense'); // a panel or row tintThree roles, because three is what a real app read — migrating it used a fill, a text variant and a wash per pole and reached for nothing else, leaving 7 of each ramp's 10 shades unread. It cost 110 lines of hand-written
app_tokens.dartto get them, and that only worked becausecolorsaccepts arbitrary string keys, so role names could be smuggled in as ramps — an accident rather than an API.PlinthSemanticColorcarries the contrast floor per role (level:, defaultPlinthContrast.body) rather than per call site, since the floor is a fact about the content: a heading-only role can honestly sit at.large, a table cell cannot.Roles live in their own map, so declaring
expenseno longer spends theredkeyplinth_componentshardcodes in 12 places for error states. An undeclared role falls back to reading the name as a ramp key, sosemantic('blue')works and the pre-existing smuggling pattern renders as it did.Additive —
semanticColorsdefaults to empty and no component declares a role, so nothing renders differently until an app opts in. It does not on its own retire an adopter's hand-written tier: the roles resolve against a ramp, and PR-03 (anchoring a supplied brand colour so shade 6 returns what you fed it) is still open. -
PlinthSpacing— the spacing scale as compile-time constants (xxs4,xs8,sm12,md16,lg24,xl32), on a 4px base unit exposed askSpaceUnit. (PR-07)The named
spacingmap starts atxs: 10, above the values both this library and its adopters reach for most:plinth_componentswritesspacing[PlinthSize.xs]! * 0.4in 38 places and* 0.8in 8 more — 4px and 8px — and 80 of its 87 spacing multipliers resolve below 10. The library has been rebuilding a sub-xsscale out of fractions.Constants rather than a theme lookup on purpose: spacing does not vary by theme, and a lookup costs a
BuildContextand forces the widget out ofconst. Applying this to the subject app converted 314 literals and every one stayedconst.PlinthTheme.space(steps)is there for runtime multiples. -
PlinthTheme.wash(name, {alpha})— a background tint that survives the brightness flip. (PR-05)shaded(name, 0)mirrors to shade 9 in a dark theme, turning the lightest tint into the most saturated shade:shaded('green', 0)is#F2F8F3in light and#245B2Ein dark, a saturated panel where a wash was wanted.washcomposites oversurfaceinstead, so the role holds in both themes. -
PlinthTheme.generateShadesis public. (PR-02) It was private, so an app supplying its own brand colour could not reach the function that built the library's own palette and had to copy it. -
ThemeData.plinth— the same lookup ascontext.plinthfor code that already holds aThemeData. (PR-10) A helper written the idiomatic Flutter way (Widget _badge(ThemeData theme, …)) had the theme and still had to grow aBuildContextparameter.
Known gaps #
generateShadesstill normalises a base colour onto fixed lightness stops rather than anchoring it, so feeding it#FF3B30and asking for shade 6 returns#F00D00. Making it public does not yet let an app retire its own copy — that needs PR-03.- The 4px grid covered 314 of the subject app's 353 spacing literals.
The 39 that did not fit cluster on half-steps (
6×20,2×10,10×8), which is either a missing 2px sub-unit or drift worth normalising.
1.0.0-beta.1 #
No changes to this package. The version moves because the three Plinth packages are now released in lockstep, and this is the first release under that convention.
The 1.0.0-beta line is the rehearsal for that promise, not the
promise itself: the API is what 1.0.0 intends to ship, and the beta
exists so the three-package release sequence gets run once while a
mistake is still cheap. flutter pub add still resolves the last
stable release unless a prerelease is asked for.
0.2.1 #
Added #
- An
example/, so pub.dev's Example tab shows how to register the theme and read tokens from it rather than sending people to the repo. Since this package has no widgets, the example is plain Flutter styled entirely fromPlinthTheme— includingshaded,contrastingOn, andreadableOn, which are the parts hardest to infer from the API alone.
0.2.0 #
Added #
-
Contrast-aware color resolution. Measured against WCAG, the palette was failing badly in three separate ways, and each needed a different fix:
contrastingOn(background)picks a foreground by the fill's lightness. White onyellowmeasured 2.12:1 and onteal1.82:1, against the 4.5:1 AA asks for — a filled button whose label you could see but not read. Which way it should fall depends on the fill, not the theme.shaded(name, shade)andshadeFor(shade)mirror a shade for the theme's brightness. A shade-0 wash is nearly white behind a dark alert, and a shade-6 accent measured 1.97:1 as text on the dark surface. Mirroring keeps each shade's role while flipping its lightness.readableOn(name, background)walks the ramp for a shade that clears a contrast threshold. Mirroring can't fix this half: the ramps differ in intrinsic lightness, so no single index serves every hue —violetat shade 6 reads comfortably on white wherecyanat shade 6 lands at 2.19:1.
Use
shadedfor fills,contrastingOnfor what sits on them, andreadableOnfor a palette colour used as text or an icon. -
onFilledInverse, the dark counterpart toonFilled, for fills too light to carry white text.
Changed #
- Components resolving a palette colour now go through these rather
than a fixed shade 6. Colours that already met contrast —
blue,red,violet,indigo,grape,pink— are unaffected; the lighter half of the palette changes appearance, which is the point.
0.1.0 #
Added #
-
Dark mode.
PlinthTheme.darkThemesits alongsidedefaultTheme, and abrightnessfield says which is which:MaterialApp( theme: ThemeData(extensions: [PlinthTheme.defaultTheme]), darkTheme: ThemeData(extensions: [PlinthTheme.darkTheme]), )The color ramps are shared rather than darkened — a blue button is the same blue in either theme, as in Mantine. What changes is the neutral chrome the ramps never covered.
-
Surface, text, and border tokens for that chrome:
surface,surfaceMuted,surfaceSunken,border,borderMuted,text,textMuted,textDisabled, plusonFilled,shadow, andscrim.onFilleddeliberately does not followbrightness: a filled button is saturated in either theme, so its label stays light in both. Flipping it with the theme is how you get dark text on a dark-blue button.The light values are exactly the literals components hardcoded before, so registering
defaultThemerenders identically to 0.0.1. -
Nine more color ramps in
PlinthTheme.defaultTheme, bringing it to Mantine's standard set:pink,grape,violet,indigo,cyan,teal,lime,yellow, andorangejoingray,red,blue, andgreen.This fixes colors that silently rendered as the primary blue.
color()falls back toprimaryColorfor an unrecognized name, so a palette missing a color callers reasonably expect doesn't fail — it quietly renders the wrong thing, which is harder to spot than an error.PlinthBadge(color: 'grape')was blue; it is now grape. -
PlinthTheme.hasColor(name), for telling a real ramp from one that would fall back — useful when offering swatches rather than rendering one.
Changed #
PlinthMarknow picks up the realyellowramp instead of its literal amber fallback, since the theme definesyellowat last. Its highlight shifts slightly as a result.
0.0.1 — Initial development release #
PlinthTheme: aThemeExtension<PlinthTheme>holding the design-token layer every Plinth component reads from — color palettes, spacing, corner radius, and font-size scales, keyed byPlinthSize.- Color-shade generator: produces a 10-shade ramp (
PlinthColorShades) from a single base color, using non-linear lightness stops and shade-dependent saturation rather than plain linear interpolation, so light shades read as soft tints and dark shades stay rich. context.plinthextension for convenient theme access from anyBuildContext.- Shared tokens:
PlinthSize(xs–xl) andPlinthVariant(filled,light,outline,subtle,transparent,defaultVariant) used consistently across every component inplinth_components.