delft_ui 0.1.0
delft_ui: ^0.1.0 copied to clipboard
A porcelain design system for Flutter. White glaze, cobalt pigment, and a continuous relief scale where colour pools in the recesses.
Changelog #
All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased] #
Added #
-
A composed page, first in the gallery and first in the README. One screen of an application built out of the exported API with no styling at the call site. It exists because isolating a component proves a rule and composing several finds the seams between rules — which is literally what happened: the contrast bug below had survived three milestones and 243 tests, and putting a label on a mineral's neat fill for the first time is what surfaced it. The showcase renders the same class the gallery mounts, so the sheet cannot drift from the page.
-
An Open Graph card, rendered by the painter like everything else. The site had
og:titleandog:descriptionand no image, so a pasted link rendered as a text-only card — which for a system whose entire argument is how it looks is the most expensive thing on the page to get wrong. 1200x630, laid out at half and rasterised at 2 for the same reason the app icon is: the modelling is calibrated in logical pixels and a piece drawn 1:1 at card size is a large flat rectangle. The URL is absolute, because a crawler resolvesog:imageagainst nothing and is under no obligation to honour the document's<base href>. -
pub.dev metadata:
topics,documentation, and fivescreenshotswith the card first, since pub.dev uses the first as the search-result thumbnail — a small image beside a line of text, which is the card's job and not a specimen sheet's. -
CI runs
dart docanddart pub publish --dry-run. Both are pub.dev's scoring moved to where it costs nothing to fix: dartdoc treats an unresolved[reference]as a warning and pub.dev treats it as lost points, and the dry run catches a screenshot path that no longer exists.
Changed #
-
The palette contrast sweep asserted
contrastRatio(mineral.ink, mineral.stain)— a pairing that never reaches a screen. It now resolves throughpalette.inkOn, the call components actually make, over both of a mineral's neat values. A test can be green and vacuous at the same time. -
Recorded rather than fixed: gold cannot carry text on itself either.
gilt.fillisgilt600at 0.206 luminance and the darkest step in the ramp reaches 4.08:1 against it, so a primary button in gilt is under AA. Darkening toporcelain950scrapes 4.51:1 and answers the measurement without answering the question — whether a mineral that can only ever be a line should have a solid variant at all. A test pins the number so it cannot drift either way.
Fixed #
-
Every dark palette was rendering a primary button's label at 1.69–2.25:1.
DelftPigment.inkis the text colour for a solid area of the mineral, and in the two dark palettes it was a light value sitting on a light fill. Each dark mineral now inks in its own 900 step: cobalt dark goes from 2.25:1 to 7.14:1, terracotta dark to 7.55:1, and all four clear the 7:1 enhanced floor of WCAG 1.4.6 rather than scraping the 4.5:1 of 1.4.3.The field's own documentation was the cause. It said "text on a surface of undiluted
stain", andstainis not a colour anything paints a region in — it exists to be mixed into the body, and text over a stained surface is resolved against the pooled result byDelftPalette.inkOn. The value that becomes a solid area behind a label isfill. In a light theme the two are the same dark value and the mistake is invisible; in a dark themestainis near-black andfillis a light tint.inkOnofferspalette.inkalongsidemineral.inkand returns whichever reads better, so the two have to straddle — one light, one dark. Every dark palette had two light candidates and therefore a resolver that could not produce a dark answer. It returned the least bad light one without complaining. -
An unresolved dartdoc reference on
DelftContrast.
0.1.0 — 2026-08-13 #
First tagged release. Everything below had been building up unreleased since M0; this is the point at which the basics are all present and the thing can be looked at by someone who is not holding a clone of it.
Added #
-
The gallery is deployed.
example/gained a web target, and pushing tomainbuilds and publishes it to GitHub Pages. It is the same code the package ships, compiled for the browser — knobs, both palettes, light and dark. A design system that can only be seen by cloning it and running a desktop build is a design system nobody looks at. -
The app mark — one carved circle, painted — is rendered by the shipping painter in
render_showcase_test.dartand derived from there into the favicon and the PWA icon set. Same rule as the specimen sheets: the images come out of the renderer, not out of a drawing tool. -
PLAN.md, for ideas that have a shape but no milestone yet.
Changed #
-
There are two surfaces now, and the difference is whether it is a piece of material at all.
DelftSurfaceis the plain base — a fill, a box, and no painter — for the parts of an interface that are marks on the page rather than bodies with volume: backgrounds, text panels, icons, dividers. The modelled one moved toDelftReliefSurface.relief: 0never meant absent; it means flush, and a flush piece keeps its occlusion ring and a quarter of its cast shadow because a tile set level with the floor is still a tile. That is correct and it is also not what people arrive expecting, because in a system where depth is the only axis0has to mean flat. The missing idea was not a lower number. It was that on a plate the body has geometry and the decoration painted onto it has none. -
Pigment is a parameter, and it is a
DelftPigment.painted: boolbecameDelftPigment? pigmenton every surface, card and button, andDelftPalette.surfaceAt(relief, paintedWith:)on the palette. Null is bare glaze. Two arguments became one, and the failure mode that bit this system twice — an argument that silently does nothing depending on the value of another — is now unreachable, because absence is the argument.The idiomatic call still names a palette field, so ordinary use keeps every colour in
tokens/tokens.json. Passing anything else is an escape hatch and reads like one at the call site. -
A plain surface uses its pigment exactly as given. Pooling is a relief phenomenon — pigment settles because there is somewhere downhill to go — so a mark with no geometry has nothing to pool into.
-
The palette carries four minerals instead of a handful of loose colours.
pigment,accent,inkOnPigmentandpoolStrengthare gone; in their place areaccent,muted,giltanddanger, each aDelftPigment.glaze,ink,inkMutedandbrightnessare unchanged.A colour cannot answer the questions the resolvers ask. The value that stains a groove is not the value that reads as a link, is not the value that draws a 3:1 outline, is not the value written on top of the undiluted mineral. A light theme collapses several of those into one number and a
Colorgets away with it; a dark theme pulls them apart, and gold has no reading value at any lightness.strengthmoved from the palette onto the mineral, because how heavily something stains is a fact about the substance rather than a decision a brand gets to make. The move immediately caught an error: danger had inherited iron oxide's heavy hand on the strength of sharing the word "iron", when iron red is the strongest colourant in the file — 72% saturation against 52% — and needs the lightest. At the heavy setting its deepest carve landed in the dead zone where no ink reaches 4.5:1. -
A border is drawn in the mineral's own boundary value, at every height. It is not resolved against the piece's fill per frame. See Fixed.
-
DelftPalette's…Onresolvers take an optionalpigment, so a surface can say which mineral it was made with. It matters for exactly one and it is the one that would otherwise be wrong: gold needs dark text, where cobalt, slate and iron all need light.
Added #
-
DelftField— a well carved into the page, and left white. It is the one component that demonstrates the system rule by declining it, and the reason is a measurement rather than a preference.Focus painted the well at first, which is a lovely demonstration of pooling and made the text inside hard to read. A full wash at
DelftRelief.deepestlands the surface in the middle of its range, and a mid-tone is where no ink is comfortable: the body ink measures 5.47:1 on cobalt light — legal, and navy on blue, which is the same colour twice — while the mineral's own light ink, the obvious alternative, measures 3.07:1 and is not legal at all. Across every light theme and mineral it sits between 1.95 and 3.21. Capping the wash to a contrast budget was tried next and moved the number without moving the perception, because the problem was never the ratio.A potter would have said so sooner: you do not paint over the part of a piece that carries an inscription, the decoration goes around it. So the well stays bare body and the pigment becomes a line — which makes this the component that shows what
nullhas meant since M2.5. Structure is white and colour is asked for; this is the one that never asks.The line is gold by default, and that closes a loop the palette opened in M2.6.
giltcarries noreadableat any lightness — it cannot hold a sentence, only a line — and a field's rim is the one place in an interface where only a line is wanted. It also leaves the accent free: a blue field spends the theme's own mineral on "the keyboard is pointing here", which is the least meaningful thing an interface has to say.Focus is that line as a ring, resolved to 3:1 against the page under WCAG 1.4.11, present in the layout at all times so a form does not shuffle as you tab through it, and shown for pointer focus too — a button that has focus behaves like one that does not, and a field routes your keystrokes. An error is a status, so it is drawn on the piece rather than around it: a rim that is there whether or not anyone is looking, plus a sentence, plus
SemanticsValidationResult.invalid, plusDelftFeedbackIntent.erroronce on the way in. Danger displaces a named mineral outright rather than blending toward it, because interpolating between two minerals travels the short way round the hue wheel. Disabled shallows the carve toward flush instead of relying on a drop in opacity.Nothing animates. A focus indicator that fades in is absent for four frames, and there is no material travelling anywhere to justify the delay — which is also why this is the one control with no reduced-motion branch in it.
It asserts that it has a label. A placeholder is not one — it disappears exactly when the user needs it, mid-correction — and WCAG 3.3.2 has been asking for the real thing since 2008.
hintis for an example of the value.Built on
EditableTextdirectly, so the package still has no dependency on Material. The cost is that a platform selection toolbar has to be supplied by the app. -
DelftTexttakes aDelftPigment, soDelftTextTone.accentcan name which mineral it means. That is how a validation message is written in iron red without any component acquiring acolorparameter. Gold falls through to a readable ink, structurally, becauseaccentOnalready drops a nullreadablefrom its candidates. -
DelftBoldText, the platform bold-text setting as a foundation rather than a private helper insideDelftText. See the fix below for why it moved. -
DelftToggle— a knob riding in a carved channel. Turning it on paints the groove and changes nothing else: same channel, same knob, same relief. It is the thesis with the fewest moving parts.The knob is painted too, at its own height. A pure white knob in a cobalt channel is the classic switch look and it was the first thing built here; it is also a hand-listed exception to the one rule that does not get exceptions, since the knob sits inside the decorated area and pigment drains off it. Deriving it puts one mineral at two concentrations inside a single 54×30 control, with nobody choosing either number.
-
DelftRadio<T>— the same carve, plus a raised bead. A control whose two states differ only as white groove and blue groove fails WCAG 1.4.1 however far apart the colours measure, because the difference is a hue and nothing else. The bead is geometry, so the control still reads with no pigment at all — a test asserts exactly that, with a stain equal to the body.DelftToggleneeded none of this because its knob travels.Both take a
DelftPigment, dispatchDelftFeedbackIntent.select, carry an optional label that is part of the tap target, and answer to space and enter. A radio is silent when re-taking the option it already holds: a haptic that fires when nothing changed teaches the user that the haptic means nothing. -
DelftContrast.disabledOpacity, so the three components that fade an unavailable control read the number from one place. It is still an open question, and three copies of an open question is how one gets left behind. -
DelftPigment— one mineral in every form the interface needs it:stain,fill,boundary, and a nullablereadable. Plusstrength, andoutlinedfor a mineral that must always carry a line.readableis null on gold and that is the point. Gilt reaches 2.86:1 against porcelain at its 500 step and 3.72:1 at 600; the first lightness clearing 4.5:1 has stopped being gold. Callers fall through structurally — the value is simply absent from the candidate list — rather than by a branch someone has to remember. -
Three new ramps:
slate,giltandiron, taking the generated primitives from 96 to 126. -
borderonDelftSurface,DelftReliefSurface,DelftCard,DelftButtonandGlazeMaterial, taking aDelftPigment. A gilded rim and a high-contrast accessibility outline are one mechanism at two weights — 1.0 for a rim, 1.5 for an outline — because a piece carrying both would draw two concentric lines and read as neither. High contrast overrides a decorative border: gold clears 3:1 and is legal, but a user who asked for high contrast has said legal is not what reaches them. -
A pigment marked
outlinedsupplies its own border with nobody asking. Set ondanger, and it is the only version of WCAG 1.4.1 that survives a component author who has not read it. On terracotta the danger mineral and the theme's own iron oxide are 14° apart and pooling makes them inseparable — 29 of channel distance, against 269 for cobalt on bare porcelain. Crimson was measured as an alternative and bought nothing: 31 instead of 29. -
DelftPigment.fill, the mineral as a solid area. A primary button is a piece made of the mineral rather than stained with it, and a stain chosen to darken a groove is invisible as an object in a dark theme. -
DelftReliefSides, so two surfaces that touch can read as one piece. Modelling a shared edge puts a rim, an occlusion ring and half a cast shadow along the join. Turning a side off says the material continues past there.Implemented as a single geometry change: the silhouette is pushed out beyond the visible bounds on that side, and every effect derived from it stops describing an edge that is no longer there. No effect is told about sides, and the squared corner on a continued edge falls out for free.
-
lerpPigment, kept deliberately separate fromlerpHsl. Mixing a stain and travelling between two colours are different operations, andlerpHslstill does the travelling forDelftPalette.lerp. -
The gallery is in five groups with a divider and an emphasised heading — the idea, tokens, components, feel & access, and a lab — plus specimens for plain vs modelled, for continuity, and a pigment lab with HSL sliders and live contrast readouts.
Fixed #
-
The bold-text accessibility setting did nothing to body copy, and had never done anything.
DelftTexthonouredMediaQuery.boldTextOfby steppingTextStyle.fontWeightalone — which selects between declared static faces, of which a variable font has one. Inter's weight axis is driven byFontVariation, so the setting moved every heading (Spectral: static, two bundled weights) and nothing at all in the text people actually read.The trap was already documented in this repository, in three places, including in the doc comment of the type scale that emits
fontVariationscorrectly. It was simply not applied at the call site that came afterwards, and the failure is invisible by construction: a feature that works on headings and not on body is indistinguishable from a designer deciding headings respond more.The logic now lives in
DelftBoldTextand drives both, and it caps atw700because asking a static family for a weight it does not have is silently substituted rather than refused. -
A pigment whose hue was far from the glaze's came out the wrong colour entirely.
lerpHsltakes the short way around the hue wheel, which is right when travelling between two chosen colours and wrong when thinning a mineral into a near-neutral body. Porcelain carries the cobalt hue at 215°, so the short way to an iron red at 4° runs 149° upward: a red stain rendered at hue 297, purple, and a gold at 43° rendered at hue 120, mint green.Neither shipped palette could expose it, because each glaze was authored carrying its own pigment's hue — cobalt on porcelain is 215° to 215°. It only became reachable when pigment became a parameter. Cobalt now moves by at most one 8-bit step; terracotta shifts six degrees toward its pigment, which is the direction the correction is for.
-
A trace of a foreign pigment brightened a dark recess. Pooling must never add light or a carved surface reads as embossed, and gold broke that in both dark themes. The obvious reading — gold is too light, give it a darker step — is measurably wrong: darkening it through four successive lightness values moved the worst overshoot by nothing at all.
The overshoot is not at high concentration, it is at trace concentration.
lerpPigmentpins the hue to the pigment fromt = 0⁺, so the first molecule of gold turns the whole mix gold-hued while its lightness and saturation are still the glaze's — and relative luminance is hue-dependent, green carrying 0.7152 of it against blue's 0.0722. The mix is discontinuous in luminance at zero, and no choice of endpoint flattens a jump that happens before the endpoint is approached.poolPigmentnow caps the mix at the glaze's luminance. An invariant asserted in a test over four hand-picked pigments is a claim about four pigments; enforced in the mix, it holds for an arbitrary one passed in from outside. -
A border changed colour partway through a press. Borders were resolved with
firstReadableagainst the piece's own fill, andfirstReadableis a step function. A danger fill deepens as it presses until iron red no longer measures 3:1 against it, so the rim fell through to the theme's ink at relief −0.30 — while a button presses to −0.55. From 3.05:1 to 7.48:1 between two frames, which is exactly the defect this palette was designed to prevent: a control changing hue on the way down for no physical reason.A border is now the mineral's own boundary value at every height. WCAG 1.4.11 governs a control's boundary against its adjacent colours, which for a rim is the page behind it and not the piece's own fill.
-
A primary button ignored its pigment. The variant is a piece made of the mineral rather than stained with it, and that different use of the argument had been mistaken for no use of it — so a destructive primary control, the one case where an outline is least optional, was the one case that could not have one.
-
Two specimen sheets rendered their captions as filled boxes. A bare
Textwith a hand-rolledTextStyleasks for a font family nothing registered — the families are declared withpackage:— and Flutter substitutes silently. In a sheet it reads as a caption background, which is why it survived several rounds of looking at the images. -
Paint is a separate input from relief.
DelftPalette.surfaceAtnow takespaintedas well as a height. Depth concentrates pigment; it does not create it, so an incision through bare body stays white.paintedis a bool rather than an amount, because a brush either went over this part of the piece or it did not — and how dark it comes out is relief's job, in both directions: pigment settles into a groove and drains off a rise, so a painted surface carries colour at every height. It defaults to false onDelftSurface,DelftCard,DelftButtonandDelftSurfaceLevel, so a carved surface is now white unless asked otherwise — previously every recess in the system was pigmented. Passpainted: trueto restore the old appearance on a surface.
Added #
- M2 — feel.
DelftFeedback: components dispatch a semanticDelftFeedbackIntentand one file decides what the hardware does about it. Haptics viaflutter/services, no dependency. Fires on touch-down rather than on the callback, and stays silent on release.DelftSoundPlayeras a pluggable interface, withDelftSystemSoundbuilt fromSystemSoundalone. Sound is off by default; haptics are on.DelftContrastModeandDelftContrast, defaulting fromMediaQuery.highContrastOf. Adds a measured outline to every surface, stops muted text receding, and raises the disabled opacity floor.DelftQualitywith three tiers,DelftQualityLevelto publish one, andDelftQualityGovernorto drop tiers when raster time misses budget.DelftFrameBudgetholds the policy on its own so it can be tested.- Quality-tier and contrast-mode specimens in the gallery, and generated specimen sheets for both.
Fixed #
-
DelftPalette.accentOnresolved text colour against a 3:1 threshold — the WCAG 1.4.11 floor for non-text boundaries — rather than the 4.5:1 required for text. Over deeply carved surfaces the accent could measure as low as 1.89:1 and still be returned. Split intoaccentOn(4.5:1, text) andboundaryOn(3:1, outlines), with a test sweeping the whole relief scale. -
M1 — four primitives.
DelftSurface,DelftCard,DelftButton,DelftText. Components accept nofontSizeand nocolor; surfaces publish their depth throughDelftSurfaceLeveland text reads it.DelftPalettewithsurfaceAt(relief)in place of a list of surface colours, plus cobalt and terracotta in light and dark.DelftTypographyover bundled Inter and Spectral,DelftThemeas a plain inherited widget with no Material dependency, andAnimatedDelftTheme.- Foundations for touch targets and reduced motion.
- Gallery app driven by a single specimen registry, with a typed knob inspector and a responsive shell.
-
M0 — foundation.
- DTCG token source at
tokens/tokens.jsonwith a pure-Dart generator (tool/build_tokens.dart) emittinglib/src/tokens/primitives.g.dart. - Glaze rendering engine:
GlazePainter,GlazeShape,GlazeTokens. DelftRelief— a continuous height scale from-1.0(deepest carve) through0.0(flush) to+1.0(highest relief).- Colour utilities with WCAG relative-luminance contrast.
- Calibration playground in
example/, tuned from clay to porcelain.
- DTCG token source at
