feat(templates): promote the finished template bundles into the library - #633
Merged
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.
…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>
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.