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.
delft_ui #
A porcelain design system for Flutter.
White glaze, cobalt pigment, and one idea holding the whole thing together: colour pools in the recesses.
→ Open the gallery · every specimen, every knob, both palettes, light and dark. It is the same Flutter code this package ships, compiled for the web.

Look at how little colour there is. The page has structure before it has a single blue pixel — cards are raised, wells are carved — and the blue that is there marks the option you chose, the action you would take next, and the field you are in. Everything else is white porcelain. That is the argument, and the rest of this file is why it is a rule rather than a preference.
Status: 0.1.0. Token pipeline, rendering engine, seven components, theming and a gallery are in, with haptics, high-contrast mode and an enforced blur budget. Typography decisions land in M3.
Every image in this file was rendered by the painter that ships, in a test that runs in CI. None of them was drawn.
Try it #
dependencies:
delft_ui:
git:
url: https://github.com/siloebb/delft_ui.git
ref: v0.1.0
import 'package:delft_ui/delft_ui.dart';
DelftTheme(
data: DelftThemeData.cobaltLight,
child: DelftCard(
child: DelftButton(label: 'Fire', onPressed: () {}),
),
);
Or clone it and run the gallery locally, which is the version with the calibration knobs attached:
cd example && flutter run -d macos
The thesis #
In blue-and-white porcelain — Delftware, Gzhel, Chinese qinghua — cobalt oxide is painted onto the unfired body and then sealed under a clear glaze. Where the surface has been incised, the pigment runs into the groove and settles. The line is not drawn darker; it becomes darker because there is more pigment sitting in the dip.
delft_ui takes that literally and makes it a system rule rather than a
styling choice:
cobalt saturation ∝ −relief, where there is cobalt
Two inputs, because depth concentrates pigment and does not create it. Incise an undecorated pot and the groove comes out white — there was no cobalt on that part of the piece to run anywhere. Where there is cobalt, depth decides how much of it stays: it settles into a groove and drains off a rise.
| bare body | painted | |
|---|---|---|
+1.0 … +0.3 raised |
white glaze | a pale wash — pigment ran off |
≈ 0.0 flush |
white glaze | the flat of a decorated area |
−0.3 … −1.0 carved |
white glaze, modelled only | cobalt bodies up in the groove |

The pigment is a colour or nothing rather than an amount — a brush either went over this part of the piece or it did not — and it defaults to nothing. How dark it comes out is what relief decides, in both directions: pigment settles into a groove and drains off a rise. So the whole left column is white and the right column is a gradient, which is what lets colour mean something: an input well and a selected input well are the same carve, painted or not.
It is expressed as DelftPigment? pigment rather than a flag, which removes a
failure this system hit twice: an argument that quietly does nothing depending
on the value of another argument. There is no flag left to be a no-op, because
absence is the argument. The idiomatic call names a palette field, so ordinary
use keeps every colour in the token file:
DelftReliefSurface(relief: DelftRelief.deepest) // a white well
DelftReliefSurface(relief: DelftRelief.deepest, pigment: p.accent) // a selected one
The payoff is that colour and depth stop being two independent things a designer has to keep in sync by hand. Change one and the other follows, because they are the same physical fact. A new theme is a new pigment — terracotta is iron oxide, which behaves the same way — and the whole system re-derives.
A pigment is not a colour #
The parameter above takes a DelftPigment, and the object is not ceremony. Ask
a mineral for "its colour" and the honest answer is for what?
| role | what it is | cobalt, light theme | gold |
|---|---|---|---|
stain |
mixed into the body, concentrated by depth | #184C95 |
#B18C2F |
fill |
a solid area you can see against the page | #184C95 |
#997929 |
boundary |
a 3:1 line, WCAG 1.4.11 | #184C95 |
#997929 |
readable |
4.5:1 text, WCAG 1.4.3 | #184C95 |
none |
In a light theme three of those collapse into one number and a plain Color
gets away with it. A dark theme pulls them apart immediately: the stain has to
darken the body or a carved surface would read as embossed, so it is nearly
black — and a nearly black cobalt is useless as a link. That gap is the whole
reason for the type.
readable is nullable, and gold is why. Measured against porcelain, gilt
reaches 2.86:1 at its 500 step and 3.72:1 at 600; the first lightness that
clears 4.5:1 has stopped being gold and become bronze. There is no honest gold
that carries text on a white body. Substituting a bronze would hide the
limitation, so the type states it and callers fall through — structurally, by
the value being absent from a candidate list, rather than by a branch someone
has to remember to write.
The palette ships four minerals:
accent |
the theme's own — cobalt here, iron oxide on terracotta |
muted |
slate, for surfaces that recede without meaning anything |
gilt |
gold. Decorative. Draws lines, never carries text |
danger |
iron red, and it outlines itself whether or not you asked |
glaze is deliberately not one of them: painting the body with the body
returns the body, which is what null already means.
That last row is WCAG 1.4.1 made structural. On the terracotta palette the
danger mineral and the theme's own iron oxide are 14° apart, and once both are
pooled into the same body they are not separable by colour at all — 29 of
channel distance, against 269 for cobalt on bare porcelain. Shifting danger to
crimson was measured too and bought nothing: 31 instead of 29. So the outline is
not a supplement to the colour there, it is the entire mechanism, and a pigment
that carries outlined supplies one without any component being told to. It is
the only version of "do not use colour alone" that survives a component author
who has not read the guideline.
DelftCard(border: p.gilt, child: …) // a gilded rim
DelftButton(label: 'Delete', pigment: p.danger, onPressed: …) // outlines itself

Not everything is a piece of porcelain #
relief: 0 means flush, not absent. A flush piece still carries 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 most people arrive expecting — in a
neumorphic library where depth is the only axis, 0 has to mean flat. The
missing idea here was never a lower number. On a plate the body has geometry and
the decoration painted onto it has none: a cobalt line on a flat plate is not
raised, it is pigment. Page backgrounds, text panels, icons and dividers are
marks, not bodies.
So there are two surfaces:
DelftSurface |
a mark on the page. Fill and box, no painter, no shadows at any height |
DelftReliefSurface |
a body with geometry. Runs the material painter and pools its pigment |
And because two pieces that touch should not each model the edge they share,
DelftReliefSides turns the modelling off on a given edge. It is one geometry
change rather than four conditionals: the silhouette is pushed out past the
visible bounds on that side, so the cast shadow, occlusion ring, inner bands and
rim all stop describing an edge that is no longer there — none of them knows
sides exist. The squared corner on a continued edge falls out for free.

The relief scale is continuous #
There is no enum { concave, flat, convex } here, on purpose.
Sunken and raised are not two adjacent effects. They are one number crossing zero, and every value in between is valid and animatable:
DelftRelief.deepest; // -1.0 wells, inset panels
DelftRelief.sunken; // -0.62 slider and toggle tracks
DelftRelief.ground; // 0.0 flush with the surface
DelftRelief.raised; // 0.62 cards, tiles
DelftRelief.highest; // 1.0 buttons at rest, knobs, thumbs
A button pressing itself into the surface is that number sweeping from +1.0
toward −0.55, passing through 0.0 on the way. The painter derives both inner
bands from the sign, so the highlight and shadow physically swap sides
mid-gesture. An enum cannot express the frame where the button is exactly flat.
Why it is hand-painted #
Flutter has no native inner shadow (flutter/flutter#18636), and for
this material the inner shadow is the effect. So the surface is drawn by a
CustomPainter in a fixed order that is not interchangeable:
- cast shadow, outside the shape
- body fill with a directional gradient
- ambient occlusion ring, clipped to the shape
- inner highlight + inner shadow
- rim hairline along the lit edge
Step 3 is the one most neumorphic libraries skip. It is a soft dark ring hugging
the entire silhouette with no directional offset at all — the light that
never arrived, absorbed near the object's own boundary. It applies at every
relief including ground, because a flush surface is still made of the same
material. Without it, shapes read as glossy bubbles no matter how carefully the
directional shadows are tuned.
Glaze, not clay #
The same engine can render either material. The difference is not the algorithm,
it is the calibration — and it comes down to one perceptual knob, matte:
- A gloss highlight is narrow, bright and achromatic. It is the light source reflected off the surface, so it carries the light's colour, not the object's.
- A matte highlight is wide, dim and chromatic. The light enters the material, scatters, and comes back out tinted by the pigment it bounced around in.
Porcelain is vitrified, so it sits near the gloss end: a tight bright rim, a
long cool cast shadow, an almost uniform body. Dry clay sits at the other end.
Raising matte widens the rim, dims it, and drags its colour from pure white
toward the object's own hue — one slider, several physically coupled
consequences.
Tokens #
tokens/tokens.json is the single source of truth, in DTCG format.
tool/build_tokens.dart generates lib/src/tokens/primitives.g.dart from it —
pure Dart, no Node toolchain.
Primitives are private to the package, and exactly two files may read them:
tokens/semantic.dart and theme/palettes.dart — the two places where a brand
decision is actually made. Components read the semantic layer and never the
generated file, because a component has no vocabulary to say cobalt500, only
"carved surface". Rebranding is repointing a handful of primitives, and CI fails
if the generated file drifts from its source:
dart run tool/build_tokens.dart --check
Accessibility #
Neumorphism is intrinsically low-contrast, which puts it in direct tension with WCAG 1.4.11 — 3:1 between a control and its surroundings, measured between two flat colour samples. This material separates a control from the page by modelling rather than by colour, so measured the way the criterion is written, a well-modelled button fails. The criterion is not wrong to measure it that way: soft shadow is a real cue for some people and no cue at all for others.
There is no clever reading of the spec here. There are two renderings.
DelftContrastMode.standard is the material as calibrated;
DelftContrastMode.high adds an explicit outline at a measured ratio, stops
muted text receding, and stops disabled controls dissolving. It defaults from
MediaQuery.highContrastOf, so a user who turned on Increase Contrast in their
OS gets it without the app doing anything.

Everything else follows from measurement rather than from taste. Contrast comes from WCAG relative luminance, not an HSL lightness threshold. Text colour is resolved against the surface it is actually written on, at a 4.5:1 floor — kept deliberately separate from the 3:1 resolver used for outlines, because a colour that is legal as a border is not necessarily legal as a link. A test sweeps every palette and every mineral across the whole relief scale and fails the build if any pair drops below its ratio.
A border is the exception, and it is deliberate: it is drawn in the mineral's own boundary value at every height rather than resolved per frame. Resolving it was tried and produced the defect this system exists to avoid. A danger fill deepens as the control presses until iron red no longer measures against it, so the resolver fell through to the theme's ink — at relief −0.30, while a button presses to −0.55. The rim snapped from red to near-black between two frames. Picking the first candidate that clears a floor is a step function; it is right for a label, which is rendered once, and wrong for anything drawn on a surface that animates across the crossing point. 1.4.11 governs a control's boundary against its adjacent colours anyway, which for a rim is the page behind it and not the piece's own fill.
The strongest argument for any of this is a component that ended up refusing the system's own rule. A text field was painted at first — focus washed the well in the theme's mineral, which is a lovely demonstration of pooling and made the text inside hard to read. A full wash at the deepest carve lands 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, while the mineral's own light ink 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 moved the number and not the perception.
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 field is the
one surface here that is never stained — the well stays bare body and the
pigment becomes a line, which is the component that finally shows what null has
meant all along. The line is gold, because gold has no reading value at any
lightness and can only ever be a line, and a rim is the one place where only a
line is wanted.

The same measurement says something about the dark themes. A fully painted well measures 2.79:1 against the page on cobalt light and 1.02:1 on cobalt dark: the stain is chosen to always darken, the dark body is already dark, and pooling has nowhere left to go. Nothing is broken by that — the ink flips, the rim clears 3:1, and the piece is perfectly legible because the material painter is drawing it rather than the fill. But it means the pigment wash is decoration in one theme and very nearly nothing in the other. Any design where colour carried the state — focus, error, selection — would have shipped a dark theme with no indicator at all, and the light theme would have looked fine the entire time. Blue is only safe to give meaning because blue is never the only thing carrying it.
The most useful test in the suite is the one that was asserting the wrong pair.
Every palette is swept for contrast, and one of those sweeps read text on the
undiluted mineral as ink against stain — which passes trivially and means
nothing, because nothing ever paints a region in stain. That value exists to
be mixed into the body. The value that becomes a solid area behind a label is
fill, and in a dark theme fill is light while stain is near-black, so
an ink chosen against one is illegible on the other. A primary button is
exactly that surface, and every dark palette shipped one at 2.25:1, under a
green suite, for three milestones. The page above is what found it: composing
the components put a label on a mineral for the first time.
A test can be green and vacuous at the same time, and the difference is not visible from inside the test.

Reduced motion drops the travel and keeps the end state; bold text steps the
weight without flattening the type scale, and drives the variable font's weight
axis rather than only its fontWeight — for two milestones it did the latter
alone, which made the setting a silent no-op on every word of body copy; text
scaling is not clamped; hit areas meet the platform floor regardless of how
large the control looks.
What it costs #
The glaze is built from blurred paths, and a blur is the one operation here
worth budgeting. DelftQuality defines three tiers that remove whole effects
rather than weakening them — a weakened blur still costs a blur:
| Tier | Blurred paths per surface |
|---|---|
full |
5 |
reduced |
3 |
flat |
0 |
Those numbers are public API and a test counts the painter's actual draw calls
against them, so the budget cannot quietly drift. DelftQualityGovernor watches
raster time and drops a tier when the frame budget is missed, one way only.
flat is the interesting one. With every shadow gone, a painted carved
surface is still darker and more saturated than a raised one, because the
pooling rule put depth into the fill colour long before the painter was
involved. On bare body the fill is the same at any height, and the body gradient
carries the sign of relief alone.
Roadmap #
| Milestone | Scope | |
|---|---|---|
| M0 | Token pipeline, glaze rendering engine, calibration playground | ✅ |
| M1 | DelftSurface, DelftCard, DelftButton, DelftText, theming, gallery app |
✅ |
| M2 | Haptics + sound feedback, accessibility, performance budget | ✅ |
| M2.5 | Plain vs modelled surface, per-side relief, pigment as a parameter | ✅ |
| M2.6 | Four minerals: DelftPigment, gilt, danger, borders |
✅ |
| M2.7 | Controls: DelftToggle, DelftRadio, DelftField |
✅ |
| M3 | Typography: the display-face decision, argued from a legibility test | |
| M4 | Ink trail — cobalt that darkens where the stroke slows |
Ideas with a shape but no milestone yet live in PLAN.md.
License #
Apache-2.0. See LICENSE, NOTICE, and THIRD_PARTY_NOTICES.md for bundled asset licensing.
