Text Foundation¶
What's built
Actually built and tested, on feature/neo-1253-retail-studio-text-engine — the only
pre-existing code this whole brief treats as a trusted foundation:
interpreter/solve/ and interpreter/animate/ modules (interfaces designed for the full
model, implementing absolute + text only); canvas-based measurement extended in
text-metrics.ts (word advances, cap-height, self-computed greedy line-breaking); the
wrap-then-shrink solver in rich-text.tsx; the schema reshape's text-run half (content item
union, text-run only admitted); typography unit unification ({ ofRoot }) + resolver;
pattern-size migration script; editorModeSchema rename (the schema declaration — the apps/web consumer sweep is Positioning's); validate.ts reserved-mode gating.
The background-padding-paint-only fix (below) is also built. Two real pieces not yet
done: point/box text consolidation (below, genuinely new work, not started anywhere), and the
layout-shape correction that belongs to Positioning, not this feature (the
tested worktree ships a nested {mode, box} shape; the decided shape is flat-sibling).
This is the foundation everything else in this brief is built on: canvas-measured text, self-computed
line-breaking, and the render-time pipeline's absolute + text implementation. See
Foundations for the shared pipeline this feature was the first to
implement.
Content-item text runs¶
textLayerPropsSchema.content stays an array; each item is either a text run
({ value, typography? }, today's shape, discriminated by the value key) or a number run
(discriminated by the number key), which covers prices, percentages, quantities and ratings
through one formatter (see Price). Text-run only admitted until the number run
ships. The discount-lockup's suffix is authored as a plain inline text run within the hero's own
content array (see Positioning → pin),
not a separate pinned layer — no new content-item shape needed for it.
Runs on one line share a baseline: a smaller run sits on the same baseline as its neighbours unless
its typography shifts it (baseline: n, a fraction of that run's own font size, positive up).
patterns (regex → typography, today's shipped mechanism) run after every run has resolved,
including price runs after formatting, over the concatenation of the line's final text — so
\*$ finds a trailing asterisk whether it was typed or emitted by a formatter. A layer's
typography case applies to text runs and to a price run's literal parts (a currency-code fallback,
a free-text unit), never to digits. A glyph a pattern lifts or enlarges still counts toward the
layer's content bounds: the pill hugs what is actually painted.
A \n in a run's value is a hard break in every shape. A "content"-width layer never wraps, but
it does break where told, and its width is then its widest line.
Typography units, unified under a non-colliding name¶
typographyStyleSchema.size (and comparable values) accept an absolute number or a relative
{ ofRoot: number }, resolved to absolute by the typography resolver below. This replaces the
split where run size was absolute and pattern size multiplied.
ofRoot is a multiplier, not an offset
resolved = rootValue * ofRoot, never rootValue + ofRoot. An ofRoot of 1 means
"identical to the root value" (rootValue * 1), not "root value plus one." An ofRoot of
0.5 halves it; 2 doubles it. Worth stating explicitly since the name alone doesn't rule out
the additive reading — a producer or importer that assumes addition will silently emit the
wrong size everywhere except where the two readings happen to coincide by accident.
Not { rel }
That shape is already the schema's reserved spelling for an additive offset from a layer's
resting value (animatableValueSchema); reusing it here for a multiplicative
relative-to-root value would collide on the one token producers and importers would read side
by side. { ofRoot } is free.
Typography resolver¶
A pure function in a new, dedicated module in packages/retail-studio-schema
(Node-safe, no canvas) — not resolve.ts, which is entirely about
applyBindings (writing bound campaign values into a document), an unrelated
concern with no existing typography-resolution logic to extend (checked before
building new, per architecture-standards / Reuse Before Build — the existing
file just isn't the right home). Resolves the
ancestor chain base per-ratio typography → content-item (run/price) → part/role → pattern, where each
{ ofRoot: x } multiplies the nearest already-resolved ancestor's value, emitting absolute
numbers. Documented resolution order so nesting is unambiguous.
Point/box text consolidation¶
Today's boxTextLayerSchema and pointTextLayerSchema are two hardcoded layer types, confirmed
byte-identical except their type literal (packages/retail-studio-schema/src/zod.ts in the
tested worktree) — same commonLayerFields, same perRatioTypographySchema, same
textLayerPropsSchema.
Decided
The uniform w/h sizing rule (number | "content", see
Coordinate System & Sizing) collapses these into
one text layer type, not two. Today's two fixed points become two points in this space
(with a third the space admits for free), not two hardcoded schemas.
w |
h |
Behaviour | Today's equivalent | |
|---|---|---|---|---|
| Point text | "content" |
"content" |
Hugs ink. Never wraps, never shrinks. | pointTextLayerSchema |
| Auto-height box | explicit number | "content" |
Wraps to width (if multiline), grows to whatever height that produces. |
New — nothing today expresses it |
| Fixed box | explicit number | explicit number | Wraps to width (if multiline), then shrinks to fit height — the wrap-then-shrink solver. |
boxTextLayerSchema + FitText |
fit: what happens when the text doesn't fit its box¶
A layer whose w is a number has a width its text can exceed, so it needs a stated answer. That
answer is fit, on the text layer, valid only when w is a number and meaningless (and rejected)
when it is "content":
fit |
Behaviour |
|---|---|
wrap |
Break lines at the width. The default for a multiline layer. |
shrink |
Scale the text down until it fits. On a fixed box this is the wrap-then-shrink solver, wrapping first and scaling only if the result still overflows the height. On an auto-height box there is no height to overflow, so it scales to fit the width on one line — which is the single-line shrink-to-width every price box does today, and which nothing else in this vocabulary expresses. |
clip |
Paint only what falls inside the box. |
show |
Let it overflow the box and paint anyway. |
Named fit, not overflow
overflow is already taken, by containers: a flowBox and a Scene both carry
overflow: show \| hide for content extending past their bounds
(Positioning). That is a different question from
what a text layer does with a line too long for its own width, and giving both the same name
would mean the word meant one thing on a container and another on a layer inside it. The
shipped enum this replaces (PR #1295 / NEO-1932) was named overflow before containers had
one; renaming it now costs a sweep of one field name and removes the collision permanently.
Behaviour falls out of the w/h mode × a multiline flag, not which layer type was authored.
Worth being precise about what 'images too' actually means
The layout.mode reshape itself (Positioning), the coordinate-unit change
(real px, dropping the 0-1000 grid) and the anchor grid (both
Coordinate System & Sizing) are layer-agnostic schema fields — they already apply to every layer type, images included, since none of them
live on textLayerPropsSchema. An image layer can already be pinned, flowed, or positioned in
real px the same as any other layer once Positioning ships; nothing about this delivery
excludes images from that. What genuinely isn't built this round is narrower: the renderer
logic to interpret h: "content" as aspect-ratio-preserving auto-height specifically for an
image component (w: 150, h: "content", a genuinely different intent from
fit: cover/contain/fill, complementing rather than replacing it) is new, per-layer-type
behaviour — only text's interpretation of "content" sizing (the point/box collapse above) is
being built here. Images gain the schema capability to express h: "content" as part of this
reshape, but not the renderer behaviour to act on it yet — a confirmation the sizing rule
generalises rather than being text-specific, not a claim that images are untouched by this
delivery.
Why 'extents' is deliberately not a third sizing value
Worth getting right the first time rather than fielding the question twice: extents is never
independently variable from content — it's always exactly content plus a fixed, declared,
paint-only constant (padding/stroke/shadow spread) — so w: "extents" could never produce a
box w: "content" doesn't already produce once its own paint additions expand outward
normally. Offering it as a sizing mode would be a redundant spelling of "content", not a
materially different box.
Concretely, what this means for the real, tested code, not just the schema:
PointTextLayer.tsx/BoxTextLayer.tsxand their respective content components (PointTextBox/BoxTextContent, both referenced fromrich-text.tsx) collapse toward one component branching on resolvedw/hmode, not two components selected bylayer.type. Exact merge shape is implementation's call, not fixed here.- The tested worktree's real wrap-then-shrink solver,
resolveWrappedLinesFittingHeight(rich-text.tsx), only ever applies to the fixed-box shape (explicitwandh) — point text never wraps, an auto-height box wraps but has no height to shrink into, and neither needs this solver. BoxTextContenttoday re-runs wrap-then-shrink every frame against the layer's animated box, which is why the builtsolveScenetakes no measurer. Under v2w/hdon't animate (the motion graph), so wrapping happens once insolveagainst the resting box and animatedscaleresamples the placed result — one measurement per layer per campaign, not one per frame.BoxTextContent's realbox: {width, height}prop (needed today forresolveWrappedLinesFittingHeight) needs reconciling against whatever the unified component's props end up being — not a drop-in, since a "content"-sized box doesn't have a fixedwidth/heightto hand the solver.- PR #1295 / NEO-1932's
overflowenum (formerly PR #1282's booleanscaleToFit) becomesfitabove, meaningful wheneverwis a number rather than only in the fixed-box shape. Its actual diff needs re-threading through the tested worktree's already-reworked fit calculation (padding already removed from the fit-scale denominators, raw-ink-gap fields already added, effect deps already onJSON.stringify(innerStyle)), not merged as-is.
Cap-height, for the discount-lockup alignment¶
Decided: vertical font metrics come from the font file's own tables, read once per font.
The engine needs four vertical metrics per font that canvas either rounds or can't give at all:
typographic ascent and descent (to place a baseline inside a line box — canvas's
fontBoundingBox* is what Chrome rounds to whole pixels, the one confirmed cross-engine gap, see
Foundations), and cap-height and x-height (for the capHeight
anchor tier and the lockup — canvas has no such metric, so the built code approximates it by
measuring a reference glyph). All four live in two small fixed-layout tables inside every
TrueType/OpenType file, hhea and OS/2, and reading them is a few dozen lines against a
DataView, not a library dependency: text-metrics.ts gains a table reader, cached per font file
(the same (family, weight) key the rest of its cache uses). Font files are already fetched as
bytes at render time (brand-fonts-for-render.ts), so nothing new is loaded.
Reference-glyph canvas measurement stays as the fallback, not the primary: sCapHeight/
sxHeight only exist in OS/2 version 2 and later, and a user-uploaded brand font is an arbitrary
S3 upload that may carry an older or malformed table. The reader returns what it can prove; the
measurer fills the rest. That fallback is why this doesn't reopen the parity question — a font
that falls back is measured identically in Firefox and Chromium, just less precisely.
Why a reader rather than staying on the reference glyph
Two approximations were about to coexist: a rounded fontBoundingBox for ascent/descent and
an ink-measured glyph for cap-height. One table read replaces both, and it's the shape the
whole engine is built on — we own the layout maths and canvas only rasterises, so inheriting
canvas's rounding or a glyph's content-dependent ink box for metrics the font already states
exactly is the kind of dependency this pipeline exists to remove.
Pattern-size migration (explicit deliverable)¶
The typography-unit unification inverts what pattern size currently means (a multiplier) into an
absolute value — confirmed to silently shrink real pattern-styled runs to sub-pixel size on 10
sites across two shipped templates (betta-red-hot-sale, betta-eofy). The rewrite of existing
pattern size values into the new typography-unit shape is a step of upgradeV1ToV2
(Coordinate System & Sizing), applied to a
document only as it moves to schemaVersion: 2 — a schemaVersion: 1 document keeps its
multiplier semantics because the frozen legacy pipeline still reads them that way. Verified against
those two templates' still-render diff, which keeps this feature's "no visual regression" done-when
literally true.
Background padding is paint-only, never a layout input¶
Built and tested
A text layer's background padding must never influence the text's own position, contribute to
its box, or trigger a resize/rescale — increasing padding grows the visual fill/pill around the
text without moving, shrinking, or otherwise affecting any box that positioning,
group-membership, spanning-background-union derivation, or wrap-before-shrink fit logic reads.
Stated in bounds terms (Coordinate System & Sizing):
padding grows extents outward and never touches content. A layer with w: 309 and
padding: 20 has a content width of 309 and an extents width of 349, and its text wraps at
309. The authored number is always the content box, on a "content" axis and a numeric one
alike, so there is no per-axis rule to remember and no case where changing padding re-wraps a
line. A designer who measured the painted pill at 309 authors w: 269 with padding: 20; a
Builder can show both numbers so the subtraction isn't done by hand.
Applies to a flowBox's background (Positioning)
the same way, and to Background's spanning layer type, which insets its own
derived box by its own padding. Built separately from the rest of this feature's remaining work
because the confirmed-consumer list has grown to span two rendering paths (the default render
path and the motion-blur capture path) and a required apps/web fix.
Safe to enforce, not just a hoped-for rule: text's own fit-to-box guarantee doesn't depend on
padding at all (FitText already scales text down to fit its box based on ink alone) — so removing
padding's influence on layout can't make text overflow its box; the box just stops growing to match
the padding, and the visual pill bleeds outward from it instead.
Two distinct fix shapes are needed, by which text primitive a consumer is built on:
FitText/BoxTextContent-based consumers already have independent scale-to-fit safety, so relaxing their clipping once padding stops growing the box is safe outright.PointTextBox-based consumers have no scale-to-fit at all — their existing clip is the only thing stopping an oversized string from spilling past its box, so text safety has to be kept while the padded pill itself is decoupled onto a separate, unclipped visual layer. Confirmed consumers needing this shape:betta-discount-box,freshmart-price-box(see Price for its own currency logic),freshmart-save-box,betta-price-box.
Builder selection-ring fix (apps/web, the one required cross-cutting change — see
Surfaces touched): the Builder's selection ring
currently measures the padded box, so once padding stops growing it, the ring shrinks on the
edge-aligned axis and no longer matches the visible pill. The ring code is shared by both
pipelines, so the fix is schemaVersion-aware: a schemaVersion: 1 document's padding still
grows its box and its ring must keep measuring that. (A separate, pre-existing centred-axis
mismatch in the same ring is out of scope for this fix.)
Accepted by design
An edge-anchored, heavily-padded layer near the canvas edge can bleed its pill past the anchor point, or off-canvas, once padding no longer grows the box. Not a blocker, no clamp — handled case-by-case (author reduces padding or repositions) if it occurs.
Frontend impact¶
- Rewritten:
rich-text.tsx(run resolution + placement),FitText.tsx(wrap-before-shrink; padding no longer folded into fit-scale — see above),text-metrics.ts(word advances,hhea/OS/2font-table metrics with reference-glyph fallback, line breaking). - The three shapes: point text (both axes
"content") hugs ink, no wrap, no fit; an auto-height box wraps to width and grows; a fixed box wraps to width first, scales down only if still overflowing height.
Alternatives considered¶
| Alternative | Why rejected |
|---|---|
| Full font-shaping engine (fontkit/harfbuzz in JS) | Maximum determinism but reimplements shaping/kerning/wrapping the browser already does perfectly |
| Delegate line wrapping to the browser (self-compute only vertical/place) | Wrapping can differ on edge cases (hyphenation, whitespace); rejected in favour of self-computed greedy line-breaking, revisit only if it proves overkill for retail copy |
Risks¶
Not hypothetical: raster-text-as-a-fallback is real and already partially shipped
NEO-1612 is a real, confirmed bug: a point-text layer with a continuous scale animation causes
headless, software-GL Chromium (the actual render environment — never Studio/Player preview,
which already looks correct) to re-hint live text's glyph outlines at a new fractional effective
size on every independently-captured frame, which reads as a visible wobble, worse the smaller
the font. The shipped fix, interpreter/layers/point-text-scale-raster.tsx + text-raster.ts
(identical on main and this plan's own worktree — untouched by this feature, so both
pipelines already share it unchanged), rasterizes the text once at its resting typography
into a bitmap (a DOM-clone → SVG-foreignObject → canvas capture, not a measureText-based
mechanism) and lets the already-existing ancestor CSS scale() transform resample that bitmap
for every later frame, sidestepping the font engine's per-frame re-hinting entirely. Scoped
narrowly, not a wholesale move to raster text: point-text layers with a continuous scale
animation, render-only. An analogous capture already exists for motion-blur trails
(point-text-motion-blur-capture.tsx/box-text-motion-blur-capture.tsx, which this component
was adapted from — that pair has a small, real diff between main and this plan's worktree,
unlike the two raster files above). This doesn't invalidate the parity-first approach: the
raster capture isn't a second measurement mechanism competing with measureText — layout and
measurement still come from canvas measureText and the solver, and place still emits the
positioned spans; rasterizing snapshots those already-placed spans, strictly after layout, as
a per-frame stability fix for one confirmed failure mode. Worth checking once they exist whether the same continuous-scale wobble
affects flow/pin-composed layers (Positioning) or the
Keyframe Animation feature's own animations, since continuous scale
isn't unique to today's fixed archetypes.
- This is a large foundational rewrite of
rich-text/FitText/text-metricsand could regress working text — mitigated in practice by the still-render diff (the parity harness itself was never confirmed to run against this feature's built core, see Open questions below), and narrowed since much of the run/pattern typography resolution logic already exists and is extended, not rebuilt from scratch. - Canvas
measureTextcross-engine agreement is confirmed to have one real gap (fontBoundingBox*rounding, see Foundations), not merely assumed; the font-table reader above is the fix, and canvasfontBoundingBox*is only ever a fallback for a font whose tables can't be read. - A font whose
OS/2table predates version 2 falls back to reference-glyph cap-height, an approximation — acceptable because it's deterministic and cross-engine consistent, just less exact than the table value it stands in for.
Open questions¶
- Whether every padding-fix-touched file besides
rich-text.tsx/FitText.tsx/text-metrics.tsneeds forking or not (see Schema-Version Gating) — the ~10-file list this feature's padding fix names hasn't each been checked againstmainthe same way those three were (all three turned out to need real forking once checked, so "some may turn out trivial" should not be assumed going in). - Re-threading PR #1295 / NEO-1932's
overflowenum (formerly a booleanscaleToFit) through the reworkedFitText.tsxand the new point/box-collapsed component isn't designed yet, only flagged as necessary. The PR's own diff can't be merged as-is; what the re-threaded version actually looks like against the unified component is real, unscoped work. - A cross-engine parity harness confirming the Firefox/Chromium claim has not been confirmed to
run, and the core already shipped without it. What actually verified parity was unit tests plus
a narrower still-render regression check. The repo also has no browser test runner today
(
render-stills.tsdrives Remotion's Chromium-only renderer). Whether that gap is acceptable, or a real Playwright-driven Chromium/Firefox/WebKit harness still needs to run retroactively against the real built behaviour, is unresolved, not assumed fine. FitText.tsx's padding-removal and raw-ink-gap logic has zero dedicated test coverage in the tested worktree — closing this belongs with the point/box consolidation's own component rewrite, not carried forward as an accepted gap once that rewrite lands.
Done when¶
- The text core is done when: a multiline box wraps then shrinks; the same document renders
layout-identically in Firefox and Chromium; the point/box collapse above and the layout-shape
correction in Positioning are built and tested.
betta-red-hot-saleandbetta-eofyspecifically, the two templates the pattern-size migration is confirmed to affect, render via the new pipeline (i.e. migrated toschemaVersion: 2as part of this delivery, a mandatory exception to migration otherwise being optional and manual) with no visual regression against their current, legacy-pipeline rendering. This is the one pair of documents this delivery migrates itself — every other existing template stays onschemaVersion: 1, unmigrated. - The padding fix is done when: an invariant test confirms the solved box is byte-identical
at
padding: 0andpadding: N(anyN) for every text layer, across both fix shapes above; a mechanical sweep of every paint-clipping mechanism (overflow:hidden,clipPath,mask*,contain:paint) on the full ancestor chain between a layer's root and its text — not only files directly referencing a named text primitive — runs clean; the Builder's selection ring matches the visible pill on the edge-aligned axis.