From d685e100928be0347e7067c1af59c0d71dcf9447 Mon Sep 17 00:00:00 2001 From: Linards Berzins Date: Sun, 4 Oct 2026 21:44:04 +0100 Subject: [PATCH 1/2] 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) --- .agents/plans/ce-7-font-fallback-stacks.md | 4 ++-- app/design_sync/font_stacks.py | 8 +++++--- app/design_sync/tests/test_card_composite.py | 2 +- app/design_sync/tests/test_column_text_styling.py | 4 ++-- app/design_sync/tests/test_component_renderer.py | 11 +++++++++++ app/design_sync/tests/test_font_stacks.py | 9 ++++++++- 6 files changed, 29 insertions(+), 9 deletions(-) diff --git a/.agents/plans/ce-7-font-fallback-stacks.md b/.agents/plans/ce-7-font-fallback-stacks.md index dc36c58a..43c3c9cb 100644 --- a/.agents/plans/ce-7-font-fallback-stacks.md +++ b/.agents/plans/ce-7-font-fallback-stacks.md @@ -12,7 +12,7 @@ The following plan should be complete, but validate documentation, codebase patt |---|---|---| | D1 | New module `app/design_sync/font_stacks.py`: `FontCategory` (`StrEnum` SANS/SERIF/MONO), `_CATEGORY_STACK: dict[FontCategory, tuple[str, ...]]`, `font_category(family) -> FontCategory`, `font_stack(family) -> str`. | Issue scope: "one stack builder … new builder module". | | D2 | Stacks exactly as the issue: SANS `Helvetica, Arial, sans-serif`; SERIF `Georgia, 'Times New Roman', serif`; MONO `'Courier New', Courier, monospace`. Output = the design family first, then the category stack, deduped ignoring case and quotes, joined with `", "`. | Issue scope. Dedupe so `Arial` → `Arial, Helvetica, sans-serif` and `Courier New` → `'Courier New', Courier, monospace`. | -| D3 | Category is inferred from the family **name**. Figma exposes no generic family: `raw_figma.json` text styles carry only `fontFamily`, `fontPostScriptName`, `fontSize`, `fontStyle`, `fontWeight` (observed: `grep -o '"font[A-Za-z]*"' data/debug/10/raw_figma.json`, 29 each), and `structure.json` carries `font_family` only. Tokenise the lower-cased name on spaces/hyphens. Order: MONO (any token **starting with** `mono`, `code`, `courier`, `consol`, `menlo`, `monaco` — prefix so `Consolas` matches) → SANS (token starting with `sans`, `grotesk`, `grotesque`) → SERIF (any token equal to `serif`, `slab`, or a name in a short known-serif list — any token, so `EB Garamond` matches: Georgia, Times, Garamond, Baskerville, Bodoni, Didot, Caslon, Playfair, Merriweather, Lora, Cambria, Palatino, Cormorant, Crimson, Spectral, Domine) → default SANS. | Mono first so `Roboto Mono`/`Noto Sans Mono` stay mono; sans before serif so `Merriweather Sans` is sans. The name list is font data, not fixture data (generality rule holds: keyed on a Figma text property, never a design/node/colour). | +| D3 | Category is inferred from the family **name**. Figma exposes no generic family: `raw_figma.json` text styles carry only `fontFamily`, `fontPostScriptName`, `fontSize`, `fontStyle`, `fontWeight` (observed: `grep -o '"font[A-Za-z]*"' data/debug/10/raw_figma.json`, 29 each), and `structure.json` carries `font_family` only. Tokenise the lower-cased name on spaces/hyphens. Order: MONO (any token **equal to** `mono` or `code`, or **starting with** `courier`, `consol`, `menlo`, `monaco` — prefix so `Consolas` matches; whole-word `mono`/`code` since PR #472 review F1, so `Codec Pro` and `Monotype Corsiva` are not mono) → SANS (token starting with `sans`, `grotesk`, `grotesque`) → SERIF (any token equal to `serif`, `slab`, or a name in a short known-serif list — any token, so `EB Garamond` matches: Georgia, Times, Garamond, Baskerville, Bodoni, Didot, Caslon, Playfair, Merriweather, Lora, Cambria, Palatino, Cormorant, Crimson, Spectral, Domine) → default SANS. | Mono first so `Roboto Mono`/`Noto Sans Mono` stay mono; sans before serif so `Merriweather Sans` is sans. The name list is font data, not fixture data (generality rule holds: keyed on a Figma text property, never a design/node/colour). | | D4 | Input that is already a list: sanitise each family; if the last is a CSS generic family, return the normalised list unchanged (idempotent); otherwise keep every authored family and append the first family's category stack, deduped. | Providers other than Figma (penpot, html import, tests at `test_component_matcher.py:310`, `test_vml_button.py:161`) already pass lists. Idempotence lets the renderer re-apply the builder as a guard. | | D5 | Sanitising lives in the builder: strip `'`/`"`, keep only `\w` (Unicode, so `_` stays) plus ` .-` per family (regex `[^\w .-]` removed), collapse spaces; a family emptied by this is dropped; an empty result gives the SANS stack alone. Multi-word names are wrapped in single quotes. Call sites **do not** `html.escape` the builder's output. | `html.escape(quote=True)` turns `'` into `'`, whose `;` ends the renderer's `font-family:\s*[^;"]+` match (`component_renderer.py:440-447`, `:466-473`) and `_upsert_style_decl` (`:2051`) on a second write, leaving a junk declaration. The builder's charset contains no `"`, `<`, `>`, `&`, so it is attribute-safe without escaping. No template uses single-quoted `style='…'` attributes (observed: `grep -rln "style='" email-templates/components` → none). Exception kept: `vml_button._attr` (`vml_button.py:56-57`) still escapes the spec font into `'` in the VML `
`; safe because it sits inside an `