Skip to content

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

Merged
DemchaAV merged 93 commits into
developfrom
feature/template-promotion
Sep 12, 2026
Merged

feat(templates): promote the finished template bundles into the library#633
DemchaAV merged 93 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 28 commits September 12, 2026 11:05
…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>
A marker's one span was built in the list's own text style, so a design
with an accent mark beside near-black copy could not be written as a
list. What the designs that wanted it did instead was a paragraph per
item — the mark, then a run of spaces standing in for the gap, then the
text — which buys the mark and loses the column. Measured on that
construction at a width that wraps: the first line's text starts at
25.246 and every line after it at 12.000, because a paragraph has no
marker column to hang under. The spaces cost too; rounded to a whole
count and then measured at each item's own type size, one declared
9.175pt gap came out as 9.308pt in one column of a real CV and 8.356pt
in another.

ListBuilder.marker(Consumer<RichText>) takes the marker as inline runs —
the same runs a paragraph and a list item are made of — so a disc, an
icon, or a glyph in a colour of its own all go through one mechanism.
marker(String) is unchanged.

A drawn marker is measured as what it draws: a disc's column is its
diameter, an icon's is its box. It is measured once per distinct marker,
through the pipeline that measures the items' own content, which is why
no renderer needed a new branch — the marker's pieces are ordinary
paragraph spans, and whatever draws a run inside a paragraph draws it
inside a marker column. markerGap is then points of real space and
contentX is markerX + width + gap exactly.

Everything the marker column already promised holds: one x for every
visual line of an item, the marker drawn once when an item continues
onto later pages, a child's marker starting where its parent's text
does, and the marker riding the item's first baseline without making the
row taller. One drawn larger than that line overflows it, the answer a
marker wider than its column already gets.

hangingIndent(true) is therefore required, and the layout says so when
it is missing: a drawn marker's plain reading is empty, so the older
marker-in-text layout would render the list unmarked with no signal.

No lambda overload of markerFor(int, ListMarker): null clears a
per-depth override there, and a second overload would make that call
ambiguous for code that already compiles. A drawn marker at depth goes
through ListMarker.ofRuns.

In the semantic DOCX export a text marker keeps the colour and face it
was given, because a run's style is something Word holds; one that draws
a disc or an icon has no Word analogue, so it drops with the export's
usual per-kind warning and its item is written unmarked rather than
taking a glyph nobody asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Its rail now runs from the first marker to the last entry's bound, which
is what the sheet's design draws; the committed preview still showed the
approximation. The examples module is outside the reactor gate, so the
render and the preview move independently and this one was left behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TealPulseStyles.DOT_GAP declares the distance from the teal dot to its
label as px(17) = 9.175pt. The competencies, the experience highlights
and the certifications were a paragraph per label — the dot, then a run
of spaces standing in for that gap, then the text — and spaces cannot
deliver it: the count is rounded to a whole number of them and then
measured at each label's own type size, so one constant rendered as
9.308pt beside the competencies, 8.356pt beside the highlights and
8.806pt in the closing band. Wrapped labels cost more: with no marker
column to hang under, every line after the first returned to the label's
own left edge.

Each block is now one list whose marker is the dot and whose markerGap
is DOT_GAP, so the gap is that number in all three places and every line
of a wrapped label starts where its first line's text does.

Measured: 7 named nodes move and every one is a placementWidth — the
sidebar 0.133pt narrower, the three highlight blocks 0.819pt wider, the
certifications column 0.369pt wider, each exactly its own gap
correction. No x, no y, no page ownership, still one page; these are
content-sized boxes, so the correction is all that moves them. 22 883
pixels of 457 555 change at zero tolerance, maxDelta 192, inside the
50 000 the gate already allowed. The baseline and the committed preview
are re-recorded, and all 126 baselines were hashed either side to prove
only Teal Pulse's moved.

The rows are one list node per block rather than one paragraph node per
label, so the snapshot has 96 named nodes where it had 121 and the
per-label names go with the paragraphs that carried them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each name was a paragraph opened by an accent dot, because a list marker
used to take the list's own text colour and these dots are accent
against charcoal copy. The gap between dot and name was four literal
spaces — a measurement of the face, not a distance the sheet states.

The dot is now the list's marker and the gap is SKILL_BULLET_GAP, the
nearest round value in the sheet's own unit; the constant records that it
was recovered from those spaces rather than declared by the design, so
the next reader does not take it for a measured intent.

Measured: one named node moves, by placementWidth +0.067pt — the
difference between the four spaces and the declared gap — with no x, no
y, no page ownership, still one page. 4 784 pixels of 500 395 change at
zero tolerance, maxDelta 15, under the tolerance the gate already
allowed for most of them; the baseline is re-recorded anyway and all 126
were hashed either side to prove only this one moved.

Eleven Skill* paragraph nodes become one SkillNames list node, so the
snapshot has 103 named nodes where it had 113.

The committed preview is left alone: cv-orange-ops-v2.pdf is one of the
four whose drift predates this work and has not been explained yet, and
re-rendering it is how that explanation would be lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…below it

Professional Sidebar's degree title sat in a container of exactly
ENTRY_HEAD_HEIGHT, centred in it, which put its centre on the rail dot's.
That works for a title of one line and for no other. Measured with a real
degree — "MSc Advanced Computer Science and Software Engineering" — the
title needs three lines in a sidebar that narrow and the box stayed 9.3pt
tall: under OVERFLOW_VISIBLE the extra lines drew rather than vanished,
putting the title's foot 16.840pt below its own institution line, across
that line and the dates under it.

A declared box that stays its declared size is the engine doing as it was
told, so this is fixed where the box was declared. The title is the
entry's own paragraph now, with the band's surplus over one line as air
above and below it — EDUCATION_DEGREE_AIR, declared as air rather than
left implicit in a height, so a title needing two lines gets two lines
with the same air around them.

Nothing about the canonical sheet moves. The paragraph occupies the same
9.3pt, its centre stays on the dot's, and it wraps at the same width,
because the container's declared width and the timeline's content column
resolve to the same expression. Zero named nodes move in the layout
snapshot; the two EducationHead_* wrappers are gone with the containers,
and the pixel baseline and the committed preview are untouched.

The regression test states the invariant as the geometry rather than as a
pixel: a degree that wraps must not reach past its own institution line,
and a taller first entry must push the second one down.

Also dropped two sentences of stale commentary above the block: they said
starting the rail at the first marker was not expressible, which it now
is, and named an extent constant that no longer exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every themed CV preset draws its rows through one shared path, and that
path put the bullet inside the paragraph's text as a bulletOffset and
indented what followed with a run of spaces measured to clear it.
Decoration.stackedIndent — the two spaces under a stacked row's bold
name — carries its own contract: it "must visually occupy the same width
as bulletGlyph". Measured on the pixel gate's canonical document it does
not, by 0.720pt to 3.078pt depending on the theme's type size, so the
body of every Projects-style row began slightly to the left of the name
it hangs under.

Each row is a list now. A bulleted row is one item, so its marker sits in
a measured column and every visual line starts at one content origin. A
stacked row is one item with a markerless child: a child's marker column
opens at its parent's content origin and a child with no marker takes no
marker width and no gap, so the body begins exactly where the name's text
begins — by construction rather than by a count of spaces. The content is
inline runs, so the bold label of a "label: body" row stays part of one
item. The gap after a bullet is a quarter of the type size, which is what
the trailing space inside bulletGlyph was approximating.

Per preset, on that same document — page count, the row text origin, the
stacked body against its name, and the pixel delta at zero tolerance:

  blue_banner          2->2  38.437->38.391  own path        7422 maxD 12
  boxed_sections       2->2  38.003->38.063  36.180->38.338 26085 maxD 201
  centered_headline    2->2  35.273->35.221  own path        8709 maxD 12
  executive            2->2  35.819->35.885  32.864->35.885 29553 maxD 202
  minimal_underlined   2->2  42.003->42.063  40.180->42.338 26300 maxD 202
  modern_professional  2->2  36.280->36.000  35.560->36.000 32165 maxD 98
  panel                1->1  33.858->33.802 313.451->316.440 29990 maxD 201

Blue Banner and Centered Headline render stacked rows their own way and
are untouched by that column. The row text origin moves by at most
0.280pt, the difference between a quarter of the type size and the space
it replaces. Every multi-line count and every page count is identical
before and after; the one structural change is an extra fragment per row,
which is the marker's own.

The stacked pair states its own item spacing because the name and the
body used to be two children of the host section with the host's spacing
between them, and one list is one child. Without it the rows closed up by
that gap and content moved a page — caught by an entry body that jumped
from page 1 to page 0 on two presets.

Seven pixel baselines and seven committed previews are re-recorded, with
all 126 baselines and all 118 previews hashed either side: exactly seven
of each moved. engineering_resume's baseline differs from its render by
306 pixels for a reason that predates this work — verified by measuring
it against the unmodified code — so it is left as it was, as are the four
preview drifts under separate investigation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…used

ParagraphPrimitive.write took a bullet glyph and applied it as
bulletOffset under DocumentTextIndent.ALL_LINES. Bulleted rows are lists
now, so the only callers left — writeBody and writeSubtitle — both passed
null and the branch was reachable only that way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every other assertion about a drawn marker measures where it is. A
drawn marker contributes no glyphs, so no text-layer or span assertion
can tell a painted disc from a described one: the layout would carry
the shape span either way.

This renders the page and counts ink, against the same list with no
marker. Sabotage-proven — with the emit phase ignoring the marker's
measured spans it goes red, while every geometry assertion beside it
stays green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…aseline

Provenance, measured rather than assumed. CommittedAssetDriftTest is
GREEN at c587a17, the pre-merge tip of this branch: every committed
preview matched a fresh render there, these four included. Immediately
after the develop merge, with no template work done yet, seven previews
were already drifted — the four here plus Charcoal Gold, Slate Orange
and the receipt, which later migrations happened to re-render. So the
drift is not older than this integration: the merge caused it, and the
examples module sits outside the reactor gate, so nothing failed.

What moved each one, measured against its own pre-merge render:

  invoice-consulting-v2   5358 px, maxD 213, bbox (62,326)-(184,469)
      The service-lines table: each line's title and description sit a
      point higher. Those are composed table cells, so this is develop's
      composed-cell anchor work — and a composed cell emits fragments but
      no PlacedNode, which is exactly why its layout snapshot did not
      move and no gate saw it.

  invoice-workspace-v2     339 px, maxD 158, bbox (509,808)-(567,817)
      A right-aligned footer element moves left by about 4pt: develop's
      fix for a row child's horizontal margin being taken off twice. Its
      layout snapshot WAS refreshed during merge resolution; only the
      preview was missed.

  cv-violet-grid-v2       1168 px, maxD 150, bbox (263,377)-(566,735)
      Same row-child margin fix in the body column; its snapshot was
      refreshed then too. The dated-rail migration moved it again later.

  cv-orange-ops-v2        1091 px, maxD 159, bbox (489,271)-(572,565)
      Same fix, in the right-hand column. The skills-list migration moved
      it again later.

Each is an upstream correction, so the current rendering is the right one
and these are re-rendered rather than reverted.

engineering_resume-page-0.png is a different case and a genuinely older
one: it differs from its render by 306 px, maxD 99, in a 60x7 region at
the top right — and by exactly that at c587a17 as well. The phone number
is drawn underlined and in the accent colour, like the email under it,
because it became a tel: link in 61f7ea6 and 77283a5, both ancestors of
c587a17. The baseline was never refreshed and 306 px sits inside the
50 000 budget, so no gate noticed. The link is the intent, so the
baseline is brought up to it.

All 118 previews and all 126 pixel baselines were hashed either side:
exactly four previews and exactly one baseline moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven presets build their timeline in a holder and lay it in through a
layer stack, and none of them said why. The reason is that a timeline
publishes a row, a row cannot nest inside a row cell, and these blocks
can sit in one — the stack is what insulates it.

Two sites already explained themselves: Terracotta Rail's education cell
and Slate Orange's role line. The other seven now do too, so a reader
deciding whether a stack is load-bearing does not have to work it out
from the layout rules.

The stacks stay. Removing the ones that turn out not to need it is
cleanup with no capability behind it, and it would reshape every timeline
snapshot, so it belongs in its own deliberate change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@DemchaAV
DemchaAV merged commit ef52c1d into develop Sep 12, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the feature/template-promotion branch September 12, 2026 22:28
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