Skip to content

feat(templates): promote the finished template bundles into the library - #633

Open
DemchaAV wants to merge 82 commits into
developfrom
feature/template-promotion
Open

feat(templates): promote the finished template bundles into the library#633
DemchaAV wants to merge 82 commits into
developfrom
feature/template-promotion

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Why

The finished GraphCompose AI Flow template bundles lived outside the library: each
was a standalone project pinned to its own engine version, reachable only by copying
its source. This branch collects the promotion of all of them onto the built-in
layered stacks, so a caller reaches them as presets on the shared document models.

Each bundle landed as its own reviewed PR against this branch; this merge lands the
finished set.

What

Fourteen presets promoted, each pixel-identical to the bundle it came from:

Family Presets Count
cv ProfessionalSidebar, NavySidebar, SerifHeadline, CharcoalGold, TerracottaRail, TealPulse, SlateOrange, VioletGrid, OrangeOps 9
invoice ClassicInvoice, ConsultingInvoice, LumaStudioInvoice 3
proposal NorthlineProposal, EditorialProposal 2

Two new document models the bundles needed and the library did not have: the
structured proposal model (#609) and the structured invoice model (#611), the latter
widened in #622 once a second published invoice was fitted to it.

Five additions to the CV model, each general rather than one design's whim:
CvIdentity.portrait (#614), CvEntry.place / icon / builder (#618),
CvEntry.link (#620 group), and CvSkill.note (#628). Every constructor that
predates one is kept explicitly, so existing calls compile and link unchanged.

One behaviour applied across every preset that draws contacts: a link is drawn as
its own label with the address behind it, so a contact row's width does not depend on
how long somebody's profile URL happens to be (#625, #626, #616 and the presets
after them).

Tests

  • Every preset ships three gates: a smoke test, an exact layout snapshot, and a
    pixel-parity baseline — the snapshot catches geometry shifts the pixel budget
    absorbs, and the pixel gate catches ink the snapshot cannot see (composed table
    cells emit no layout node).
  • Every promotion was proved against its bundle before it was written up: the bundle
    frozen on its pinned engine, then the port raster-diffed against it. The set landed
    at 0 differing pixels apart from the deliberate link-label change, which is
    measured and stated in each PR.
  • The examples showcase gains one entry per preset, each with a committed preview
    under assets/readme/examples/.
  • Full reactor gate green on the branch head:
    ./mvnw -B -ntp clean verify -pl :graph-compose-core,:graph-compose-render-pdf,:graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates,:graph-compose-testing,:graph-compose-qa,:graph-compose-coverage -am

One thing a reader should know

OrangeOps is the first preset whose display family the engine does not carry —
Oswald is not in graph-compose-fonts. It names the family through
OrangeOps.DISPLAY_FONT and the caller registers it; the qa gate and the example
each supply the faces from their own module's resources, with the SIL OFL text beside
them. No published artifact gains a font.

DemchaAV and others added 24 commits August 15, 2026 11:36
…thout line items

A bank's transfer confirmation is the document most people receive from a
document engine and the one nothing in this repo could produce. The nearest
family, invoice, is built around a table of line items and asks for money; a
receipt reports money that already moved, and reads for one number and its
provenance.

So the fifth layered family. `ReceiptDocumentSpec` carries the amount, the two
parties, titled groups of label/value rows, the steps the payment went through,
and the footer small print — all as display text, because a receipt restates
what a payment system already decided and must never recompute it.
`ModernReceipt` sequences six widgets over `BrandTheme.receiptModern()`.

The layout decisions follow from what a reader does with the page. The amount
takes the theme's headline slot, larger than the title, because on a receipt it
is the headline. The status carries a chip coloured by tone — settled green,
failed red — in every theme, so checking whether money arrived never means
learning a colour scheme per issuer. The two parties share one panel with an
arrow between them rather than sitting in two tables the reader has to relate.
Detail rows are joined by a dotted leader, the construction the
table-of-contents builder already uses, because across a full page width a bare
label and a bare value read as two unrelated columns. The verification QR code
and the small print are seated on the bottom margin: the engine has no vertical
flex, so the preset measures what the body left on the last page and spends it,
and only when the receipt owns the session, since the measurement costs a
second composition pass.

The theme carries no brand colour at all. An issuer's mark and accent arrive per
document through `ModernReceipt.Options`, so one preset and one theme render
every institution instead of one theme per institution — and `SvgGlyph.fromFile`
lets the mark be a file beside the application rather than one repackaged into
its jar. That variant does not cache: a file can be replaced, and a service
rendering for a hundred issuers should not hold a hundred glyphs for its life.

Tests: 34 in qa (`ReceiptDataTest` normalization and freezing,
`ModernReceiptSmokeTest` across factories, themes, every status tone and the
empty paths, `ModernReceiptFooterPinTest` for the pinned position, the
shared-session skip and a receipt that paginates, `SvgGlyphFileTest`,
`ReceiptVisualParityTest` against a new baseline) plus the examples catalogue,
which now renders and holds `receipt-modern.pdf`. Full reactor gate and the
examples module both BUILD SUCCESS.
…model (#623)

The structured invoice model landed with one consumer. The fields added
beside it fitted a second published invoice; this is that invoice —
`luma-co-studio-invoice`, a studio sheet built around a cream sidebar —
brought into the templates module so it ships with the library.

It is the first promoted preset that has to paginate. The line-items table
repeats its dark header, the totals stack and each closing block stay whole,
and the paper tint, the sidebar column and the dark foot band are page
backgrounds, so every page carries the same frame and the folio always has a
dark ground.

The sign-off carries its own strip rather than relying on that background
band, which is the one deliberate departure from the sheet being ported: the
band is pinned to the paper's edge, so on the last page of a longer invoice
the published template sets white words on pale paper. Drawing the strip with
the words fixes it wherever they land, at 7,440 of 2,173,720 pixels on the
reference sheet and one extra layout node. It is flow content only because
footer chrome carries text today; once a footer zone can hold a node the
sign-off belongs in one.

Amounts take their mark from the data's currency code rather than a second
stated field, and the contact channels carry dial, mail and web targets
derived from their values, with a parenthesised trunk prefix dropped from the
dial target.

Guarded by a smoke test, an exact layout snapshot over a one-page and a
three-page invoice, and a pixel-parity gate; the examples showcase gains
`invoice-luma-studio-v2`.
An architect's one-page CV: a narrow column with a serif monogram over a
terracotta rule, the contact channels behind their marks, two bulleted lists,
the credentials and a block of closing facts, beside a wide column with a
letter-spaced masthead, the summary, the roles held on a ringed rail, a
projects grid and the degrees.

It ports a published standalone template onto the existing CvDocument with no
model change: eight berths reach their sections by title, and the software
berth is claimed before the competency one so a section titled for both is
drawn once rather than twice.

The monogram is drawn from the name's own initials rather than a field of its
own, so a document states its name once. A link in the contact block is set
smaller than the channels above it, because a URL is one long token that
cannot be broken and is the line that outgrows that column first; it takes the
mark of the network it points at, or a globe. The headings are letter-spaced
with a real space character rather than a style property — a hair space in the
sidebar, a thin space in the wider column.

Like its siblings it holds one page: the body is a single atomic row, so a
longer CV raises AtomicNodeTooLargeException rather than losing an entry.

Guarded by a smoke test, an exact layout snapshot and a pixel-parity gate; the
examples showcase gains `cv-terracotta-rail-v2`.
The sidebar had one row that did not belong with the other three. The sheet
this preset ports writes a profile's whole URL out, which is long enough that
it has to be set smaller and started further in than the channels above it —
so the link sat on an axis of its own, and the block's width was whatever that
particular profile happened to be called.

A link is now drawn as its own label with the address behind it:
Link("LinkedIn", "https://linkedin.com/in/…") sets the word and links the URL.
Every contact row is one size, one mark width and one gap, so the block is the
same shape for every document. The link row's smaller type went with it —
LINK_SCALE was the only reason it existed.

The departure from the ported sheet is measured: two of 154 layout nodes
narrow, 1,743 of 2,173,720 pixels change, and nothing moves vertically. Both
baselines were re-recorded with it.

Neither gate can see a label swap, so the smoke test now asserts that the text
layer carries the label and not the URL while the annotation still carries the
URL, and that the four rows share one height.
Only a project title could point somewhere. A role, a degree and a credential
are just as often things a reader wants to open — a company, a campus, a
registry entry — and the model already carries the target on every entry.

Each of them is now a link when its entry carries CvEntry.link(). It costs the
layout nothing, because a link is an annotation rather than ink: both gates
pass on the baselines recorded before this change, which is the proof.

The closing facts stay plain. Their bold line is a label for the values under
it — "Languages:", "Availability:" — rather than the name of something a
reader could open.
A one-page clinical sheet in five bands: a heart crossed by a flat pulse
beside a letter-spaced name; a contact strip divided by short rules; a
two-column body carrying the competencies as dotted lines beside the summary
and the roles under badged headings; a three-column closing band for the
degree, the certifications and the facts; and a tracked line under a rule
that ends in a small heart.

It ports a published standalone template onto the existing CvDocument with no
model change. Six berths reach their sections by title, including a tagline
berth whose body is drawn and whose title is not.

The preset sets its own page: the design was drawn on a raster whose
proportion is not A4's and every length is a share of that grid, so a caller's
page size and margin are overwritten. Both vertical rules are the left border
of the column to their right, so they are the grid rather than lines beside
it, and the main headings are laid over their own rule with the paper knocked
out behind every letter — a row cannot nest in a row cell, and the rule's
visible length still has to follow the words.

Past one page it behaves unlike the other ported CV presets: the bands are
stacked in the page flow, so a longer CV carries the closing band onto a
second page rather than losing anything, while the body row is atomic and a
body taller than a page raises AtomicNodeTooLargeException.

A link is drawn as its own label with the address behind it, the one
deliberate departure from the ported sheet: six of 121 nodes shift sideways
and 7,724 of 1,987,720 pixels change, with nothing moving vertically.

Guarded by a smoke test, an exact layout snapshot and a pixel-parity gate; the
examples showcase gains `cv-teal-pulse-v2`.
A rated skill could say how much — a number in [0, 1] a preset draws as dots
or a meter — or it could say it in words by not being a skill at all and
living in a RowsSection instead. It could not say both, which is why
CharcoalGold sets its languages as rows: that design writes "Native" and
"B2 – Upper Intermediate" out, and a number could not carry them back.

A design that shows a rating and names it in the same line had nowhere to go.
CvSkill now carries `note`, a plain string blank when absent, reachable
through CvSkill.of(name, level, note). The two channels stay separate because
a number cannot carry a wording and a wording cannot be measured into a meter;
a preset draws whichever it has room for, or both.

The two-argument constructor and both existing factories are kept explicitly,
so existing calls compile and link unchanged and every skill built through
them still carries no note.
* feat(templates): give CvSkill the level as the document words it

A rated skill could say how much — a number in [0, 1] a preset draws as dots
or a meter — or it could say it in words by not being a skill at all and
living in a RowsSection instead. It could not say both, which is why
CharcoalGold sets its languages as rows: that design writes "Native" and
"B2 – Upper Intermediate" out, and a number could not carry them back.

A design that shows a rating and names it in the same line had nowhere to go.
CvSkill now carries `note`, a plain string blank when absent, reachable
through CvSkill.of(name, level, note). The two channels stay separate because
a number cannot carry a wording and a wording cannot be measured into a meter;
a preset draws whichever it has room for, or both.

The two-argument constructor and both existing factories are kept explicitly,
so existing calls compile and link unchanged and every skill built through
them still carries no note.

* feat(templates): add the Slate Orange masthead-and-rail CV preset

A one-page sheet built as a full-bleed slate band over a two-column body: an
orange monogram tile beside the name, the role line and a tracked specialism
strip, with the contact lines across an orange hairline; then a narrow column
of marked competencies, trophied achievements, rated languages and closing
facts, beside a wide column carrying the profile, the roles held on a rail,
and a credentials footer.

It ports a published standalone template onto the existing CvDocument, and is
the first preset to use CvSkill.note: its language rows show a rating and name
it on the same line, which is the pair the model could not carry before.

The four fills that reach a paper edge — the slate band, the orange tile and
the two column dividers — are page backgrounds rather than section fills,
because a fill on a section is bounded by its content. Every horizontal pair
inside a body column is a table with fixed widths: a row cannot nest in a row
cell and both columns are cells, and where a cell needs several stacked lines
it holds a single-column table of its own.

Seven berths reach their sections by title, including a specialism berth whose
body is drawn and whose title is not. As on the presets before it a link is
drawn as its own label with the address behind it, the one deliberate
departure from the ported sheet at 2,100 of 2,173,720 pixels, and a role or a
degree becomes a link when its entry carries one.

Guarded by a smoke test, an exact layout snapshot and a pixel-parity gate; the
examples showcase gains `cv-slate-orange-v2`.
A one-page sheet with no page-level grid at all: a two-tone name beside the
contact list, three opening lines, a six-up grid of marked skills divided by
dotted rules, a strip of tools on inline discs, the roles held on a dated
timeline whose dates sit outside the rail, the projects behind tinted tiles,
education and languages side by side, and a tinted band closing on a
quotation.

Every split is local to one band, and none of the four knows about the
others — which is why every horizontal arrangement is either a top-level row
or a table and none of them nests: a row cannot sit inside a row's cell.

Three marks are relationships rather than lengths. A section rule starts where
its heading ends because it is a weighted column taking what an auto column
leaves; the timeline rail is the left border of every entry but the last, so
consecutive entries butt into one line that stops at the final marker; and a
project's hairline is its copy's left border, so it is exactly as tall as the
copy. None has to be re-measured when the content changes.

Unlike its ported siblings this sheet flows: each experience entry is held
together, so a longer CV runs onto a second page rather than cutting a role in
half.

As on the presets before it a link is drawn as its own label with the address
behind it, the one deliberate departure from the ported sheet, at 1,882 of
2,173,720 pixels.

Guarded by a smoke test, an exact layout snapshot and a pixel-parity gate; the
examples showcase gains `cv-violet-grid-v2`.
A one-page operations CV in three bands over a split body: a two-tone
name above a dark role bar whose right edge and three accent slashes
lean by one ratio, a contact strip on hairlines, then a narrow column of
dotted skills, achievement discs, a degree and certifications beside a
wide column of profile, dated roles, a four-metric strip and the closing
lines.

Ships as cv.presets.OrangeOps on the existing CvDocument model, so the
port needed no model change. It is the first preset whose display family
the engine does not carry: Oswald is not in graph-compose-fonts, so the
preset names it through OrangeOps.DISPLAY_FONT and the caller registers
it, with the gate and the example each supplying the two faces from
their own module's resources.

Eight berths reach their sections by title, four to a column, and a
berth nobody fills takes its heading, its accent rule and its join
hairline with it. A main-column heading's parenthetical is split off at
the title's first bracket and set smaller beside it.

Guarded by a 19-case smoke test, an exact layout snapshot and a
pixel-parity gate.

@github-advanced-security github-advanced-security AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CodeQL found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

* feat(templates): add the Midnight Navy plate CV preset

A one-page CV on a full-height navy plate: an outlined monogram over a
two-weight name and a tracked role line, then contact, education, metered
skills and dotted languages down the plate, beside a paper column with
the summary, roles on a rail, achievement discs and divided certification
columns.

Ships as cv.presets.MidnightNavy on the existing CvDocument model, so the
port needed no model change. The plate is a page background rather than a
section fill — it reaches three paper edges — sized by the same ratio the
body row splits on. The monogram and the role line are built from the
identity, so a document fills neither in twice.

One page strictly: the body is a single row and a row is atomic, so a
longer CV is refused with the node that could not fit rather than being
cut. Splitting the row would leave the plate on one page and half the
aside on the next.

Guarded by a 15-case smoke test, an exact layout snapshot and a
pixel-parity gate.

* fix(templates): centre the Midnight Navy rail markers on their rail

An accent is drawn centred on the edge it belongs to, so an entry
section's left edge already is the rail's axis: a marker reaches it by
walking back across the gutter and half its own width, and nothing else.
Correcting by half the rail's thickness on top of that pushed every
marker a rail width to the right of the line it sits on — 0.72 pt,
measured on the render as a marker centre at x=449.5 against a rail
centre at x=448.0 at 150 dpi.

Centred, the residual is half a raster pixel at any resolution (0.24 pt
at 150 dpi, 0.06 pt at 600), which is antialiasing rather than geometry.

The change costs 200 of 2 173 720 pixels against the ported sheet, and
both baselines are re-recorded with it.
A design that opens each service line with a glyph — a card for a
billing line, a shield for fraud screening, a globe for a hosted service
— had nowhere to say which one, and deriving it from the description
would have been guesswork dressed as a feature.

InvoiceServiceLines.Line now carries icon, a plain string blank when
absent, exactly as CvEntry.icon already works on the CV side: the token
means something only to the preset that packages it, and a preset that
draws no marks ignores it. It costs the layout nothing where nothing is
set.

Both constructors that predate it — the one before the per-line tax rate
and the one before the mark — are kept explicitly, so existing calls
compile and link unchanged and every line built through them still
carries no mark.
* feat(templates): add the Payments paginating invoice preset

A lavender-and-navy invoice: a diagonal band crossing the masthead, a
half-split issuer and metadata header, two addressed parties on discs, a
marked service-line table, a settlement row pairing bank details against
the totals, a note block and a two-cell document footer.

Ships as invoice.presets.PaymentsInvoice on the existing
StructuredInvoiceData model. It is the first preset in the family built
to flow: the table's header repeats on every page it reaches, a
continuation page reserves a deeper bottom margin than page one, and
every page carries its number.

The table is one column, not five. The design shows an outer box and a
rule between rows with no interior verticals, which is exactly what a
single-column table draws; the five columns are a row inside each cell.
The other construction would leave every continuation page ending in an
empty bordered strip, because a section's box fills its page fragment
rather than hugging its rows.

The lockup beside the title is the caller's — a logo drawn to the
design's measured height, or the brand's name as a wordmark. Figures are
written with the locale stated rather than inherited, because both the
grouping of a number and the symbol for a currency code change with it.

Guarded by a 15-case smoke test, an exact layout snapshot and a
pixel-parity gate.

* fix(examples): re-render the Payments preview from this branch's own build

The committed preview was rendered against jars in the local repository
that did not match this branch, so the catalogue's drift test failed on
CI while passing locally — and the eleven files that appeared to drift
locally were the same staleness seen from the other side.

Built from source, the local drift list narrows to exactly the file CI
named. Re-rendered, both lists are empty.
A supplier could already state a tax registration through
InvoiceContactBlock.taxRegistrationLabel / taxRegistrationNumber, and the
party being billed could not — but most B2B invoices print the customer's
VAT or tax number too, under its address.

subline was the only spare field and it is contractually the attention
line that sits ABOVE the address, so a registration put there renders in
the wrong place. InvoiceRecipient now carries registrationLabel and
registrationNumber, mirroring the pair the supplier block already has,
with hasRegistration() for the presets that draw the row only when there
is a number to draw.

Both are plain strings, blank when absent, and the six-argument
constructor is kept explicitly, so existing calls compile and link
unchanged.
A violet-accented sheet: a brand masthead over a short accent bar, a
half-split issuer and metadata header, two addressed parties on filled
discs, a service-line table whose marks sit on coloured tiles, a
settlement row pairing the bank details against the totals, and a closing
band carrying the wordmark.

Ships as invoice.presets.WorkspaceInvoice on the existing
StructuredInvoiceData model. Like PaymentsInvoice it flows: the table's
header repeats, a continuation page reserves a deeper bottom margin, and
every page carries its number. The design's own closing band cannot be
page chrome — a header footer zone takes only strings and the band holds
a wordmark and a link — so it is body content and the number is the only
chrome.

This design states its currency once per money column and writes the
figures under it bare, carrying the code only on the total, which is the
opposite of what PaymentsInvoice does; a quantity is written with what it
counts. Both parties print a registration under their address, and one
with no number prints no label.

Two of the design's type corrections are carried as measured constants:
half a pixel of screenshot antialiasing in every cap, and the 6% the body
face adds to an all-caps run at the same cap height.

Guarded by a 16-case smoke test, an exact layout snapshot and a
pixel-parity gate.
DemchaAV and others added 30 commits September 7, 2026 08:49
fix(templates): dial the number the sheet printed, not one like it
The rule, its seal and the build line were the last three nodes in the
flow, so they were drawn wherever the board happened to end rather than
against the bottom margin -- forty points above it on the sample week.
They are now a page zone, which is what they had always been describing.
The build line stays against the left margin, where the board's own first
column starts -- it is a credit, not part of the device above it.

A zone is measured from the foot of the sheet, not from the margin, so
the band has to reserve the margin as well as the furniture: at the
furniture's own height the build line printed within three millimetres of
the paper edge, inside the dead zone of most office printers, and this
board is meant to be printed.

What made the old foot look off centre was the artwork, not the seal. The
seal had always been within a five-thousandth of a point of the centre
line, because equal weights centre its slot whatever is drawn beside it.
The rules were the problem: pinned at 292pt inside slots of 382pt, and a
shape narrower than its slot is drawn at the slot's left edge, so the seal
carried 98 points of air on one side and 8 on the other while the right
rule stopped 90 points short of the margin. The rule width now comes off
the page -- printable width less the seal and its two gaps, halved -- so
both rules reach the seal and the right one ends on the margin. The rules
also sat 2.45pt below the middle of a 13.7pt seal; they now share its
centre line exactly.

The seal's letter rode high in its pill. A line box is tall enough for a
descender and a capital has none, so centring the box centres the empty
space under the letter along with it. The pill's padding now carries that
half descent, computed from the Times-Bold metrics rather than nudged, and
the ink sits within 0.07pt of the pill's centre.

The letter is no longer a hardcoded S. It is the board's own initial, read
as a code point so a brand opening outside the basic plane is not sealed
with half a character, and a blank brand is rejected rather than sealed
with nothing.

Being a zone rather than flow content, the foot is drawn on every page
instead of once at the end, and reserves its height on every page. The
sample week is far enough inside that reservation that its table does not
move; a rota long enough to run over now carries its foot on both pages.

Tests: the examples module verifies green, 77 tests, CommittedAssetDrift
included -- the committed preview under assets/readme is regenerated here,
because the foot of it moved. Every measurement above is read out of the
rendered file, not out of the source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(examples): foot the schedule board at the bottom, on the centre line
The receipt family was written against 2.2.0-SNAPSHOT and reached this
branch unchanged through the merge of feat/templates-payment-receipt, so
four things in it still described that release and that engine:

- its CHANGELOG entry landed, by context, under the released v2.2.0
  heading; it now opens the Templates section of v2.4.0 — Planned, the
  release that will carry it;
- `SvgGlyph.fromFile(Path)` said `@since 2.1.2`, a version that never
  shipped; it first ships in 2.4.0;
- `knowledge/api/templates.{json,md}` did not list the family, so the
  surface check would fail here. Regenerated from the reactor: 22 new
  types, every one of them in `templates.data.receipt` or
  `templates.receipt.*`, plus the `receiptModern()` theme factories and
  `SvgGlyph.fromFile`;
- `assets/readme/examples/receipt-modern.pdf` was rendered by the August
  engine, and `CommittedAssetDriftTest` named it as the one preview that
  no longer matches what its example renders. Re-rendered on this branch
  with the suite's pinned display version and render date.

Verified on this branch: templates 118 tests, qa 1315 tests (the five
receipt suites and the receipt pixel baseline included), examples 77
tests, the ten CI architecture and documentation guards, and all five
knowledge-pack checks.
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.
The receipt family shipped with a pixel gate alone. A 50k-pixel budget on a
Helvetica render cannot say where a box went: it absorbs a small shift and
reports the same number for a moved hairline as for a recoloured one, and the
receipt is built from measured text - a status chip sized to its label,
spaced-caps group titles, a hero amount, and the rail of the status trail.

ModernReceiptLayoutSnapshotTest freezes the resolved geometry of the canonical
receipt - 130 nodes on one page - and asserts the page count, so a pagination
change cannot pass quietly. The canonical spec moves out of the pixel test into
ReceiptFixtures, so both gates describe one document rather than two copies free
to drift apart.
The roles were a timeline built out of parts: a left accent per entry standing in
for the rail, and the marker positioned into a layer stack by walking back across
the gutter and half its own width. The class javadoc gave two reasons a timeline
could not hold them - no slot for a date on the far side of the rail, and a marker
that cannot centre on it. markerOnRail and leadingColumn have since made both
false, and this preset never needed the second: its date sits in the header row,
not beside the rail.

addTimeline carries it now. ENTRY_BOUNDS spans the entries own boxes, so
consecutive segments meet and nothing trails past the last marker - what the
per-entry accents did. The axis column centres the rail, so the timeline starts
half an axis to the left and the rail lands back on the column edge the heading
rule shares. The marker keeps its drop by declaring a box px(3.1) taller than the
disc: a declared box is what the timeline reserves and derives the rail from, so
the offset rides inside the marker instead of being positioned against the header.

Geometry is unchanged. 237 of the 240 nodes keep their name, kind, x, y, width,
height and page ownership; the three that go are the ExperienceEntry sections that
carried the accent. Content columns stay at x=230.429 w=323.053, markers at
x=212.639 y=634.019/464.368/321.488, one page, and the rail moves 0.0005pt - the
marker centre against the old section edge. The pixel gate passes against the
baseline it already had, so no PNG was re-approved.

The timeline is wrapped in a layer because a row nested in a row cell is refused
and both columns of this sheet are row cells - the wrapper every other horizontal
pair in this preset already goes through.
The row had two cells where the design has three. The comment said why: a marker
in its own column would sit in a different node from the rail, "leaving the rail
free to paint straight through it", so the date cell carried the marker width and
the body border stood in for the rail. A timeline resolves that - markerOnRail
runs the rail through the marker centre, so the marker owns a column and nothing
paints through it.

The leading column is the old date cell less ENTRY_INDENT, because a row spaces
every pair of columns: the gap is paid once before the axis and once after it, and
the second payment is the indent the body padding used to provide. The ring keeps
its node name by declaring its box rather than being drawn through
TimelineMarker.circle.

Geometry is unchanged. 230 of the 248 nodes keep name, kind, x, y, width, height
and page ownership; the 18 that go are the scaffolding - ExperienceEntry_NStack,
its Layer and RowNode, EntryBody_N, MarkerCap_N and RoleLayer_N. MarkerRing_N
stays at x=285.488 (rail 288.588, unmoved), Role_N and Employer_N at x=304.588,
Highlights_N at 304.588, Period_N at 214.01, one page, same page ownership. The
pixel gate passes against the baseline it already had, so no PNG was re-approved.
…saying so

The rail was the section left accent, and an accent runs the full height of what
its section holds - so the last role body was composed in a sibling section,
ExperienceTail, outside the rail, purely to keep the line from running on to the
foot of the block. The javadoc said as much. MARKER_TO_MARKER says it directly,
and the sibling section is gone.

The rail ends move, and that is the point of the change. Measured: the bottom end
327.937 -> 331.337 and the top end 613.487 -> 610.087, each shorter by 3.4 - one
marker radius - because the extent now runs between the marker anchor points
rather than between the disc edges the accent happened to reach. The discs are
opaque and the rail draws beneath them, so the retired segments were already
hidden: rendered against the committed baseline the page differs by two pixels,
both at x=213 (the rail) and at y=228 and y=513, which are the two ends and
nothing else. Well inside the gate budget, so the baseline is left as it is
rather than re-recorded on this host.

Everything else holds. 79 of the 87 nodes keep name, kind, x, y, width, height and
page ownership; markers stay at x=209.697 y=606.687/467.312/327.938, titles,
employers and highlights are untouched, one page, no node spans pages. The head
band and the dates move from the rail x to the content x (213.097 -> 232.097,
width 345.679 -> 326.679) because the marker has left the band for the axis
column; both keep the right edge 558.776, so the right-aligned dates render where
they did.

The engine contract this leans on is already pinned upstream by
TimelineRailGeometryTest - markerToMarkerRunsBetweenTheAnchorPointsItIsNamedFor
and markerToMarkerStopsAtTheMarkersAndEntryBoundsDoesNot - both green here.
…y saying so

The same construction Navy Sidebar had, verified here rather than assumed: the
rail was the section left accent, and an accent runs the full height of what its
section holds, so the last role body was composed in a sibling ExperienceTail
outside the rail to stop the line running on to the foot of the block. The javadoc
said so, and the loop rendered bodies and separators only while i < last.
MARKER_TO_MARKER says it directly; the sibling section is gone.

The rail ends move by this preset own marker radius, not by Navy Sidebar. The
marker is 6.395 across, so each end shortens by 3.1975: the bottom 381.894 ->
385.0915 and the top 638.644 -> 635.4465, because the extent runs between the
marker anchor points rather than the disc edges the accent reached. Rendered
against the committed baseline the page differs by two pixels, both at x=36 - the
rail - at pdf y 638 and y 382, which are the two ends, with a maximum channel
delta of 3. Measured with the budget and the per-pixel tolerance both at zero,
then restored; at the gate settings the difference is nothing at all, so the
baseline stands as recorded.

Everything else holds. 235 = 227 identical + 8 removed, 266 = 227 identical + 39
added (11 named, 28 unnamed timeline wrappers). Markers stay at x=33.077,
y=632.249/496.166/381.894; titles, employers, highlights and the entry separators
keep content x=60.748; one page, nothing spans. The head band and the dates move
from the rail x to the content x (36.275 -> 60.748, width 338.384 -> 313.91)
because the marker has left the band for the axis column; the right edge holds at
374.659 -> 374.658, a rounding step, so the right-aligned dates render where they
did.
A different workaround from the two before it: no tail section, but accentLeft on
every entry except the last, each entry padded ENTRY_GAP at the foot so the borders
abut into one line. The javadoc states the intent - the rail stops at the final
marker rather than running past the final bullet - and the entry list decides which
entry that is.

Measured, the old line did not stop at the marker. The accent spans an entry box
including its padding, so the three accented boxes ran from the top of entry 0,
533.54, to the bottom of entry 2, which is the top of entry 3 at 202.31 - the top
edge of the last marker box, not its centre at 198.95. It was neither
MARKER_TO_MARKER nor ENTRY_BOUNDS but the union of every entry box except the last,
which lands on a marker edge by construction.

MARKER_TO_MARKER is the stated intent, so that is what it says now, and both ends
move down by half a marker line box: the top 533.54 -> 530.18 and the bottom
202.31 -> 198.95, each 3.36 = MARKER_DIAMETER * LINE_FACTOR / 2. The rail keeps its
length, 331.23, rather than shortening; it is the same line drawn between anchors
instead of between box edges.

Geometry holds elsewhere. 71 = 66 identical + 5 removed, 109 = 66 identical + 43
added (6 named, 37 unnamed timeline wrappers). The four markers keep x=224.227 and
y=526.82/420.74/314.66/195.59, the role tables, employers and highlights keep
content x=240.027 and every width, so nothing rewraps; one page, no node spans one.
The removed five are the entries host and the four ExperienceEntry sections that
carried the accent and the padding.

Isolated against a pre-migration render of the same tree, this change moves 16
pixels: six in the rail column, the two ends and the two seams where abutting
per-entry borders used to meet and a single line no longer does, and ten elsewhere
at a colour delta of one, which is rasteriser rounding rather than movement - the
snapshot puts every content node at the same coordinate. The committed baseline
still passes its gate, so it is left as recorded.
These six PNGs are engine-merge baseline updates, not template migration output.
No template changed in this commit.

Merging develop moved rendering for part of the promoted catalogue while every
gate stayed green: a 50k mismatch budget is a gate, not evidence that a baseline
is current. Re-recording every baseline from the current engine and diffing
against the committed file showed 114 of 126 byte-identical and 12 adrift. The
same sweep at the pre-merge commit c587a17 re-recorded all but one of those
twelve byte-identical, which is what proves the drift here is the merge and not
the host: a rasteriser or font difference would have moved most of the 126.

Normalized, with the drift each carried against the merged engine:

  consulting_invoice   5463 px, maxDelta 219
  obsidian_invoice     4280 px, maxDelta 212
  violet_grid          1563 px, maxDelta 148
  orange_ops           1124 px, maxDelta 161
  workspace_invoice     316 px, maxDelta 207
  modern_receipt          2 px, maxDelta   2

violet_grid and workspace_invoice also moved their layout snapshots at merge
time, from the fix that stops a row child horizontal margin being taken off
twice. The other four moved with no snapshot change at all, so their cause sits
below node level - fragments, borders and decorations, which a layout snapshot
records nothing about.

Deliberately not here. slate_orange, charcoal_gold, midnight_navy, navy_sidebar
and serif_headline are adrift too, but their renders already carry timeline
migration output and a PNG cannot be split between the two causes; they get a
separate refresh once the migration campaign ends, attributed to the per-preset
evidence already recorded. engineering_resume is adrift by 306 px, maxDelta 99 -
the same figure it carried at c587a17, so it predates this integration
entirely and is left for its own investigation.

Exactly six files changed, the other 120 baselines verified byte-identical by
checksum before and after; no layout snapshot moved; the six presets gates pass.
…lines

Two rails in one sheet, migrated as two independent timelines: the roles on a grey
hairline and the degrees on a terracotta one. Two timelines are two rail owners
and never merge, so each keeps its own stroke and its own extent.

Both were built the same way and measured the same way. An accent on every entry
but the last, each entry padded at the foot, so the borders abutted into a line
that ended where the last entry began - the top edge of the last ring rather than
the anchor the design means. The javadoc said the line stops at the last marker;
MARKER_TO_MARKER says it in the API.

Experience: the rail ran 384.197 to 621.417 and now runs 381.097 to 618.317.
Education: 74.391 to 105.476, now 71.291 to 102.376. Each end moves down 3.1,
half a ring, and each rail keeps its length - the same line drawn between anchors
instead of between box edges. Markers unmoved: x=202.245 at y 615.217, 490.872
and 377.997 for the roles, 99.276 and 68.191 for the degrees.

Education needed a layer of its own, and that was verified rather than assumed.
EducationBandStack wraps the band row, one level above the cell the entries live
in, so it insulates nothing placed inside that cell: with the layer removed the
compiler refuses the timeline row at
EducationBandStack/EducationBandLayer/EducationBand/EducationEntries/
EducationRailHolder, naming the nested horizontal row. The layer restored, it
renders.

154 = 147 identical + 7 removed; 205 = 147 identical + 58 added (11 named, 47
unnamed timeline wrappers). Every content node keeps its coordinate; the two host
sections widen by 0.01 from the half-ring margin arithmetic. One page, nothing
spans one. Against the current baseline the page moves 3 pixels, all in the rail
column at x=205 and all at rail ends - pdf y 384, 105 and 74 - at a maximum
channel delta of 2; the experience rail top shows nothing because the retired
segment is under its ring. Measured with budget and tolerance at zero, then
restored, so the baseline stands as recorded.

railedLine loses the marker it positioned and becomes entryLine, which is what it
does now: wrap a table in the layer a row cell cannot hold it without. The
contact-axis smoke test grew a null guard - a timeline contributes nodes the
engine names nothing, and the filter called startsWith on that name.
…ed anything

The rail was the block left accent, and the first entry filled its own head band
with the sidebar colour, painted a wider shape over the rail edge above its dot,
and redrew the rail below it. The javadoc said why: so the line would begin at the
first marker rather than at the top of the block.

Measured against the render, it never did. Scanning the rail column of the
committed baseline: background down to pdf y 192, then rail at 191 and 190 - the
stretch the mask covers - then the dot at 189..186, then rail continuously to the
foot at 110. An accent draws above the container fill and above the mask shape, so
the masked stretch stayed visible and the only thing the construction achieved was
drawing 2.5pt of rail twice below the dot, a shade darker than the rest.

So the old rail is ENTRY_BOUNDS, top of the entries to their foot, and that is
what it says now. The mask, the band fill and the redraw are gone: 56 lines out,
and renderEducationHead drops from positioning four nodes to positioning one.

What the design asked for - begin at the first marker, still run to the foot of
the entries - no extent can say today. MARKER_TO_MARKER would start at the first
anchor but also stop at the last, cutting 26.7pt of rail the block draws on
purpose. Recorded as an API gap rather than approximated again.

Geometry: 158 = 153 identical + 5 removed, 178 = 153 identical + 25 added (6
named, 19 unnamed timeline wrappers). Markers unmoved at x=18.45, y 184.934 and
142.594 - the dot is centred by declaring the marker box as the head band and
carrying half of it above the dot, because a timeline top-aligns where the
container used to centre. Degrees, institutions and dates untouched; the head
bands move to the content column, x 20.6 -> 33.6 and width 104.656 -> 91.656,
keeping the right edge at 125.256. One page, nothing spans one.

Six pixels change, at x=20 and 21 and pdf y 183..185: the double-drawn stretch
returns to the rail tone the rest of the line has, (219,222,225) -> (226,228,231),
and the column beside it to plain background. Nothing at 190 or 191, which is the
proof that ENTRY_BOUNDS reproduces the accent and the mask was inert. Measured
with budget and tolerance at zero, then restored; maximum channel delta 7, inside
the gate, so the baseline stands.

The known degree-title wrapping defect is untouched and stays a follow-up.
The preset is drawn for a single sheet and documents what happens when a
CV outgrows it: the sheet runs onto a second page rather than losing
anything, and each role is held together so none is cut in half. The
canonical fixture fits one page, so nothing exercised either half of that
and nothing would have noticed it breaking.

overflowCv() is the canonical document with six earlier roles appended
and no other change. Six rather than three on purpose: three leaves the
roles on the first page and moves only the sections after them, which
never breaks the rail; six carries the list itself over the boundary,
which is the case worth pinning. The entries host then spans both pages
and its box as a whole reads bot=-104.201, a coordinate belonging to
neither.

A role is identified by the parts that carry its index - its dates, its
disc, its title line, its bullets - and never by the box that holds them.
The promise is about the role; naming the container would pin one way of
drawing it, and the test would need rewriting the moment that changed,
which is exactly when it is meant to be watching.

Three tests. The sheet grows a page and every part of a role lands on one
page; the sections after the roles follow them over; and all nine roles
hold the same three columns on both pages - dates at 29.905 flush with
the page margin, disc centres at 105.796, copy at 125.826 by 439.535.
The last is the one a second page can break quietly, so it samples every
role rather than one per column.

Measured, not assumed: a 1pt shift in MARKER_OFFSET turns the column test
red with "the disc's centre is the rail".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The highlight lists asked for their air by writing the marker "•  " and
letting the second space stand in for a gap, with a comment saying the
marker was the only place that gap could be authored. Two things were
wrong with that. A list marker's authored trailing whitespace is stripped
and a single space re-appended, so the second space has never reached the
page: measured off the render, the gap between the dot's advance and the
text has always been 2.067pt, one space, not two. And the gap has a
surface of its own now, so the marker is no longer the only place to put
it.

The dot and the gap are now separate, the gap being 0.256 em of the body
face - that face's real space advance, which is 0.049pt wider than the
0.25 em rounding SPACE_ADVANCE_EM keeps for the tracking spacer. With a
real gap the list can carry a real hanging indent, and that is the part a
reader sees: a highlight that wraps used to resume 1.518pt PAST its own
first line, because the legacy indent pads the continuation back to the
marker column in whole spaces and cannot land on anything finer.

Every dot still sits at x=60.748 and every first line still starts at
x=67.498, both to 0.001. The two continuations on the canonical sheet
move 69.016 -> 67.498. The list boxes and the separators below them are
identical, so the sheet is still one page and nothing downstream moved.
1541 pixels change, maxDelta 185, confined to two row bands; the PNG
baseline is refreshed with the rest of the campaign's.

Neither parity gate can see any of this - a list's items are not layout
nodes, so the snapshot records the box and nothing in it, and 1.5pt on
two lines is far inside the pixel budget. So the contract is read off the
glyph positions instead: one marker column, one text column, and wrapped
lines on the text one. Sabotaged against the previous implementation, it
reports that no continuation has anything of its own in the marker
column - which is precisely the padding it replaces.
…d a page

The module body lines asked for a dash marker with bulletOffset("-") and
have never drawn one. A paragraph's bullet prefix is emitted only where
the indent strategy asks for it - the first line under indentFirstLine,
the wrapped ones under indentWrappedLines - and this site sets no
strategy, so it takes the default, which asks for neither. The dash was
built, measured for the continuation indent, and then applied to no line.

So there is nothing here to move onto the list API: the design on the
page is plain lines, and the call was only claiming otherwise. It goes,
and a comment says why, so the next reader does not restore it as a
missing marker.

Proven rather than assumed to change nothing: the shared visual parity
gate was rerun for this preset with the budget and the per-pixel
tolerance both set to 0, before and after, and timeline_minimal matched
its committed baseline exactly on both runs. (engineering_resume fails at
0/0 either way, by the 306 pixels already recorded against it; it is
untouched here.) Its snapshot, pagination, text-layer and fidelity gates
are unchanged.

Restoring the dash would be a different change - a visual one, needing a
new baseline and a decision that the marker is wanted - and is recorded
as a question rather than made here.
… words

Six places built their spaced caps by rewriting the string with a space
between every pair of letters. It drew the right page and stored the
wrong file: extracted, the canonical receipt read "N O R T H W I N D P A
Y", "A M O U N T C O L L E C T E D", "T R A N S F E R D E T A I L S", "N
O T E S". A receipt is a record - it gets searched, copied out of, read
aloud by a screen reader, and parsed by whatever the recipient files it
with - and none of that survives a name spelled out letter by letter.

Tracking is typography, so it moves to the style. ReceiptStyles.eyebrow
and .groupTitle carry SPACED_CAPS, which is what their own javadoc has
always claimed they were ("small spaced-caps caption", "spaced-caps
heading"), and the five callers now pass the words. The masthead keeps
its conditional: with no mark the issuer's name IS the wordmark and takes
the caps, beside a mark it stays as written, so the tracking is applied
there rather than in the shared style.

Geometry, from the snapshot: one page, 130 nodes, and 9 of them changed -
every one a placementWidth, none an x, y, height or page. The labels
narrow 5-9% (166.474 -> 152.530 at the widest) because 0.18 em of
tracking is less than the whole space glyph the transform used, which is
the trade SPACED_CAPS was chosen for: the same look at close to the old
width rather than a per-gap match that would have pushed every heading
wider. Nothing re-flowed and nothing moved.

4059 pixels of 500395 change, maxDelta 242, so the PNG is re-recorded -
one baseline, scoped to this test, and all 126 hashed before and after to
show nothing else moved.

The text layer is what this is for and no pixel or geometry gate can see
it, so it gets its own assertion: the labels as words, and no letter-
padded copy of one. Run against the previous implementation it fails and
prints the old text layer, which is the clearest statement of the defect
available.
…ing it

The masthead label was set by rewriting the string with a space between
every letter, so the page read PROPOSAL and the file stored "P R O P O S
A L". ProposalBrand's own javadoc calls the field "the document label,
set in tracked capitals" - tracking is what it wanted, and there is a
surface for it now.

DOC_LABEL carries SPACED_CAPS and the widget passes the word. It is the
style's only caller, so the tracking belongs there rather than at the
call site.

The label is right-aligned in its own section, so its paragraph node
takes the section's width and does not move when the glyphs inside it
do: the layout snapshot is byte-identical, and the change is only
visible in pixels and on the text layer. 316 pixels change on each page,
maxDelta 219, the label being on both mastheads; both PNGs are
re-recorded and all 126 baselines hashed before and after to show
nothing else moved.

The text layer gets the assertion, because it is the reason for the
change and no other gate can see it: the label as a word, and no
letter-padded copy of it.
The last of the padded spaced-caps labels. Same defect as its sibling's:
the masthead label was built by rewriting the string with a space between
every letter, so the page read PROPOSAL and the file stored "P R O P O S
A L".

DOC_LABEL carries SPACED_CAPS - it is the style's only caller - and the
widget passes the word.

Like Editorial, the label is right-aligned in its own section, so its
paragraph node takes the section width and the layout snapshot does not
move: 351 pixels change on each page, maxDelta 254, and both PNGs are
re-recorded with all 126 baselines hashed either side to show nothing
else did. The text-layer assertion goes beside the table-text one that
was already there for the same reason - the snapshot cannot see inside
these cells either.

No call site of TextOrnaments.spacedUpper is left in the templates
module. The method stays, deprecated, for callers compiled against 2.3.0.
Each of these presets had its rail rebuilt on the timeline API during this
integration, and each migration left its PNG as it stood because the
change was inside the gate's budget. Budget is a gate, not a statement
that a baseline is current, so the whole suite was re-measured with the
budget and the per-pixel tolerance both at zero: 118 of the 126 baselines
are exact, and 8 are not.

Seven of those eight are this campaign's own output and are re-recorded
here, every figure at the baselines' own 72 dpi:

  navy_sidebar          2 px, maxDelta 27
  midnight_navy         2 px, maxDelta  8
  terracotta_rail       3 px, maxDelta  2
  professional_sidebar  6 px, maxDelta  7
  charcoal_gold       208 px, maxDelta 18
  serif_headline      498 px, maxDelta 165
  slate_orange      12485 px, maxDelta 209

Three reconcile exactly with what their migration commit measured and
recorded at the time - navy_sidebar's two pixels, terracotta_rail's three
in the rail column, professional_sidebar's six at x=20 and 21.
serif_headline carries its rail change plus the bullet gap becoming a
measurement. charcoal_gold and midnight_navy carry their rail change plus
the row-margin correction the develop merge brought, which the earlier
baseline audit traced to f75def6 and deferred to this commit rather than
folding into the merge normalization.

slate_orange is the one worth naming: of its 12485 pixels only 16 are the
migration's - six in the rail column, the ends and the seams - and 12469
were already adrift before the campaign touched it, measured by stashing
the preset and re-probing. Its refresh therefore absorbs pre-existing
drift as well as migration output, which is why it is called out rather
than listed.

engineering_resume is the eighth and is deliberately NOT re-recorded: its
306 pixels at maxDelta 99 are nothing this branch did, and it stays its
own follow-up. It lives in the shared parity test, so leaving that class
out of the approval run is what keeps it out.

126 baselines hashed before and after: exactly these seven files changed.
Re-measured at zero afterwards, engineering_resume is the only one left
that differs at all.
The seven rails moved onto the timeline API, Serif Headline's bullet gap
becoming a measurement, the Timeline Minimal marker that never drew, and
the receipt and both proposals giving up padded spaced caps - with the
figures each change was measured at, and with Violet Grid's two API gaps
stated as the reason it keeps the rail it has.

The Public API section already said no built-in preset calls
spacedUpper; that was true of the CV and cover-letter families and not
of these three. It is true now.
The examples module resolves the engine from the local repository and is
excluded from the reactor gate, so a template change moves these
committed bytes without anything noticing. Eight of them render presets
this work touched.

A regenerated PDF always differs byte for byte - fresh timestamps and a
fresh document id - so each was compared as pixels against a fresh render
instead. Of the 115 committed previews with a generated counterpart, 18
differ. These eight are the ones whose presets this campaign changed:

  cv-professional-sidebar-v2      2 px
  cv-navy-sidebar-v2              4 px
  cv-charcoal-gold-v2            20 px
  proposal-editorial-v2         438 px
  proposal-northline-v2         600 px
  cv-serif-headline-v2          771 px
  receipt-modern               2624 px
  cv-slate-orange-v2          10445 px

Read out of the content stream rather than out of an extractor, the
receipt preview now stores "AMOUNT COLLECTED" and "AMOUNT BREAKDOWN"
where it stored "A M O U N T   C O L L E C T E D" and "A M O U N T   B R
E A K D O W N". Worth naming the instrument: both PDFBox and MuPDF
synthesise spaces from the gaps they measure, so tracked caps still
extract with spaces between the letters through either of them - what
changed is the string the file holds, which is what a viewer copies and a
parser reads.

cv-midnight-navy-v2 and cv-timeline-minimal-v2 are left alone: their
renders moved by two and three pixels at a maximum channel delta of 8 and
2, which is below anything a preview exists to show.

The other ten that differ are not this campaign's. None of their presets
appears among the files these commits touched - arabic-article,
cv-orange-ops-v2, cv-violet-grid-v2, engine-deck, engine-deck-v2,
hebrew-invoice, invoice-consulting-v2, invoice-workspace-v2,
linkedin-carousel, maven-banner - so they are left as they are rather
than folded in here.
The previous commit refreshed eight previews and left these two on the
grounds that their renders moved by two and three pixels, which is below
anything a preview exists to show. That was the wrong instrument.
CommittedAssetDriftTest compares an exact digest with only the machine-
written parts dropped, and says so in as many words: a budget is a number
nobody can defend a year later. Two pixels is drift.

It also renders with graphcompose.examples.displayVersion pinned to the
version the committed files were rendered at, which a plain exec:java
run does not. These two are copied from that run, so they carry 2.3.0
like their neighbours rather than the working version.

The guard now names four files, and none of them is this campaign's:
cv-orange-ops-v2, cv-violet-grid-v2, invoice-consulting-v2 and
invoice-workspace-v2. All four presets differ from develop and none was
touched by any commit in this work, so their drift arrived with earlier
template-promotion work. They are deliberately left alone: what moved
those renders is not identified, and re-recording a preview is how you
stop being able to find out.
…t on

A timeline crossing a page drew a rail below its last entry, down to the
bottom margin: 55.25pt of it on Violet Grid's overflow sheet, where the
drawing stops at the last role. The rail was not the bug.

A box records its top where the flow stands and places its children
afterwards. The only thing it checked first was whether its own
margin.top + padding.top would fit, which for a bare wrapper is zero and
therefore always fits. So when the first thing inside it then had to move
- it asked to be kept whole, or it is indivisible and taller than the
space left - the box stayed behind spanning a page it holds nothing on,
and every consumer of that box believed it. A timeline's entry anchor
reported a slice on the page its entry skipped, and the rail, which is
the union of those slices, followed it to the band.

That is general, and it is visible without a timeline anywhere near it: a
bordered section with no keepTogether of its own, holding one child that
has it, paints 24.6pt of accent at the foot of the page beside no content
at all. A section carrying keepTogether itself escapes it, which is why
this had never surfaced.

So the box relocates too. This is the keep-together rule already in
compileComposite, asked of the node's leading unit instead of the whole
node - leadingUnitHeight was already there for keep-with-next runs - with
the same fallback: a unit that would not fit a fresh page either has
nothing to gain from breaking, so it flows where it stands. prepare is
memoized on (node, width), so asking costs map lookups and not a second
measurement pass.

It moves no content. The content had already moved: measured either side
on the oversized keep-together case, all five shapes sit at the same y on
pages 1 and 2 and the document is three pages long. What changes is that
the section stops claiming page 0, so SectionKeepTogetherTest now expects
startPage 1 - its old expectation, and the comment that the section
"starts in the remaining space on page 0", described a box holding
nothing there.

Three layout snapshots move, four nodes between them, every one a box
whose recorded top was on a page its content never reached:
northline_proposal Header y 6.539 -> 775.890, timeline_paginated's two
layer nodes y -10.350 -> 117.175, available_fonts FontSection_Ubuntu y
-43.554 -> 736.466, each startPage 0 -> 1. Page counts and node counts
are identical and nothing else in any of them moved. Two of those were
negative coordinates - pages those boxes could not be on.

On Violet Grid's overflow sheet the page-0 rail now ends at 65.394, which
is the last role's foot and the same number the hand-built rail stops at,
to 0.000.

Guarded by BoxExtentAcrossPagesTest - the box relocates, the child is
where it always was, and a box whose content genuinely spans still keeps
a slice on every page - and by a timeline case for the rail. The existing
multi-page rail test could not have caught this: its entry is splittable,
so it really does occupy the foot of the page. The new one measures
against the page's content band, which is the only reference that tells
an entry-derived extent from a band-derived one. Sabotaged: both go red,
and the rail test's first assertion passes even then, because the anchors
lie consistently.
A three-column timeline could not be asked for what it looks like. A row
spaces every pair of its columns equally, so with one gap the leading
width was not the author's to choose: writing x0 for where the columns
start, L for the leading width and A for the axis, one gap s puts the
rail at x0 + L + s + A/2 and the content at x0 + L + 2s + A, and
subtracting them leaves L = (rail - x0) - (s + A/2). Whatever A and s are
given, L is decided by where the rail and the content sit.

A dated timeline states all three - where the dates are, where the rail
is, where the copy starts - and could have any two of them. Measured on a
real one the date column came out 55.860pt against a longest date of
57.005 and broke "2022 - Present" over two lines. Neither cheaper fix
works, and both were ruled out by the same arithmetic rather than by
trying them: padding the leading column only narrows what goes in it, and
widening the axis re-pins L to the same number.

leadingGap(double) is the one degree of freedom that was missing. The
three columns become [leading][leadingGap][axis][markerGap][content] with
the row spacing nothing, so no row API changed and each gap is a column
of its own. It also leaves markerGap meaning what its javadoc has always
said it means - "the horizontal gap between the marker and the entry
title" - rather than a number silently applied on both sides of the
marker.

Unset, leadingGap is markerGap, and that is the single-gap layout exactly:
fixed columns take their width either way and the weighted remainder is
the same subtraction in a different order. Measured rather than asserted -
on charcoal_gold, the only three-column timeline in the repository, 234
named nodes before and 234 after and NOT ONE of them moved. What does
change is the node tree: two empty gap sections per entry, 266 nodes to
272 there and 55 to 63 in the leading-column case, so both snapshots are
re-recorded for structure with zero geometry behind it. Three tests that
asserted a column count of three now say five and keep what they were
each pinning - columns aligned across entries, the leading column present
even when an entry puts nothing in it, the content-bearing columns
distinct and in order.

leadingColumn, axisWidth, markerOnRail and rail are all @SInCE 2.4.0 and
unreleased, so the three-column layout had no released behaviour to
preserve. markerGap, gutter and markerColumnWeight predate them and the
two-column layout is untouched by construction - it still shares one gap,
and asking for a leading gap without a leading column changes nothing.

Five tests in TimelineColumnModelTest: the single-gap formulae pinned as
the compatibility case, two leading widths proven to give the same rail
and content, the wrap that a too-narrow leading column causes and a wide
one does not, the two-column row, and a weighted leading column still
resolving against the row. Sabotaged by pinning leadingGap to markerGap:
exactly the two capability tests go red and the three compatibility ones
stay green.
The one preset held back from the timeline campaign, because two things
it needs did not exist. Both do now, so it goes on the rail like its
siblings and gives up the three constructions that stood in for them.

The rail was a left accent border repeated on every entry and withheld
from the last, with the host indented so each entry's left edge landed on
it. The dates rode the title line's layer stack at DATE_OFFSET, a
negative x of minus the rail indent and the entry indent together, and
the disc rode the same stack at its own negative offset. What all of that
was describing is three columns - dates at the page margin, discs on the
rail, copy inside it - and a timeline states those directly.

The dates' column is the rail indent less half a disc less the air after
the dates, which comes to 61.93pt. That way round on purpose: the longest
date this design sets measures 57.01, and a column named directly would
be a number picked to clear one string. The air is the design decision
and the column follows from it, in the preset's own drawing units.

Geometry on the canonical sheet, from the snapshot: one page, and of 132
named nodes exactly ONE moved - ExperienceEntries, whose width goes
535.456 -> 535.466, the 0.01 of table slack the old indented host
absorbed. It draws nothing. Every other named node is identical: the
dates at x=29.905 and 57.005 wide on ONE line, the disc centred at
105.796, the copy at 125.826 by 439.535, and every section below them.
Six named nodes go - three EntryTitle layer stacks and three
ExperienceEntry accent sections - and three marker wrappers arrive.

On the overflow sheet the acceptance test written before any of this
passes unchanged: two pages, the same role on the same page as before,
every role still whole, the same three columns on both. The rail ends at
65.394 on page 0, which is the last role's foot and the number the
hand-built rail stopped at, and page 1 opens at 828.348 as it did.
ProjectsHeading still sits at 628.396 on page 2, so nothing downstream
re-flowed.

What a reader sees change is the rail's two outer ends, 5.652pt each -
half-leading 1.843 plus the disc's radius 3.809. It now stops on the disc
centres rather than at the top edge of the line boxes they sit in, which
is what this preset's javadoc always said it did. 72 pixels of 500395,
maxDelta 36; the PNG is re-recorded and all 126 baselines hashed either
side to show nothing else moved.

No LayerStack, no DATE_OFFSET, no negative margin, no rail running to the
bottom margin, no role split and no role on a different page.
A rail had one value naming both of its ends, and the design that wanted
them different could not be asked for. Professional Sidebar's education
block draws its line from the first dot to the foot of the entries.
ENTRY_BOUNDS gave it the foot and a 4.650pt stub above the first dot -
half a marker - and MARKER_TO_MARKER gave it the dot and cut 26.690pt of
tail the block draws on purpose. So the preset painted the stub over with
the page colour and redrew the line below it, and measured against its
own baseline that never worked: an accent draws above both the fill and
the mask, so the masked stretch stayed visible and the only thing the
construction achieved was drawing 2.5pt of rail twice.

TimelineRailEnd is ENTRY_BOUND or MARKER and rail(r -> r.from(..).to(..))
takes one at each end, so all four readings exist and the two mixed ones
are the point. TimelineRailExtent is gone rather than kept beside it: it
is @SInCE 2.4.0 and unreleased, so there is nothing to be compatible
with, and carrying a second way to say two of the four would be carrying
it for no one. TIMELINE_BOUNDS went with it - defined, not implemented,
threw when asked for, and on one page indistinguishable from the entries'
bound. With the ends separate there is nothing left for it to mean.

Each end trims only its own page. The start trims the page the first
marker is on, the end trims the page the last is on, and a page holding
neither is bounded by its own entries whatever was asked for - which is
what lets the ends be independent across a break rather than only on one
page. Trimming is min at the top and max at the foot, so an end on a
marker can only shorten the line and never invent rail outside the
entries.

Professional Sidebar now says from(MARKER).to(ENTRY_BOUND) and gets the
line it is drawn as: the rail starts at 187.084, which is the first
marker's centre to 0.000, and ends at 118.054, which is the last entry's
foot to 0.000. Four pixels change, maxDelta 21 - the stub - and its PNG
is re-recorded with all 126 baselines hashed either side.

The five other presets and every existing test say the same thing in the
new words and are unchanged: 45 timeline tests green through the reshape,
because the two symmetric readings are the two the old constants named.
Two new tests - the four combinations proven to be four distinct lines,
each sharing exactly one end with the symmetric pair, and the per-end
trimming across a page break. Sabotaged by tying the end to the start:
exactly those two go red and the twenty symmetric ones stay green.

japicmp is green on both baselines. Removing a public enum is invisible
to it here because the enum was never in a published one.
A list item was a string in one style. A design that emphasises the
opening words of each bullet had to be built as a two-column row per
item, and that stops being a list: the marker becomes a table cell, the
wrapped lines are the column's business rather than the item's, and
nothing about it paginates as one thing.

ListBuilder.addItem(Consumer<RichText>) and its nested overload take the
same RichText that ParagraphBuilder.rich takes, so a chip, an icon, a
link or a coloured span inside an item is whatever it already is inside a
paragraph. ListItem and ListItemSpec carry the runs beside the label, and
prepareMarkerContentList builds the item's paragraph from them — the
marker column, markerGap and contentX are the ones a plain item gets,
shared rather than reimplemented. An item is content whatever its runs
draw, so a row of an icon and no text stays a row; it reads as the empty
string exactly as an item that draws nothing does, and the runs are what
separate the two.

hangingIndent(true) is required, and a list without it says so and names
the call: the older layout makes the marker part of the item's text and
carries one style for the whole item, so rendering the plain reading
would drop every style, icon and chip silently.

Runs do not fit a list of labels, so one rich item makes the list carry
an item tree — a change of representation, which must not become a
change of markers. A dashed flat list still dashes at its top level; a
list whose author declared depth still resolves every level from the
per-depth cascade and markerFor(...), the contract that is there today.

The semantic DOCX export carries this one, unlike the geometry around
it. Word holds a style per run inside a paragraph, so a rich item writes
one Word run per authored run with the marker leading in the list's own
style, and image, shape and SVG runs drop with the same one-per-kind
warning a rich paragraph's do. Laying nothing out, it needs no opt-in.

Which kinds of run read as text is now stated once, in
InlineRun.plainText and InlineRun.textRuns; ParagraphNode.text() and
inlineTextRuns() come from those, so a new run variant has one place to
be taught rather than one per surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

2 participants