Skip to content

feat(timeline): one continuous rail, resolved from where the markers landed - #673

Merged
DemchaAV merged 24 commits into
developfrom
feat/timeline-rail
Sep 10, 2026
Merged

DemchaAV merged 24 commits into
developfrom
feat/timeline-rail

Conversation

@DemchaAV

Copy link
Copy Markdown
Owner

Why

A timeline's rail was a left border repeated on every entry section. That is why it sat at
the entry's edge whatever the markers did, could not stop short of them, drew a slightly
lighter row wherever two entries abutted, and could never pass through a marker — a border
belongs to a box, and the line wanted to belong to the timeline.

Making it one line meant computing it from geometry that only exists after layout, which the
engine could not express. Two generic capabilities came out of that, and the timeline is their
first consumer rather than their subject.

What changed

The timeline

  • One logical continuous rail. Computed after layout from the markers' and entries'
    resolved positions and contributed as one fragment per page it crosses — one line however
    many pages it spans, bounded on each by that page alone. It is drawn beneath the markers, so
    a filled marker covers the line running under it.
  • LEADING | AXIS | CONTENT. The rail belongs to the axis. A leading column
    (leadingColumn(...), per-entry leading(...)) sits to its left and never moves it; it
    belongs to the timeline, so every entry reserves the same column. auto() is refused — an
    auto column sized per entry would move the axis on every row.
  • markerOnRail() aligns each marker's declared anchor with the axis. With the centre
    anchor, markers of different declared sizes share one rail x: a 6pt dot, a 14pt numbered disc
    and a 24pt square sit on the same line rather than on the same left edge. An entry's body
    moves with them into the content column, so the line has only markers left to cross. Works
    with a fixed axis (axisWidth(...)) and a weighted one (markerColumnWeight(...)) alike —
    neither is converted into the other, and configuring both throws.
  • Markers of any construction, and the declared box is authoritative.
    TimelineMarker.custom(width, height, recipe) reserves exactly that box: the timeline lays
    out around it and the anchor reports it. A recipe may draw smaller and leave the rest empty,
    or larger and overflow it visibly; the box does not follow the ink, and an outline's
    thickness does not change it. The box need not be square. The four built-in factories draw
    exactly what they declare, so none of them moved.
  • TimelineRailExtent: ENTRY_BOUNDS (default — every entry on each page, spacing
    included, no tail after the last) and MARKER_TO_MARKER (the same per-page bands trimmed to
    the outer markers; a page carrying no marker runs its whole band; one entry emits no rail at
    all rather than a line of no length). Neither moves the rail sideways.
  • Pagination. A timeline paginates between entries and a tall entry splits within itself,
    with the rail continuing across the break. Nothing is repeated because content continued —
    no second marker on a continuation page — and under markerOnRail() the body keeps the same
    content-column x and width on every page it reaches.
  • Advanced entries. entry(spec -> spec.marker(...).content(column -> ...)) hands over the
    content column whole; the convenience title / meta / body mode still exists, the two are
    mutually exclusive, and both normalize into one internal model.

The engine, generically

  • Resolved-layout pass seam. A wrapped node leaves one anchor fragment per page it
    occupies; a pass reads those after compilation and contributes drawing. It cannot move
    anything, change a width or add a page. Ownership is object identity, discovered from the
    anchors — nothing is registered.
  • Resolved horizontal band. A row records each column it resolved as
    ResolvedHorizontalBand{x, width} under an identity; a later vertical block lays itself out
    inside one of them. It applies before the consumer is measured — width decides wrapping,
    wrapping decides height, height decides pagination — which is why a post-layout pass could
    not do this job. The consumer stays splittable and keeps one x and one width across pages;
    fixed and weighted columns behave identically because the band is read rather than
    recomputed; storage is per compile and keyed by identity, with no registry and no second
    pass. Missing, duplicate, out-of-range, empty and wrong-context cases all fail closed with a
    message naming the mistake. Rows remain atomic — nothing here makes a horizontal
    composite splittable.
  • Under-body means under the feature's own content. An under-body fragment used to go to
    the front of the page's fragment list, which put it beneath the fill of whatever the feature
    was inside: a timeline in a card lost its rail entirely — present in the geometry, absent
    from the page. It is now spliced immediately before the first fragment that feature drew on
    that page.

Backends

  • PDF and PPTX draw the rail through the shape payload every fixed backend already
    handles; no handler was invented for it.
  • DOCX keeps a timeline's content. The semantic backend writes what it recognises and used
    to drop the subtree of what it did not, so once entries were anchored the export produced a
    well-formed document with every word gone. Two wrappers — a layout anchor and an alignment —
    are transparent now, in the document walk and the row-cell walk alike, through one predicate
    rather than a list per walker. No timeline-specific logic was added.

Verification

./mvnw clean verify over the eight gated modules: BUILD SUCCESS, 2283 tests, qa
1049, 0 failures / 0 errors. Examples module 75 green including CommittedAssetDriftTest;
Javadoc 0 warnings; all four knowledge-pack gates green (API surface current, stability doc,
claims, routes).

Public API: 0 removals, 16 Timeline additions — 2540 members before, 2556 after, every
addition @since 2.4.0. The band types and the marker anchor stay internal and excluded from
the authoring surface.

Legacy geometry is preserved, and it was measured rather than argued. Eleven documents
written against the pre-rework builder alone — every marker factory, every knob, a body across
a page break, an entry taller than four pages, a timeline near the bottom of a page, one inside
a padded section, two on a page — laid out on this branch and on the base and diffed: every
placed node identical, every page count identical, and the 31 per-entry borders replaced by 16
rail fragments covering the same span at the same x, worst |Δx| 0, worst |Δy| 1.4e-14
across sixteen page-instances. A legacy timeline never opts into the new placement: no band
node appears in its graph at all.

The render is not pixel-identical, deliberately. Six pixels differ in a three-entry
timeline and three in a two-entry one, at the rows where two entry borders used to abut — each
drew its own antialiased end there, so the seam came out lighter than the rail's own colour.
One continuous line has no seams and paints the colour asked for. That delta is intentional and
is the only one.

Performance, measured: about +0.3 ms on a six-entry document end to end (build and
layout), classic growing 25 → 37 nodes and 16 → 20 fragments. Band lookup is O(1) with two
allocations per producer; there is no extra layout pass and no full-document scan per consumer.

Sabotage: twelve deliberate breakages across the work, each recorded against the test that
caught it. Two of them changed the result rather than confirming it — a marker-bounds sabotage
that caught nothing exposed a declared box that reserved nothing, and a page-break sabotage
that caught nothing exposed a missing test for a producer and consumer separated by a page
turn.

Known limits

  • TIMELINE_BOUNDS is declared and rejected with a message. On one page it is the same
    line as ENTRY_BOUNDS, and across pages there is nothing to measure it against.
  • DOCX omits the drawn rail by design, not by defect: the rail is resolved fixed-layout
    geometry and DocxSemanticBackend consumes the semantic tree, never a LayoutGraph. The
    timeline's content exports in full and the export does not throw. Recorded in the backend
    capability matrix.
  • A timeline inside a row cell is refused — a row cannot hold a row. Unchanged from develop
    and pinned by a test so it stays a known engine limit rather than a surprise.

Follow-ups (not blocking)

  • An AlignNode's alignment is not mapped to w:jc in DOCX. The wrapper is transparent now,
    so its content survives where it previously vanished, but the centring does not reach
    Word. Worth a line in the capability matrix.
  • HorizontalBandTest.twoOwnersOnOnePageKeepTheirColumnsApart asserts a true thing but is not a
    reliable detector for an identity mix-up — the nested-owner case is the guard that fires.
  • DocxSemanticBackend recognises an @Internal layout node by name. A marker interface would
    let backends ask a question instead of naming types.
  • A pass that contributes to a page where it has no anchor still falls back to the front of the
    list. Unreachable through the timeline — rail segments come from per-page entry anchors — but
    worth remembering when a second consumer arrives.

Lane: shared-engine + canonical (document.dsl timeline surface, document.layout seams,
one semantic-backend fix).

…he rail

Phase 0a of the rail rework needs three facts before any design holds, and
reading the compiler does not settle them. This spike renders a three-entry
timeline twice, at marker size 8 and 20, and dumps the resolved nodes and
fragments.

It answers: PlacedNode boxes and PlacedFragment boxes share one coordinate
space, bottom-left with y growing up, so a post-layout pass needs no
conversion; markers are findable, though only as EllipseNode, which a custom
SVG marker would not be.

And it measures the defect. Each entry emits its own side border at the entry
section's left edge, x=20 — three borders that abut, not one rail. The marker
box starts at x=28 (margin 8 + gutter 8), so its centre is at 32 at size 8 and
38 at size 20. The rail is 12pt to the left of the marker centre and the gap
widens with the marker, because rail x is the section edge while the centre is
margin + gutter + size/2.

Throwaway: delete or promote before the PR.
Phase 0b of the rail rework blocks on one question: can leading | axis |
content hold content that continues onto the next page? Two spikes answer it.

No horizontal composite flows. LayoutCompiler routes Axis.HORIZONTAL to
compileHorizontalRow and Axis.STACK to the stacked compiler, both atomic; only
the vertical path reaches compileComposite, which flows children across pages.

The table looked like the exception, and does split — 14 rows over 4 pages as
one TableNode spanning p0..3 — but it cannot carry a timeline body. Cells do
not wrap: an auto() column measured a two-sentence cell at 518pt against 260pt
and threw, and fixed(150) refused a natural width of 434. Its marker cells also
emit fragments without a PlacedNode, and the fragment box is the cell's, so an
8x8 ellipse reports 16x8 and a centre taken from it would be wrong.

The shape already in use is the answer. One entry with a 40-sentence body on a
300x160 page: the entry section spans p0..p4, its header row stays whole on p0
with the marker, and the body paragraph flows p0..p4. No new container is
needed — only the consequence that header-row content, leading included, cannot
split, which belongs in the Javadoc rather than in a surprise.

Throwaway: delete or promote before the PR.
The rail is about to stop being a per-entry left border and become one logical
axis anchored to resolved markers. Nothing pinned what it renders today: three
node-level tests and a blank-page pixel smoke. The timeline_minimal baselines
in this repository belong to the CV preset, which draws through
TimelineAxisWidget and never calls addTimeline.

TimelineBuilderTest grows from 3 cases to 18, one per public method and one per
default, so a rework that quietly drops connector, gutter, markerGap,
markerColumnWeight, spacing, the three styles, keepTogether or
keepEntriesTogether goes red by name. Sabotaging the gutter and entry-spacing
defaults turns it red on the assertions that name them.

Two exact snapshots come with it, a classic timeline and one that paginates,
because the two levels answer different questions. Raising the entry's internal
spacing from 4 to 6 passes all eighteen node tests and fails both snapshots:
the node test says the builder still sets the gutter, the snapshot says the
gutter still lands where it did.
…t lands

The layout snapshots pin geometry; they say nothing about a rail drawn in the
wrong colour, drawn over its markers instead of under them, or not drawn at
all. None of those move a coordinate, and the rework changes exactly that
layer.

Sabotaging the default ink colour from dark grey to red passes all eighteen
node tests and both layout snapshots, and turns this baseline red. The three
levels catch different things: the defaults fail the node tests, the entry's
internal spacing fails the snapshots, colour fails only here.

A small page on purpose — a full-A4 baseline drifts across platforms by more
than the signal it carries. Marker sizes differ (6, 14, 9pt) because keeping
the rail aligned under unequal markers is a goal of the rework, and today the
marker centre is margin + gutter + size/2 while the rail sits at the section
edge, so this records a rail the markers do not sit on.

Approved with the test class alone; the other 86 baselines hash identically
before and after.
TimelineBuilder read its entries' fields directly and resolved every style
inside the loop that built the tree, so the layout knew which slot a paragraph
came from and which style it had fallen back to. A second authoring API cannot
reach that code without teaching it a second shape.

Three package-private records now sit between them. TimelineEntryBuilder
resolves its own styles and hands back content already split the only way the
layout depends on: what goes beside the marker, indented past the marker
column, and what goes below it at the entry's own left edge. buildInto is
layout(normalize(), timeline), and layout reads nothing but the spec.

Geometry is unchanged, which is the point of doing this before anything moves:
both layout snapshots and the pixel baseline pass without being re-recorded.

Per-entry style overrides had no test. The existing ones cover the timeline's
styles and the fallback to defaults, but not an entry overriding one slot while
keeping the timeline's for the rest — which is exactly the resolution this
moves. Covered now, and making the default always win turns it red.
An entry could describe itself only as a title, a meta line and a body. Content
that is none of those — a table, a chart, a row of chips — had to arrive through
add(...), which hangs below the body rather than sitting where the entry's
content belongs, and inherits a shape the caller did not ask for.

entry(e -> e.marker(...).content(column -> ...)) hands the content column over
whole. The marker moves inside the lambda for that form; entry(marker, ...)
stays exactly as it was and builds the same entry, which a test asserts by
comparing the two trees rather than by assuming the shorthand is sugar.

The two vocabularies do not mix. title/meta/body/add describe slots the timeline
styles and arranges; content() says it should arrange nothing, and the timeline's
title, meta and body styles then describe slots the entry does not have. Calling
both on one entry throws at authoring time in either order — including a bare
style override, which names one of those slots as surely as the text does.
Letting one win silently would only show up in the rendered document.

Two more states that used to be expressible and wrong: an entry with no marker
at all, and one declaring a marker both through the shorthand and inside the
lambda. Each throws naming the call to fix.
marker(...) asked whether the marker had come from the entry(marker, ...)
shorthand, so it caught a marker declared beside the shorthand and missed one
declared twice in the advanced form. entry(e -> e.marker(a).marker(b)) kept b
and said nothing — one entry, two markers written, no error, and a difference
visible only in the rendered page. The method's own documentation said it threw.

It asks whether the entry has a marker at all now, and says so: an entry has
exactly one, and the message names the call to remove depending on where the
first one came from.
A DATE | MARKER | CONTENT timeline could not be authored. The dates had to go
in the content column with the title, or in a hand-built row outside the
timeline, which gives up the rail.

leadingColumn(...) declares the width once for the whole timeline and
leading(...) fills it per entry. The width belongs to the timeline rather than
to the entry because that is the only way the markers after it can start at the
same x, and every entry gets the column — an entry that skips it keeps the
empty space, and dropping that column is not even structurally valid: RowBuilder
rejects three column specs against two children.

auto() is rejected, with the reason in the message. An auto column is measured
from its own row's content, so it cannot align across rows: measured before
deciding, one leading text of "2023" and one of "September 2024 - present" put
their markers 131pt apart, while fixed(48) and weight(0.3) both aligned exactly.
Leading content with no column declared throws too, naming the call to add,
rather than inventing a width for that entry.

marker, leading and content are now all declared once rather than assigned. Each
takes a whole column, so a second call is a mistake and not an override, and the
shape of an entry should not depend on which call came last. Plain values like
title(...) still replace, as builder setters normally do.

The alignment is asserted, not only recorded. A snapshot of a misaligned
timeline passes forever as long as it stays misaligned, so the layout test reads
the column positions out of the graph and says they are the same in every entry;
the snapshot and the pixel baseline are there for what it cannot see. A timeline
that declares no leading column is untouched — two-column header row, both
earlier snapshots and the classic baseline byte-identical.
A timeline's marker column could only be a weight, so the markers sat a
different distance from the edge on every page width. Asking for points meant
computing a weight from a width the caller would have had to know.

axisWidth(18) says points; markerColumnWeight(0.10) still says a share. Both
survive to the layout as a two-case TimelineAxisSize, and neither is converted
into the other. A conversion needs a row width, and the one that exists is
right on exactly one page: the default 0.10 resolves to 24pt at 320pt wide and
38.5pt at 480pt. So the row is handed whichever it was given and resolves it
itself.

Declaring both throws, naming both calls, since they are two answers to one
question. A markerColumnWeight(...) call that was already ignored — the method
has always dropped a non-positive value — still does not count as declaring
anything, or code that used to work would start failing.

The two-page-width assertion is the one that matters and it is new. Sabotaging
the default weight into Fixed(24) leaves both layout snapshots green, because
24pt is exactly what 0.10 resolves to on the 320pt page they use; only the
pixel baseline on a 300pt page caught it. Any check at one page width is blind
to a converted weight by construction, so the axis test lays the same timeline
out at two widths and says a share has to give two answers.

Also measured and pinned: the section inside the axis column reports the
marker's width, not the column's — 6, 14 and 20 under a 20pt axis. A rail
derived from that number would drift with marker size, which is the defect
being reworked, so the phases that build the rail have to anchor on the
marker's resolved box instead.
The four built-in shapes were the only markers a timeline could have. Anything
else — a ring around a disc, a date pill, an icon — meant a new factory inside
TimelineMarker, which is to say a change to the library for a change to one
document.

custom(width, height, recipe) takes a declared box and a recipe that fills it,
and the four built-ins now go through the same door. The box is declared, not
measured, and need not be square: a marker of three stacked shapes has one box
exactly as a marker of one ellipse does, so the geometry around it cannot tell
how it was built. That is asserted, not assumed — a three-shape marker and a
one-shape marker of the same declared box put the content beside them in the
same place — and a baseline shows both actually painted, because a recipe that
drew nothing would satisfy every layout assertion here.

The recipe stays an internal Consumer. Nothing outside this package calls it, so
it can grow later — a marker that needs to know it is the first or the last —
without any of that reaching the public factories.

TimelineMarker.size() is gone. Its documentation said the size laid out the rail
column; it never did, the column comes from the axis, and nothing read the field
at all.
The rail is a left border on each entry section, which is why it sits at the
section's edge rather than through the markers, and why it cannot know where a
marker of a different size ended up. Computing it from the markers needs the
markers' resolved positions, and a node definition is handed its own box and
nothing else.

Each marker is now wrapped in an anchor, so the finished layout carries one per
marker: its own box rather than its column's, on the page the marker is actually
on, and the same box whether the marker drew one shape or three. The wrapper
goes inside the row's column rather than being it — a row hosts a fixed set of
child types and this is not one of them, and widening a public builder's
contract for an internal wrapper is the wrong trade.

Identity is an owner object allocated once per timeline, so a pass asks for its
own markers and gets that timeline's and nobody else's; two timelines on a page
never merge, which a test holds by making one owner shared and watching it fail.
No path, no node name, no string convention.

Nothing is drawn and nothing moves. All three pixel baselines pass untouched,
every node box that was in the graph is still there at the same coordinates and
the page counts are unchanged; what the wrapper adds is two nodes per marker and
a level in the paths. The three layout snapshots are re-recorded on that
evidence, not on the strength of the tests going green.
connector(colour, width) was the whole of a timeline's rail, which is fine while
a rail is a colour and a width and awkward the moment it is anything else. Every
property the rail grows would have to arrive as another argument, or as a second
way of configuring the same line.

rail(r -> r.stroke(...)) is that configuration, and connector(...) normalizes
into it: the same TimelineRailSpec, the same tree, asserted by comparing the two
rather than by describing them as equivalent. Saying it both ways throws and
names both calls.

Saying it twice the same way does not. The first version of that guard keyed on
"the rail was configured" and would have broken connector(colour, 0) followed by
connector(null, width) — a documented way to set the two halves separately, and
working code. Repeating one setter is accumulation; reaching for the other
spelling is the ambiguity worth refusing.
The rail was a left border repeated on every entry section. That is why it sat
at the entry's edge whatever the markers did, why it could not stop short of
them, and why it drew a slightly darker row wherever two entries abutted: each
border antialiased its own end into the shared row.

It is now computed after layout from the anchors the entries and markers
resolved to, and contributed as one fragment per page — one logical rail however
many pages it crosses, bounded on each by that page alone, spliced ahead of the
body so a filled marker covers the line passing under it.

Two independent choices, kept independent. TimelineRailExtent says how far:
ENTRY_BOUNDS, the union of the entries' per-page slices and what every existing
timeline already draws, or MARKER_TO_MARKER, which starts and stops at the
anchor points it is named for. markerOnRail() says where: through the markers'
centres rather than a gutter to their left. One entry with MARKER_TO_MARKER
emits nothing rather than a line of no length. TIMELINE_BOUNDS is named and
rejected — one page makes it identical to its neighbour and no page makes it
measurable.

There is no legacy branch. Both anchors are one equation, marker box plus
fraction plus offset, and the default resolves to the offset the old rail
already had: a constant gutter, which is what it is at every marker size where
a centre would need a different number for each.

A leading column sits to the left of the axis and does not move the rail to the
entry boundary — LEADING | AXIS | CONTENT, with the rail belonging to the axis.
Its baseline is re-recorded on that decision; it has never shipped.

Nothing registers the pass. A timeline's owner is a ResolvedLayoutPass, so the
driver finds it among the anchors the document resolved and asks only whether an
owner is a pass, never what kind of thing it is. document.api names no feature.

Rendering: the geometry matches the border it replaces to 0.000000 in x and in
both ends on every page. Two pixels differ in the three-entry baseline and one in
the two-entry one, located rather than assumed — pdf y 177.50 and 145.50 against
measured entry junctions at 177.175 and 145.1375. They are the seams the old
per-entry borders drew twice. Matching them would mean emitting a fragment per
entry again, which is the architecture this removes.

Snapshots re-recorded: every node box that was there is still there at the same
coordinates, page counts unchanged, and the additions are exactly one anchor
wrapper per entry.
markerOnRail() drew the rail through one marker's centre and left the rest
beside it. Markers were packed to the left of the axis column, so a 6pt dot and
a 24pt square had centres 9pt apart and only the first sat on the line the
method is named for.

Where a marker sits in the axis now follows from its anchor. An anchor on the
left edge wants the marker at the column's left edge; one on the centre wants it
at the column's centre — and the engine's AlignNode, inside the axis column and
around the anchor, is that placement. Markers of every size then share one
centre, because it is the column's centre and not their own, and a weighted axis
needs no special handling: the align fills whatever the column resolves to.

The anchor stays around the marker rather than around the align, so it still
reports the marker's own box. Nothing derives the rail from the column.

Placement is exhaustive over the fractions that exist and refuses the rest:
0.0 is LEFT, 0.5 is CENTER, and anything else throws instead of centring
something whose resolved anchor would then disagree with the rail. RIGHT is not
wired up because no anchor produces it.

Legacy is untouched, and that is measured rather than argued: every timeline
that does not call markerOnRail() keeps left-packed markers and its old rail
relation, and all three existing pixel baselines are byte-identical.
…not draw

DocxSemanticBackend writes what it recognises and skips what it does not. A
node it skips takes its subtree with it, so once timelines started anchoring
their entries the export produced a well-formed document with every word gone —
no exception, no warning, just nothing.

Two wrappers were unknown to it. A layout anchor reports where its child landed
and an alignment says where in the available width to put it; Word lays text out
itself, so neither survives as geometry, but each has exactly one child and that
child is the document. They are transparent now, in both walkers — the
document-level one and the row-cell one — because a wrapper handled in one and
missed in the other loses a subtree just as completely, which is the shape a
timeline's marker column has.

The two walkers ask one predicate rather than each keeping a list, so the next
transparent wrapper is added once.

The tests are written against the wrappers, not against a timeline: an anchor
and an align at document level, the two nested, and both inside a row's cell.
The timeline case stays as integration coverage, and it now needs a text
extractor that reads tables — a row exports as a one-row table, so paragraphs
alone cannot see a timeline's title or meta. That is checked by a control case
before the timeline is asked anything.

The rail itself is absent from DOCX by construction and that is recorded in the
capability matrix, not treated as a gap: it is resolved fixed-layout geometry
and this backend consumes the semantic tree.
The rail stopped being a border repeated on every entry and became one line
drawn from resolved anchors, and the markers can now sit on it. Ten scenes
say what that looks like — and each is given the instrument that can decide
it, rather than all three out of habit.

A layout snapshot decides none of them. It records nodes, the rail is a
fragment, and grepping either committed timeline snapshot for the word
returns nothing: so none was added and none re-recorded. A picture decides a
shape and a paint order, and that is the whole of what the new baselines are
spent on — three marker sizes strung on one axis, the two extents drawn on
one identical scene so the pair can be read as a diff, a rail crossing three
pages, and a ring the line disappears behind. Everything expressible as a
coordinate is written as one.

The assertions are the ones that only exist once the parts are assembled:
one x for a whole timeline however many pages it crosses and whichever
extent, a date of any length leaving the axis where it was, no fragment
reaching outside the band of its own page, MARKER_TO_MARKER across pages —
the first page trimmed at the top, the last at the bottom, and the page
between them, which carries no marker, trimmed at neither — a hollow marker
anchored by its own box, a marker of three shapes railing exactly as a plain
one of the same declared box, and two timelines on different anchors not
sharing a rail.

Four of the ten needed nothing new: they were already drawn and already
pinned, and the tenth wanted the assertion it was missing rather than a
picture of what a picture cannot show. The four baselines recorded before
the rework are byte-identical.
…whole page

A resolved-layout pass says whether what it contributes goes under the body or
over it, and under meant the front of the fragment list. The front of the list
is before everything — including the fill of whatever the feature is inside. So
a timeline in a card had its rail painted first and covered a moment later: the
fragment was in the graph, at the right coordinates, on the right page, and not
on the paper. The feature catalogue is exactly that shape and its timeline had
lost its line.

Under-body now means under the contributing feature's own content: the fragment
is spliced immediately before the first fragment that feature's anchors produced
on that page, found in one scan of the graph for every pass at once. A pass that
anchored nothing on a page still goes to the front, which is what a page-wide
backdrop wants and what the seam's existing ordering tests describe — they pass
unchanged.

The rest of this is the audit that found it. A timeline written before the rail
moved has to lay out where it always did, and that was measured rather than
argued: eleven documents using nothing but the builder as it shipped, laid out on
this branch and on the base and diffed. Every placed node matched, every page
count matched, and the thirty-one per-entry borders became sixteen rail fragments
covering the same span at the same x — worst |Δx| 0, worst |Δy| 1.4e-14 across
sixteen page-instances. Nothing left the public surface: 2540 members before,
2556 after.

What can be re-checked on one branch is now a test, including the one thing a
coordinate cannot state. A rail that is present in the geometry and painted over
is invisible to every assertion in this repository, so that case is asserted in
rendered pixels — the only instrument that can see it.

The committed catalogue preview is re-rendered. It differs by three pixels on one
row: the junction where two entry borders used to abut, each painting its own
antialiased end, which came out lighter than the rail colour. One line has no
junctions.
… what it drew

TimelineMarker.custom(width, height, recipe) took a box and then ignored it.
The timeline measured whatever the recipe happened to draw, so the two numbers
a caller wrote reserved nothing at all — a 10pt dot inside a declared 30pt box
resolved to 10, a 30pt dot inside a declared 10pt box resolved to 30, and
everything derived from a marker followed the ink rather than the declaration.
The class had said "the box is declared, not measured" since the factory
arrived; it was describing an intention.

The recipe is now handed a canvas of exactly the declared size and draws from
its origin. Content smaller than the box leaves the rest of it empty; content
larger overflows visibly rather than growing it. The declaration outranks the
axis column too — the rail is derived from this box, so a box squeezed into a
narrower column would put the line where neither the marker nor the axis asked
for it.

Nothing that ships moved. The four built-in factories draw exactly what they
declare, so every pixel baseline is byte-identical, and the three layout
snapshots gained one node per marker with not one coordinate changed — the
diff is additive, checked kind by kind and box by box before it was recorded.
…that follows it

A row works out where each of its columns starts and how wide it is — from
points, from shares of what is left, or from a mixture — lays its children into
those slots and throws the arithmetic away. Content that has to line up with one
of those columns and is also long enough to cross a page has had nowhere to go: a
row is laid out on one page, so putting the content in the column makes the whole
entry atomic, and computing the column a second time outside the row cannot see a
share until the row is laid out.

So the row keeps the arithmetic. A wrapper gives it an identity, each slot is
recorded as it is resolved, and a later sibling lays itself out inside one of
them — measured at that width, wrapped at that width, paginated like any other
vertical block. It works during compilation and not after it, because the width
decides the wrapping, the wrapping decides the height and the height decides the
pagination; a pass reading resolved geometry afterwards is too late to change any
of that.

Identity is an object compared by reference, never a name, a path or a position.
Two features that describe their columns alike are still two features, and there
is nothing for a third to collide with by accident.

Everything else fails closed and says which part of the arrangement is wrong: a
column nobody published, content that comes before its row, two rows under one
identity, a column the row does not have, a column that resolved to nothing, and
content in a fixed slot where the surrounding rectangle is already decided. None
of them falls back to the parent's width — a silent fallback is a layout that
looks deliberate and is not.

A row that nobody wrapped publishes nothing and is compiled exactly as before.
Rows stay atomic; nothing here makes a horizontal composite splittable, which is
a much larger contract and a separate question.

Internal plumbing: the three node types are in the @internal layout package and
none of them reaches the authoring surface, which is unchanged at 2556 members.
…he line

Putting the markers on the rail moves the line into the middle of the axis
column. An entry's body spanned the whole entry, so the line was drawn straight
through ordinary body text — measured on a 28pt axis, the rail at x 42.000 and
the body occupying 28.000 to 283.075.

The body now starts where the title starts, in the content column of the entry's
own header row. Not an indent computed in points: the row publishes the column it
resolved and the body lays itself out in it, so a column given in points and one
given as a share of the row behave identically. And not inside the row: a row is
laid out on one page, and an entry has to be able to be longer than one, so the
body stays a vertical sibling and splits with the same x and the same width on
every page it reaches.

A timeline that does not call markerOnRail() is untouched — the rail stays beside
the axis, the body still spans the entry and clears the line by the gutter. Its
five pixel baselines are byte-identical and its three layout snapshots do not
move. The four baselines that did change are the marker-on-rail scenes, and one
of them takes a page more than it did: a narrower body wraps into more lines,
which is the cost of the text no longer running under the line.
…o a fresh page

A band is recorded while a row is compiled and read while a later sibling is
compiled. That is only safe if a node is compiled once — if the engine ever laid
a block out, decided it did not fit and laid it out again, the second attempt
would hit the guard against two rows publishing under one identity, and a block
that merely moved down the page would fail to render.

It does not, and now that is asserted rather than believed. A kept-together block
that does not fit in what is left of the page but fits on a fresh one lays out
with its row and its body on the same page, and the body takes the column of the
placement that survived — in points and as a share. A timeline does the same
under keepEntriesTogether and under keepTogether, with the rail still left of the
body afterwards.

The guarantee has two halves and both are in the code rather than in the tests.
Relocation is decided before anything is compiled: compileComposite compares the
measured height against what is left and calls newPage() ahead of the child loop,
so there is no abandoned attempt to leave anything behind. And a compile's
records belong to that compile: LayoutCompiler.compile builds a fresh
CompilerState, which is what makes a document laid out several times — a page
reference makes it a fixed point — publish the same identity on each pass without
colliding with itself. That last one is the case a single-pass test cannot reach,
so it has a test of its own.
…was planned

The recipe still opened with "the rail is a left accent border on each entry".
That stopped being true when the rail became one line resolved after layout, and
everything written on top of it — how far the line runs, what moves it, what
cannot — was missing or wrong.

It now describes the finished model: LEADING | AXIS | CONTENT, the rail belonging
to the axis, a leading column that never moves it and why auto() is refused, the
two axis sizings and that neither converts into the other, both ways to fill an
entry, what a declared marker box means and what happens when a recipe draws
outside it, what markerOnRail() does to the markers and to the body, the two
extents that work and the one that is declared and rejected, pagination, and what
each backend does with a rail — including why DOCX has none.

The compatibility sentence was overstated and is corrected. The geometry is
unchanged to 0.000000 and page counts hold, but the render is not pixel-identical:
a few pixels differ where two entry borders used to abut, and the measurement says
that seam came out *lighter* than the rail colour, not darker as the entry
claimed. markerOnRail()'s own Javadoc still said nothing outside the axis column
moves, which the body now does.

Two engine seams are written down together because the difference between them is
the thing worth knowing: a resolved-layout pass reads geometry that is already
settled and can only draw, while a resolved horizontal band is read during the
same compile and changes what is measured after it. The note carries the paint
order, including what "under the body" means now that it is not the front of the
page's fragment list.

The catalogue gains the block that shows all of it at once: dates in their own
column, a 6pt dot, a 16pt ring and a 22pt square on one rail, an outlined marker
breaking the line, and bodies that stay in the content column. It is one block
longer, so the preview is eight pages instead of seven and everything after the
timeline moves down by one block.
Making the declared box authoritative moved the only read of it onto the field
itself, and left the package-private accessor behind with no callers. A getter
that nothing calls is the same shape as the dead state this work set out to
remove; it goes with it.
… they need

The baselines are recorded on Windows and compared on Linux by CI, and PDFBox
rasterises text differently there. Measured on these exact scenes: 716 to 2 539
pixels of a structurally identical page disagree, worst per-channel delta 202,
and 6 598 on the paginated scene where body text is most of the image. Asserted
with a zero-pixel budget, all nine failed on every JDK the moment they left this
machine — which is where they were only ever proven.

They now carry a budget, the way ShapeContainerVisualRegressionTest already does
for the same reason, and the paginated scene carries its own rather than
loosening the other eight to the worst case.

The Javadoc says what that costs, because it is not free. A paint-order flip
moves 129 to 178 pixels on these scenes — an order of magnitude below the drift —
so these images cannot be the guard for it. They are not: the rail being painted
under the markers and under the text is asserted on the fragment list, and its
surviving an opaque panel is asserted by counting rail-coloured pixels on a
rendered page, which no platform difference can answer wrongly. What is left to
these images is gross change — a rail not drawn, a marker missing, content in the
wrong column, a scene reflowing.

No baseline is re-recorded: the pictures are the same pictures, compared with a
threshold that matches where they are compared.
@DemchaAV
DemchaAV merged commit a47de42 into develop Sep 10, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the feat/timeline-rail branch September 10, 2026 09:48
DemchaAV added a commit that referenced this pull request Sep 12, 2026
Brings the 2.4.0 engine work onto the promotion branch: native letter
spacing (#676), opt-in list hanging indent (#674), the resolved timeline
rail (#671-#673), the row-child margin fix, the RTL documentation
corrections (#679, #680) and the templates japicmp gate (#681).

Eight files conflicted. CHANGELOG.md is a union of both v2.4.0 sections,
with the branch-local "### Deprecated" folded into the house heading
"### Deprecations" and the sections ordered the way released entries are.
The other seven are generated and were regenerated from the merged source
rather than resolved by side: knowledge/api/templates.json and .md through
extract-api --from-reactor, and the five cv preview PDFs by re-rendering
their example classes.

Five qa baselines moved, all from f75def6, which stops a row child's
horizontal margin being taken off twice. Each of the four layout snapshots
changes by exactly one node's own horizontal margin - HeadingRule_EXPERIENCE
+9.0, EducationHeadingRule +11.285, FooterDueIcon -3.479 (a negative margin)
and FooterSite +1.693 (a right margin) - with startPage and endPage
unchanged, so no page ownership moved. cobalt_rota keeps its geometry
snapshot and moves only in pixels, inside composed table cells, which emit
fragments rather than PlacedNodes and so cannot appear in a layout snapshot;
the changed region is the day-header and note cells. One of 126 pixel
baselines changed, verified by checksum before and after.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant