feat(timeline): one continuous rail, resolved from where the markers landed - #673
Merged
Merged
Conversation
…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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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-entryleading(...)) sits to its left and never moves it; itbelongs to the timeline, so every entry reserves the same column.
auto()is refused — anauto column sized per entry would move the axis on every row.
markerOnRail()aligns each marker's declared anchor with the axis. With the centreanchor, 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.
TimelineMarker.custom(width, height, recipe)reserves exactly that box: the timeline laysout 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, spacingincluded, no tail after the last) and
MARKER_TO_MARKER(the same per-page bands trimmed tothe 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.
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 samecontent-column x and width on every page it reaches.
entry(spec -> spec.marker(...).content(column -> ...))hands over thecontent column whole; the convenience
title/meta/bodymode still exists, the two aremutually exclusive, and both normalize into one internal model.
The engine, generically
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.
ResolvedHorizontalBand{x, width}under an identity; a later vertical block lays itself outinside 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.
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
handles; no handler was invented for it.
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 verifyover the eight gated modules: BUILD SUCCESS, 2283 tests, qa1049, 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 fromthe 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),
classicgrowing 25 → 37 nodes and 16 → 20 fragments. Band lookup is O(1) with twoallocations 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_BOUNDSis declared and rejected with a message. On one page it is the sameline as
ENTRY_BOUNDS, and across pages there is nothing to measure it against.geometry and
DocxSemanticBackendconsumes the semantic tree, never aLayoutGraph. Thetimeline's content exports in full and the export does not throw. Recorded in the backend
capability matrix.
developand pinned by a test so it stays a known engine limit rather than a surprise.
Follow-ups (not blocking)
AlignNode's alignment is not mapped tow:jcin 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.twoOwnersOnOnePageKeepTheirColumnsApartasserts a true thing but is not areliable detector for an identity mix-up — the nested-owner case is the guard that fires.
DocxSemanticBackendrecognises an@Internallayout node by name. A marker interface wouldlet backends ask a question instead of naming types.
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.dsltimeline surface,document.layoutseams,one semantic-backend fix).