Repository navigation
fix(design-sync): font fallback stacks by category (CE-7) - #472
Conversation
The override path wrote bare design fonts (font-family:Geist Mono), so clients without the font rendered the browser default serif, and the inline paths appended ,sans-serif to every font, so mono and serif design fonts fell back to sans. New font_stacks.font_stack() classifies the family by name (mono, then sans, then serif keywords, else sans) and appends that category's web-safe stack, sanitised so it is attribute-safe without html.escape (an escaped quote's ";" broke the renderer's second font write). The matcher's six inline sites, the VML spec and the override emissions call it; the renderer's three font appliers re-apply it idempotently. CE-2's six font_generic allow-list entries are deleted; a category invariant guards a minimal Figma tree and all 7 corpus cases. Snapshots regenerated (font values only, masked audit x7). Fidelity baseline re-stamped for cases 6, 7, 10: c7 2833:1942 -0.0059 ratified (heading now renders sans like the reference). Closes #425 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing |
linardsb
left a comment
There was a problem hiding this comment.
Verdict: APPROVE (with one Medium to fix or ratify before merge). Recorded as a comment because GitHub blocks a formal self-approval.
PR #472 review: fix(design-sync): font fallback stacks by category (CE-7)
Head 468be5e7 · Base main @ 9a19d74ccdad00e6bfa6ab0e18af35ded392a8c3 (equals the live origin/main tip; round 1, so the guarantees and fix-mechanism passes don't apply)
Summary
Every converter font-family write now goes through font_stack(), which appends a fallback stack of the font's own type: mono, sans or serif. A client without the design font now falls back to the same kind of face, instead of the default serif or a sans for every font.
The code-reviewer agent read all 21 files in full, and I re-ran validation myself. There are no Critical or High issues. One Medium: a few sans and display fonts are classed as monospace, which is worse than the old blanket sans-serif. The rest are Low.
Issues
Medium
F1 · app/design_sync/font_stacks.py:57,100 · Some sans fonts are classed as monospace.
- Why:
_MONO_PREFIXESmatches by "starts with" onmonoandcode, and the mono check runs before the serif check. - Observed:
font_stack('Codec Pro')returns'Codec Pro', 'Courier New', Courier, monospace, and'Monotype Corsiva'also gets the mono stack. - Same mechanism, not run:
Monotype Garamond,MonotonandMonofett. - Effect: before this PR, every font fell back to
sans-serif. A Codec Pro body font now renders as Courier New in clients without it. A font wrongly put in sans is no worse than before; a font wrongly put in mono or serif is a regression. - Fix: match
monoandcodeas whole words. Keep "starts with" matching for the brand stems (consol,courier,menlo,monaco). AddCodec ProandMonotype Corsivato the classifier tests.
Low
- F2 ·
font_stacks.py:57-81· Some real mono fonts fall to the sans stack:Inconsolata(observed),Cousine(observed),Anonymous ProandIosevka. That is no worse than before. Optionally add them as whole-word tokens. - F3 ·
font_stacks.py:113· Names containing.stay unquoted._cleankeeps., but_renderonly quotes names with a space or a leading digit.Gill.Sansis not a valid unquoted CSS name, so the browser drops the whole declaration. The old path did the same, so this is not a regression. Fix: quote any name that is not a CSS identifier. - F4 ·
font_stacks.py:83,89· Some non-Latin characters are deleted rather than turned into spaces: combining marks (Thai, Indic), NBSP and U+3000. The deletion glues words together. Plain CJK survives (observed:思源黑体). Figma family names are Latin in practice. - F5 ·
component_renderer.py:1669· No unit test fails if_replace_heading_fontgoes back tohtml.escape. The existing heading tests useGeorgia, serifandHelvetica, where the two functions give the same output. Only the corpus'check on case 7 would catch it, and that mutation was not in the reported mutation runs. Add a heading-override test withNoto Sans. - F6 ·
test_font_stacks.py(test_minimal_tree_every_font_matches_category, last assert) ·v.endswith("serif")also passes forsans-serif. Thecategory_violations == []line above does the real check. Useendswith(", serif"). - F7 · Test names are stale:
test_column_text_styling.py:148,231(*_escapes_font_family) andtest_card_composite.py:213. They now check that characters are stripped, not escaped. Rename.
Code scanning
gh api …/code-scanning/alerts?ref=refs/pull/472/merge&state=open returned 0 open alerts from Semgrep and CodeQL (observed).
Validation
| Gate | Result |
|---|---|
make check-full at 468be5e7 (local, this review) |
exit 0: pytest 9123 passed / 0 failed / 123 skipped, vitest 780 passed, mypy and pyright clean, .env.example no drift; lint rewrote nothing (observed) |
CI on 468be5e7 |
all 14 checks green, Ready for review passed (observed, gh pr checks 472) |
test_font_stacks.py count |
80 collected (observed: --collect-only), matches the PR body |
Numbers pass
- Fidelity baseline deltas: all 9 deltas in
data/debug/fidelity_baseline.jsonmatch the PR body anddocs/fidelity-gate.mdexactly (derived from the JSON diff). Examples: c72833:19420.8611 → 0.8552 = −0.0059, and c62833:14300.9178 → 0.9182 = +0.0004. - Diff stat: "21 files, 1159+/285−" is correct (observed).
- Allowlist: "six
font_genericentries deleted" is correct (observed, 6 removed). - Updated tests: "15 existing tests updated" is labelled derived, and 7 + 1 + 1 + 1 + 5 = 15.
- Older runs: RED-on-base, mutation and A3 figures are attributed to branch commit
6c5c6f18. The reviewer confirmed that6c5c6f18..468be5e7changes only a pyright pragma line plus the baseline JSON, so the attribution holds. - I found no figure presented as observed without a run behind it.
Deferred items
There is no ledger entry for #425 or CE-7. ce-3-file-wide-tokens-body-font overlaps the topic and is correctly left out of scope (Q1). Nothing needs closing or stamping.
Done well
- Escaping: the cleaned output cannot contain
",;,&,<,>or\, so it cannot breakstyle="…"or end the renderer'sfont-family:[^;"]+rewrites early. It also removes the old risk of a backslash in are.subreplacement. - Idempotent:
font_stackreturns the same stack when given its own output, so re-applying it in the renderer adds nothing. Dedupe ignores case and quotes. - Tests:
- RED-on-base is recorded, and the guard-revert mutation fails the corpus check.
- The double-write test runs on real per-node anchors and has a guard so it cannot pass on empty output.
- Fixtures are the real corpus and a real Figma tree run through
convert_document. No synthetic email HTML was added.
- Scope: a masked diff of all 7
expected.htmlfiles shows onlyfont-familyvalues changed, and MSO blocks are untouched. The c7 drop and the out-of-scope body font are documented and ratified.
Recommendation
Approve. Before merge, fix F1 (a small change to the classifier plus two test cases) or ratify it as accepted. The Lows can ship as they are or be picked up together in a follow-up. A human makes the merge call.
PR #472 review F1: prefix matching on "mono"/"code" classed sans and display fonts (Codec Pro, Monotype Corsiva, Monoton) as monospace, so they fell back to Courier New, worse than the old blanket sans-serif. Brand stems (courier, consol, menlo, monaco) keep prefix matching. Also F5: heading-override unit test that fails if _replace_heading_font goes back to html.escape; F6: tighten the serif endswith assert; F7: rename tests that now check stripping, not escaping. Plan D3 updated. Converter output unchanged: all 7 snapshot tests pass. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Whole-word mono/code matching (PR #472 F1) dropped one-word monospace families (MonoLisa, Monoid, Mononoki, Monofur, CodeNewRoman) and a leading generic monospace/ui-monospace to the sans stack. Add the brand stems as prefixes and monospace as a whole token (PR #473 review F1, F2). Converter output unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…CE-7 review) (#473) * fix(design-sync): match mono/code as whole words in font classifier PR #472 review F1: prefix matching on "mono"/"code" classed sans and display fonts (Codec Pro, Monotype Corsiva, Monoton) as monospace, so they fell back to Courier New, worse than the old blanket sans-serif. Brand stems (courier, consol, menlo, monaco) keep prefix matching. Also F5: heading-override unit test that fails if _replace_heading_font goes back to html.escape; F6: tighten the serif endswith assert; F7: rename tests that now check stripping, not escaping. Plan D3 updated. Converter output unchanged: all 7 snapshot tests pass. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(design-sync): keep one-word mono brands and leading monospace mono Whole-word mono/code matching (PR #472 F1) dropped one-word monospace families (MonoLisa, Monoid, Mononoki, Monofur, CodeNewRoman) and a leading generic monospace/ui-monospace to the sans stack. Add the brand stems as prefixes and monospace as a whole token (PR #473 review F1, F2). Converter output unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Summary
Clients without a design font rendered the converter's text in the browser default serif: the override path wrote the bare family (
font-family:Geist Mono), and the inline paths appended,sans-serifwhatever the font, so mono and serif design fonts fell back to sans. Every converter font write now carries a fallback stack of the design font's own category, so a client without the font falls back to the right kind of face.What changed
fix(design-sync): font fallback stacks by category (CE-7)(468be5e7)app/design_sync/font_stacks.py:font_category()infers mono / sans / serif from the family name (Figma exposes no generic family);font_stack()returns the family plus'Courier New', Courier, monospace/Helvetica, Arial, sans-serif/Georgia, 'Times New Roman', serif, deduped, idempotent, and sanitised to\wplus.-so the value is attribute-safe withouthtml.escape(an escaped'becomes', whose;breaks the renderer's second font write).component_matcher.py: the six inline font sites, the VML button spec and the three override emissions callfont_stack.component_renderer.py:_replace_heading_font,_replace_body_fontand_apply_text_node_style(font-family only) re-applyfont_stackas a guard instead ofhtml.escape.data/debug/content_check_allowlist.yaml: the six CE-2font_genericentries owned by Font fallback stacks by category #425 are deleted.data/debug/{5,6,7,8,9,10,reframe}/expected.htmlregenerated: font values only (masked auditmasked_equal=True× 7, observed).data/debug/fidelity_baseline.jsonre-stamped for cases 6, 7, 10, note indocs/fidelity-gate.md§Re-stamping.app/design_sync/tests/test_font_stacks.py(80 tests, observed:uv run pytest app/design_sync/tests/test_font_stacks.py -q→ 80 passed at468be5e7): builder, each matcher site, a second font write on a real per-node anchor, a minimal Figma tree throughconvert_document, and a category invariant plus no escaped font outside MSO over all 7 corpus cases. 15 existing tests updated to the stacks (derived: 7 + 1 + 1 + 1 + 5 acrosstest_column_text_styling,test_card_composite,test_component_matcher,test_component_renderer,test_content_checks), plus the two pill constants intest_cta_fidelity.py.git diff --stat origin/main..HEAD→ 21 files changed, 1159 insertions(+), 285 deletions(-) (observed).Validation
observed—make check-full, at468be5e7, exit 0:full log: .claude/state/gate/20261004T173719Z-468be5e7.log
Converter evidence. Re-run at
468be5e7(observed): corpus invariantuv run pytest app/design_sync/tests/test_font_stacks.py -q -k corpus→ 7 passed; ladderuv run pytest app/design_sync/tests/test_converter_data_regression.py -q -k ladder→ 7 passed; the pinned-imagetest_fidelity_gate_holds_baselinePASSED inside themake check-fullrun above (log line). The remaining figures are from the implementation runs on the code committed as branch commit6c5c6f18(observed there; RED and mutation runs reverted parts of it in the working tree); they hold at this head becausegit diff --stat 6c5c6f18 468be5e7 -- app/ data/debug/shows only the baseline re-stamp and one pyright comment line intest_font_stacks.py(derived):_text_node_overridesemission fails only its emission test (the guard covers the rest).python -m app.design_sync.tests.content_checks→ "No problems.",font_genericpass on all 6 cases.data/debug/ladder_snapshot.jsonunchanged (git diff origin/main -- data/debug/ladder_snapshot.jsonempty).2833:1942−0.0059 beyond the 0.005 margin; in margin: 62833:1430+0.0004; 72833:1870−0.0024,2833:1882−0.0034,2833:1898−0.0011; 102833:1141−0.0007,2833:1176+0.0001,2833:1197+0.0003,2833:1227−0.0004; every other section +0.0000. After the stamp: passes (see above).Times, afterCourier New.Notes for the reviewer
2833:1942gate drop (−0.0059; the 30 px Noto Sans heading now renders in Liberation Sans instead of Liberation Serif in the pinned image, and the design reference is a sans) and the A3 drops on c5 idx 8 and c6 idx 1.<body>font from tokens (token_transforms._font_stack), owned byce-3-file-wide-tokens-body-font.<center>font inside<!--[if mso]>stays HTML-escaped byvml_button._attr; nothing rewrites it later.email-headerand dropped the fonts under test.font-family:'outside MSO blocks: lxml decodes entities, so the category invariant alone stayed green when the renderer guard was reverted._apply_image_corner_radiusis unchanged (the planning spike touched it; it only receivesborder-*-radiusprops)._card_text_rowusesfont_stack(text.font_family or "Arial")(ruff SIM108; same output).<!--[if !mso]><!-->twin, which the reader counts twice.-kfilter.6c5c6f18, which is gone after the WIP squash (convention indocs/fidelity-gate.md§Re-stamping).Linked
Closes #425 · Epic #439 · Plan
.agents/plans/ce-7-font-fallback-stacks.mdOpened as a draft; CI's
readyjob flips it when every ci.yml job (backend,frontend,sdk-check,trivy,migrations,integration,migration-lint,commit-lint,e2e-smoke) andcodeqlare green on this head. A red job leaves it here with the failing check on the PR.🤖 Generated with Claude Code