Skip to content

feat(text): add bold and color rich text tags - #729

Merged
stormmuller merged 6 commits into
devfrom
claude/design-rich-text-tags
Oct 6, 2026
Merged

stormmuller merged 6 commits into
devfrom
claude/design-rich-text-tags

Conversation

@stormmuller

Copy link
Copy Markdown
Member

Summary

Implements design/rich-text-tags.md (backlog 5.7 of design/ui-system.md), both phases.

TextEcsComponent.text and shapeText now understand <b>...</b> and <color=#rrggbb>...</color> (#rgb, #rgba and #rrggbbaa also work).

  • Parsing. New public parseRichText(markup) returns the plain string plus flat, sorted, non-overlapping colorRuns/boldRuns over its UTF-16 indices (DL-1, option b). Tags nest. When colors nest, the innermost wins. <b> and <color> are independent, so </b> closes the innermost open <b> even if a <color> was opened inside it.
  • Shaping. shapeText parses the tags itself and then tokenizes, kerns and wraps the stripped string as before. shapeWord tracks each character's source index so it can stamp the style onto each glyph. A tagged string lays out exactly like its untagged equivalent.
  • GlyphQuad has two new fields:
    • color?: Color: undefined falls back to TextEcsComponent.color, so recoloring untagged text still needs no reshape.
    • embolden: number: the faux-bold edge shift in distance-field units, 0 for regular glyphs.
  • <b> = synthetic bold (§7 option b). This is the same approach as FreeType's FT_GlyphSlot_Embolden, browsers' fake bold and TextMeshPro's SDF dilate.
    • Each glyph's edge is pushed out by FAUX_BOLD_EMBOLDEN (0.02 em) per side, and its advance grows by 2 × 0.02 em so bold letters don't collide and wrap width accounts for them.
    • The shift is converted to distance-field units once per shapeText call, from the atlas's pixels per em.
    • The quad doesn't grow, because plane bounds already include distanceRange / 2 pixels of padding.
  • Rendering.
    • New 1-float textEmboldenInstanceDataSegment (InstanceComponents.textEmbolden).
    • The fill pass now uses a new msdf-fill.vert: sprite.vert plus the embolden passthrough.
    • msdf-fill.frag and msdf-effects.frag both clamp the shift to the same atlas budget, so they agree on where the bold edge is.
    • Outline and shadow are measured from the bold edge, so they wrap the bold ink, and they get the budget the bold shift leaves over.
    • Bold and regular glyphs of one atlas still batch into one draw call.
  • Docs. New "Rich text tags" section in rendering-text.md and a budget note in text-effects.md.
  • Demo. The text demo gets a rich-text section, and the playground's default text includes tags.
  • Changelog. Entry added under [Unreleased].
  • Design docs. design/ui-system.md row 5.7 is marked landed and its non-goal line is reworded. design/rich-text-tags.md is deleted, since everything in it is implemented.

Changes from the design, given the current code

  • shapeText parses tags itself. Task 1.2 had callers pass a pre-parsed string plus runs. Six docs demos call shapeText directly to size guide boxes, so parsing inside means every measurement matches what's drawn, and no caller has to repeat the parse step.
  • Malformed markup renders literally instead of throwing. See open question 3 below.
  • Merged with fix(text): center 'middle' text on its cap height and measure capHeight without padding #721 (cap-height 'middle'). That required one conflict fix in the demo blurb. The new e2e scene anchors by 'baseline', so it doesn't depend on 'middle' semantics.

Decisions taken from the design's open questions

  1. What does <b> mean? Option (b), faux bold from the existing atlas, which is the doc's recommendation. There's no new asset type and no change to the generator. The amount is a fixed engine constant, not an option, the same as browser and FreeType synthetic bold.
  2. Escapes for literal </>/&. The doc's §8.2 proposal, the simpler rule: no escape syntax. A < that doesn't start a complete <name>, <name=value> or </name> is literal (HP < 50%, a<3). The exact text <b> can't be shown. This is documented in the guide. The solution reviewer recommended adding an escape (<noparse> or &lt;) plus an escapeRichText helper for untrusted strings such as player names. I didn't add it, because the doc proposes no escaping for v1. It's a small follow-up if wanted.
  3. Malformed tags: throw, or degrade gracefully? The doc's proposal (an unknown tag throws, an unclosed tag runs to the end) contradicts its own Q2 proposal, where a < not followed by a recognized tag name is literal. It also contradicts shapeText's existing rule that bad content "is a content problem, not a programming error". Throwing would also stop the game from a system that runs every frame on player or translated text. The solution reviewer flagged this as well. Chosen: an unknown tag (<i>), an invalid value (<color=red>, <b=1>) or a closing tag with nothing of its name open is drawn as literal text. An unclosed tag runs to the end of the string, as proposed.
  4. Does <color> carry alpha? As proposed, the run's color replaces RGB and alpha. #rgba and #rrggbbaa set alpha, and #rgb/#rrggbb are opaque. opacityMultiplier still applies on top.

Solution reviewer verdict

REVISE, on four points:

  • Throw → literal. Done.
  • Embolden computed once per atlas, not per glyph. Done.
  • Same clamp in both shader passes. Done.
  • Add an escape syntax. Not done. The design proposes none, so it's noted above as a follow-up.

It also recommended keeping the design doc. I deleted it anyway, because the coordinating task asks for implemented designs to be removed (as in #720). Otherwise it confirmed the approach: parsing inside shapeText, GlyphQuad.color optional, one writer per value, no configurable queries, and faux bold matching TextMeshPro and FreeType.

Related issue(s)

Backlog 5.7 in design/ui-system.md.

Verification checklist

  • npm run check-types passes with 0 errors (also check-types:e2e)
  • npm test passes (1993 tests; new: parse-rich-text.test.ts, text-embolden-instance-data-segment.test.ts, rich-text cases in shape-text.test.ts and glyph-quad.test.ts)
  • npm run lint passes with 0 errors
  • npm run cspell passes with 0 errors
  • npm run check-exports passes
  • Any new/changed public API is exported from the module's index.ts
  • Documentation under /documentation-site/docs/docs is updated
  • Text demo updated and verified:
    • npm run build at the root, then typecheck and build in documentation-site
    • Loaded in Chromium: tags render, the outline wraps bold ink, and there are no page errors
    • Pre-existing, not changed here: the demo already lays out more than its 600 visible world units, so the playground and effects sections sit below the canvas's visible area.
  • E2E: new rich-text-tags.spec.ts checks rendered pixels with relative, same-run measurements:
    • A <color> glyph renders green between red ones, at the same pixel positions as the untagged text.
    • A <b> glyph's ink is wider by 2 × FAUX_BOLD_EMBOLDEN × size, and the glyphs after it shift right by the same amount.
    • The full e2e suite passes (43).

Changelog

  • A bullet has been added under ## [Unreleased] in CHANGELOG.md

🤖 Generated with Claude Code

https://claude.ai/code/session_01Hwf91srGvueqSiuvLN5Kap


Generated by Claude Code

claude added 3 commits October 6, 2026 21:43
TextEcsComponent.text and shapeText parse <b>...</b> and
<color=#rrggbb>...</color>. Tags are stripped before shaping, so kerning and
wrapping see only the visible text; each GlyphQuad carries its tag color and
a faux-bold embolden that the MSDF fill and effects shaders apply. Markup
that isn't a valid tag renders literally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hwf91srGvueqSiuvLN5Kap
…t-tags

# Conflicts:
#	documentation-site/src/pages/demos/text/index.tsx
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hwf91srGvueqSiuvLN5Kap
@stormmuller stormmuller changed the title feat(text): add <b> and <color> rich text tags feat(text): add bold and color rich text tags Oct 6, 2026
@stormmuller
stormmuller enabled auto-merge (squash) October 6, 2026 21:59
@codecov

codecov Bot commented Oct 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.69027% with 6 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/text/utilities/shape-text.ts 86.20% 2 Missing and 2 partials ⚠️
src/text/utilities/parse-rich-text.ts 97.22% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@stormmuller
stormmuller merged commit d268b4c into dev Oct 6, 2026
12 checks passed
@stormmuller
stormmuller deleted the claude/design-rich-text-tags branch October 6, 2026 22:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants