TypeRoof Documentation

** This file was created with an agent. **

Columns Runion — Analysis & Design Report

Status: delivered (2026-09, branch runion/horizontal-layout). Code lives in lib/js/components/layouts/type-stage/horizontal-layout-runion.mjs; see §10 for the delivery status against this report.

1. Goal

A runion (runion_01_columns) consumed via SyntheticValue that derives { columns, lineLengthEn, columnGapEn, marginLeftEn, marginRightEn } from availableWidthEn plus a locale-aware configuration, replacing the legacy _runion_01_columns and overriding the user-settable generic/columnWidth when the algorithm is active.

2. Analysis of the legacy version

Strategy: pass 1 — fewest columns whose natural line length lands in (min, max]; pass 2 — most columns not below min, line capped at maxLineLength, remainder split into padding 3/5 left, 2/5 right.

Simulated transition map (legacy EN config, widths in EN):

Width Result Notes
0–65 1 col, fit
65.5–69 1 col capped 65, pad <= 4 dead zone (2x33 excluded by exclusive min)
69.5–133 2 cols, 33.25 to 65
133.5–155 3 cols, 42.8 to 50
155.5–166 4 cols, 37.4 to 40
166.5+ 4 cols capped 40 padding unbounded (234 EN at W=400)

DE config: dead zone 65.5–87.5 (up to 22 EN padding while 2x41.5 is rejected 0.5 under min), plus a 3-col pad zone 155.5–174.5.

Issues found (legacy): throws on width <= 0; exclusive-min knife-edge at exactly 69 EN; implicit pass-2 invariant; positional return value; top-level minLineLength/maxLineLength duplicate the columns array; hardcoded 3/5-2/5 split; dead zones not detected; dead data (1-col gap); config param misnamed (columnConfig = the columns array).

3. Decisions (operator, 2026-07-19)

  1. Inclusive min (lineLengthEn >= min).
  2. Dead zones: fewer columns + padding; config is followed strictly, no soft-min.
  3. Margins unbounded; split ratio configurable (default 3/5, 2/5); optional growColumns: N | Infinity keeps adding columns using the last config entry.
  4. Gap = pluggable algorithm (constant, linear, future); linear = two (columnWidth, gap) points + slope + clamp (same math as runion_01_lineHeight).
  5. Wiring mirrors leading (createDynamicModel algorithm choice) and overrides user columnWidth; availableWidth is assumed to be available as a property.
  6. Gap points: a=[33->2], b=[65->3], clamp [2, 3].
  7. Registry keeps room for other gap algorithms (see 4).

4. Config (compact, JSON-serializable)

{ minLineHeight: 1.1, maxLineHeight: 1.3     // for the leading runion
, minLineLength: 33                          // EN, counts >= 2; 1 col always allowed
, columns: [65, 65, 50, 40]                  // maxLineLength per count; length = max
, growColumns: false                         // false | N | Infinity
, columnGap: { algorithm: "linear"
             , a: { columnWidth: 33, gap: 2 }, b: { columnWidth: 65, gap: 3 }
             , min: 2, max: 3 }
, paddingRatio: [3/5, 2/5] }

DE shrinks to { minLineLength: 42, columns: [65, 65, 50, 45] } + link to Latn-en. Removed redundancy: top-level min/maxLineLength, repeated per-count mins, dead 1-col gap entry. JSON-able data addresses the i18n explicit-link TODO. Registration stays with the existing addToI18NConfig plumbing (ported unchanged); normalizeColumnConfig expands + freezes at query time, validateColumnConfig throws early on bad input and warns about dead zones (would have caught the DE 65–87 zone).

5. Gap interpolation and the circularity

Gap depends on line length, line length depends on gap. For linear an exact fixed-point solve is used (n*line + (n-1)*gap(line) = W, piecewise-linear, monotone, hence unique solution; clamp fallback provably consistent). Verified numerically: width identity error <= 5.7e-14 over W in [1,400], n in [1,4]. Algorithms without an exact solve get a one-refinement fallback. Effect on transitions (EN): 1->2 at 68.25 (was 69.5), 2->3 at 133.25, 3->4 at 155.25; dead zone shrinks to (65, 68].

6. Metamodel proposal (configuration)

Mirrors the leading pattern (AutoLinearLeadingModel vs gap points, createDynamicModel for algorithm choice):

, ColumnGapPointModel = _AbstractStructModel.createClass(
      "ColumnGapPointModel"
    , ["columnWidth", ColumnWidthModel]
    , ["gap", ColumnWidthModel])              // or a dedicated GapNumberModel
, LinearColumnGapModel = _AbstractStructModel.createClass(
      "LinearColumnGapModel"
    , ["a", ColumnGapPointModel], ["b", ColumnGapPointModel]
    , ["minGap", ColumnWidthOrEmptyModel], ["maxGap", ColumnWidthOrEmptyModel])
, ConstantColumnGapModel = _AbstractStructModel.createClass(
      "ConstantColumnGapModel"
    , ["value", ColumnWidthModel])
, { ColumnGapAlgorithmModel, createColumnGapAlgorithm
  , deserializeColumnGapAlgorithmModel } = createDynamicModel(
      "ColumnGapAlgorithm"
    , [ ["constant", "Constant", ConstantColumnGapModel]
      , ["linear", "Linear", LinearColumnGapModel] ])

The typeSpec-level algorithm choice (overrides user columnWidth when active):

, Runion01ColumnsModel = _AbstractStructModel.createClass(
      "Runion01ColumnsModel"
      // all OrEmpty: per-instance overrides of the locale config
    , ["minLineLength", ColumnWidthOrEmptyModel]
    , ["columnGap", ColumnGapAlgorithmOrEmptyModel]  // or required w/ default
    , ["paddingRatioStart", PercentNumberOrEmptyModel]
    , ["growColumns", NumberOrEmptyModel])           // Infinity: TBD how
, ManualColumnWidthModel = _AbstractStructModel.createClass(
      "ManualColumnWidthModel"
    , ["columnWidth", ColumnWidthOrEmptyModel])      // current field moves here
, { ColumnsAlgorithmModel, createColumnsAlgorithm
  , deserializeColumnsAlgorithmModel } = createDynamicModel(
      "ColumnsAlgorithm"
    , [ ["ManualColumnWidth", "Manual", ManualColumnWidthModel]
      , ["Runion01Columns", "Runion 01", Runion01ColumnsModel] ])

Notes: columns (per-count maxes) could be an _AbstractListModel of ColumnWidthModel if modeled at all — recommend keeping it in the i18n config only, not per-typeSpec. Infinity has no natural metamodel representation — options: a boolean growColumnsUnbounded or a sentinel; decide when modeling. Legacy data migration: existing typeSpecs default to ManualColumnWidth (backwards compatible).

7. properties-generators.mjs proposal

New columnsGen, sibling of leadingGen, same shape:

function* columnsGen(outerTypespecnionAPI, hostInstance) {
    // read columnsAlgorithm field; ForeignKey.NULL = inherit (like leadingGen)
    // "ManualColumnWidth": current broom-wagon behavior moves here
    //   (yield generic/columnWidth from the field if set)
    // "Runion01Columns":
    //   - locale from hostInstance languageTag -> getFromI18NConfig(COLUMN_CONFIG_I18N, locale)
    //   - merge per-instance overrides from the model onto the locale config
    //   - yield ["columns/config", normalizeColumnConfig(merged)]   (plain data)
    //   - yield ["columns/setup", new SyntheticValue(runion_01_columns,
    //         [`${GENERIC}availableWidth`, "columns/config"])]
    //   - extractors (SyntheticValue chains are supported, cf. fontSize -> opsz):
    //       ${GENERIC}columnWidth <- setup.lineLengthEn   // AutoLinearLeading keeps working
    //       columns/count, columns/gap, columns/marginLeft, columns/marginRight
}

If columns/setup resolves to null (degenerate width), the extractor chain is dropped gracefully by the existing resolution rule.

8. Drive-by findings (not introduced by this work)

  • Bug in type-specnion.mjs resolveSyntheticProperties: the drop-check uses arguments.length !== synthProp.dependencies.length — arguments of the static method is always 2, so the check is bogus; and after resultMap.delete(propertyName) execution continues and synthProp.call(...args) may re-set a partially resolved property. Should be args.length !== ... plus continue. Recommend a separate fix + test.
  • Legacy i18n plumbing (normalizeLocale/validateLocale/deepKeys/ getFromI18NConfig) ports as-is; localeToLabel/localeToValue docstrings are copy-pasted and do not describe their difference.

9. Open points

  • availableWidth property: name/unit (pt vs EN) — assumed provided by the stage.
  • Infinity modeling for growColumns (see section 6).
  • Whether per-count gap overrides are ever needed (escape hatch exists in the normalized runtime form; not modeled).
  • UI for the algorithm choice (analogous to the leading UI) — out of scope here.

10. Delivery status (2026-09, branch runion/horizontal-layout)

Delivered in lib/js/components/layouts/type-stage/horizontal-layout-runion.mjs, executed in the node-properties channel (horizontalLayoutRunionGen), configured via HorizontalLayoutRunionModel and the dynamic algorithm UI. Deviations and extensions against the plan above:

Architecture (vs §7). The proposal’s columns/setup synthetic in the typeSpecnion became a two-channel design: the style channel yields the description (horizontalLayout/algorithm, horizontalLayout/* config keys, fully inheriting); the node channel decides where it executes, converts availableWidth pt→EN at the boundary, runs the runion, and yields pt facts. An executing scope consumes the description (inheritance-policy tombstones), so children fill the column instead of re-running the algorithm — this replaced the “re-run per node” instability (geometry depended on each node’s fontSize). availableWidth is pt at the boundary (§9 point resolved).

Result contract (vs §1). {lineLength, columnCount, columnGutter, marginStart, marginEnd, fullWidth} — marginLeft/Right renamed to marginStart/End, fullWidth added; margins route to generic/inlineMargins/{start,end}(/pt) (padding-inline in CSS).

New semantics beyond the plan.

  • minPadding (EN, default 0): reserved up front (budget = width − reserve), re-added to the remainder, split by paddingRatio.
  • Empty columns list: pure min-driven growth; columns widen (unbounded max) instead of padding. growColumns: false + empty = explicit single padded column. Unset growColumns ≡ false; unset minLineLength ≡ 1.
  • Overflow instead of shrink: nothing fits ⇒ one column at the (config-aware) minimum, wider than the available width; the host scrolls. The runion never returns null.
  • Configured maxes are authoritative: minLineLengthFor = min(min, maxFor) — an explicit narrow max waives the general minimum; growth ceiling uses min(minLineLength, ...columns).
  • paddingRatio default is [0, 1] (left-aligned), not 3/5–2/5.
  • Gutter: constant/linear with exact solve (§5 kept); missing or invalid gutter resorts to constant 0; points outside [min,max] are not an error (the clamp is output-side, slope defined by raw points).
  • All EN→pt conversions truncate to 2 decimals toward zero (string manipulation) for sub-centipt fit wiggle-room.

§8 drive-by, resolved. The resolveSyntheticProperties drop-check bug was fixed in the resolver rewrite (args.length check, delete-and-continue semantics); the i18n plumbing did not port (locale config became typeSpecnion inheritance, per the model comments).

Still open. Per-count gap overrides (escape hatch unused, not modeled); the dead-zone check is warning-only; column-count-based gap algorithm was deferred (the registry is extensible).