feat(templates): promote the finished template bundles into the library - #633
Open
DemchaAV wants to merge 82 commits into
Open
feat(templates): promote the finished template bundles into the library#633DemchaAV wants to merge 82 commits into
DemchaAV wants to merge 82 commits into
Conversation
…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.
…e on the structured invoice (#622)
…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.
There was a problem hiding this comment.
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.
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
…into feature/template-promotion
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>
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
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:
ProfessionalSidebar,NavySidebar,SerifHeadline,CharcoalGold,TerracottaRail,TealPulse,SlateOrange,VioletGrid,OrangeOpsClassicInvoice,ConsultingInvoice,LumaStudioInvoiceNorthlineProposal,EditorialProposalTwo 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), andCvSkill.note(#628). Every constructor thatpredates 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
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).
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.
under
assets/readme/examples/../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 -amOne thing a reader should know
OrangeOpsis the first preset whose display family the engine does not carry —Oswald is not in
graph-compose-fonts. It names the family throughOrangeOps.DISPLAY_FONTand the caller registers it; the qa gate and the exampleeach supply the faces from their own module's resources, with the SIL OFL text beside
them. No published artifact gains a font.