Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/deploy-to-ghpages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ jobs:
- name: 📦 Install dependencies
run: npm ci

- name: 🔬 Run tests
run: npm run test

- name: 🧪 Run linters & formatters
run: npm run lint

Expand Down
186 changes: 186 additions & 0 deletions docs/planning/columns/REPORT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
---
title: 'Columns Runion — Analysis & Design Report'
eleventyNavigation:
parent: Planning
key: columns-runion-report
title: 'Columns: Runion Report'
order: 44
agent-created: true
---

# {{title}}

Status: planning. Code lives in `column-layout.mjs` next to this report, not yet wired into `lib/`.

## 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)

```js
{ 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):

```js
, 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):

```js
, 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:

```js
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.
183 changes: 183 additions & 0 deletions docs/planning/columns/column-layout.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// column-layout.mjs — PLANNING STAGE, not wired into lib/ yet.
// Lives in docs/planning/columns/ alongside REPORT.md until approved.
//
// Computes { columns, lineLengthEn, columnGapEn, marginLeftEn, marginRightEn }
// from availableWidthEn + a compact, JSON-serializable, locale-aware config.
// Successor of the legacy _runion_01_columns (see REPORT.md for the analysis).

// ---------------------------------------------------------------------------
// Gap algorithms (registry, extensible: add an entry + optional exact solve)
// ---------------------------------------------------------------------------

// Same math as runion_01_lineHeight — candidate for a shared helper (DRY).
export function linearInterpolationClamp(a, b, x, min, max) {
const slope = (b.gap - a.gap) / (b.columnWidth - a.columnWidth)
, intercept = a.gap - slope * a.columnWidth;
return Math.min(max, Math.max(min, slope * x + intercept));
}

// Fallback for algorithms without an exact solve: one refinement pass.
function defaultGapSolve(fn, availableWidthEn, columns) {
if (columns === 1) return [availableWidthEn, 0];
const gaps = columns - 1
, firstLineEn = (availableWidthEn - gaps * fn(availableWidthEn / columns)) / columns
, gapEn = fn(firstLineEn);
return [(availableWidthEn - gaps * gapEn) / columns, gapEn];
}

// Exact fixed-point: n*line + (n-1)*gap(line) = W, gap piecewise-linear+clamped.
// Monotonicity makes the clamp fallback provably consistent (see REPORT.md).
function linearGapSolve({ a, b, min, max }, availableWidthEn, columns) {
if (columns === 1) return [availableWidthEn, 0];
const gaps = columns - 1
, slope = (b.gap - a.gap) / (b.columnWidth - a.columnWidth)
, intercept = a.gap - slope * a.columnWidth;
let lineLengthEn = (availableWidthEn - gaps * intercept) / (columns + gaps * slope)
, gapEn = slope * lineLengthEn + intercept;
if (gapEn < min) { gapEn = min; lineLengthEn = (availableWidthEn - gaps * min) / columns; }
else if (gapEn > max) { gapEn = max; lineLengthEn = (availableWidthEn - gaps * max) / columns; }
return [lineLengthEn, gapEn];
}

const COLUMN_GAP_ALGORITHMS = {
constant: {
fn: ({ value }) => (() => value)
, solve: ({ value }, availableWidthEn, columns) => [
(availableWidthEn - (columns - 1) * value) / columns
, columns > 1 ? value : 0
]
}
, linear: {
fn: ({ a, b, min, max }) =>
(columnWidthEn) => linearInterpolationClamp(a, b, columnWidthEn, min, max)
, solve: linearGapSolve
}
};

export function createColumnGap(config) {
const algorithm = COLUMN_GAP_ALGORITHMS[config.algorithm];
if (!algorithm)
throw new Error(`KEY ERROR unknown columnGap algorithm "${config.algorithm}".`);
const fn = algorithm.fn(config)
, solve = algorithm.solve
? (W, n) => algorithm.solve(config, W, n)
: (W, n) => defaultGapSolve(fn, W, n);
return { fn, solve };
}

// ---------------------------------------------------------------------------
// Validate + normalize
// ---------------------------------------------------------------------------

export function validateColumnConfig(raw) {
const errors = [], warnings = [];
if (!(raw.minLineLength > 0)) errors.push('minLineLength must be > 0.');
if (!Array.isArray(raw.columns) || !raw.columns.length)
errors.push('columns must be a non-empty array of per-count maxLineLength.');
else
for (const [i, max] of raw.columns.entries())
if (!(max > 0)) errors.push(`columns[${i}] must be > 0.`);
if (raw.columnGap?.algorithm === 'linear') {
const { a, b, min, max } = raw.columnGap;
if (a.columnWidth === b.columnWidth)
errors.push('columnGap: a.columnWidth === b.columnWidth (NaN slope).');
for (const p of [a, b])
if (p.gap < min || p.gap > max)
errors.push('columnGap: point outside [min, max].');
}
if (raw.growColumns !== undefined && raw.growColumns !== false
&& raw.growColumns !== Infinity
&& !(Number.isInteger(raw.growColumns) && raw.growColumns >= raw.columns.length))
errors.push('growColumns must be false, Infinity, or an integer >= columns.length.');
if (errors.length)
throw new Error(`VALUE ERROR columnConfig:\n - ${errors.join('\n - ')}`);
// Dead-zone warning: natural max width of n columns vs entry width of n+1.
const gap = createColumnGap(raw.columnGap);
for (const [i, maxLine] of raw.columns.entries()) {
const n = i + 1;
if (n === raw.columns.length) break;
const maxNaturalEn = n * maxLine + (n - 1) * (n > 1 ? gap.fn(maxLine) : 0)
, minEntryEn = (n + 1) * raw.minLineLength + n * gap.fn(raw.minLineLength);
if (minEntryEn > maxNaturalEn)
warnings.push(`dead zone ${n} -> ${n + 1} columns: (${maxNaturalEn.toFixed(1)}, ${minEntryEn.toFixed(1)}] EN`);
}
return warnings;
}

export function normalizeColumnConfig(raw) {
validateColumnConfig(raw);
return Object.freeze({
minLineLength: raw.minLineLength
, columns: Object.freeze(Array.from(raw.columns))
, growColumns: raw.growColumns ?? false
, gap: createColumnGap(raw.columnGap)
, paddingRatio: Object.freeze(Array.from(raw.paddingRatio ?? [3 / 5, 2 / 5]))
});
}

// ---------------------------------------------------------------------------
// The runion
// ---------------------------------------------------------------------------

function compose(columns, lineLengthEn, columnGapEn, paddingEn, [left, right]) {
return { columns, lineLengthEn, columnGapEn
, marginLeftEn: paddingEn * left, marginRightEn: paddingEn * right };
}

// columnConfig: output of normalizeColumnConfig. Returns null for degenerate
// input (width <= 0) so SyntheticValue resolution drops the property chain
// instead of crashing (legacy version threw).
export function runion_01_columns(columnConfig, availableWidthEn) {
if (!(availableWidthEn > 0)) return null;
const { minLineLength, columns, growColumns, gap, paddingRatio } = columnConfig
, maxColumns = growColumns === false
? columns.length
: growColumns === Infinity
? Math.floor(availableWidthEn / minLineLength) + 1 // bounded
: Math.max(columns.length, growColumns)
, maxLineLengthFor = (count) => columns[Math.min(count, columns.length) - 1]
, minLineLengthFor = (count) => count === 1 ? 0 : minLineLength;
// Pass 1: fewest columns with a natural fit (min inclusive).
for (let count = 1; count <= maxColumns; count++) {
const [lineLengthEn, gapEn] = gap.solve(availableWidthEn, count);
if (lineLengthEn >= minLineLengthFor(count) && lineLengthEn <= maxLineLengthFor(count))
return compose(count, lineLengthEn, gapEn, 0, paddingRatio);
}
// Pass 2: most columns not below min, capped at max, rest to margins.
for (let count = maxColumns; count >= 1; count--) {
const [naturalLineEn] = gap.solve(availableWidthEn, count);
if (naturalLineEn <= minLineLengthFor(count)) continue;
const lineLengthEn = maxLineLengthFor(count)
, gapEn = count > 1 ? gap.fn(lineLengthEn) : 0
, paddingEn = availableWidthEn - count * lineLengthEn - (count - 1) * gapEn;
return compose(count, lineLengthEn, gapEn, paddingEn, paddingRatio);
}
return null; // unreachable with a valid config (1-col min = 0)
}

// ---------------------------------------------------------------------------
// Configuration data (compact form). Registration into COLUMN_CONFIG_I18N
// happens via the existing addToI18NConfig plumbing (ported unchanged);
// 'Latn-de' links to 'Latn-en' for everything it doesn't override.
// ---------------------------------------------------------------------------

export const COLUMN_CONFIG_EN = Object.freeze({
minLineHeight: 1.1
, maxLineHeight: 1.3
, minLineLength: 33 // EN, counts >= 2; 1 column always allowed
, columns: Object.freeze([65, 65, 50, 40]) // maxLineLength per count
, growColumns: false
, columnGap: Object.freeze({
algorithm: 'linear'
, a: Object.freeze({ columnWidth: 33, gap: 2 })
, b: Object.freeze({ columnWidth: 65, gap: 3 })
, min: 2, max: 3
})
, paddingRatio: Object.freeze([3 / 5, 2 / 5])
});

export const COLUMN_CONFIG_DE = Object.freeze({
...COLUMN_CONFIG_EN
, minLineLength: 42
, columns: Object.freeze([65, 65, 50, 45])
});
Loading
Loading