** This file was created with an agent. **
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.
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.
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).
lineLengthEn >= min).growColumns: N | Infinity keeps adding columns using the last config entry.constant, linear, future); linear =
two (columnWidth, gap) points + slope + clamp (same math as runion_01_lineHeight).createDynamicModel algorithm choice) and overrides
user columnWidth; availableWidth is assumed to be available as a property.a=[33->2], b=[65->3], clamp [2, 3].{ 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).
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].
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).
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.
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.normalizeLocale/validateLocale/deepKeys/
getFromI18NConfig) ports as-is; localeToLabel/localeToValue docstrings
are copy-pasted and do not describe their difference.availableWidth property: name/unit (pt vs EN) — assumed provided by the stage.Infinity modeling for growColumns (see section 6).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.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.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.[min,max] are not an error
(the clamp is output-side, slope defined by raw points).§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).