buildClaymorphismCandidate function
Builds one unvalidated Claymorphism candidate (the generator repairs
and validates it, retries with palette/retry/n and finally falls back
to a Claymorphism hero).
Implementation
ThemeSpec buildClaymorphismCandidate(
int seed,
FamilyDefinition def,
int engineVersion, {
int attempt = 0,
bool accessible = false,
Steering? steering,
}) {
final lim = accessible
? GuardrailLimits.accessible
: GuardrailLimits.standard;
final policy = lim.labelPolicy;
final bile = def.bile;
// Style first: the label solve needs the shade depth and the highlight.
final st = Rng.stream(seed, RngTag.style);
final styleKind = pickWeightedValue(st, def.styles);
final blurCdp = clayShadowBlurCdp.sampleStep(st, 200);
final shadePermille = clayInnerShadePermille.sampleStep(st, 10);
final highlightAlpha = clayHighlightAlpha.sampleStep(st, 10);
final highlight = withAlpha(0xFFFFFFFF, highlightAlpha);
// Palette.
final paletteTag = attempt == 0
? RngTag.palette
: RngTag.sub(RngTag.palette, 'retry', attempt);
final p = Rng.stream(seed, paletteTag);
final drawnDark = p.pickWeighted(def.brightnessWeights) == 0;
final steeredBrightness = steering?.brightnessFor(def);
final dark = steeredBrightness == null
? drawnDark
: steeredBrightness == ThemeBrightness.dark;
final brightness = dark ? ThemeBrightness.dark : ThemeBrightness.light;
final ranges = def.ranges(brightness);
final variant = dark
? ClayVariant.night
: ClayVariant.values[p.pickWeighted(clayLightVariantWeights)];
final tonal = variant == ClayVariant.tonal;
final anchors = dark ? clayNightBodyHues : clayLightBodyHues;
final drawnAnchor = p.pickWeighted([for (final a in anchors) a.weight]);
final drawnBodyHue = _wrap(anchors[drawnAnchor].value.sample(p));
// Steering (engine v4): the body stays on one of the family's anchors —
// the one nearest the intent's base hue, inside their overlap. A base
// hue far from every anchor colours the operators instead. Steered hues
// come from their own sub-stream.
final ir = steering == null ? null : Rng.stream(seed, intentTag(paletteTag));
final baseDraw = ir?.nextU32();
var anchor = drawnAnchor;
var bodyHue = drawnBodyHue;
int? baseAsAccent;
final target = steering?.baseRange;
if (steering != null && target != null) {
final m = clayAnchorFor(anchors, target.lo, target.span);
anchor = m.index;
final a = anchors[anchor].value;
if (m.overlapSpan != null) {
bodyHue = _wrap(steering.pickIn(m.overlapLo!, m.overlapSpan!, baseDraw!));
} else {
bodyHue = _wrap(steering.pickIn(a.lo, a.hi - a.lo, baseDraw!));
if (m.distance > clayAccentFallbackTenths) {
baseAsAccent = steering.pickIn(target.lo, target.span, baseDraw);
}
}
}
final steeredOpHue =
(ir == null ? null : steering!.accentHue(ir, 0)) ?? baseAsAccent;
final steeredEqHue = ir == null ? null : steering!.accentHue(ir, 1);
final butter = !dark && anchor == clayButterAnchor;
final warmBody = !dark && (anchor == 1 || butter);
var baseLMilli = ranges.background.lMilli.sample(p);
if (butter) {
final r = ranges.background.lMilli;
baseLMilli = _spread(clayButterLMilli, baseLMilli - r.lo, r.hi - r.lo);
}
if (dark && accessible) {
final lo = ranges.background.lMilli.lo;
final span = ranges.background.lMilli.hi - lo;
const target = clayAccessibleNightBodyLMilli;
baseLMilli =
target.lo + (baseLMilli - lo) * (target.hi - target.lo) ~/ span;
}
final baseRel = p.nextIntIn(
ranges.background.chroma.lo,
ranges.background.chroma.hi,
);
final scheme = pickWeightedValue(
p,
tonal ? clayTonalSchemes : clayCandySchemes,
);
final jOp = tonal ? clayTonalJitter : clayOpJitter;
final jFn = tonal ? clayTonalJitter : clayFnJitter;
final jEq = tonal ? clayTonalJitter : clayEqJitter;
final drawnOpHue = _wrap(bodyHue + scheme.$1 + p.nextJitterTenths(jOp));
final rawFnHue = _wrap(bodyHue + scheme.$2 + p.nextJitterTenths(jFn));
final drawnEqHue = _wrap(bodyHue + scheme.$3 + p.nextJitterTenths(jEq));
final rawOpHue = steeredOpHue ?? drawnOpHue;
final rawEqHue = steeredEqHue ?? drawnEqHue;
final opSign = scheme.$1 >= 0 ? 1 : -1;
final srcA = tonal ? _wrap(bodyHue + 300 * opSign) : rawOpHue;
final srcB = tonal ? _wrap(bodyHue - 300 * opSign) : rawFnHue;
final rawBlobAHue = _wrap(srcA + p.nextJitterTenths(claySphereJitter));
final rawBlobBHue = _wrap(srcB + p.nextJitterTenths(claySphereJitter));
int blobL() => dark
? clayNightBlobLMilli.sample(p)
: baseLMilli + clayLightBlobLMilli.sample(p);
int blobC() => dark
? clayNightBlobChromaRel.sample(p)
: clayLightBlobChromaMilli.sample(p);
final blobALMilli = blobL();
final blobAC = blobC();
final blobBLMilli = blobL();
final blobBC = blobC();
final cream = dark ? p.nextChance(1, 2) : p.nextChance(2, 3);
final digitHue = cream
? _wrap(
clayCreamHueTenths.sample(p) +
(warmBody ? clayCoolMilkShiftTenths : 0),
)
: _wrap(bodyHue + p.nextJitterTenths(clayDigitHueJitter));
var digitLMilli = ranges.digit.lMilli.sample(p);
final digitCMilli = p.nextIntIn(
ranges.digit.chroma.lo,
ranges.digit.chroma.hi,
);
var fnLMilli = ranges.function.lMilli.sample(p);
final fnRel = p.nextIntIn(
ranges.function.chroma.lo,
ranges.function.chroma.hi,
);
var opLMilli = ranges.operator.lMilli.sample(p);
final opRel = p.nextIntIn(
ranges.operator.chroma.lo,
ranges.operator.chroma.hi,
);
var eqLMilli = ranges.equals.lMilli.sample(p);
final eqRel = p.nextIntIn(ranges.equals.chroma.lo, ranges.equals.chroma.hi);
// Retired draws (the tray pick and the old pillow chroma): kept so the
// label chroma below keeps its place in the stream.
p
..pickWeighted(clayPanelWeights)
..nextIntIn(ranges.surfaceChroma.lo, ranges.surfaceChroma.hi);
final labelCMilli = p.nextIntIn(25, ranges.labelMaxChromaMilli);
// ---- derived (no draws) ----
// Candy and night hues leave the band on their own nearer side; tonal
// ones on the side of the body their scheme put them (the operator and
// sphere A on one side, function keys, `=` and sphere B on the other),
// so the moved operator and function hues never meet.
final pivotA = tonal ? srcA : null;
final pivotB = tonal ? srcB : null;
int candy(int h, int? pivot) => clayCandyHue(
h,
clayCandyKeyBand,
clayCandyKeyTargets,
pivot ?? h,
maxPush: clayCandyKeyPush,
);
int candyOp(int h) {
final c = candy(h, pivotA);
return dark ? clayNightOperatorHue(c) : c;
}
int candyFn(int h) => candy(h, pivotB);
final opHue = candyOp(rawOpHue);
var fnHue = candyFn(rawFnHue);
// Body.
final baseL = baseLMilli / 1000;
final int bodyHueFinal;
final double baseC;
if (dark) {
final c = _min(
baseRel / 1000 * maxChroma(baseL, bodyHue / 10),
ranges.background.chroma.capMilli / 1000,
);
baseC = _max(c, clayNightBodyChromaFloorMilli / 1000);
bodyHueFinal = bile.apply(bodyHue, baseL, baseC);
} else if (butter) {
final r = ranges.background.chroma;
baseC = _min(
_spread(clayButterChromaMilli, baseRel - r.lo, r.hi - r.lo) / 1000,
maxChroma(baseL, bodyHue / 10),
);
bodyHueFinal = bodyHue;
} else {
final c = _min(
baseRel / 1000 * maxChroma(baseL, bodyHue / 10),
ranges.background.chroma.capMilli / 1000,
);
baseC = _max(c, clayBodyChromaFloorMilli / 1000);
bodyHueFinal = bodyHue;
}
final base = oklchToArgb(baseL, baseC, bodyHueFinal / 10);
// `=`: the candy band, the dull band, grape beside a green operator.
int candyEq(int h) => clayEqualsHue(
h,
opHue,
pivot: pivotB,
nightBodyHue: dark ? bodyHueFinal : null,
);
var eqHue = candyEq(rawEqHue);
final topWash = dark
? oklchToArgb(
baseL + clayTopWashNightMilli / 1000,
baseC,
bodyHueFinal / 10,
)
: oklchToArgb(
baseL + clayTopWashLightMilli / 1000,
baseC * 0.8,
bodyHueFinal / 10,
);
// Spheres: (lit, body, shade, hue). Night spheres leave the warm and
// green-teal bands (sphere B also keeps clear of sphere A).
(int, int, int, int) sphere(
int rawHue,
int? pivot,
int lMilli,
int cDraw, {
int? other,
}) {
var hue = candy(rawHue, pivot);
if (dark) hue = clayNightSphereHue(hue, bodyHueFinal, other: other);
final l = lMilli / 1000;
double c;
if (dark) {
double night(int h) {
final max = maxChroma(l, h / 10);
return _max(
_min(cDraw / 1000 * max, clayNightBlobChromaCapMilli / 1000),
_min(clayNightBlobChromaFloorMilli / 1000, 0.9 * max),
);
}
c = night(hue);
final rotated = bile.apply(hue, l, c);
if (rotated != hue) {
hue = rotated;
c = _min(c, maxChroma(l, hue / 10));
}
} else {
final max = maxChroma(l, hue / 10);
c = _min(cDraw / 1000, _min(0.12, max));
if (clayLightSphereWarmBand.contains(hue)) {
c = _max(c, _min(clayLightSphereWarmChromaMilli / 1000, 0.9 * max));
}
}
final h = hue / 10;
final body = oklchToArgb(l, c, h);
final int lit;
final int shade;
if (dark) {
lit = oklchToArgb(l + 0.070, c * 0.9, h);
shade = oklchToArgb(_max(0.12, l - 0.070), c * 0.9, h);
} else {
lit = oklchToArgb(_min(0.975, l + 0.050), c * 0.75, h);
shade = oklchToArgb(l - 0.075, _min(c * 1.10, 0.12), h);
}
return (lit, body, shade, hue);
}
final (aLit, aBody, aShade, aHue) = sphere(
rawBlobAHue,
pivotA,
blobALMilli,
blobAC,
);
final (bLit, bBody, bShade, _) = sphere(
rawBlobBHue,
pivotB,
blobBLMilli,
blobBC,
other: aHue,
);
final shadowCore = dark
? oklchToArgb(_max(0.10, baseL - 0.080), baseC * 0.8, bodyHueFinal / 10)
: oklchToArgb(baseL - 0.085, _min(baseC + 0.02, 0.08), bodyHueFinal / 10);
final colors = List<int>.unmodifiable([
base,
topWash,
aLit,
aBody,
aShade,
bLit,
bBody,
bShade,
shadowCore,
]);
final b = Rng.stream(seed, RngTag.background);
final background = BackgroundSpec(
kind: pickWeightedValue(b, def.backgrounds),
colors: colors,
angleTenths: 150 * b.nextInt(6),
);
// Surface: the pillow card, 0.06 L above the body (a pale tint of it
// in light themes, the body's own chroma at night).
final surface = dark
? oklchToArgb(baseL + 0.060, baseC, bodyHueFinal / 10)
: oklchToArgb(
_min(clayPillowMaxLMilli / 1000, baseL + 0.060),
_clamp(
clayPillowChromaRel / 1000 * baseC,
clayPillowChromaMilli.lo / 1000,
clayPillowChromaMilli.hi / 1000,
),
bodyHueFinal / 10,
);
// Faces.
if (!dark) {
final floor = baseLMilli + clayDigitAboveBodyMilli;
if (digitLMilli < floor) digitLMilli = floor;
if (digitLMilli > clayDigitMaxLMilli) digitLMilli = clayDigitMaxLMilli;
final cap = baseLMilli - clayFunctionBelowBodyMilli;
if (fnLMilli > cap) fnLMilli = cap;
}
if (accessible && opLMilli < clayAccessibleOperatorMinLMilli) {
opLMilli = clayAccessibleOperatorMinLMilli;
}
if (accessible) {
final r = ranges.equals.lMilli;
const t = clayAccessibleEqualsLMilli;
eqLMilli = t.lo + (eqLMilli - r.lo) * (t.hi - t.lo) ~/ (r.hi - r.lo);
}
int digitFace() =>
oklchToArgb(digitLMilli / 1000, digitCMilli / 1000, digitHue / 10);
int accentFace(
int hue,
int lMilli,
int rel,
ChromaRange range, {
bool warmFloor = false,
bool equalsFloor = false,
}) {
final l = lMilli / 1000;
final cap = range.capMilli / 1000;
double chroma(int h) {
final max = maxChroma(l, h / 10);
var c = _min(rel / 1000 * max, cap);
if (equalsFloor) {
c = _max(
c,
_min(clayEqualsChromaMilli / 1000, clayEqualsChromaRel / 1000 * max),
);
}
if (!warmFloor || !clayWarmFunctionBand.contains(h)) return c;
return _max(c, _min(clayWarmFunctionChromaMilli / 1000, 0.95 * max));
}
var c = chroma(hue);
final h = bile.apply(hue, l, c);
if (h != hue) c = chroma(h);
return oklchToArgb(l, c, h / 10);
}
int fnFace() => accentFace(
fnHue,
fnLMilli,
fnRel,
ranges.function.chroma,
warmFloor: true,
);
int opFace() => accentFace(opHue, opLMilli, opRel, ranges.operator.chroma);
int eqFace() => accentFace(
eqHue,
eqLMilli,
eqRel,
ranges.equals.chroma,
equalsFloor: true,
);
var digit = digitFace();
var function = fnFace();
var op = opFace();
var eq = eqFace();
// 1. Operators away from digits.
bool opOk() =>
contrastRatio(op, digit) >= 1.60 && minCvdDeltaE(op, digit) >= 0.10;
final opFloor = accessible ? clayAccessibleOperatorMinLMilli : 640;
while (!opOk() && opLMilli - 20 >= opFloor) {
opLMilli -= 20;
op = opFace();
}
for (
var i = 0;
i < 4 && !opOk() && digitLMilli + 15 <= clayDigitMaxLMilli;
i++
) {
digitLMilli += 15;
digit = digitFace();
}
// 2. `=` away from the operators (a quarter turn either way, else a
// half turn: the first that clears, or the best), and (normal-mode
// night) off the body.
if (minCvdDeltaE(eq, op) < 0.065) {
final from = eqHue;
var bestHue = eqHue;
var best = eq;
var bestDe = minCvdDeltaE(eq, op);
for (final turn in const [900, -900, 1800]) {
eqHue = candyEq(_wrap(from + turn));
eq = eqFace();
final de = minCvdDeltaE(eq, op);
if (de > bestDe) {
bestHue = eqHue;
best = eq;
bestDe = de;
}
if (de >= 0.065) break;
}
eqHue = bestHue;
eq = best;
}
if (dark && !accessible) {
while (contrastRatio(eq, base) < clayNightEqualsMinContrast &&
eqLMilli + 10 <= clayNightEqualsMaxLMilli) {
eqLMilli += 10;
eq = eqFace();
}
}
// 3. Light function keys below the body, for every eye (generation
// backstop): darker 0.015 L at a time while the inner shade still
// holds a dark label at the floor, then (still too close for a
// colour-blind eye) a turn of the hue.
if (!dark) {
final bodyL = _lightness(base);
bool fnOk(int f) =>
deltaEOKArgb(f, base) >= 0.045 &&
bodyL - _lightness(f) >= 0.03 &&
minCvdDeltaE(f, base) >= clayFunctionMinCvdDeltaE;
bool ringOk(int f) =>
contrastRatio(0xFF000000, clayInnerShade(f, shadePermille)) >=
lim.labelRatio + 0.2;
for (var i = 0; i < 6 && !fnOk(function); i++) {
fnLMilli -= 15;
final next = fnFace();
if (!ringOk(next)) {
fnLMilli += 15;
break;
}
function = next;
}
if (!fnOk(function)) {
final from = fnHue;
for (final turn in const [300, -300, 600, -600]) {
fnHue = candyFn(_wrap(from + turn));
final next = fnFace();
if (fnOk(next) && ringOk(next)) {
function = next;
break;
}
fnHue = from;
}
}
}
// 4. Labels (the function label also on its inner shade), then the
// candy + bile re-check on the final faces.
final labelC = labelCMilli / 1000;
Lch darkStart(int face) =>
(l: ranges.labelStartLMilli / 1000, c: labelC, h: argbToOklch(face).h);
const creamStart = (l: 0.985, c: 0.012, h: 85.0);
KeyColors keyFor(int face, Lch start, {bool ring = false}) {
final f = argbToOklch(face);
final pair = repairPair(f, start, policy: policy);
var label = pair.label;
if (ring) {
label =
solveOnAll(label, [
pair.face,
clayInnerShade(pair.face, shadePermille),
], lim.labelRatio) ??
label;
}
return KeyColors(face: pair.face, label: label, edge: 0);
}
// The final faces' hues are re-checked by the bile rule only: a hue the
// builder placed on a band edge may drift a tenth into the band when
// quantised, and must not be folded again for that.
KeyColors recheck(
KeyColors k,
Lch start,
int Function(int) rule, {
bool ring = false,
}) {
final f = argbToOklch(k.face);
final hue = _hueTenths(f);
if (bile.apply(hue, f.l, f.c) == hue) return k;
final rotated = bile.apply(rule(bile.apply(hue, f.l, f.c)), f.l, f.c);
return keyFor(oklchToArgb(f.l, f.c, rotated / 10), start, ring: ring);
}
final digitKey = keyFor(digit, darkStart(digit));
var fnKey = keyFor(function, darkStart(function), ring: true);
var opKey = keyFor(op, darkStart(op));
var eqKey = keyFor(eq, creamStart);
if (bile.recheckFinal) {
fnKey = recheck(fnKey, darkStart(function), candyFn, ring: true);
opKey = recheck(opKey, darkStart(op), candyOp);
eqKey = recheck(eqKey, creamStart, candyEq);
}
// 5. Texts on the panel; the focus ring on the scene.
final bgs = claymorphismSceneSamples(background)!;
final backdrops = [surface];
int onAll(int start, List<int> on, double target, int fallback) =>
solveOnAll(start, on, target) ?? fallback;
final h = bodyHueFinal / 10;
final opH = opHue / 10;
final previewTarget = lim.previewRatio < ratioText
? ratioText
: lim.previewRatio;
final displayText = onAll(
dark ? oklchToArgb(0.97, 0.015, h) : oklchToArgb(0.27, 0.045, h),
backdrops,
lim.displayRatio,
blackOrWhite(surface),
);
final previewText = onAll(
dark ? oklchToArgb(0.80, 0.030, h) : oklchToArgb(0.44, 0.035, h),
backdrops,
previewTarget,
blackOrWhite(surface),
);
final error = onAll(
dark ? oklchToArgb(0.76, 0.14, 22) : oklchToArgb(0.50, 0.17, 25),
backdrops,
lim.displayRatio,
blackOrWhite(surface),
);
final caretL = dark ? 0.80 : 0.52;
final caret = onAll(
oklchToArgb(
caretL,
_min(0.8 * maxChroma(caretL, opH), dark ? 0.14 : 0.17),
opH,
),
backdrops,
lim.caretRatio,
displayText,
);
final edged = _withEdges(
Palette(
background: base,
surface: surface,
displayText: displayText,
previewText: previewText,
error: error,
caret: caret,
focusRing: 0,
accent: caret,
digit: digitKey,
operator: opKey,
equals: eqKey,
function: fnKey,
shadow: dark
? withAlpha(oklchToArgb(0.08, 0.03, h), 0x80)
: withAlpha(oklchToArgb(0.32, 0.06, h), 0x40),
highlight: highlight,
seedColor: opKey.face,
),
base,
dark: dark,
);
// The ring on the scene and where it crosses a key's own shadow.
final focusL = dark ? 0.88 : 0.42;
final focus = onAll(
oklchToArgb(
focusL,
dark ? 0.08 : _min(0.8 * maxChroma(focusL, opH), 0.12),
opH,
),
[...bgs, ...clayFocusShadowSamples(edged, colors)],
lim.focusRingRatio,
blackOrWhite(base),
);
final palette = edged.copyWith(focusRing: focus);
// Motion, shape, style, type, name.
final motion = def.motionRanges.vary(
def.motion,
Rng.stream(seed, RngTag.motion),
);
final s = Rng.stream(seed, RngTag.shape);
final keyShape = KeyShapeSpec(
kind: pickWeightedValue(s, def.shapes),
cornerPermille: def.cornerPermille.sampleStep(s, 10),
);
final keyStyle = def.keyStyle.copyWith(
kind: styleKind,
shadowOffsetCdp: motion.depthCdp,
shadowBlurCdp: blurCdp,
edgeShadePermille: shadePermille,
);
final type = _type(Rng.stream(seed, RngTag.type), def);
String generatedName() => generateThemeName(
rng: Rng.stream(seed, RngTag.name),
accentArgb: palette.operator.face,
family: ThemeFamily.claymorphism,
dark: dark,
);
final name = steering?.nameOr(generatedName) ?? generatedName();
return ThemeSpec(
seed: seed,
family: ThemeFamily.claymorphism,
engineVersion: engineVersion,
variant: steering == null ? ThemeVariant.generated : ThemeVariant.steered,
intent: steering?.intent.forSpec,
intentCentred: steering?.centred ?? false,
brightness: brightness,
accessible: accessible,
palette: palette,
keyShape: keyShape,
keyStyle: keyStyle,
type: type,
background: background,
motion: motion,
sound: def.sound,
name: name,
nonColourOperatorCue: def.nonColourOperatorCue,
);
}