delft_ui 0.1.0 copy "delft_ui: ^0.1.0" to clipboard
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.

CI Gallery License

A page built out of the system, light and dark

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 relief scale, bare and painted

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

The four minerals across the relief scale

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.

Surface, card, button and text

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:

  1. cast shadow, outside the shape
  2. body fill with a directional gradient
  3. ambient occlusion ring, clipped to the shape
  4. inner highlight + inner shadow
  5. 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.

The same interface in standard and high contrast

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.

A field at rest, focused, in error and unavailable

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.

The same page at night

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.

2
likes
160
points
95
downloads
screenshot

Documentation

Documentation
API reference

Publisher

unverified uploader

Weekly Downloads

A porcelain design system for Flutter. White glaze, cobalt pigment, and a continuous relief scale where colour pools in the recesses.

Repository (GitHub)
View/report issues

Topics

#design-system #ui #theming #accessibility #widget

License

Apache-2.0 (license)

Dependencies

flutter

More

Packages that depend on delft_ui