buildPaperInkCandidate function

ThemeSpec buildPaperInkCandidate(
  1. int seed,
  2. FamilyDefinition def,
  3. int engineVersion, {
  4. int attempt = 0,
  5. bool accessible = false,
})

Builds one unvalidated Paper & ink candidate (the generator repairs and validates it, retries with palette/retry/n and finally falls back to a Paper & ink hero).

Implementation

ThemeSpec buildPaperInkCandidate(
  int seed,
  FamilyDefinition def,
  int engineVersion, {
  int attempt = 0,
  bool accessible = false,
}) {
  final paletteTag = attempt == 0
      ? RngTag.palette
      : RngTag.sub(RngTag.palette, 'retry', attempt);
  final p = Rng.stream(seed, paletteTag);
  final lim = accessible
      ? GuardrailLimits.accessible
      : GuardrailLimits.standard;
  final policy = lim.labelPolicy;
  final bile = def.bile;

  // P1–P3: brightness, sheet, tone.
  final dark = p.pickWeighted(def.brightnessWeights) == 0;
  final brightness = dark ? ThemeBrightness.dark : ThemeBrightness.light;
  final sheetPick = p.pickWeighted(
    dark ? paperInkDarkSheetWeights : paperInkLightSheetWeights,
  );
  final sheet = PaperSheet.values[dark ? 5 + sheetPick : sheetPick];
  final tone = p.pickWeighted(paperInkToneWeights);
  final kraft = sheet == PaperSheet.kraft;

  // P4–P6: paper.
  final paperRange = dark && tone == 1
      ? paperInkDarkTone1Papers[sheet.index - 5]
      : paperInkPapers[sheet.index];
  final paperL = paperRange.l.sample(p);
  final paperC = paperRange.c.sample(p);
  final paperH = paperRange.h.sample(p);
  final paper = oklchToArgb(paperL / 1000, paperC / 1000, paperH / 10);

  // P7–P10: ink.
  final inkPick = p.pickWeighted(switch (sheet) {
    PaperSheet.inkPaper => paperInkInkPaperInkWeights,
    PaperSheet.blackboard => paperInkBlackboardInkWeights,
    _ => paperInkLightInkWeights,
  });
  final inkType = PaperInkType.values[dark ? 4 + inkPick : inkPick];
  final inkRange = paperInkInks[inkType.index];
  var inkL = inkRange.l.sample(p);
  final inkC = inkRange.c.sample(p);
  final inkHueDraw = inkRange.h.sample(p);
  final inkH = inkType == PaperInkType.carbon ? paperH : inkHueDraw;
  if (accessible) {
    inkL = dark
        ? (inkL < paperInkAccessibleDarkInkMinL
              ? paperInkAccessibleDarkInkMinL
              : inkL)
        : (inkL > paperInkAccessibleLightInkMaxL
              ? paperInkAccessibleLightInkMaxL
              : inkL);
  }
  final inkLch = (l: inkL / 1000, c: inkC / 1000, h: inkH / 10);
  final ink = oklchToArgb(inkLch.l, inkLch.c, inkLch.h);

  // P11–P14: the stamp.
  final stampInk = p.pickWeighted(
    dark ? paperInkDarkStampWeights : paperInkLightStampWeights,
  );
  final stampH = paperInkStampHues[stampInk].sample(p);
  var stampL = (dark ? paperInkDarkStampL : paperInkLightStampL[stampInk])
      .sample(p);
  final stampRel = paperInkStampRel.sample(p);
  if (accessible) {
    stampL = dark
        ? (stampL < paperInkAccessibleDarkStampMinL
              ? paperInkAccessibleDarkStampMinL
              : stampL)
        : (stampL > paperInkAccessibleLightStampMaxL
              ? paperInkAccessibleLightStampMaxL
              : stampL);
  }
  final stampCap = dark
      ? paperInkDarkStampCapMilli
      : paperInkLightStampCapMilli;

  // P15–P18: the operator wash.
  final washOffsetPick = p.pickWeighted(paperInkWashOffsetWeights);
  final washJitter = p.nextJitterTenths(100);
  final washCMilli = dark ? p.nextIntIn(42, 50) : p.nextIntIn(45, 75);
  final washDL = dark ? p.nextIntIn(25, 45) : p.nextIntIn(45, 75);

  // P19–P24: slips.
  final digitDL = dark ? p.nextIntIn(35, 60) : p.nextIntIn(12, 28);
  final cardL = p.nextIntIn(935, 960);
  final cardC = p.nextIntIn(18, 30);
  final cardH = p.nextIntIn(800, 900);
  final fnDL = p.nextIntIn(35, 55);
  final fnC = dark ? p.nextIntIn(15, 25) : p.nextIntIn(12, 22);

  // P25–P27: rule ink.
  final ruleRange = paperInkRules[sheet.index];
  final ruleL = ruleRange.l.sample(p);
  final ruleC = ruleRange.c.sample(p);
  final ruleHueDraw = ruleRange.h.sample(p);
  final ruleH = sheet == PaperSheet.inkPaper ? paperH : ruleHueDraw;
  final rule = oklchToArgb(ruleL / 1000, ruleC / 1000, ruleH / 10);

  // P28–P29: the display panel.
  final panelA = p.nextIntIn(dark ? 30 : 975, dark ? 50 : 995);
  final panelB = p.nextIntIn(0, 22);
  final int panel;
  if (dark) {
    panel = oklchToArgb(
      (paperL + panelA) / 1000,
      paperC * 0.9 / 1000,
      paperH / 10,
    );
  } else if (sheet == PaperSheet.graph) {
    final l = paperL + 8 + (panelA - 975) * 7 ~/ 20;
    panel = oklchToArgb((l > 995 ? 995 : l) / 1000, paperC / 2000, paperH / 10);
  } else if (kraft) {
    panel = oklchToArgb(
      (950 + (panelA - 975)) / 1000,
      (15 + panelB * 10 ~/ 22) / 1000,
      85,
    );
  } else {
    panel = oklchToArgb(
      panelA / 1000,
      (3 + panelB * 7 ~/ 22) / 1000,
      paperH / 10,
    );
  }

  // Faces (lightness × 1000).
  final int digitL;
  final Lch digitFace;
  if (kraft) {
    digitL = cardL;
    digitFace = (l: cardL / 1000, c: cardC / 1000, h: cardH / 10);
  } else if (dark) {
    var l = paperL + digitDL;
    if (accessible && l > 340) l = 340;
    digitL = l;
    digitFace = (l: l / 1000, c: paperC * 0.8 / 1000, h: paperH / 10);
  } else {
    final l = paperL + digitDL;
    digitL = l > 992 ? 992 : l;
    digitFace = (l: digitL / 1000, c: paperC * 0.6 / 1000, h: paperH / 10);
  }
  final Lch fnFace;
  {
    var l = dark ? digitL + fnDL : digitL - fnDL;
    if (dark && accessible && l > 380) l = 380;
    // Kraft: a darker cream card (the brown rule hue at low chroma reads
    // pinkish grey); elsewhere the rule hue.
    fnFace = (l: l / 1000, c: fnC / 1000, h: (kraft ? cardH : ruleH) / 10);
  }

  // The style stream (independent of the palette stream) fixes the hatch
  // opacity the operator face must carry.
  final st = Rng.stream(seed, RngTag.style);
  final keyStyle = def.keyStyle.copyWith(
    kind: pickWeightedValue(st, def.styles),
    outlineWidthCdp: paperInkOutlineWidthCdp.sampleStep(st, 25),
    edgeShadePermille: paperInkHatchPermille.sampleStep(st, 50),
    shadowOffsetCdp: 0,
    stateLayerPermille: 0,
  );

  final Lch opFace;
  {
    var offset = paperInkWashOffsets[washOffsetPick];
    var washH = _wrap(stampH + offset + washJitter);
    if (dark && paperInkDarkOliveHues.contains(washH)) {
      offset = -offset;
      washH = _wrap(stampH + offset + washJitter);
    }
    if (dark) washH = paperDarkWashHue(washH);
    var l = dark ? digitL + washDL : digitL - washDL;
    if (dark && accessible && l > 380) l = 380;
    double chroma(int lMilli) =>
        _capped(0.9 * maxChroma(lMilli / 1000, washH / 10), washCMilli);
    // Light sheets: the hatch (the operator cue) must show on the wash —
    // lighten the face until it does.
    if (!dark) {
      while (l < 970 &&
          paperHatchContrast(
                KeyColors(
                  face: oklchToArgb(l / 1000, chroma(l), washH / 10),
                  label: ink,
                  edge: ink,
                ),
                keyStyle.edgeShadePermille,
              ) <
              paperHatchRatio + 0.05) {
        l += 10;
      }
    }
    opFace = (l: l / 1000, c: chroma(l), h: washH / 10);
  }

  final stampChroma = _capped(
    stampRel / 1000 * maxChroma(stampL / 1000, stampH / 10),
    stampCap,
  );
  var eqFace = (l: stampL / 1000, c: stampChroma, h: stampH / 10);

  // Keys: ink labels (repairPair keeps the ink when it passes); the stamp
  // label knocked out to the slip (light) or the sheet (dark).
  final paperLch = argbToOklch(paper);
  final eqLabelStart = dark ? paperLch : digitFace;
  KeyColors key(Lch face, Lch start) {
    var pair = repairPair(face, start, policy: policy);
    if (bile.recheckFinal) {
      final f = argbToOklch(pair.face);
      final hue = (f.h * 10).round() % 3600;
      final rotated = bile.apply(hue, f.l, f.c);
      if (rotated != hue) {
        pair = repairPair(
          (l: f.l, c: f.c, h: rotated / 10),
          start,
          policy: policy,
        );
      }
    }
    return KeyColors(face: pair.face, label: pair.label, edge: ink);
  }

  final digit = key(digitFace, inkLch);
  final function = key(fnFace, inkLch);
  final operator = key(opFace, inkLch);
  var equals = key(eqFace, eqLabelStart);
  if (minCvdDeltaE(equals.face, operator.face) < 0.065) {
    // `=` must stand apart from the operators (CVD included): move the
    // stamp 0.10 further from the slips, inside the lock clamps.
    var l = eqFace.l + (dark ? 0.10 : -0.10);
    if (accessible) {
      l = dark
          ? (l < paperInkAccessibleDarkStampMinL / 1000
                ? paperInkAccessibleDarkStampMinL / 1000
                : l)
          : (l > paperInkAccessibleLightStampMaxL / 1000
                ? paperInkAccessibleLightStampMaxL / 1000
                : l);
    }
    eqFace = (l: clamp01(l), c: eqFace.c, h: eqFace.h);
    equals = key(eqFace, eqLabelStart);
  }

  // The sheet.
  final background = _background(
    Rng.stream(seed, RngTag.background),
    sheet,
    paper: paper,
    rule: rule,
  );
  final bgs = paperInkSceneSamples(background)!;

  // Outlines: the ink, re-solved to 3:1 on every sheet sample.
  final edge = solveOnAll(ink, bgs, ratioLarge) ?? blackOrWhite(paper);
  KeyColors edged(KeyColors k) => k.copyWith(edge: edge);

  // Display texts on the panel and its grain / rule composites.
  final backdrops = paperPanelSamples(
    panel,
    background,
    accessible: accessible,
  );
  int onAll(int start, List<int> on, double target, int fallback) =>
      solveOnAll(start, on, target) ?? fallback;
  final previewTarget = lim.previewRatio < ratioText
      ? ratioText
      : lim.previewRatio;
  final displayText = onAll(
    ink,
    backdrops,
    lim.displayRatio,
    blackOrWhite(panel),
  );
  final previewText = onAll(
    oklchToArgb(dark ? 0.78 : 0.42, inkLch.c, inkLch.h),
    backdrops,
    previewTarget,
    blackOrWhite(panel),
  );
  // The red pen is solved 0.05 past its floor: it lands right on it, and
  // the theme transition's OKLab round trip (at its rubber-band frames)
  // could otherwise take it a hair under on a grain fleck.
  final error = onAll(
    oklchToArgb(dark ? 0.76 : 0.50, dark ? 0.12 : 0.17, 27),
    backdrops,
    lim.displayRatio + paperTextMargin,
    blackOrWhite(panel),
  );
  final caretL = dark ? 0.80 : 0.46;
  final caretC = 0.8 * maxChroma(caretL, stampH / 10);
  final caret = onAll(
    oklchToArgb(caretL, caretC < 0.18 ? caretC : 0.18, stampH / 10),
    backdrops,
    lim.caretRatio,
    displayText,
  );
  final focus = onAll(edge, bgs, lim.focusRingRatio, blackOrWhite(paper));

  var palette = Palette(
    background: paper,
    surface: panel,
    displayText: displayText,
    previewText: previewText,
    error: error,
    caret: caret,
    focusRing: focus,
    accent: equals.face,
    digit: edged(digit),
    operator: edged(operator),
    equals: edged(equals),
    function: edged(function),
    shadow: edge | 0xFF000000,
    highlight: withAlpha(edge, paperWashRimMaxAlpha),
    seedColor: equals.face,
  );
  palette = palette.copyWith(
    highlight: fitPaperWash(palette, palette.highlight, lim),
  );

  // Motion, shape, 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 type = _type(Rng.stream(seed, RngTag.type), def);
  // The name accent is the stamp (the family's one chromatic ink), not the
  // operator face `generateThemeName` documents: Paper's operator faces
  // are faint washes, often too pale to name a colour.
  final name = generateThemeName(
    rng: Rng.stream(seed, RngTag.name),
    accentArgb: equals.face,
    family: ThemeFamily.paperInk,
    dark: dark,
  );

  return ThemeSpec(
    seed: seed,
    family: ThemeFamily.paperInk,
    engineVersion: engineVersion,
    brightness: brightness,
    accessible: accessible,
    palette: palette,
    keyShape: keyShape,
    keyStyle: keyStyle,
    type: type,
    background: background,
    motion: motion,
    sound: def.sound,
    name: name,
    nonColourOperatorCue: def.nonColourOperatorCue,
  );
}