From e8f6799ebdbbc678aa7eb8d55238aead5fa9aa97 Mon Sep 17 00:00:00 2001 From: Linards Berzins Date: Mon, 5 Oct 2026 10:37:17 +0100 Subject: [PATCH 1/2] feat(design-sync): document schema v2 with body tree (CE-26) Add EmailDesignDocument version 2.0: a typed `body` tree of nine DSL nodes (app/design_sync/dsl/nodes.py) beside the unchanged `sections`, so the DSL compiler spike (CE-19) plugs into an existing contract. from_json and validate dispatch on version; a v1 document with `body` and unknown versions raise ValueError, which the import service already turns into the legacy re-fetch. Relax the v1 schema (typography maxItems 200 -> 1000, declare column `dividers`): every committed case failed v1 validation before. A relaxation cannot invalidate a valid v1 document. Closes ledger entry phase-53.7-typography-maxitems-cap; logs whole-file cap breaches as ce-26-whole-file-documents-exceed-v1-caps. Converter HTML is byte-identical on all 7 cases. Refs #466 Co-Authored-By: Claude Opus 5.5 (1M context) --- .agents/deferred-items.json | 26 +- .agents/plans/ce-26-document-schema-v2.md | 463 ++++ app/design_sync/dsl/__init__.py | 5 + app/design_sync/dsl/nodes.py | 483 +++++ app/design_sync/dsl/tests/__init__.py | 0 app/design_sync/dsl/tests/test_nodes.py | 160 ++ app/design_sync/email_design_document.py | 70 +- app/design_sync/routes.py | 2 +- app/design_sync/tests/test_document_v2.py | 256 +++ .../tests/test_email_design_document.py | 57 +- .../tests/test_golden_roundtrip.py | 3 + cms/packages/sdk/openapi.json | 2 +- cms/packages/sdk/src/client/sdk.gen.ts | 2 +- data/schemas/email-design-document-v1.json | 17 +- data/schemas/email-design-document-v2.json | 1864 +++++++++++++++++ 15 files changed, 3385 insertions(+), 25 deletions(-) create mode 100644 .agents/plans/ce-26-document-schema-v2.md create mode 100644 app/design_sync/dsl/__init__.py create mode 100644 app/design_sync/dsl/nodes.py create mode 100644 app/design_sync/dsl/tests/__init__.py create mode 100644 app/design_sync/dsl/tests/test_nodes.py create mode 100644 app/design_sync/tests/test_document_v2.py create mode 100644 data/schemas/email-design-document-v2.json diff --git a/.agents/deferred-items.json b/.agents/deferred-items.json index 9272c3e0..a06ef1ba 100644 --- a/.agents/deferred-items.json +++ b/.agents/deferred-items.json @@ -572,7 +572,7 @@ "id": "phase-53.7-typography-maxitems-cap", "phase": "53.7", "title": "email-design-document-v1.json caps tokens.typography at maxItems:200 — LEGO converter output emits 234, so validate() would reject a real document", - "status": "deferred", + "status": "closed", "severity": "speculative", "introduced": "2026-06-12", "introduced_commit": "33d20b9d", @@ -582,7 +582,9 @@ "app/design_sync/email_design_document.py:1379 (validate())" ], "symptom_if_broken": "Any future feature that schema-validates a persisted EmailDesignDocument (import gate, API contract check, snapshot lint) hard-fails on typography-rich designs like LEGO with a maxItems violation — looking like a converter regression when it is a stale schema cap.", - "closes_when": "The cap is raised (or typography tokens deduped below it) AND EmailDesignDocument.validate() passes on case-7 converter output, guarded by a test that validates a real fixture document." + "closes_when": "The cap is raised (or typography tokens deduped below it) AND EmailDesignDocument.validate() passes on case-7 converter output, guarded by a test that validates a real fixture document.", + "closed_commit": "pending", + "notes": "2026-10-05 CE-26 (#466): closed. All 7 committed cases (5-10, reframe) emit 234 typography tokens, not only LEGO, and every from_legacy document also failed on the undeclared column `dividers` (cases 5, 8, 9, 10). Cap raised to 1000 and `dividers` declared in v1 and v2; guarded by app/design_sync/tests/test_document_v2.py::test_case_v1_document_validates (all 7 cases, validate() == [])." }, { "id": "phase-53.7-asset-reexport-prerequisite", @@ -1490,7 +1492,25 @@ ], "symptom_if_broken": "A CE-16 change that feeds FIXED/FILL/HUG into compute_column_width_fractions passes a RED test written against _build_column_groups, but on the real converter path every group reads sizing_horizontal=None and column widths fall back to geometry without any error (same inert-bridge trap as RC-A/RC-B).", "closes_when": "CE-16 or CE-26 carries sizing_horizontal across from_legacy -> to_layout_description (DocumentColumn and, if used, DocumentSection plus its cache canonical form), with a test that asserts the value on the to_layout_description output.", - "fix_sketch": "Add sizing_horizontal to DocumentColumn (from_column_group/to_column_group/to_dict/from_dict) when CE-16 starts reading it; decide the DocumentSection side together with the section-cache canonical form." + "fix_sketch": "Add sizing_horizontal to DocumentColumn (from_column_group/to_column_group/to_dict/from_dict) when CE-16 starts reading it; decide the DocumentSection side together with the section-cache canonical form.", + "notes": "2026-10-05 CE-26 (#466) left this open on purpose: adding the field changes v1 sections that nothing reads yet, and DSL nodes carry their own `sizing`. CE-16 is the first reader and closes it." + }, + { + "id": "ce-26-whole-file-documents-exceed-v1-caps", + "phase": "ce-26", + "title": "Whole-design-system-file documents exceed v1 schema caps, so validate() rejects persisted rows that load fine", + "status": "deferred", + "severity": "speculative", + "introduced": "2026-10-05", + "introduced_commit": "pending", + "summary": "The 2 local design_token_snapshots.document_json rows are one whole design-system file (\"The Ultimate Email Design System (Community)\"): 397 sections vs cap 100, section texts 103 vs 50, element_gaps 106 vs 100, content-group texts 73 vs 50 (observed 2026-10-05). They load and round-trip, but EmailDesignDocument.validate() rejects them. Latent because no load path validates. CE-26 raised only the typography cap and declared `dividers` (user decision 2026-10-05).", + "code_refs": [ + "data/schemas/email-design-document-v1.json (properties.sections maxItems 100, $defs/section texts/element_gaps, $defs/content_group texts)", + "app/design_sync/email_design_document.py (EmailDesignDocument.validate)", + "app/design_sync/import_service.py:239 (persisted-document load, no validate)" + ], + "symptom_if_broken": "A future import gate, snapshot lint or API contract check that validates persisted documents hard-fails on whole-file imports with maxItems errors, looking like a converter regression.", + "closes_when": "Either the caps are sized for whole-file imports, or whole-file snapshots stop being persisted as one document; and a test validates a whole-file document." } ], "tooling_followups": [ diff --git a/.agents/plans/ce-26-document-schema-v2.md b/.agents/plans/ce-26-document-schema-v2.md new file mode 100644 index 00000000..76852fe9 --- /dev/null +++ b/.agents/plans/ce-26-document-schema-v2.md @@ -0,0 +1,463 @@ +# Feature: CE-26 document schema v2 (`body` tree beside `sections`, version dispatch) + +The following plan should be complete, but validate documentation and codebase patterns and task sanity +before you start implementing. Pay special attention to the names of existing types and import paths. + +Base: `origin/main` `b9cccdf8` (observed: `git fetch` 2026-10-05). Line refs are at that SHA. Branch from +`origin/main`, not from the current `fix/ce-7-review-f1` checkout. + +## Feature Description + +`EmailDesignDocument` (the converter's single JSON contract) gets a second version. `"2.0"` adds a `body` +field: a typed tree of nine DSL node types (`wrapper`, `section`, `column`, `text`, `image`, `button`, +`divider`, `spacer`, `raw`) next to the unchanged flat `sections`. The loader (`from_json`) and the +validator (`validate`) dispatch on `version`. Nothing produces a v2 document yet; the tree builder, the +MJML compiler and the feature flag are later tickets (CE-19 #437, CE-20 #438). + +## User Story + +As the converter team building the DSL compiler spike (CE-19 #437) +I want a persisted, schema-validated document format that can carry a typed layout tree +So that the spike's tree builder and compiler plug into an existing contract instead of inventing one, and +persisted v1 documents keep loading unchanged. + +## Problem Statement + +Observed 2026-10-02 (issue #466) and re-read at `b9cccdf8`: + +- `EmailDesignDocument.from_json` (`app/design_sync/email_design_document.py:1456`) never reads `version` + beyond copying it, so a `body` key would be silently dropped. +- `_load_schema` / `_get_validator` (`:54-61`) are single-entry `lru_cache`s pinned to + `data/schemas/email-design-document-v1.json`, whose root has `additionalProperties: false` and + `version: {"const": "1.0"}`. A v2 document cannot validate. + +Found while planning (observed: `from_legacy` → `to_json` → `validate` on all 7 committed cases, +2026-10-05, scratch probe run on checkout `94e84b95`. Its `email_design_document.py` is identical to +`b9cccdf8`; it lacks CE-6's sizing-field reads in `protocol.py`/`diagnose/report.py`/`figma/`, which add +no tokens and no dividers, so the figures below should hold on `b9cccdf8` (derived, not re-run there)): + +- **Every real converter document fails v1 validation today.** `tokens.typography` has 234 entries on all + 7 cases (cases 5–10 and reframe share one token library) against `maxItems: 200`. Deduplication does not + help: 232 distinct entries ignoring `name`. +- `DocumentColumn.dividers` (added in #365, `:878-909`) is emitted by `to_json` but never declared in the + v1 `column` def, so 4 cases also fail with `'dividers' was unexpected`: case 5 (sections 5, 7), 8 (10), + 9 (7, 9), 10 (12, 14). A field-by-field sweep of every `Document*` dataclass against its `$defs` + entry finds `dividers` as the only drift. +- Round-trip is already sound: `json.dumps(from_json(d).to_json()) == json.dumps(d)` on all 7 (observed, + same probe). + +Prototyped: the v1 schema with only the two Task 1 edits applied validates all 7 case documents with 0 errors (observed 2026-10-05, in-memory edit of the base schema, jsonschema 4.26.0). + +User decision 2026-10-05: **relax v1 and v2 together.** Raise the typography cap and declare `dividers` in +both files. A relaxation (larger `maxItems`, one new optional property) cannot make a valid v1 document +invalid, so the ticket's "Wrong if" line holds. v2 is then exactly "v1 plus `body`". + +## Solution Statement + +1. Relax `email-design-document-v1.json`: `tokens.typography.maxItems` 200 → 1000; declare `dividers` on + `$defs/column`. +2. Add `data/schemas/email-design-document-v2.json`: a full copy of v1 with `$id`/`title` changed, + `version.const` = `"2.0"`, `body` added to `properties` and `required`, and new `node_*` + + `container_style` `$defs`. A parity test keeps the shared `$defs` identical to v1. +3. Add `app/design_sync/dsl/nodes.py`: frozen dataclasses for the nine nodes with `to_json`/`from_json`. + The text, image and button leaves wrap `DocumentText`/`DocumentImage`/`DocumentButton` verbatim. +4. `EmailDesignDocument` gains `body: tuple[BodyNode, ...] = ()`. `from_json` dispatches on `version` + (`"1.0"` → `body=()`, `"2.0"` → parse `body`, else `ValueError`). `validate` picks the validator by + `version`. `to_json` writes `body` only for `"2.0"`. +5. Close ledger entry `phase-53.7-typography-maxitems-cap` with a test that validates all 7 real cases. + +## Out of Scope / Non-Goals + +- Not included: the tree builder (FR1–FR6), the compiler, `compile_tree`, the `dsl_compiler_enabled` flag + (CE-19 #437 / DSL-1). `from_legacy` keeps writing `version="1.0"`. +- Not included: a `/schema/v2` endpoint (no consumer yet; DSL-1). `GET /schema/v1`'s docstring + (`routes.py:869`) stays true and is not edited. `POST /validate-document` starts accepting v2 bodies + through `validate()` dispatch, so its docstring (`routes.py:887`) is updated in Task 11 and the SDK + regenerated, since that text is copied into `cms/packages/sdk/openapi.json:6706` and + `cms/packages/sdk/src/client/sdk.gen.ts:1798` (observed: `git grep` at base). +- Not included: `sizing_horizontal` on `DocumentColumn`/`DocumentSection` (ledger + `ce-6-sizing-horizontal-dropped-at-document-bridge`, carried to CE-16). DSL nodes carry their own + `sizing` field, so the DSL path does not need that bridge. +- Not changing: `EmailDesignDocument.schema()` and `GET /schema/v1` keep serving the v1 file (its content + changes by the two relaxations only). +- Not changing: converter output. Every case renders byte-identical HTML. + +## Feature Metadata + +**Feature Type**: New Capability (data contract) plus a schema-drift fix +**Estimated Complexity**: Medium (no runtime behaviour change, but a public JSON contract) +**Primary Systems Affected**: `app/design_sync/email_design_document.py`, `data/schemas/`, new `app/design_sync/dsl/` +**Dependencies**: none new. `jsonschema` 4.26.0 (observed: `uv pip show jsonschema`), `Draft202012Validator`. + +## Related Work + +**Implements**: #466 (CE-26) · **Epic**: #439; spec `docs/architecture/dsl-compiler.md` S1 + M2; slices +`.agents/plans/dsl-epic-slices.md:39-44`. + +**Back-references**: + +- `.agents/plans/36.1-email-design-document-schema.md` — Why: original v1 schema/dataclass design this extends. +- `.agents/plans/ce-6-sizing-fields.md` — Why: source of the `Sizing` vocabulary (`FIXED|HUG|FILL`, + `app/design_sync/protocol.py:172-174`). + +**Forward-references**: CE-19 (#437) tree builder writes v2; DSL-1 adds `/schema/v2` + route docstrings. + +## Deferred Items Touching This Plan + +Grep run 2026-10-05 against `origin/main:.agents/deferred-items.json` for phase `ce-26`/`53.7` and every +file below. + +| id | match (phase / code_ref) | decision | why | +|----|--------------------------|----------|-----| +| `phase-53.7-typography-maxitems-cap` | `data/schemas/email-design-document-v1.json`, `email_design_document.py` `validate()` | **close** | Task 1 raises the cap; Task 7 validates all 7 real cases (its `closes_when`). Its "LEGO only" wording is stale: all 7 cases emit 234 (observed). Close via `.claude/skills/deferred-items/SKILL.md` Close step, `closed_commit: pending` until squash. | +| `ce-6-sizing-horizontal-dropped-at-document-bridge` | `email_design_document.py:936/951/1279` | **carry forward** | Its `closes_when` names CE-16 or CE-26. Adding the field changes v1 sections that nothing reads yet; CE-16 is the first reader. Append a `notes` line saying CE-26 deliberately left it. | +| `ce-10-unnamed-button-icon-not-exported` | `import_service.py:598` | **avoid** | This plan does not edit `import_service.py`; it only relies on `:239`'s existing `except ValueError`. | + +--- + +## CONTEXT REFERENCES + +### Relevant Codebase Files (read before implementing) + +- `app/design_sync/email_design_document.py:46-61` — schema path + two `lru_cache(maxsize=1)` loaders to make per-version. +- `app/design_sync/email_design_document.py:488-858` — `DocumentCornerRadiusSpec`, `DocumentText`, `DocumentImage`, `DocumentButton`: the `to_json` (omit-when-default) / `from_json` (`data.get`) style to mirror, and the three leaf payloads the nodes wrap. +- `app/design_sync/email_design_document.py:860-935` — `DocumentColumn.dividers` serialisation (keys `node_id`, `stroke_color`, `stroke_weight`) that the schema must declare. +- `app/design_sync/email_design_document.py:1424-1492` — `EmailDesignDocument` fields, `to_json`, `from_json` (KeyError/TypeError → `ValueError`), `validate`, `schema`. +- `app/design_sync/email_design_document.py:1518-1619` — `from_legacy`: unchanged, still writes `"1.0"` (`:1559`, `:1610`); shows the function-level-import pattern (`:1532-1534`). +- `app/design_sync/import_service.py:236-245` — persisted-document load: catches `(ValueError, KeyError)` and falls back to the legacy re-fetch. An unsupported version must raise `ValueError` to land here. +- `app/design_sync/routes.py:870-911` — `GET /schema/v1` (`schema()`) and `POST /validate-document` (`validate()` on arbitrary client JSON; `version` may be any JSON type). +- `data/schemas/email-design-document-v1.json` — 521 lines; root `additionalProperties: false`; `$defs` keys: `source tokens color typography spacing gradient gradient_stop variable section content_group padding style_run corner_radius_spec text image button column layout compatibility_hint token_warning`. Note `text`, `image`, `button`, `section`, `column` are taken: node defs need a `node_` prefix. +- `app/design_sync/font_stacks.py:26-31` — `FontCategory` StrEnum (`sans-serif`/`serif`/`monospace`): the generic that must end a text node's `font_stack` (spec S3). +- `app/design_sync/figma/layout_analyzer.py:38` — `EmailSectionType(StrEnum)`: the enum style to mirror. +- `app/design_sync/tests/test_email_design_document.py:45-56` (`_make_document`), `:173-322` (`TestSchemaValidation`), `:207-215` (`test_wrong_version` uses `"2.0"`, must change), `:324-403` (`TestRoundtrip`). +- `app/design_sync/tests/regression_runner.py:31-61` — `discover_cases`, `run_case_conversion` (`load_structure_from_json` → `from_legacy` → `convert_document`): the real-fixture oracle. +- `app/design_sync/tests/test_golden_roundtrip.py:69-88` — `TestGoldenSchemaValidation.test_schema_valid`: golden components through `HtmlImportAdapter`. + +### New Files to Create + +- `data/schemas/email-design-document-v2.json` — v1 copy + `body` + `node_*`/`container_style` `$defs`. +- `app/design_sync/dsl/__init__.py` — package docstring only. +- `app/design_sync/dsl/nodes.py` — enums, `ContainerStyle`, nine node dataclasses, `body_from_json`. +- `app/design_sync/dsl/tests/__init__.py` — empty (sibling `tests/` packages have one, e.g. `app/design_sync/tests/__init__.py`). +- `app/design_sync/dsl/tests/test_nodes.py` — node round-trip, enum/schema parity, illegal-child and guard tests. +- `app/design_sync/tests/test_document_v2.py` — version dispatch, v1/v2 schema parity, real-case corpus checks. + +### Relevant Documentation + +- `docs/architecture/dsl-compiler.md` § S1 (node schema, loader rule), § S2 (DSL nesting table), § S3 (value contracts) — the decided shapes; do not re-decide. +- [JSON Schema 2020-12 `contains`](https://json-schema.org/understanding-json-schema/reference/array#contains) — "at least one section in a wrapper" while `raw` siblings stay allowed. +- [`oneOf` + `const` discriminator](https://json-schema.org/understanding-json-schema/reference/combining#oneOf) — child unions. Errors from a failed `oneOf` report at the parent path (`body.0.children.0`), which is what the unknown-type test asserts. + +### Patterns to Follow + +**Dataclasses**: `@dataclass(frozen=True)`, hand-written `to_json` that omits `None`/default fields and `from_json` classmethods using `data["required"]` / `data.get("optional")` (`email_design_document.py:541-589`). Sequences inside nodes are `tuple[...]` (spec S1), not `list`. +**Enums**: `class X(StrEnum)` (`layout_analyzer.py:38`); serialise with `.value`, parse with `X(value)` (raises `ValueError` on unknown, which is the desired loader error). +**Errors**: loader errors are `ValueError` with a `Malformed EmailDesignDocument: …` style message (`:1475`). No new exception class; nothing new is logged (pure data module, no logger in the file today). +**Imports**: `dsl/nodes.py` imports `DocumentText`, `DocumentImage`, `DocumentButton` from `app.design_sync.email_design_document` at module level. `email_design_document.py` imports `dsl.nodes` **only inside `from_json`** and under `TYPE_CHECKING` for the `BodyNode` annotation, to avoid a circular import (pattern: `from_legacy`'s function-level imports, `:1532-1534`; `TYPE_CHECKING` block at `:42-44`). +**Lint**: no rule flags the function-level import (ruff `PLC0415` is not selected; the file already does it at `:1532`). Ruff `TC` rules are not selected (CLAUDE.md "Linter safety"), so do not move runtime imports under `TYPE_CHECKING` beyond the annotation-only `BodyNode`. + +--- + +## IMPLEMENTATION PLAN + +### Phase 1: Schemas (v1 relaxation + v2 file) +Tasks 1–2. Pure JSON; RED tests for them land in Phase 4 but are written first (Task 0). + +### Phase 2: DSL node dataclasses +**Independent of:** Phase 1 at the code level (the parity test in Task 8 joins them). +Task 3. + +### Phase 3: Version dispatch in `EmailDesignDocument` +**Depends on:** Phases 1 and 2. +Tasks 4–5. + +### Phase 4: Tests, ledger, gates +Tasks 6–12 (Task 11 touches `cms/packages/sdk/`). + +--- + +## STEP-BY-STEP TASKS + +### Task 0: RECORD the base and capture converter output before any edit + +- **IMPLEMENT**: `git rev-parse origin/main` into the report. Dump every case's HTML at the base: + `uv run python -c "from pathlib import Path; from app.design_sync.tests.regression_runner import discover_cases, run_case_conversion; out=Path('$SCRATCH/before'); out.mkdir(parents=True, exist_ok=True); [ (out/f'{c.name}.html').write_text(r.html) for c in discover_cases() if (r:=run_case_conversion(c)) ]"` + (`$SCRATCH` = the session scratchpad). Expect 7 files (5–10, reframe). +- **GOTCHA**: run with `DESIGN_SYNC__SECTION_CACHE_ENABLED=false` so a warm cache cannot mask a change. +- **VALIDATE**: `ls $SCRATCH/before | wc -l` → 7. +- **SATISFIES**: AC 5. + +### Task 1: UPDATE `data/schemas/email-design-document-v1.json` + +- **IMPLEMENT**: (a) `$defs.tokens.properties.typography.maxItems` 200 → 1000. (b) add to `$defs.column.properties`: + `"dividers": {"type": "array", "maxItems": 50, "default": [], "items": {"type": "object", "required": ["node_id", "stroke_color"], "additionalProperties": false, "properties": {"node_id": {"type": "string", "maxLength": 200}, "stroke_color": {"type": "string", "maxLength": 20}, "stroke_weight": {"type": ["number", "null"], "default": null}}}}`. + Nothing else in the file changes. +- **PATTERN**: sibling arrays in the same def (`texts`, `images`: `maxItems` + `default: []`). +- **GOTCHA**: 1000 is a chosen ceiling (expected), not a measured need: observed max is 234. The cap exists to bound + `POST /validate-document` work; the route's 5 MB body limit (`routes.py:863`) still bounds it. + `stroke_color` is required because `DocumentColumn.from_json` reads `dv["stroke_color"]` (`:928`); a + document without it would validate but fail to load. +- **VALIDATE**: `uv run python -c "import json; json.load(open('data/schemas/email-design-document-v1.json'))"` and Task 7's corpus test. +- **SATISFIES**: AC 6, ledger close. + +### Task 2: CREATE `data/schemas/email-design-document-v2.json` + +- **IMPLEMENT**: copy the Task-1 v1 file, then change only: + - `$id` → `email-design-document/v2`; `title` → `Email Design Document v2`; `description` names the `body` tree. + - `properties.version` → `{"const": "2.0", …}`. + - `properties.body` → `{"type": "array", "maxItems": 100, "items": {"oneOf": [{"$ref": "#/$defs/node_wrapper"}, {"$ref": "#/$defs/node_section"}, {"$ref": "#/$defs/node_raw"}]}}`; add `"body"` to root `required`. + - New `$defs` (all `"type": "object"`, `"additionalProperties": false`, `required` includes `type`, `id`, `name`; `id`/`name` are `string` `maxLength: 200`; `type` is `{"const": ""}`): + +| def | extra properties (required in **bold**) | +|---|---| +| `container_style` | `background_color` (`#/$defs/hex6`), `radius` (`oneOf`: integer ≥0, or array of exactly 4 integers ≥0), `padding` (array of exactly 4 integers ≥0: top, right, bottom, left), `vertical_align` (enum `top middle bottom`), `sizing` (`#/$defs/sizing`), `full_width` (boolean, default false), `background_image` (`#/$defs/image`) | +| `node_wrapper` | `style` (`container_style`), **`children`**: array, `minItems: 1`, `maxItems: 50`, items `oneOf` [`node_section`, `node_raw`], `contains: {"properties": {"type": {"const": "section"}}}` | +| `node_section` | `style`, **`children`**: same shape, items `oneOf` [`node_column`, `node_raw`], `contains` type `column` | +| `node_column` | `style`, **`children`**: array `maxItems: 100`, items `oneOf` [`node_text`, `node_image`, `node_button`, `node_divider`, `node_spacer`, `node_raw`] | +| `node_text` | **`text`** (`#/$defs/text`), **`role`** (enum `heading body label cta`), **`font_stack`** (array of strings `maxLength: 200`, `minItems: 1`, `maxItems: 20`) | +| `node_image` | **`image`** (`#/$defs/image`), **`alt`** (string `maxLength: 2000`), `href` (string `maxLength: 2000`), `sizing` | +| `node_button` | **`button`** (`#/$defs/button`), `align` (enum `left center right`), `sizing` | +| `node_divider` | **`thickness`** (integer ≥0), **`color`** (`hex6`), **`style`** (enum `solid dashed dotted`), `width` (integer ≥0) | +| `node_spacer` | **`height`** (integer ≥0), `background_color` (`hex6`) | +| `node_raw` | **`html`** (string `maxLength: 100000`), **`reason`** (string `pattern: ^[a-z][a-z0-9_.]*$`, `maxLength: 64`) | +| `hex6` | `{"type": "string", "pattern": "^#[0-9a-f]{6}$"}` (S3: lowercase six-digit) | +| `sizing` | `{"enum": ["FIXED", "FILL", "HUG"]}` (raw upper-case, as `protocol.py:173`) | + +- **PATTERN**: v1 `$defs` style (`additionalProperties: false`, `maxLength` on strings). +- **GOTCHA**: `text`, `image`, `button`, `section`, `column` are existing v1 `$defs`; node defs **must** be prefixed `node_` or they overwrite them. `node_divider.style` and the containers' `style` share a key name with different meaning (enum vs object); this is per-def and legal. +- **GOTCHA**: JSON Schema cannot constrain the last array item, so S3's "font_stack ends in a generic" is enforced in the dataclass (Task 3), not here. Say so in the def's `description`. +- **GOTCHA**: `raw.html` is stored, not sanitised, here. The spec's "sanitised table-only payload" is the builder's job (`sanitize_web_tags_for_email()` before construction, CE-19). Put that in the def's `description`. +- **VALIDATE**: `uv run python -c "import json; from jsonschema import Draft202012Validator as V; V.check_schema(json.load(open('data/schemas/email-design-document-v2.json')))"`. +- **SATISFIES**: AC 2, AC 3. + +### Task 3: CREATE `app/design_sync/dsl/__init__.py`, `app/design_sync/dsl/nodes.py` + +- **IMPLEMENT** (`nodes.py`): + - Enums (`StrEnum`): `NodeType` (9 members, values = schema `const`s), `Sizing` (`FIXED FILL HUG`), `TextRole` (`heading body label cta`), `HorizontalAlign` (`left center right`), `VerticalAlign` (`top middle bottom`), `DividerStyle` (`solid dashed dotted`). + - `Padding = tuple[int, int, int, int]`; `Radius = int | tuple[int, int, int, int]`. + - `ContainerStyle` (all optional: `background_color: str | None`, `radius: Radius | None`, `padding: Padding | None`, `vertical_align: VerticalAlign | None`, `sizing: Sizing | None`, `full_width: bool = False`, `background_image: DocumentImage | None`), `to_json`/`from_json`, omit-when-default. + - Leaves: `TextNode(id, name, text: DocumentText, role: TextRole, font_stack: tuple[str, ...])`, `ImageNode(id, name, image: DocumentImage, alt: str, href: str | None = None, sizing: Sizing | None = None)`, `ButtonNode(id, name, button: DocumentButton, align: HorizontalAlign | None = None, sizing: Sizing | None = None)`, `DividerNode(id, name, thickness: int, color: str, style: DividerStyle, width: int | None = None)`, `SpacerNode(id, name, height: int, background_color: str | None = None)`, `RawNode(id, name, html: str, reason: str)`. + - Containers: `ColumnNode(id, name, children: tuple[ColumnChild, ...], style: ContainerStyle | None = None)`, `SectionNode(… children: tuple[ColumnNode | RawNode, ...] …)`, `WrapperNode(… children: tuple[SectionNode | RawNode, ...] …)`. + - Each node: class-level `type: ClassVar[NodeType]`; `to_json` writes `"type": self.type.value` first, then `id`, `name`, then fields; payload leaves call `self.text.to_json()` etc. + - Aliases: `ColumnChild = TextNode | ImageNode | ButtonNode | DividerNode | SpacerNode | RawNode`; `BodyNode = WrapperNode | SectionNode | RawNode`. + - `NODE_CLASSES: dict[NodeType, type[...]]` mapping every member to its class. + - Allowed children follow S2's "DSL allows" column. One private `_node_from_json(data)` reads `NodeType(data["type"])` and dispatches via `NODE_CLASSES`; per-container helpers narrow the result (see the strict-pyright GOTCHA) and raise `ValueError(f"node type {t!r} not allowed here")`. + - `body_from_json(items: list[dict[str, Any]]) -> tuple[BodyNode, ...]` (public; used by `EmailDesignDocument.from_json`). + - `TextNode.__post_init__`: `font_stack` non-empty and `font_stack[-1] in {c.value for c in FontCategory}`, else `ValueError` (S3). +- **PATTERN**: `DocumentButton.to_json`/`from_json` (`email_design_document.py:740-805`); `FontCategory` from `app/design_sync/font_stacks.py:26`. +- **IMPORTS**: `from app.design_sync.email_design_document import DocumentButton, DocumentImage, DocumentText`; `from app.design_sync.font_stacks import FontCategory`. +- **GOTCHA**: radius/padding JSON is a list; `from_json` converts to `tuple` (and 4-length check) so round-trip equality and hashing hold; a scalar radius stays `int`. +- **GOTCHA**: `from_json` must not use `data.get("type")` defaults; a missing `type` is `KeyError`, which `EmailDesignDocument.from_json` already converts to `ValueError`. +- **GOTCHA (strict pyright, prototyped)**: the shared parser returns the node union, which strict pyright will not assign to `tuple[ColumnNode | RawNode, ...]`. Use this shape (prototyped 2026-10-05 on a 4-node cut: `pyright` 0 errors, `mypy` clean, `ruff check` clean, observed): + - `_node_from_json(data, allowed: frozenset[NodeType], parent: str) -> AnyNode`: `NodeType(data["type"])` first, then `if node_type not in allowed: raise ValueError(f"node type {node_type.value!r} not allowed in a {parent}")`, **then** `NODE_CLASSES[node_type].from_json(data)`. + - Per-container helpers `_body_child`, `_wrapper_child`, `_section_child`, `_column_child` call it with `_BODY_CHILDREN` / `_WRAPPER_CHILDREN` / `_SECTION_CHILDREN` / `_COLUMN_CHILDREN`, then narrow with `if not isinstance(node, ColumnNode | RawNode): raise ValueError(...)` (unreachable after the set check; it exists for the type checker). No `cast`, no `assert`. + - Check the tag **before** parsing. The prototype's first cut parsed first and narrowed after; an illegal `section` under a `column` then failed inside the wrong parser with `KeyError: 'children'` instead of the "not allowed" `ValueError` (observed). + - Unknown tags fail in `NodeType(...)` with `ValueError: 'carousel' is not a valid NodeType` (observed). + - `NODE_CLASSES: dict[NodeType, type[AnyNode]]` typechecks with `.from_json` called on the union of classes (observed). +- **GOTCHA (lint)**: fields named `type` and `id` shadow builtins; ruff's `A` family is not selected (`pyproject.toml` `[tool.ruff.lint] select` = E W F I B C4 UP ANN S DTZ RUF ARG PTH SIM PERF T20 PIE PGH RET FURB FBT D, observed 2026-10-05), so no rule flags them. `FBT` targets function parameters, not dataclass fields, so `full_width: bool = False` is clean. +- **GOTCHA**: `ClassVar` fields are not dataclass fields, so `type` is excluded from `__init__`; that is intended (a `TextNode` cannot claim to be a `spacer`). +- **GOTCHA**: S2 minimums (a wrapper needs ≥1 section, a section ≥1 column) are enforced by the schema only, not by `from_json`. Loading does not validate; callers that need validity call `validate()` (the existing contract: `import_service.py:239` loads without validating). +- **VALIDATE**: `uv run mypy app/design_sync/dsl && uv run pyright app/design_sync/dsl && uv run ruff check --no-fix app/design_sync/dsl && uv run ruff format --check app/design_sync/dsl`. +- **SATISFIES**: AC 2, AC 3. + +### Task 4: UPDATE `app/design_sync/email_design_document.py` (validator dispatch) + +- **IMPLEMENT**: + - Replace `_SCHEMA_PATH` (`:46-48`) with `_SCHEMA_DIR = Path(__file__).resolve().parents[2] / "data" / "schemas"` and `_SCHEMA_FILES: dict[str, str] = {"1.0": "email-design-document-v1.json", "2.0": "email-design-document-v2.json"}`. + - `_load_schema(version: str = "1.0")` and `_get_validator(version: str = "1.0")` with `@lru_cache(maxsize=len(_SCHEMA_FILES))`. + - `validate(data)`: `v = data.get("version")`; `key = v if isinstance(v, str) and v in _SCHEMA_FILES else "1.0"`; iterate `_get_validator(key)`. An unknown or missing version therefore reports the v1 `const`/`required` error exactly as today. + - `schema()` unchanged (returns `_load_schema()` = v1). + - Module docstring (`:1-6`): name both schema files. +- **GOTCHA**: `version` in `POST /validate-document` bodies is arbitrary JSON; a list is unhashable and `v in dict` would raise `TypeError` (500). The `isinstance(v, str)` guard is required. Add a test (Task 6). +- **GOTCHA**: keep a default argument so `schema()` and any external `_load_schema()` caller keep v1 behaviour (only internal callers exist: `:1480`, `:1492`). +- **VALIDATE**: `uv run pytest app/design_sync/tests/test_email_design_document.py -q` (expect only `test_wrong_version` red until Task 6). +- **SATISFIES**: AC 3, AC 4. + +### Task 5: UPDATE `EmailDesignDocument` fields, `to_json`, `from_json` + +- **IMPLEMENT**: + - Add `body: tuple[BodyNode, ...] = ()` as the last field (`:1438`), with `BodyNode` imported under the existing `TYPE_CHECKING` block (`:42-44`) from `app.design_sync.dsl.nodes`. + - `__post_init__`: `if self.version != "2.0" and self.body: raise ValueError("body requires version 2.0")`. This is the "never silently drop `body`" guard on the Python side. + - `to_json`: after `"layout"`, `if self.version == "2.0": d["body"] = [n.to_json() for n in self.body]` (always written for v2, even empty, because v2 requires it). + - `from_json`: read `version = data["version"]` first. `"1.0"`: if `"body" in data` raise `ValueError("Malformed EmailDesignDocument: body is not allowed in version 1.0")`; `body = ()`. `"2.0"`: function-level `from app.design_sync.dsl.nodes import body_from_json`; `body = body_from_json(data["body"])`. Anything else: `raise ValueError(f"Unsupported EmailDesignDocument version: {version!r}")`. Keep the existing `except (KeyError, TypeError)` wrapper around the whole construction. +- **GOTCHA**: `to_json` must not emit `body` for `"1.0"`. Emitting `"body": []` would break v1 byte identity and fail v1's root `additionalProperties: false`. +- **GOTCHA**: test helpers construct `EmailDesignDocument` by keyword (`_make_document`, `test_email_design_document.py:45`); a new last field with a default breaks none of them (grep `EmailDesignDocument(` positional calls: none found in `git grep` at base; re-check). +- **GOTCHA**: `import_service.py:239` catches `ValueError`; an unsupported version or a v1-with-body document therefore falls back to the legacy re-fetch (spec N2 path) instead of 500-ing. Do not add a new exception type. +- **GOTCHA**: read `data["version"]` **inside** the existing `try`. `test_import_service.py:686` and `:747` give the import service a snapshot `{"schema": "email-design-document-v1"}` with no `version`; today that `KeyError` becomes `ValueError` and the import falls back to the legacy path. Reading `version` before the `try` turns it into an uncaught `KeyError`. +- **GOTCHA**: `version != "2.0"` (not `== "1.0"`) in `__post_init__`, so an unknown version string cannot carry a body either. +- **VALIDATE**: `uv run mypy app/design_sync/email_design_document.py app/design_sync/dsl && uv run pyright app/design_sync/email_design_document.py app/design_sync/dsl`. +- **SATISFIES**: AC 1, AC 2, AC 4. + +### Task 6: UPDATE `app/design_sync/tests/test_email_design_document.py` + +- **IMPLEMENT**: + - `test_wrong_version` (`:207`): change `"2.0"` → `"3.0"` (2.0 is now valid-shaped and fails on missing `body` instead). Keep the assertion. + - ADD `test_non_string_version_reports_error`: `version: ["1.0"]` → `validate` returns a non-empty list, no exception. + - ADD `test_v1_with_body_key_fails_validation_and_load`: `validate` error path `(root)` mentions `body`; `from_json` raises `ValueError`. + - ADD `test_unsupported_version_from_json_raises_value_error` (`"3.0"`). + - ADD `test_v1_constructor_rejects_body`: `EmailDesignDocument(version="1.0", …, body=(,))` raises `ValueError`. + - ADD `test_v1_to_json_has_no_body_key` on `_make_full_document()`. +- **PATTERN**: `TestSchemaValidation` (`:173`), `TestRoundtrip.test_malformed_raises_value_error` (`:391`). +- **GOTCHA (RED-first)**: write these before Tasks 4–5 and record the failing run; `test_v1_with_body_key…`'s `from_json` half and `test_v1_constructor_rejects_body` are red on the base. +- **VALIDATE**: `uv run pytest app/design_sync/tests/test_email_design_document.py -q`. +- **SATISFIES**: AC 1, AC 4. + +### Task 7: CREATE `app/design_sync/tests/test_document_v2.py` + +- **IMPLEMENT**: + - `_all_nine_body()` helper: one `WrapperNode` → `SectionNode` → `ColumnNode` holding one each of text (`font_stack=("Inter", "Helvetica", "sans-serif")`), image, button, divider, spacer, raw; plus a top-level `RawNode` and a top-level `SectionNode`. Leaf payloads (`DocumentText`, `DocumentImage`, `DocumentButton`) are **taken from case 5's `from_legacy` sections** (first text/image/button found), so the v2 fixture carries real payloads, not invented ones. + - `test_v2_all_nine_round_trip_and_validate`: `doc = replace(case5_doc, version="2.0", body=_all_nine_body())`; `j = doc.to_json()`; `validate(j) == []`; `json.dumps(EmailDesignDocument.from_json(json.loads(json.dumps(j))).to_json()) == json.dumps(j)`; and `{n.type for n in walk(doc.body)} == set(NodeType)`. + - `test_v2_unknown_node_type_fails_validation`: copy `j`, set the column's first child `type` to `"carousel"`; assert errors non-empty and **every** error path equals `body.0` (control: the unmutated `j` validated clean above). A nested failure surfaces at the outermost `oneOf`, not at the leaf: prototyped, an unknown leaf type, an unknown top-level type, a section with only `raw` children and an upper-case hex colour each report exactly `[("body.0", "oneOf")]` (observed 2026-10-05, jsonschema 4.26.0). Do not assert a deep path such as `body.0.children.0.children.0`. Also `from_json` raises `ValueError`. + - `test_v2_illegal_nesting_fails`: a `node_text` directly under `body` → error path `body.` (`oneOf`); `from_json` raises `ValueError`. + - `test_v2_missing_body_fails_validation`. + - Corpus (parametrized over `discover_cases()` that have `structure.json` and `tokens.json`; `pytest.skip` otherwise, as `test_converter_data_regression.py:132`): `test_case_v1_round_trip_byte_identical` (`json.dumps` equality after `from_json`) and `test_case_v1_document_validates` (`validate(doc.to_json()) == []`). This is the ledger's `closes_when` test. + - `TestSchemaParity`: v2 `$defs[k] == v1 $defs[k]` for every v1 key; v2 `properties` minus `body`/`version` equals v1's; `set(v2.required) == set(v1.required) | {"body"}`; new defs == `{f"node_{t.value}" for t in NodeType} | {"container_style", "hex6", "sizing"}`. + - Enum parity (checked mapping rule): `set(NODE_CLASSES) == set(NodeType)`; schema `sizing.enum` == `{s.value for s in Sizing}`; `node_text.role.enum` == `TextRole`; `node_divider.style.enum` == `DividerStyle`; `node_button.align.enum` == `HorizontalAlign`; `container_style.vertical_align.enum` == `VerticalAlign`. +- **PATTERN**: `regression_runner.run_case_conversion` steps (`regression_runner.py:48-61`) for building the case document. +- **GOTCHA**: build the v2 fixture from a case document only **after** Task 1 (before it, the case document itself fails v1/v2 validation, and "unknown type fails" would prove nothing). +- **GOTCHA (mutation check, all halves)**: (M1) revert Task 1's `maxItems` change in the **v1 file only** → `test_case_v1_document_validates` goes red on all 7, `TestSchemaParity` goes red (shared `$defs` differ), and `test_v2_all_nine_round_trip_and_validate` stays **green** (it validates against the v2 file, which still has 1000). (M2) drop `"body"` from v2 `required` → `test_v2_missing_body_fails_validation` goes red and the round-trip test stays green. Run both mutations, record all five results. +- **VALIDATE**: `uv run pytest app/design_sync/tests/test_document_v2.py -q`. +- **SATISFIES**: AC 1, AC 2, AC 3, AC 6. + +### Task 8: CREATE `app/design_sync/dsl/tests/__init__.py`, `app/design_sync/dsl/tests/test_nodes.py` + +- **IMPLEMENT**: per node type, `X.from_json(x.to_json()) == x` with every optional field set, and once with all optionals absent (asserting omitted keys). `ContainerStyle` with scalar radius and 4-tuple radius. The per-container helpers reject a `column` under `body` and a `text` under `section` with `ValueError`. `TextNode` rejects `font_stack=()` and `("Inter",)`. Unknown `type` string → `ValueError`. +- **VALIDATE**: `uv run pytest app/design_sync/dsl/tests -q`. +- **SATISFIES**: AC 2, AC 3. + +### Task 9: UPDATE `app/design_sync/tests/test_golden_roundtrip.py` + +- **IMPLEMENT**: in `test_schema_valid` (`:83-87`) add one assertion: `json.dumps(EmailDesignDocument.from_json(doc.to_json()).to_json()) == json.dumps(doc.to_json())` (import `json`). Golden components through `HtmlImportAdapter` are the second "committed v1 document" source. +- **VALIDATE**: `uv run pytest app/design_sync/tests/test_golden_roundtrip.py -q`. +- **SATISFIES**: AC 1. + +### Task 10: UPDATE `.agents/deferred-items.json` + +- **IMPLEMENT**: close `phase-53.7-typography-maxitems-cap` per `.claude/skills/deferred-items/SKILL.md` Close step (`status: closed`, `closed_commit: "pending"`, a `notes` line: all 7 cases emit 234, cap raised to 1000 in v1+v2, guarded by `test_case_v1_document_validates`). Append a `notes` line to `ce-6-sizing-horizontal-dropped-at-document-bridge`: CE-26 left it for CE-16. **Add** a new `speculative` entry `ce-26-whole-file-documents-exceed-v1-caps` (phase `ce-26`): the 2 local persisted rows are a whole design-system file ("The Ultimate Email Design System (Community)": 397 sections vs cap 100, section texts 103 vs 50, `element_gaps` 106 vs 100, content-group texts 73 vs 50; observed 2026-10-05). They load and round-trip, but `validate()` rejects them; latent because no load path validates. `closes_when`: either the caps are sized for whole-file imports or whole-file snapshots stop being persisted as one document. Not fixed here: raising those caps is outside the user's 2026-10-05 decision (typography + `dividers` only). +- **VALIDATE**: `uv run python -c "import json; json.load(open('.agents/deferred-items.json'))"`. +- **SATISFIES**: ledger. + +### Task 11: UPDATE `app/design_sync/routes.py:887` docstring and regenerate the SDK + +- **IMPLEMENT**: `validate_document` docstring → `"Validate a JSON body against the EmailDesignDocument schema for its version (1.0, 2.0)."` (amended: `or 2.0` trips the `falsy-numeric-trap` hook). Then `make sdk-snapshot && make sdk-local` (no running backend needed, `Makefile:262-266`). +- **GOTCHA**: only `cms/packages/sdk/openapi.json` and `cms/packages/sdk/src/client/sdk.gen.ts` should change, one description line each. Any other SDK churn means the snapshot picked up unrelated drift: stop and report it rather than committing it. +- **GOTCHA**: `get_document_schema`'s docstring (`:869`) still says v1 and is still true; leave it. +- **VALIDATE**: `make sdk-check` (exits 0) and `git diff --stat cms/` shows exactly the two files. Then `make ci-fe` (the diff now touches `cms/`; CLAUDE.md Definition of Done). +- **SATISFIES**: AC 8. + +### Task 12: ADD endpoint test for the relaxed v1 schema; write the PR-body note + +- **IMPLEMENT**: in `TestDocumentEndpoints` (`test_email_design_document.py:541`), extend `test_get_schema_v1` (`:567`): served `$defs.tokens.properties.typography.maxItems == 1000` and `"dividers" in $defs.column.properties`. Add `test_validate_document_accepts_v2` posting the Task 7 v2 JSON → `valid is True`. +- **IMPLEMENT (PR body, for `piv-create-pr`)**: a "Schema contract change" section stating (1) v1 was relaxed, not reshaped, against the issue's "unchanged v1 file" wording, by user decision 2026-10-05, because every committed case failed v1 validation; (2) a relaxation cannot invalidate a valid v1 document (derived: only a `maxItems` bound rises and one optional property is added); (3) `GET /schema/v1` is served with `Cache-Control: public, max-age=86400` (`routes.py:875-878`), so a client may hold the old, stricter copy for up to 24 h; the old copy rejects more, never accepts something the new one rejects (derived), so a stale cache cannot let an invalid document through. Put this text in the implementation report so the PR step copies it. +- **VALIDATE**: `uv run pytest app/design_sync/tests/test_email_design_document.py -q -k "schema_v1 or accepts_v2"`. +- **SATISFIES**: AC 6, AC 8. + +--- + +## TESTING STRATEGY + +### Unit Tests +`app/design_sync/dsl/tests/test_nodes.py` (node shapes and guards) and the additions to +`test_email_design_document.py` (dispatch, guards). Pure, no I/O. + +### Integration Tests +No DB, network or WebSocket surface. The real-data checks are the corpus tests in `test_document_v2.py` +(7 committed cases via `from_legacy`) and the golden-component round trip (Task 9). + +### Edge Cases + +| Edge case | Verified in | +|---|---| +| v1 document with a stray `body` key | `test_v1_with_body_key_fails_validation_and_load` | +| `version` missing / unknown / non-string | `test_missing_version` (existing), `test_wrong_version`, `test_non_string_version_reports_error` | +| v2 with `body: []` | `test_v2_*`: empty body validates (no `minItems` on root `body`) — add one assertion in Task 7 | +| v2 without `body` | `test_v2_missing_body_fails_validation` | +| unknown node type / illegal nesting | Task 7 + Task 8 tests | +| scalar vs per-corner radius | Task 8 | +| `font_stack` without a trailing generic | Task 8 | +| persisted v1 rows in a real DB | Level 4 step 2 | + +--- + +## VALIDATION COMMANDS + +### Level 1: Syntax & Style +- `uv run ruff format --check app/design_sync && uv run ruff check --no-fix app/design_sync` +- `uv run mypy app/ && uv run pyright app/` + +### Level 2: Unit Tests +- `uv run pytest app/design_sync/dsl/tests app/design_sync/tests/test_document_v2.py app/design_sync/tests/test_email_design_document.py app/design_sync/tests/test_golden_roundtrip.py -q` +- `uv run pytest app/design_sync -q -m "not integration and not benchmark and not visual_regression and not collab"` (includes `test_converter_data_regression.py` ladder `test_ladder_no_drift`, `:266`). +- `make test` rewrites `date:` in `app/ai/agents/*/skill-versions.yaml`; restore before committing. + +### Level 3: Full gate +- `make check-full` (includes `fidelity-gate`, `flag-audit`, `check-env-drift`; no flag or setting is added, so the last two are unaffected). Then `git diff` again: `make lint` inside it rewrites files. +- `make ci-fe` and `make sdk-check` (Task 11 touches `cms/packages/sdk/`). +- No eval gate: the diff touches nothing under `app/ai/`. + +### Level 4: Manual Validation +1. **Converter byte identity.** Re-run Task 0's dump into `$SCRATCH/after`; `diff -r $SCRATCH/before $SCRATCH/after` prints nothing. Record "7/7 byte-identical (observed)". +2. **Persisted rows.** The local dev DB holds 2 `design_token_snapshots.document_json` rows, both `"1.0"` (observed 2026-10-05). Run: + `psql -h localhost -U Berzins -d merkle_email_hub -At -c "select document_json from design_token_snapshots where document_json is not null" > $SCRATCH/db_docs.jsonl`, then + `uv run python -c "import json; from app.design_sync.email_design_document import EmailDesignDocument as E; rows=[json.loads(l) for l in open('$SCRATCH/db_docs.jsonl')]; print([json.dumps(E.from_json(d).to_json(), sort_keys=True)==json.dumps(d, sort_keys=True) for d in rows])"` + → `[True, True]`. (`sort_keys` because JSONB does not keep key order.) Baseline at `b9cccdf8`-equivalent code: `[True, True]` (observed 2026-10-05). `validate()` on these rows reports the whole-file cap errors logged in Task 10; after the change the typography error is gone and the others remain (expected). +3. **API.** With `make dev` up: `curl -s localhost:8891/api/v1/design-sync/schema/v1 | jq '.["$defs"].tokens.properties.typography.maxItems'` → 1000 (router prefix `/api/v1/design-sync`, `routes.py:70`). `POST /validate-document` with the Task 7 v2 JSON → `{"valid": true}`. +4. **A3.** HTML is byte-identical (step 1), so A3 before = after by construction (derived: identical HTML → identical render input). Record that line instead of a scoring run; run `scripts/score-fidelity-cases.py` only if step 1 shows any diff, which is itself a stop. + +--- + +## ACCEPTANCE CRITERIA + +- [ ] AC 1 — Every committed v1 document round-trips byte-identically: 7/7 `data/debug` cases (Task 7) and the golden components (Task 9). +- [ ] AC 2 — A v2 document using all nine node types validates and round-trips byte-identically (Task 7). +- [ ] AC 3 — A v2 document with an unknown node `type` fails validation with errors at `body.0` only, and fails to load with `ValueError`. +- [ ] AC 4 — `"1.0"` loads with `body=()` against the v1 file; a v1 document with `body` is rejected by both `validate` and `from_json`; unknown versions raise `ValueError`. +- [ ] AC 5 — Converter output byte-identical on all 7 cases (Level 4 step 1); ladder unchanged. +- [ ] AC 6 — `validate()` returns `[]` for every case's `from_legacy` document (closes `phase-53.7-typography-maxitems-cap`). +- [ ] AC 7 — `make check-full` and `make ci-fe` green on the head (observed, named in the report). +- [ ] AC 8 — `/validate-document` accepts v2 and says so in its docstring; SDK regenerated and `make sdk-check` passes; `/schema/v1` serves the relaxed caps; the PR body carries the schema-contract note. + +--- + +## COMPLETION CHECKLIST + +- [ ] Tasks 0–12 done in order, each VALIDATE run +- [ ] RED runs recorded for Task 6/7 tests before the code change +- [ ] Both mutation checks in Task 7 run, five results recorded +- [ ] `make check-full` green; `git diff` re-read after it; skill-versions yaml dates restored +- [ ] Level 4 steps 1–3 run, results tagged observed +- [ ] Ledger edited; report at `.claude/reports/ce-26-document-schema-v2-report.md` + +--- + +## OPEN QUESTIONS / ASSUMPTIONS + +- **Decided (user, 2026-10-05):** relax v1 and v2 together (typography cap + `dividers`). This departs from the issue's "unchanged v1 file"; the PR body states it and why (every real document failed v1 validation). +- A1 — Typography ceiling 1000 is a chosen bound (expected), 4.3× the observed 234 (derived: 1000/234). Change it in Task 1 if the reviewer prefers another bound; nothing else depends on the number. +- A2 — `raw.reason` is a free string matching `^[a-z][a-z0-9_.]*$`, not an enum: the reason codes are the builder's (FR6) and do not exist yet. CE-19 may tighten it to an enum; that is a v2 change before any v2 document is persisted (re-plan trigger T-F). +- A3 — `ContainerStyle` is shared by wrapper, section and column, so a field meaningless on one container (e.g. `full_width` on a column) is representable. The compiler (CE-19) ignores or rejects it. Splitting per container adds three near-identical defs for no reader today. +- A4 — Persisted-row check is local-only (2 rows, both load and round-trip, observed). Production rows, if any, are not reachable from here; the relaxation argument (no valid v1 document can become invalid; `from_json` for `"1.0"` is unchanged apart from the `body` key check) covers them (derived). + +## NOTES + +- **Why copy v1 into v2 instead of `$ref`-ing it.** A cross-file `$ref` needs a `referencing.Registry`, and the public `GET /schema/*` consumers would receive a schema that does not resolve on its own. A self-contained copy plus `TestSchemaParity` gives the same single-source guarantee with no resolver. +- **Why `validate` falls back to v1 for unknown versions** rather than returning a custom error: it keeps today's error text for `"3.0"`/missing versions, so the API's existing behaviour and tests stay the same apart from `"2.0"`. +- **Why `from_json` does not enforce S2 minimums.** The loader has never validated (persisted documents load without `validate`); adding validation to the loader would change the v1 path. The schema owns structure; the dataclass owns type tags and the parent-child type table. +- **Corpus facts (observed 2026-10-05 probe on `94e84b95`):** sections per case 5:15, 6:9, 7:21, 8:11, 9:11, 10:17, reframe:16; typography 234 on all 7. + +## AMENDMENTS + +- 2026-10-05 — Risks R1–R3 from the planning report closed before execution. R1: Task 12 adds an endpoint test and the PR-body schema-contract note (stale-cache argument). R2: typing pattern prototyped and pinned in Task 3 (check-before-parse found by the prototype). R3: Task 11 brings the validate-route docstring and SDK regen into scope. Also: `version` read inside the `try` (versionless snapshot tests), error-path assertions pinned to `body.0`, persisted-row probe run and a new deferred entry for whole-file cap breaches. +- 2026-10-05 (execution, report `.claude/reports/ce-26-document-schema-v2-report.md` D1–D10): + - Task 11 docstring reads `(1.0, 2.0)`. The `falsy-numeric-trap` hook flags `1.0 or 2.0`. + - Task 2: v2 was generated from the post-Task-1 v1 with `json.dumps(indent=2)` instead of being hand-copied. Each container's `contains` also requires `type: object` and `type`. + - Task 7/8: the v2 tests and node tests could not be RED-first, because they need `dsl.nodes`. M1 and M2 stand in for that. M2 also turns `test_root_properties_v1_plus_body` red. + - Extra tests: + - `test_v2_empty_body_validates` + - `test_node_type_consts` + - `test_column_rejects_section_child` + - `test_container_style_rejects_wrong_length` + - `test_missing_type_raises_key_error` + - Task 4: `schema()` calls `_load_schema("1.0")` so it shares the validator's cache key. + - Unknown node types raise `NodeType`'s own `ValueError`, without the `Malformed` prefix. + - Level 4 step 3: `POST /validate-document` was not run live (it needs auth). The TestClient test covers it. diff --git a/app/design_sync/dsl/__init__.py b/app/design_sync/dsl/__init__.py new file mode 100644 index 00000000..bc494f8c --- /dev/null +++ b/app/design_sync/dsl/__init__.py @@ -0,0 +1,5 @@ +"""DSL layout tree for EmailDesignDocument v2 (CE-26). + +Nodes live in :mod:`app.design_sync.dsl.nodes`. The tree builder and the +MJML compiler that read and write them are later tickets (CE-19, CE-20). +""" diff --git a/app/design_sync/dsl/nodes.py b/app/design_sync/dsl/nodes.py new file mode 100644 index 00000000..d3069256 --- /dev/null +++ b/app/design_sync/dsl/nodes.py @@ -0,0 +1,483 @@ +"""DSL layout-tree nodes carried in ``EmailDesignDocument.body`` (version 2.0). + +Nine node types (spec ``docs/architecture/dsl-compiler.md`` S1-S3): three +containers (``wrapper``, ``section``, ``column``) and six leaves (``text``, +``image``, ``button``, ``divider``, ``spacer``, ``raw``). JSON Schema lives +in ``data/schemas/email-design-document-v2.json`` as the ``node_*`` defs. + +``from_json`` checks type tags and the parent-child table (S2 "DSL allows") +but not the minimums (a wrapper needs a section, a section a column); those +are the schema's job, as loading has never validated. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +from typing import Any, ClassVar + +from app.design_sync.email_design_document import DocumentButton, DocumentImage, DocumentText +from app.design_sync.font_stacks import FontCategory + + +class NodeType(StrEnum): + """Node ``type`` tag; values match the schema ``const``s.""" + + WRAPPER = "wrapper" + SECTION = "section" + COLUMN = "column" + TEXT = "text" + IMAGE = "image" + BUTTON = "button" + DIVIDER = "divider" + SPACER = "spacer" + RAW = "raw" + + +class Sizing(StrEnum): + """Figma ``layoutSizingHorizontal``, raw upper-case.""" + + FIXED = "FIXED" + FILL = "FILL" + HUG = "HUG" + + +class TextRole(StrEnum): + HEADING = "heading" + BODY = "body" + LABEL = "label" + CTA = "cta" + + +class HorizontalAlign(StrEnum): + LEFT = "left" + CENTER = "center" + RIGHT = "right" + + +class VerticalAlign(StrEnum): + TOP = "top" + MIDDLE = "middle" + BOTTOM = "bottom" + + +class DividerStyle(StrEnum): + SOLID = "solid" + DASHED = "dashed" + DOTTED = "dotted" + + +Padding = tuple[int, int, int, int] +"""(top, right, bottom, left) px.""" + +Radius = int | tuple[int, int, int, int] +"""Scalar px, or per-corner (tl, tr, br, bl) px.""" + +_GENERIC_FAMILIES = frozenset(c.value for c in FontCategory) + + +def _four(value: list[int]) -> tuple[int, int, int, int]: + if len(value) != 4: + raise ValueError(f"expected 4 values, got {len(value)}") + return (value[0], value[1], value[2], value[3]) + + +def _opt_sizing(value: str | None) -> Sizing | None: + return Sizing(value) if value is not None else None + + +@dataclass(frozen=True) +class ContainerStyle: + """Paint and box properties shared by wrapper, section and column nodes.""" + + background_color: str | None = None + radius: Radius | None = None + padding: Padding | None = None + vertical_align: VerticalAlign | None = None + sizing: Sizing | None = None + full_width: bool = False + background_image: DocumentImage | None = None + + def to_json(self) -> dict[str, Any]: + d: dict[str, Any] = {} + if self.background_color is not None: + d["background_color"] = self.background_color + if self.radius is not None: + d["radius"] = self.radius if isinstance(self.radius, int) else list(self.radius) + if self.padding is not None: + d["padding"] = list(self.padding) + if self.vertical_align is not None: + d["vertical_align"] = self.vertical_align.value + if self.sizing is not None: + d["sizing"] = self.sizing.value + if self.full_width: + d["full_width"] = self.full_width + if self.background_image is not None: + d["background_image"] = self.background_image.to_json() + return d + + @classmethod + def from_json(cls, data: dict[str, Any]) -> ContainerStyle: + radius = data.get("radius") + padding = data.get("padding") + vertical_align = data.get("vertical_align") + background_image = data.get("background_image") + return cls( + background_color=data.get("background_color"), + radius=None if radius is None else radius if isinstance(radius, int) else _four(radius), + padding=_four(padding) if padding is not None else None, + vertical_align=VerticalAlign(vertical_align) if vertical_align is not None else None, + sizing=_opt_sizing(data.get("sizing")), + full_width=data.get("full_width", False), + background_image=( + DocumentImage.from_json(background_image) if background_image is not None else None + ), + ) + + +def _opt_style(data: dict[str, Any]) -> ContainerStyle | None: + style = data.get("style") + return ContainerStyle.from_json(style) if style is not None else None + + +@dataclass(frozen=True) +class _Node: + """Fields every node carries: the Figma node ``id`` and its ``name``.""" + + type: ClassVar[NodeType] + id: str + name: str + + def _head(self) -> dict[str, Any]: + return {"type": self.type.value, "id": self.id, "name": self.name} + + +# ── Leaves ────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class TextNode(_Node): + """Text leaf; ``font_stack`` ends in a generic family (spec S3).""" + + type: ClassVar[NodeType] = NodeType.TEXT + text: DocumentText + role: TextRole + font_stack: tuple[str, ...] + + def __post_init__(self) -> None: + if not self.font_stack or self.font_stack[-1] not in _GENERIC_FAMILIES: + raise ValueError( + f"font_stack must end in one of {sorted(_GENERIC_FAMILIES)}: {self.font_stack!r}" + ) + + def to_json(self) -> dict[str, Any]: + d = self._head() + d["text"] = self.text.to_json() + d["role"] = self.role.value + d["font_stack"] = list(self.font_stack) + return d + + @classmethod + def from_json(cls, data: dict[str, Any]) -> TextNode: + return cls( + id=data["id"], + name=data["name"], + text=DocumentText.from_json(data["text"]), + role=TextRole(data["role"]), + font_stack=tuple(data["font_stack"]), + ) + + +@dataclass(frozen=True) +class ImageNode(_Node): + type: ClassVar[NodeType] = NodeType.IMAGE + image: DocumentImage + alt: str + href: str | None = None + sizing: Sizing | None = None + + def to_json(self) -> dict[str, Any]: + d = self._head() + d["image"] = self.image.to_json() + d["alt"] = self.alt + if self.href is not None: + d["href"] = self.href + if self.sizing is not None: + d["sizing"] = self.sizing.value + return d + + @classmethod + def from_json(cls, data: dict[str, Any]) -> ImageNode: + return cls( + id=data["id"], + name=data["name"], + image=DocumentImage.from_json(data["image"]), + alt=data["alt"], + href=data.get("href"), + sizing=_opt_sizing(data.get("sizing")), + ) + + +@dataclass(frozen=True) +class ButtonNode(_Node): + type: ClassVar[NodeType] = NodeType.BUTTON + button: DocumentButton + align: HorizontalAlign | None = None + sizing: Sizing | None = None + + def to_json(self) -> dict[str, Any]: + d = self._head() + d["button"] = self.button.to_json() + if self.align is not None: + d["align"] = self.align.value + if self.sizing is not None: + d["sizing"] = self.sizing.value + return d + + @classmethod + def from_json(cls, data: dict[str, Any]) -> ButtonNode: + align = data.get("align") + return cls( + id=data["id"], + name=data["name"], + button=DocumentButton.from_json(data["button"]), + align=HorizontalAlign(align) if align is not None else None, + sizing=_opt_sizing(data.get("sizing")), + ) + + +@dataclass(frozen=True) +class DividerNode(_Node): + type: ClassVar[NodeType] = NodeType.DIVIDER + thickness: int + color: str + style: DividerStyle + width: int | None = None + + def to_json(self) -> dict[str, Any]: + d = self._head() + d["thickness"] = self.thickness + d["color"] = self.color + d["style"] = self.style.value + if self.width is not None: + d["width"] = self.width + return d + + @classmethod + def from_json(cls, data: dict[str, Any]) -> DividerNode: + return cls( + id=data["id"], + name=data["name"], + thickness=data["thickness"], + color=data["color"], + style=DividerStyle(data["style"]), + width=data.get("width"), + ) + + +@dataclass(frozen=True) +class SpacerNode(_Node): + type: ClassVar[NodeType] = NodeType.SPACER + height: int + background_color: str | None = None + + def to_json(self) -> dict[str, Any]: + d = self._head() + d["height"] = self.height + if self.background_color is not None: + d["background_color"] = self.background_color + return d + + @classmethod + def from_json(cls, data: dict[str, Any]) -> SpacerNode: + return cls( + id=data["id"], + name=data["name"], + height=data["height"], + background_color=data.get("background_color"), + ) + + +@dataclass(frozen=True) +class RawNode(_Node): + """Pass-through HTML, stored as given; the tree builder sanitises it first.""" + + type: ClassVar[NodeType] = NodeType.RAW + html: str + reason: str + + def to_json(self) -> dict[str, Any]: + d = self._head() + d["html"] = self.html + d["reason"] = self.reason + return d + + @classmethod + def from_json(cls, data: dict[str, Any]) -> RawNode: + return cls(id=data["id"], name=data["name"], html=data["html"], reason=data["reason"]) + + +ColumnChild = TextNode | ImageNode | ButtonNode | DividerNode | SpacerNode | RawNode + + +# ── Containers ────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class ColumnNode(_Node): + type: ClassVar[NodeType] = NodeType.COLUMN + children: tuple[ColumnChild, ...] + style: ContainerStyle | None = None + + def to_json(self) -> dict[str, Any]: + d = self._head() + if self.style is not None: + d["style"] = self.style.to_json() + d["children"] = [c.to_json() for c in self.children] + return d + + @classmethod + def from_json(cls, data: dict[str, Any]) -> ColumnNode: + return cls( + id=data["id"], + name=data["name"], + children=tuple(_column_child(c) for c in data["children"]), + style=_opt_style(data), + ) + + +@dataclass(frozen=True) +class SectionNode(_Node): + type: ClassVar[NodeType] = NodeType.SECTION + children: tuple[ColumnNode | RawNode, ...] + style: ContainerStyle | None = None + + def to_json(self) -> dict[str, Any]: + d = self._head() + if self.style is not None: + d["style"] = self.style.to_json() + d["children"] = [c.to_json() for c in self.children] + return d + + @classmethod + def from_json(cls, data: dict[str, Any]) -> SectionNode: + return cls( + id=data["id"], + name=data["name"], + children=tuple(_section_child(c) for c in data["children"]), + style=_opt_style(data), + ) + + +@dataclass(frozen=True) +class WrapperNode(_Node): + type: ClassVar[NodeType] = NodeType.WRAPPER + children: tuple[SectionNode | RawNode, ...] + style: ContainerStyle | None = None + + def to_json(self) -> dict[str, Any]: + d = self._head() + if self.style is not None: + d["style"] = self.style.to_json() + d["children"] = [c.to_json() for c in self.children] + return d + + @classmethod + def from_json(cls, data: dict[str, Any]) -> WrapperNode: + return cls( + id=data["id"], + name=data["name"], + children=tuple(_wrapper_child(c) for c in data["children"]), + style=_opt_style(data), + ) + + +BodyNode = WrapperNode | SectionNode | RawNode +AnyNode = ( + WrapperNode + | SectionNode + | ColumnNode + | TextNode + | ImageNode + | ButtonNode + | DividerNode + | SpacerNode + | RawNode +) + +NODE_CLASSES: dict[NodeType, type[AnyNode]] = { + NodeType.WRAPPER: WrapperNode, + NodeType.SECTION: SectionNode, + NodeType.COLUMN: ColumnNode, + NodeType.TEXT: TextNode, + NodeType.IMAGE: ImageNode, + NodeType.BUTTON: ButtonNode, + NodeType.DIVIDER: DividerNode, + NodeType.SPACER: SpacerNode, + NodeType.RAW: RawNode, +} + +# Allowed children per parent (spec S2, "DSL allows" column). +_BODY_CHILDREN = frozenset({NodeType.WRAPPER, NodeType.SECTION, NodeType.RAW}) +_WRAPPER_CHILDREN = frozenset({NodeType.SECTION, NodeType.RAW}) +_SECTION_CHILDREN = frozenset({NodeType.COLUMN, NodeType.RAW}) +_COLUMN_CHILDREN = frozenset( + { + NodeType.TEXT, + NodeType.IMAGE, + NodeType.BUTTON, + NodeType.DIVIDER, + NodeType.SPACER, + NodeType.RAW, + } +) + + +def _node_from_json(data: dict[str, Any], allowed: frozenset[NodeType], parent: str) -> AnyNode: + # Check the tag before parsing, so an illegal child fails here rather than + # with a KeyError inside the wrong class's parser. + node_type = NodeType(data["type"]) + if node_type not in allowed: + raise ValueError(f"node type {node_type.value!r} not allowed in a {parent}") + return NODE_CLASSES[node_type].from_json(data) + + +def _not_allowed(node: AnyNode, parent: str) -> ValueError: + return ValueError(f"node type {node.type.value!r} not allowed in a {parent}") + + +# The isinstance checks below cannot fail after the tag check; they narrow the +# AnyNode union for the type checker. + + +def _body_child(data: dict[str, Any]) -> BodyNode: + node = _node_from_json(data, _BODY_CHILDREN, "body") + if not isinstance(node, WrapperNode | SectionNode | RawNode): + raise _not_allowed(node, "body") + return node + + +def _wrapper_child(data: dict[str, Any]) -> SectionNode | RawNode: + node = _node_from_json(data, _WRAPPER_CHILDREN, "wrapper") + if not isinstance(node, SectionNode | RawNode): + raise _not_allowed(node, "wrapper") + return node + + +def _section_child(data: dict[str, Any]) -> ColumnNode | RawNode: + node = _node_from_json(data, _SECTION_CHILDREN, "section") + if not isinstance(node, ColumnNode | RawNode): + raise _not_allowed(node, "section") + return node + + +def _column_child(data: dict[str, Any]) -> ColumnChild: + node = _node_from_json(data, _COLUMN_CHILDREN, "column") + if not isinstance(node, TextNode | ImageNode | ButtonNode | DividerNode | SpacerNode | RawNode): + raise _not_allowed(node, "column") + return node + + +def body_from_json(items: list[dict[str, Any]]) -> tuple[BodyNode, ...]: + """Parse ``EmailDesignDocument.body``; raises ``ValueError`` on an unknown or misplaced type.""" + return tuple(_body_child(item) for item in items) diff --git a/app/design_sync/dsl/tests/__init__.py b/app/design_sync/dsl/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/design_sync/dsl/tests/test_nodes.py b/app/design_sync/dsl/tests/test_nodes.py new file mode 100644 index 00000000..eabccd1e --- /dev/null +++ b/app/design_sync/dsl/tests/test_nodes.py @@ -0,0 +1,160 @@ +"""DSL node dataclasses — round-trip, omitted defaults, nesting and value guards.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from app.design_sync.dsl.nodes import ( + NODE_CLASSES, + AnyNode, + ButtonNode, + ColumnChild, + ColumnNode, + ContainerStyle, + DividerNode, + DividerStyle, + HorizontalAlign, + ImageNode, + NodeType, + RawNode, + SectionNode, + Sizing, + SpacerNode, + TextNode, + TextRole, + VerticalAlign, + WrapperNode, + body_from_json, +) +from app.design_sync.email_design_document import DocumentButton, DocumentImage, DocumentText + +_TEXT = DocumentText(node_id="1:2", content="Hello", font_size=24.0, font_family="Inter") +_IMAGE = DocumentImage(node_id="1:3", node_name="hero", width=600.0, height=300.0) +_BUTTON = DocumentButton(node_id="1:4", text="Shop now", fill_color="#000000") +_STACK = ("Inter", "Helvetica", "Arial", "sans-serif") + + +def _round_trip(node: AnyNode) -> dict[str, Any]: + data = node.to_json() + assert NODE_CLASSES[node.type].from_json(data) == node + return data + + +_FULL_LEAVES: list[ColumnChild] = [ + TextNode(id="t", name="t", text=_TEXT, role=TextRole.BODY, font_stack=_STACK), + ImageNode( + id="i", name="i", image=_IMAGE, alt="Hero", href="https://x.test", sizing=Sizing.FILL + ), + ButtonNode(id="b", name="b", button=_BUTTON, align=HorizontalAlign.RIGHT, sizing=Sizing.FIXED), + DividerNode( + id="d", name="d", thickness=2, color="#dddddd", style=DividerStyle.DASHED, width=200 + ), + SpacerNode(id="s", name="s", height=32, background_color="#f0f0f0"), + RawNode(id="r", name="r", html="
", reason="dsl.overlap"), +] + + +@pytest.mark.parametrize("node", _FULL_LEAVES, ids=lambda n: n.type.value) +def test_leaf_round_trip_all_fields(node: AnyNode) -> None: + data = _round_trip(node) + assert list(data)[:3] == ["type", "id", "name"] + assert data["type"] == node.type.value + + +def test_leaves_omit_absent_optionals() -> None: + image = _round_trip(ImageNode(id="i", name="i", image=_IMAGE, alt="")) + assert set(image) == {"type", "id", "name", "image", "alt"} + button = _round_trip(ButtonNode(id="b", name="b", button=_BUTTON)) + assert set(button) == {"type", "id", "name", "button"} + divider = _round_trip( + DividerNode(id="d", name="d", thickness=1, color="#000000", style=DividerStyle.SOLID) + ) + assert "width" not in divider + spacer = _round_trip(SpacerNode(id="s", name="s", height=8)) + assert set(spacer) == {"type", "id", "name", "height"} + + +def test_containers_round_trip_with_and_without_style() -> None: + column = ColumnNode(id="c", name="c", children=tuple(_FULL_LEAVES)) + assert "style" not in _round_trip(column) + styled = ColumnNode( + id="c2", + name="c2", + children=(), + style=ContainerStyle( + background_color="#ffffff", + radius=(4, 4, 0, 0), + padding=(8, 16, 8, 16), + vertical_align=VerticalAlign.MIDDLE, + sizing=Sizing.HUG, + full_width=True, + background_image=_IMAGE, + ), + ) + _round_trip(styled) + section = SectionNode(id="s", name="s", children=(column, RawNode("r", "r", "", "x"))) + wrapper = WrapperNode(id="w", name="w", children=(section,), style=ContainerStyle(radius=8)) + data = _round_trip(wrapper) + assert data["style"] == {"radius": 8} + + +def test_container_style_radius_shapes() -> None: + scalar = ContainerStyle(radius=12) + assert scalar.to_json() == {"radius": 12} + assert ContainerStyle.from_json(scalar.to_json()) == scalar + corners = ContainerStyle(radius=(1, 2, 3, 4)) + assert corners.to_json() == {"radius": [1, 2, 3, 4]} + restored = ContainerStyle.from_json(corners.to_json()) + assert restored == corners + assert isinstance(restored.radius, tuple) + assert ContainerStyle().to_json() == {} + + +def test_container_style_rejects_wrong_length() -> None: + with pytest.raises(ValueError, match="expected 4 values"): + ContainerStyle.from_json({"padding": [1, 2, 3]}) + with pytest.raises(ValueError, match="expected 4 values"): + ContainerStyle.from_json({"radius": [1, 2]}) + + +def test_body_from_json_rejects_column_under_body() -> None: + column = ColumnNode(id="c", name="c", children=()).to_json() + with pytest.raises(ValueError, match="'column' not allowed in a body"): + body_from_json([column]) + + +def test_section_rejects_text_child() -> None: + text = TextNode(id="t", name="t", text=_TEXT, role=TextRole.BODY, font_stack=_STACK) + data = {"type": "section", "id": "s", "name": "s", "children": [text.to_json()]} + with pytest.raises(ValueError, match="'text' not allowed in a section"): + SectionNode.from_json(data) + + +def test_column_rejects_section_child() -> None: + section = SectionNode(id="s", name="s", children=()).to_json() + data = {"type": "column", "id": "c", "name": "c", "children": [section]} + with pytest.raises(ValueError, match="'section' not allowed in a column"): + ColumnNode.from_json(data) + + +def test_unknown_type_raises_value_error() -> None: + with pytest.raises(ValueError, match="'carousel' is not a valid NodeType"): + body_from_json([{"type": "carousel", "id": "x", "name": "x"}]) + + +def test_missing_type_raises_key_error() -> None: + with pytest.raises(KeyError): + body_from_json([{"id": "x", "name": "x", "html": "", "reason": "x"}]) + + +@pytest.mark.parametrize("stack", [(), ("Inter",), ("sans-serif", "Inter")]) +def test_text_node_rejects_stack_without_trailing_generic(stack: tuple[str, ...]) -> None: + with pytest.raises(ValueError, match="font_stack must end in"): + TextNode(id="t", name="t", text=_TEXT, role=TextRole.BODY, font_stack=stack) + + +def test_node_classes_cover_every_type() -> None: + assert set(NODE_CLASSES) == set(NodeType) + assert all(cls.type is t for t, cls in NODE_CLASSES.items()) diff --git a/app/design_sync/email_design_document.py b/app/design_sync/email_design_document.py index b76dcd2b..95874ba6 100644 --- a/app/design_sync/email_design_document.py +++ b/app/design_sync/email_design_document.py @@ -1,8 +1,10 @@ -"""EmailDesignDocument v1 — canonical intermediate representation. +"""EmailDesignDocument — canonical intermediate representation. Single contract between all input sources (Figma, Penpot, MJML, HTML) -and the email converter. JSON Schema lives at -``data/schemas/email-design-document-v1.json``. +and the email converter. JSON Schemas live at +``data/schemas/email-design-document-v1.json`` (``version`` "1.0") and +``data/schemas/email-design-document-v2.json`` ("2.0": v1 plus the ``body`` +layout tree, :mod:`app.design_sync.dsl.nodes`). """ from __future__ import annotations @@ -40,25 +42,28 @@ ) if TYPE_CHECKING: + from app.design_sync.dsl.nodes import BodyNode from app.design_sync.protocol import DesignFileStructure, DesignNode from app.design_sync.vlm_classifier import VLMSectionClassification -_SCHEMA_PATH = ( - Path(__file__).resolve().parents[2] / "data" / "schemas" / "email-design-document-v1.json" -) +_SCHEMA_DIR = Path(__file__).resolve().parents[2] / "data" / "schemas" +_SCHEMA_FILES: dict[str, str] = { + "1.0": "email-design-document-v1.json", + "2.0": "email-design-document-v2.json", +} # ── helpers ───────────────────────────────────────────────────────── -@lru_cache(maxsize=1) -def _load_schema() -> dict[str, Any]: - return json.loads(_SCHEMA_PATH.read_text()) # type: ignore[no-any-return] +@lru_cache(maxsize=len(_SCHEMA_FILES)) +def _load_schema(version: str = "1.0") -> dict[str, Any]: + return json.loads((_SCHEMA_DIR / _SCHEMA_FILES[version]).read_text()) # type: ignore[no-any-return] -@lru_cache(maxsize=1) -def _get_validator() -> Draft202012Validator: - return Draft202012Validator(_load_schema()) +@lru_cache(maxsize=len(_SCHEMA_FILES)) +def _get_validator(version: str = "1.0") -> Draft202012Validator: + return Draft202012Validator(_load_schema(version)) # ── Document sub-structures ───────────────────────────────────────── @@ -1436,6 +1441,12 @@ class EmailDesignDocument: source: DocumentSource | None = None compatibility_hints: list[CompatibilityHint] = field(default_factory=list[CompatibilityHint]) token_warnings: list[TokenWarning] = field(default_factory=list[TokenWarning]) + # CE-26 — layout tree, version "2.0" only; v1 documents carry (). + body: tuple[BodyNode, ...] = () + + def __post_init__(self) -> None: + if self.version != "2.0" and self.body: + raise ValueError("body requires version 2.0") def to_json(self) -> dict[str, Any]: d: dict[str, Any] = { @@ -1444,6 +1455,9 @@ def to_json(self) -> dict[str, Any]: "sections": [s.to_json() for s in self.sections], "layout": self.layout.to_json(), } + # Always written for v2 (the schema requires it); never for v1. + if self.version == "2.0": + d["body"] = [n.to_json() for n in self.body] if self.source is not None: d["source"] = self.source.to_json() if self.compatibility_hints: @@ -1456,12 +1470,27 @@ def to_json(self) -> dict[str, Any]: def from_json(cls, data: dict[str, Any]) -> EmailDesignDocument: """Deserialize from a JSON-compatible dict. - Raises ``ValueError`` if the data is malformed (missing keys, wrong types). + Raises ``ValueError`` if the data is malformed (missing keys, wrong types), + the version is unsupported, or a "1.0" document carries ``body``. """ try: + version = data["version"] + body: tuple[BodyNode, ...] + if version == "1.0": + if "body" in data: + raise ValueError( + "Malformed EmailDesignDocument: body is not allowed in version 1.0" + ) + body = () + elif version == "2.0": + from app.design_sync.dsl.nodes import body_from_json + + body = body_from_json(data["body"]) + else: + raise ValueError(f"Unsupported EmailDesignDocument version: {version!r}") source_data = data.get("source") return cls( - version=data["version"], + version=version, tokens=DocumentTokens.from_json(data["tokens"]), sections=[DocumentSection.from_json(s) for s in data["sections"]], layout=DocumentLayout.from_json(data["layout"]), @@ -1470,14 +1499,21 @@ def from_json(cls, data: dict[str, Any]) -> EmailDesignDocument: CompatibilityHint.from_json(h) for h in data.get("compatibility_hints", []) ], token_warnings=[TokenWarning.from_json(w) for w in data.get("token_warnings", [])], + body=body, ) except (KeyError, TypeError) as exc: raise ValueError(f"Malformed EmailDesignDocument: {exc}") from exc @staticmethod def validate(data: dict[str, Any]) -> list[str]: - """Validate a dict against the JSON Schema. Returns error messages (empty = valid).""" - validator = _get_validator() + """Validate a dict against the JSON Schema for its ``version``. + + Returns error messages (empty = valid). A missing, unknown or + non-string version is checked against v1, which reports it. + """ + version = data.get("version") + key = version if isinstance(version, str) and version in _SCHEMA_FILES else "1.0" + validator = _get_validator(key) errors: list[str] = [] for error in validator.iter_errors(data): # pyright: ignore[reportUnknownMemberType] path = ( @@ -1489,7 +1525,7 @@ def validate(data: dict[str, Any]) -> list[str]: @staticmethod def schema() -> dict[str, Any]: """Return the raw JSON Schema dict (cached).""" - return _load_schema() + return _load_schema("1.0") # ── Bridge methods ────────────────────────────────────────────── diff --git a/app/design_sync/routes.py b/app/design_sync/routes.py index b9bd07fc..0d3070a5 100644 --- a/app/design_sync/routes.py +++ b/app/design_sync/routes.py @@ -884,7 +884,7 @@ async def validate_document( request: Request, current_user: User = Depends(require_role("viewer")), ) -> DocumentValidationResponse: - """Validate a JSON body against the EmailDesignDocument v1 schema.""" + """Validate a JSON body against the EmailDesignDocument schema for its version (1.0, 2.0).""" _ = current_user content_length = request.headers.get("content-length") if content_length is not None: diff --git a/app/design_sync/tests/test_document_v2.py b/app/design_sync/tests/test_document_v2.py new file mode 100644 index 00000000..da139039 --- /dev/null +++ b/app/design_sync/tests/test_document_v2.py @@ -0,0 +1,256 @@ +"""EmailDesignDocument v2 — version dispatch, v1/v2 schema parity, real-case corpus.""" + +from __future__ import annotations + +import json +from collections.abc import Iterator +from dataclasses import replace +from pathlib import Path +from typing import Any + +import pytest + +from app.design_sync.diagnose.report import load_structure_from_json, load_tokens_from_json +from app.design_sync.dsl.nodes import ( + NODE_CLASSES, + AnyNode, + BodyNode, + ButtonNode, + ColumnNode, + ContainerStyle, + DividerNode, + DividerStyle, + HorizontalAlign, + ImageNode, + NodeType, + RawNode, + SectionNode, + Sizing, + SpacerNode, + TextNode, + TextRole, + VerticalAlign, + WrapperNode, +) +from app.design_sync.email_design_document import ( + DocumentButton, + DocumentImage, + DocumentText, + EmailDesignDocument, +) +from app.design_sync.tests.regression_runner import discover_cases + +_CASES = discover_cases() +_SCHEMA_DIR = Path(__file__).resolve().parents[3] / "data" / "schemas" +_V1 = json.loads((_SCHEMA_DIR / "email-design-document-v1.json").read_text()) +_V2 = json.loads((_SCHEMA_DIR / "email-design-document-v2.json").read_text()) + + +def _case_document(case_dir: Path) -> EmailDesignDocument: + structure_path = case_dir / "structure.json" + tokens_path = case_dir / "tokens.json" + if not structure_path.exists() or not tokens_path.exists(): + pytest.skip(f"{case_dir.name}: missing structure.json/tokens.json") + return EmailDesignDocument.from_legacy( + load_structure_from_json(structure_path), load_tokens_from_json(tokens_path) + ) + + +# ── Real-case corpus (v1) ── + + +@pytest.mark.parametrize("case_dir", _CASES, ids=[c.name for c in _CASES]) +def test_case_v1_round_trip_byte_identical(case_dir: Path) -> None: + data = _case_document(case_dir).to_json() + restored = EmailDesignDocument.from_json(json.loads(json.dumps(data))) + assert json.dumps(restored.to_json()) == json.dumps(data) + + +@pytest.mark.parametrize("case_dir", _CASES, ids=[c.name for c in _CASES]) +def test_case_v1_document_validates(case_dir: Path) -> None: + assert EmailDesignDocument.validate(_case_document(case_dir).to_json()) == [] + + +# ── v2 documents built on case 5 ── + + +def _case5_document() -> EmailDesignDocument: + case_dir = next((c for c in _CASES if c.name == "5"), None) + if case_dir is None: + pytest.skip("case 5 not present") + return _case_document(case_dir) + + +def _leaf_payloads( + doc: EmailDesignDocument, +) -> tuple[DocumentText, DocumentImage, DocumentButton]: + """First text, image and button in case 5, so the fixture carries real payloads.""" + texts = [t for s in doc.sections for t in [*s.texts, *(t for c in s.columns for t in c.texts)]] + images = [ + i for s in doc.sections for i in [*s.images, *(i for c in s.columns for i in c.images)] + ] + buttons = [ + b for s in doc.sections for b in [*s.buttons, *(b for c in s.columns for b in c.buttons)] + ] + return texts[0], images[0], buttons[0] + + +def _all_nine_body(doc: EmailDesignDocument) -> tuple[BodyNode, ...]: + text, image, button = _leaf_payloads(doc) + column = ColumnNode( + id="c:1", + name="column", + style=ContainerStyle(background_color="#f4f4f4", radius=(8, 8, 0, 0), sizing=Sizing.FILL), + children=( + TextNode( + id="t:1", + name="heading", + text=text, + role=TextRole.HEADING, + font_stack=("Inter", "Helvetica", "sans-serif"), + ), + ImageNode(id="i:1", name="hero", image=image, alt="Hero", sizing=Sizing.FIXED), + ButtonNode( + id="b:1", name="cta", button=button, align=HorizontalAlign.CENTER, sizing=Sizing.HUG + ), + DividerNode( + id="d:1", name="rule", thickness=1, color="#cccccc", style=DividerStyle.SOLID + ), + SpacerNode(id="s:1", name="gap", height=24), + RawNode( + id="r:1", + name="legal", + html="
x
", + reason="dsl.overlap", + ), + ), + ) + section = SectionNode( + id="sec:1", + name="card", + style=ContainerStyle(radius=12, padding=(16, 24, 16, 24), vertical_align=VerticalAlign.TOP), + children=(column,), + ) + wrapper = WrapperNode( + id="w:1", + name="band", + style=ContainerStyle(background_color="#ffffff", full_width=True, background_image=image), + children=(section,), + ) + bare = SectionNode( + id="sec:2", name="footer", children=(ColumnNode(id="c:2", name="col", children=()),) + ) + return ( + wrapper, + RawNode(id="r:2", name="preheader", html="
", reason="dsl.overlap"), + bare, + ) + + +def _v2_document() -> EmailDesignDocument: + doc = _case5_document() + return replace(doc, version="2.0", body=_all_nine_body(doc)) + + +def v2_case5_json() -> dict[str, Any]: + """A valid v2 document using all nine node types (also used by the endpoint test).""" + return _v2_document().to_json() + + +def _walk(nodes: tuple[AnyNode, ...]) -> Iterator[AnyNode]: + for node in nodes: + yield node + if isinstance(node, WrapperNode | SectionNode | ColumnNode): + yield from _walk(node.children) + + +def _error_paths(errors: list[str]) -> set[str]: + return {e.split(": ", 1)[0] for e in errors} + + +def test_v2_all_nine_round_trip_and_validate() -> None: + doc = _v2_document() + j = doc.to_json() + assert EmailDesignDocument.validate(j) == [] + restored = EmailDesignDocument.from_json(json.loads(json.dumps(j))) + assert json.dumps(restored.to_json()) == json.dumps(j) + assert restored.body == doc.body + assert {n.type for n in _walk(doc.body)} == set(NodeType) + + +def test_v2_empty_body_validates() -> None: + j = replace(_case5_document(), version="2.0").to_json() + assert j["body"] == [] + assert EmailDesignDocument.validate(j) == [] + + +def test_v2_unknown_node_type_fails_validation() -> None: + j = v2_case5_json() + j["body"][0]["children"][0]["children"][0]["children"][0]["type"] = "carousel" + errors = EmailDesignDocument.validate(j) + assert errors != [] + # A nested oneOf failure surfaces at the outermost body item. + assert _error_paths(errors) == {"body.0"} + with pytest.raises(ValueError, match="carousel"): + EmailDesignDocument.from_json(j) + + +def test_v2_illegal_nesting_fails() -> None: + j = v2_case5_json() + column_children = j["body"][0]["children"][0]["children"][0]["children"] + j["body"].append(column_children[0]) # a text node directly under body + errors = EmailDesignDocument.validate(j) + assert _error_paths(errors) == {f"body.{len(j['body']) - 1}"} + with pytest.raises(ValueError, match="not allowed in a body"): + EmailDesignDocument.from_json(j) + + +def test_v2_missing_body_fails_validation() -> None: + j = v2_case5_json() + del j["body"] + errors = EmailDesignDocument.validate(j) + assert any(e.startswith("(root)") and "'body' is a required property" in e for e in errors) + + +# ── Schema parity ── + + +class TestSchemaParity: + def test_shared_defs_identical(self) -> None: + for key, value in _V1["$defs"].items(): + assert _V2["$defs"][key] == value, key + + def test_root_properties_v1_plus_body(self) -> None: + v1_props = {k: v for k, v in _V1["properties"].items() if k != "version"} + v2_props = {k: v for k, v in _V2["properties"].items() if k not in ("version", "body")} + assert v2_props == v1_props + assert _V2["properties"]["version"]["const"] == "2.0" + assert set(_V2["required"]) == set(_V1["required"]) | {"body"} + assert _V2["additionalProperties"] is False + + def test_new_defs(self) -> None: + assert set(_V2["$defs"]) - set(_V1["$defs"]) == {f"node_{t.value}" for t in NodeType} | { + "container_style", + "hex6", + "sizing", + } + + def test_node_type_consts(self) -> None: + for t in NodeType: + assert _V2["$defs"][f"node_{t.value}"]["properties"]["type"] == {"const": t.value} + + def test_enum_parity(self) -> None: + defs = _V2["$defs"] + assert set(NODE_CLASSES) == set(NodeType) + assert all(NODE_CLASSES[t].type is t for t in NodeType) + assert set(defs["sizing"]["enum"]) == {s.value for s in Sizing} + assert set(defs["node_text"]["properties"]["role"]["enum"]) == {r.value for r in TextRole} + assert set(defs["node_divider"]["properties"]["style"]["enum"]) == { + s.value for s in DividerStyle + } + assert set(defs["node_button"]["properties"]["align"]["enum"]) == { + a.value for a in HorizontalAlign + } + assert set(defs["container_style"]["properties"]["vertical_align"]["enum"]) == { + v.value for v in VerticalAlign + } diff --git a/app/design_sync/tests/test_email_design_document.py b/app/design_sync/tests/test_email_design_document.py index 85c6fc7a..8be365fd 100644 --- a/app/design_sync/tests/test_email_design_document.py +++ b/app/design_sync/tests/test_email_design_document.py @@ -206,7 +206,7 @@ def test_missing_layout(self) -> None: def test_wrong_version(self) -> None: data: dict[str, Any] = { - "version": "2.0", + "version": "3.0", "tokens": {}, "sections": [], "layout": {"container_width": 600}, @@ -214,6 +214,49 @@ def test_wrong_version(self) -> None: errors = EmailDesignDocument.validate(data) assert any("version" in e.lower() or "1.0" in e for e in errors) + def test_non_string_version_reports_error(self) -> None: + data: dict[str, Any] = { + "version": ["1.0"], + "tokens": {}, + "sections": [], + "layout": {"container_width": 600}, + } + errors = EmailDesignDocument.validate(data) + assert errors != [] + + def test_v1_with_body_key_fails_validation_and_load(self) -> None: + data: dict[str, Any] = { + "version": "1.0", + "tokens": {}, + "sections": [], + "layout": {"container_width": 600}, + "body": [], + } + errors = EmailDesignDocument.validate(data) + assert any(e.startswith("(root)") and "body" in e for e in errors) + with pytest.raises(ValueError, match="body"): + EmailDesignDocument.from_json(data) + + def test_unsupported_version_from_json_raises_value_error(self) -> None: + data: dict[str, Any] = { + "version": "3.0", + "tokens": {}, + "sections": [], + "layout": {"container_width": 600}, + } + with pytest.raises(ValueError, match="Unsupported EmailDesignDocument version"): + EmailDesignDocument.from_json(data) + + def test_v1_constructor_rejects_body(self) -> None: + from app.design_sync.dsl.nodes import RawNode + + raw = RawNode(id="1:1", name="raw", html="
", reason="dsl.overlap") + with pytest.raises(ValueError, match=r"body requires version 2\.0"): + _make_document(body=(raw,)) + + def test_v1_to_json_has_no_body_key(self) -> None: + assert "body" not in _make_full_document().to_json() + def test_invalid_section_type(self) -> None: data: dict[str, Any] = { "version": "1.0", @@ -571,6 +614,8 @@ def test_get_schema_v1(self, client: TestClient) -> None: assert body["$schema"] == "https://json-schema.org/draft/2020-12/schema" assert "$defs" in body assert resp.headers.get("cache-control") == "public, max-age=86400" + assert body["$defs"]["tokens"]["properties"]["typography"]["maxItems"] == 1000 + assert "dividers" in body["$defs"]["column"]["properties"] @pytest.mark.usefixtures("_auth_admin") def test_validate_document_valid(self, client: TestClient) -> None: @@ -586,6 +631,16 @@ def test_validate_document_valid(self, client: TestClient) -> None: assert body["valid"] is True assert body["errors"] == [] + @pytest.mark.usefixtures("_auth_admin") + def test_validate_document_accepts_v2(self, client: TestClient) -> None: + from app.design_sync.tests.test_document_v2 import v2_case5_json + + resp = client.post("/api/v1/design-sync/validate-document", json=v2_case5_json()) + assert resp.status_code == 200 + body = resp.json() + assert body["errors"] == [] + assert body["valid"] is True + @pytest.mark.usefixtures("_auth_admin") def test_validate_document_invalid(self, client: TestClient) -> None: data: dict[str, Any] = {"version": "2.0"} diff --git a/app/design_sync/tests/test_golden_roundtrip.py b/app/design_sync/tests/test_golden_roundtrip.py index b7f7301b..6c29eeb4 100644 --- a/app/design_sync/tests/test_golden_roundtrip.py +++ b/app/design_sync/tests/test_golden_roundtrip.py @@ -16,6 +16,7 @@ from __future__ import annotations +import json import re from pathlib import Path from unittest.mock import patch @@ -85,6 +86,8 @@ async def test_schema_valid(self, component: str) -> None: doc = await _import_component(html) errors = EmailDesignDocument.validate(doc.to_json()) assert errors == [], f"Schema errors for {component}: {errors}" + restored = EmailDesignDocument.from_json(doc.to_json()) + assert json.dumps(restored.to_json()) == json.dumps(doc.to_json()) # ── Import fidelity ────────────────────────────────────────────── diff --git a/cms/packages/sdk/openapi.json b/cms/packages/sdk/openapi.json index 869cb357..700c68b6 100644 --- a/cms/packages/sdk/openapi.json +++ b/cms/packages/sdk/openapi.json @@ -6703,7 +6703,7 @@ "design-sync" ], "summary": "Validate Document", - "description": "Validate a JSON body against the EmailDesignDocument v1 schema.", + "description": "Validate a JSON body against the EmailDesignDocument schema for its version (1.0, 2.0).", "operationId": "validate_document_api_v1_design_sync_validate_document_post", "responses": { "200": { diff --git a/cms/packages/sdk/src/client/sdk.gen.ts b/cms/packages/sdk/src/client/sdk.gen.ts index fea68641..5eb1d5fb 100644 --- a/cms/packages/sdk/src/client/sdk.gen.ts +++ b/cms/packages/sdk/src/client/sdk.gen.ts @@ -1795,7 +1795,7 @@ export const getDocumentSchemaApiV1DesignSyncSchemaV1Get = (options?: Options): RequestResult => (options?.client ?? client).post({ security: [{ scheme: 'bearer', type: 'http' }], diff --git a/data/schemas/email-design-document-v1.json b/data/schemas/email-design-document-v1.json index 3e7dbead..b6d68efe 100644 --- a/data/schemas/email-design-document-v1.json +++ b/data/schemas/email-design-document-v1.json @@ -64,7 +64,7 @@ "typography": { "type": "array", "items": { "$ref": "#/$defs/typography" }, - "maxItems": 200, + "maxItems": 1000, "default": [] }, "spacing": { @@ -468,6 +468,21 @@ "items": { "$ref": "#/$defs/button" }, "maxItems": 30, "default": [] + }, + "dividers": { + "type": "array", + "maxItems": 50, + "default": [], + "items": { + "type": "object", + "required": ["node_id", "stroke_color"], + "additionalProperties": false, + "properties": { + "node_id": { "type": "string", "maxLength": 200 }, + "stroke_color": { "type": "string", "maxLength": 20 }, + "stroke_weight": { "type": ["number", "null"], "default": null } + } + } } } }, diff --git a/data/schemas/email-design-document-v2.json b/data/schemas/email-design-document-v2.json new file mode 100644 index 00000000..c47be44b --- /dev/null +++ b/data/schemas/email-design-document-v2.json @@ -0,0 +1,1864 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "email-design-document/v2", + "title": "Email Design Document v2", + "description": "Canonical intermediate representation between design sources and the email converter. v2 adds `body`, a typed layout tree (wrapper/section/column containers and text/image/button/divider/spacer/raw leaves) beside the unchanged flat `sections`.", + "type": "object", + "required": [ + "version", + "tokens", + "sections", + "layout", + "body" + ], + "additionalProperties": false, + "properties": { + "version": { + "const": "2.0", + "description": "Schema version identifier." + }, + "source": { + "$ref": "#/$defs/source" + }, + "tokens": { + "$ref": "#/$defs/tokens" + }, + "sections": { + "type": "array", + "items": { + "$ref": "#/$defs/section" + }, + "maxItems": 100 + }, + "layout": { + "$ref": "#/$defs/layout" + }, + "body": { + "type": "array", + "maxItems": 100, + "description": "Typed layout tree: top-level wrapper, section or raw nodes.", + "items": { + "oneOf": [ + { + "$ref": "#/$defs/node_wrapper" + }, + { + "$ref": "#/$defs/node_section" + }, + { + "$ref": "#/$defs/node_raw" + } + ] + } + }, + "compatibility_hints": { + "type": "array", + "items": { + "$ref": "#/$defs/compatibility_hint" + }, + "maxItems": 200, + "default": [] + }, + "token_warnings": { + "type": "array", + "items": { + "$ref": "#/$defs/token_warning" + }, + "maxItems": 200, + "default": [] + } + }, + "$defs": { + "source": { + "type": "object", + "additionalProperties": false, + "properties": { + "provider": { + "type": "string", + "enum": [ + "figma", + "penpot", + "mjml", + "html", + "manual", + "sketch", + "canva" + ] + }, + "file_ref": { + "type": "string", + "maxLength": 500 + }, + "synced_at": { + "type": "string", + "format": "date-time" + } + } + }, + "tokens": { + "type": "object", + "additionalProperties": false, + "properties": { + "colors": { + "type": "array", + "items": { + "$ref": "#/$defs/color" + }, + "maxItems": 500, + "default": [] + }, + "typography": { + "type": "array", + "items": { + "$ref": "#/$defs/typography" + }, + "maxItems": 1000, + "default": [] + }, + "spacing": { + "type": "array", + "items": { + "$ref": "#/$defs/spacing" + }, + "maxItems": 200, + "default": [] + }, + "dark_colors": { + "type": "array", + "items": { + "$ref": "#/$defs/color" + }, + "maxItems": 500, + "default": [] + }, + "gradients": { + "type": "array", + "items": { + "$ref": "#/$defs/gradient" + }, + "maxItems": 100, + "default": [] + }, + "variables": { + "type": "array", + "items": { + "$ref": "#/$defs/variable" + }, + "maxItems": 500, + "default": [] + }, + "stroke_colors": { + "type": "array", + "items": { + "$ref": "#/$defs/color" + }, + "maxItems": 200, + "default": [] + }, + "variables_source": { + "type": "boolean", + "default": false, + "description": "Whether tokens originated from the Figma Variables API." + }, + "modes": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + }, + "default": null, + "description": "Figma variable mode names keyed by mode ID." + } + } + }, + "color": { + "type": "object", + "required": [ + "name", + "hex" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "maxLength": 200 + }, + "hex": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 1.0 + } + } + }, + "typography": { + "type": "object", + "required": [ + "name", + "family", + "weight", + "size", + "line_height" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "maxLength": 200 + }, + "family": { + "type": "string", + "maxLength": 200 + }, + "weight": { + "type": "string", + "maxLength": 20 + }, + "size": { + "type": "number", + "minimum": 0 + }, + "line_height": { + "type": "number", + "minimum": 0 + }, + "letter_spacing": { + "type": [ + "number", + "null" + ], + "default": null + }, + "text_transform": { + "type": [ + "string", + "null" + ], + "default": null + }, + "text_decoration": { + "type": [ + "string", + "null" + ], + "default": null + } + } + }, + "spacing": { + "type": "object", + "required": [ + "name", + "value" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "maxLength": 200 + }, + "value": { + "type": "number", + "minimum": 0 + } + } + }, + "gradient": { + "type": "object", + "required": [ + "name", + "type", + "angle", + "stops", + "fallback_hex" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "maxLength": 200 + }, + "type": { + "type": "string", + "enum": [ + "linear", + "radial" + ] + }, + "angle": { + "type": "number", + "minimum": 0, + "maximum": 360 + }, + "stops": { + "type": "array", + "items": { + "$ref": "#/$defs/gradient_stop" + }, + "minItems": 1, + "maxItems": 20 + }, + "fallback_hex": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "node_id": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 200 + } + } + }, + "gradient_stop": { + "type": "object", + "required": [ + "hex", + "position" + ], + "additionalProperties": false, + "properties": { + "hex": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "position": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } + }, + "variable": { + "type": "object", + "required": [ + "name", + "collection", + "type", + "values_by_mode" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "maxLength": 200 + }, + "collection": { + "type": "string", + "maxLength": 200 + }, + "type": { + "type": "string", + "enum": [ + "COLOR", + "FLOAT", + "STRING", + "BOOLEAN" + ] + }, + "values_by_mode": { + "type": "object", + "additionalProperties": true + }, + "is_alias": { + "type": "boolean", + "default": false + }, + "alias_path": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 500 + } + } + }, + "section": { + "type": "object", + "required": [ + "id", + "type" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "maxLength": 200 + }, + "type": { + "type": "string", + "enum": [ + "header", + "preheader", + "hero", + "content", + "cta", + "footer", + "social", + "divider", + "spacer", + "nav", + "unknown" + ] + }, + "node_name": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 200 + }, + "y_position": { + "type": [ + "number", + "null" + ], + "default": null + }, + "x_position": { + "type": [ + "number", + "null" + ], + "default": null + }, + "peel_row_id": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 120 + }, + "width": { + "type": [ + "number", + "null" + ], + "default": null + }, + "height": { + "type": [ + "number", + "null" + ], + "default": null + }, + "column_layout": { + "type": "string", + "enum": [ + "single", + "two-column", + "three-column", + "multi-column" + ], + "default": "single" + }, + "column_count": { + "type": "integer", + "minimum": 1, + "default": 1 + }, + "padding": { + "$ref": "#/$defs/padding" + }, + "item_spacing": { + "type": [ + "number", + "null" + ], + "default": null + }, + "background_color": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 20 + }, + "texts": { + "type": "array", + "items": { + "$ref": "#/$defs/text" + }, + "maxItems": 50, + "default": [] + }, + "images": { + "type": "array", + "items": { + "$ref": "#/$defs/image" + }, + "maxItems": 50, + "default": [] + }, + "buttons": { + "type": "array", + "items": { + "$ref": "#/$defs/button" + }, + "maxItems": 30, + "default": [] + }, + "columns": { + "type": "array", + "items": { + "$ref": "#/$defs/column" + }, + "maxItems": 12, + "default": [] + }, + "content_roles": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 20, + "default": [] + }, + "spacing_after": { + "type": [ + "number", + "null" + ], + "default": null + }, + "classification_confidence": { + "type": [ + "number", + "null" + ], + "minimum": 0, + "maximum": 1, + "default": null + }, + "element_gaps": { + "type": "array", + "items": { + "type": "number" + }, + "maxItems": 100, + "default": [] + }, + "child_content_groups": { + "type": "array", + "items": { + "$ref": "#/$defs/content_group" + }, + "maxItems": 50, + "default": [] + }, + "boundary_above": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 50 + }, + "boundary_below": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 50 + }, + "sampled_top_color": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 20 + }, + "sampled_bottom_color": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 20 + }, + "container_bg": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 20 + }, + "parent_wrapper_id": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 200 + }, + "inner_bg": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 20 + }, + "inner_radius": { + "type": [ + "number", + "null" + ], + "default": null + }, + "inner_card_fixed_width": { + "type": [ + "integer", + "null" + ], + "default": null + }, + "is_physical_card_surface": { + "type": "boolean", + "default": false + }, + "physical_card_signals": { + "type": "array", + "items": { + "type": "string", + "maxLength": 100 + }, + "maxItems": 50, + "default": [] + }, + "vlm_classification": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 50 + }, + "vlm_confidence": { + "type": [ + "number", + "null" + ], + "minimum": 0, + "maximum": 1, + "default": null + }, + "gradient_ref": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 200 + }, + "effects_summary": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 500 + }, + "stroke_color": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 50 + }, + "stroke_weight": { + "type": [ + "number", + "null" + ], + "default": null + } + } + }, + "content_group": { + "type": "object", + "required": [ + "frame_node_id", + "frame_name" + ], + "additionalProperties": false, + "properties": { + "frame_node_id": { + "type": "string", + "maxLength": 200 + }, + "frame_name": { + "type": "string", + "maxLength": 200 + }, + "texts": { + "type": "array", + "items": { + "$ref": "#/$defs/text" + }, + "maxItems": 50, + "default": [] + }, + "images": { + "type": "array", + "items": { + "$ref": "#/$defs/image" + }, + "maxItems": 50, + "default": [] + }, + "buttons": { + "type": "array", + "items": { + "$ref": "#/$defs/button" + }, + "maxItems": 30, + "default": [] + }, + "content_order": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + } + }, + "padding": { + "type": "object", + "additionalProperties": false, + "properties": { + "top": { + "type": "number", + "minimum": 0, + "default": 0 + }, + "right": { + "type": "number", + "minimum": 0, + "default": 0 + }, + "bottom": { + "type": "number", + "minimum": 0, + "default": 0 + }, + "left": { + "type": "number", + "minimum": 0, + "default": 0 + } + } + }, + "style_run": { + "type": "object", + "required": [ + "start", + "end" + ], + "additionalProperties": false, + "properties": { + "start": { + "type": "integer", + "minimum": 0 + }, + "end": { + "type": "integer", + "minimum": 0 + }, + "bold": { + "type": "boolean", + "default": false + }, + "italic": { + "type": "boolean", + "default": false + }, + "underline": { + "type": "boolean", + "default": false + }, + "strikethrough": { + "type": "boolean", + "default": false + }, + "color_hex": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 20 + }, + "font_size": { + "type": [ + "number", + "null" + ], + "minimum": 0, + "default": null + }, + "link_url": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 2000 + } + } + }, + "corner_radius_spec": { + "type": "object", + "additionalProperties": false, + "properties": { + "scalar": { + "type": [ + "number", + "null" + ], + "default": null + }, + "per_corner": { + "type": [ + "array", + "null" + ], + "items": { + "type": "number" + }, + "minItems": 4, + "maxItems": 4, + "default": null + } + } + }, + "text": { + "type": "object", + "required": [ + "node_id", + "content" + ], + "additionalProperties": false, + "properties": { + "node_id": { + "type": "string", + "maxLength": 200 + }, + "content": { + "type": "string", + "maxLength": 10000 + }, + "font_size": { + "type": [ + "number", + "null" + ], + "minimum": 0, + "default": null + }, + "is_heading": { + "type": "boolean", + "default": false + }, + "font_family": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 200 + }, + "font_weight": { + "type": [ + "integer", + "null" + ], + "default": null + }, + "line_height": { + "type": [ + "number", + "null" + ], + "minimum": 0, + "default": null + }, + "letter_spacing": { + "type": [ + "number", + "null" + ], + "default": null + }, + "color": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 20 + }, + "text_align": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 20 + }, + "text_transform": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 20 + }, + "text_decoration": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 20 + }, + "hyperlink": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 2000 + }, + "role_hint": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 20 + }, + "style_runs": { + "type": "array", + "items": { + "$ref": "#/$defs/style_run" + }, + "maxItems": 200, + "default": [] + } + } + }, + "image": { + "type": "object", + "required": [ + "node_id", + "node_name" + ], + "additionalProperties": false, + "properties": { + "node_id": { + "type": "string", + "maxLength": 200 + }, + "node_name": { + "type": "string", + "maxLength": 200 + }, + "width": { + "type": [ + "number", + "null" + ], + "default": null + }, + "height": { + "type": [ + "number", + "null" + ], + "default": null + }, + "is_background": { + "type": "boolean", + "default": false + }, + "export_node_id": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 200 + }, + "corner_radius_spec": { + "oneOf": [ + { + "$ref": "#/$defs/corner_radius_spec" + }, + { + "type": "null" + } + ], + "default": null + }, + "stroke_color": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 50 + }, + "stroke_weight": { + "type": [ + "number", + "null" + ], + "default": null + } + } + }, + "button": { + "type": "object", + "required": [ + "node_id", + "text" + ], + "additionalProperties": false, + "properties": { + "node_id": { + "type": "string", + "maxLength": 200 + }, + "text": { + "type": "string", + "maxLength": 500 + }, + "width": { + "type": [ + "number", + "null" + ], + "default": null + }, + "height": { + "type": [ + "number", + "null" + ], + "default": null + }, + "url": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 2000 + }, + "border_radius": { + "type": [ + "number", + "null" + ], + "minimum": 0, + "default": null + }, + "fill_color": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 20 + }, + "text_color": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 20 + }, + "stroke_color": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 20 + }, + "stroke_weight": { + "type": [ + "number", + "null" + ], + "minimum": 0, + "default": null + }, + "icon_node_id": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 200 + }, + "font_size": { + "type": [ + "number", + "null" + ], + "minimum": 0, + "default": null + }, + "font_weight": { + "type": [ + "integer", + "null" + ], + "default": null + }, + "font_family": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 200 + }, + "corner_radius_spec": { + "oneOf": [ + { + "$ref": "#/$defs/corner_radius_spec" + }, + { + "type": "null" + } + ], + "default": null + }, + "padding_top": { + "type": [ + "number", + "null" + ], + "minimum": 0, + "default": null + }, + "padding_right": { + "type": [ + "number", + "null" + ], + "minimum": 0, + "default": null + }, + "padding_bottom": { + "type": [ + "number", + "null" + ], + "minimum": 0, + "default": null + }, + "padding_left": { + "type": [ + "number", + "null" + ], + "minimum": 0, + "default": null + } + } + }, + "column": { + "type": "object", + "required": [ + "column_idx", + "node_id", + "node_name" + ], + "additionalProperties": false, + "properties": { + "column_idx": { + "type": "integer", + "minimum": 0 + }, + "node_id": { + "type": "string", + "maxLength": 200 + }, + "node_name": { + "type": "string", + "maxLength": 200 + }, + "width": { + "type": [ + "number", + "null" + ], + "default": null + }, + "content_order": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "stroke_color": { + "type": [ + "string", + "null" + ], + "default": null + }, + "stroke_weight": { + "type": [ + "number", + "null" + ], + "default": null + }, + "texts": { + "type": "array", + "items": { + "$ref": "#/$defs/text" + }, + "maxItems": 50, + "default": [] + }, + "images": { + "type": "array", + "items": { + "$ref": "#/$defs/image" + }, + "maxItems": 50, + "default": [] + }, + "buttons": { + "type": "array", + "items": { + "$ref": "#/$defs/button" + }, + "maxItems": 30, + "default": [] + }, + "dividers": { + "type": "array", + "maxItems": 50, + "default": [], + "items": { + "type": "object", + "required": [ + "node_id", + "stroke_color" + ], + "additionalProperties": false, + "properties": { + "node_id": { + "type": "string", + "maxLength": 200 + }, + "stroke_color": { + "type": "string", + "maxLength": 20 + }, + "stroke_weight": { + "type": [ + "number", + "null" + ], + "default": null + } + } + } + } + } + }, + "layout": { + "type": "object", + "required": [ + "container_width" + ], + "additionalProperties": false, + "properties": { + "container_width": { + "type": "integer", + "minimum": 400, + "maximum": 800, + "default": 600 + }, + "naming_convention": { + "type": [ + "string", + "null" + ], + "enum": [ + "mjml", + "descriptive", + "generic", + "custom", + null + ], + "default": null + }, + "overall_width": { + "type": [ + "number", + "null" + ], + "default": null + } + } + }, + "compatibility_hint": { + "type": "object", + "required": [ + "level", + "css_property", + "message" + ], + "additionalProperties": false, + "properties": { + "level": { + "type": "string", + "enum": [ + "info", + "warning", + "error" + ] + }, + "css_property": { + "type": "string", + "maxLength": 200 + }, + "message": { + "type": "string", + "maxLength": 1000 + }, + "affected_clients": { + "type": "array", + "items": { + "type": "string", + "maxLength": 100 + }, + "maxItems": 50, + "default": [] + } + } + }, + "token_warning": { + "type": "object", + "required": [ + "level", + "field", + "message" + ], + "additionalProperties": false, + "properties": { + "level": { + "type": "string", + "enum": [ + "error", + "warning", + "info" + ] + }, + "field": { + "type": "string", + "maxLength": 200 + }, + "message": { + "type": "string", + "maxLength": 1000 + }, + "fixed_value": { + "type": [ + "string", + "null" + ], + "default": null, + "maxLength": 500 + } + } + }, + "hex6": { + "type": "string", + "pattern": "^#[0-9a-f]{6}$", + "description": "Lowercase six-digit hex colour (#rrggbb)." + }, + "sizing": { + "enum": [ + "FIXED", + "FILL", + "HUG" + ], + "description": "Figma layoutSizingHorizontal, raw upper-case." + }, + "container_style": { + "type": "object", + "description": "Paint and box properties shared by wrapper, section and column nodes.", + "additionalProperties": false, + "properties": { + "background_color": { + "$ref": "#/$defs/hex6" + }, + "radius": { + "oneOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0 + }, + "minItems": 4, + "maxItems": 4 + } + ], + "description": "Scalar px, or per-corner (tl, tr, br, bl) px." + }, + "padding": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0 + }, + "minItems": 4, + "maxItems": 4, + "description": "(top, right, bottom, left) px." + }, + "vertical_align": { + "enum": [ + "top", + "middle", + "bottom" + ] + }, + "sizing": { + "$ref": "#/$defs/sizing" + }, + "full_width": { + "type": "boolean", + "default": false + }, + "background_image": { + "$ref": "#/$defs/image" + } + } + }, + "node_wrapper": { + "type": "object", + "description": "Full-width band holding one or more sections.", + "required": [ + "type", + "id", + "name", + "children" + ], + "additionalProperties": false, + "properties": { + "type": { + "const": "wrapper" + }, + "id": { + "type": "string", + "maxLength": 200 + }, + "name": { + "type": "string", + "maxLength": 200 + }, + "style": { + "$ref": "#/$defs/container_style" + }, + "children": { + "type": "array", + "maxItems": 50, + "items": { + "oneOf": [ + { + "$ref": "#/$defs/node_section" + }, + { + "$ref": "#/$defs/node_raw" + } + ] + }, + "minItems": 1, + "contains": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "section" + } + } + } + } + } + }, + "node_section": { + "type": "object", + "description": "Row holding one or more columns.", + "required": [ + "type", + "id", + "name", + "children" + ], + "additionalProperties": false, + "properties": { + "type": { + "const": "section" + }, + "id": { + "type": "string", + "maxLength": 200 + }, + "name": { + "type": "string", + "maxLength": 200 + }, + "style": { + "$ref": "#/$defs/container_style" + }, + "children": { + "type": "array", + "maxItems": 50, + "items": { + "oneOf": [ + { + "$ref": "#/$defs/node_column" + }, + { + "$ref": "#/$defs/node_raw" + } + ] + }, + "minItems": 1, + "contains": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "column" + } + } + } + } + } + }, + "node_column": { + "type": "object", + "description": "Column holding leaf nodes.", + "required": [ + "type", + "id", + "name", + "children" + ], + "additionalProperties": false, + "properties": { + "type": { + "const": "column" + }, + "id": { + "type": "string", + "maxLength": 200 + }, + "name": { + "type": "string", + "maxLength": 200 + }, + "style": { + "$ref": "#/$defs/container_style" + }, + "children": { + "type": "array", + "maxItems": 100, + "items": { + "oneOf": [ + { + "$ref": "#/$defs/node_text" + }, + { + "$ref": "#/$defs/node_image" + }, + { + "$ref": "#/$defs/node_button" + }, + { + "$ref": "#/$defs/node_divider" + }, + { + "$ref": "#/$defs/node_spacer" + }, + { + "$ref": "#/$defs/node_raw" + } + ] + } + } + } + }, + "node_text": { + "type": "object", + "description": "Text leaf. font_stack must end in a generic family (serif, sans-serif, monospace); JSON Schema cannot constrain the last item, so TextNode enforces it.", + "required": [ + "type", + "id", + "name", + "text", + "role", + "font_stack" + ], + "additionalProperties": false, + "properties": { + "type": { + "const": "text" + }, + "id": { + "type": "string", + "maxLength": 200 + }, + "name": { + "type": "string", + "maxLength": 200 + }, + "text": { + "$ref": "#/$defs/text" + }, + "role": { + "enum": [ + "heading", + "body", + "label", + "cta" + ] + }, + "font_stack": { + "type": "array", + "items": { + "type": "string", + "maxLength": 200 + }, + "minItems": 1, + "maxItems": 20 + } + } + }, + "node_image": { + "type": "object", + "description": "Image leaf.", + "required": [ + "type", + "id", + "name", + "image", + "alt" + ], + "additionalProperties": false, + "properties": { + "type": { + "const": "image" + }, + "id": { + "type": "string", + "maxLength": 200 + }, + "name": { + "type": "string", + "maxLength": 200 + }, + "image": { + "$ref": "#/$defs/image" + }, + "alt": { + "type": "string", + "maxLength": 2000 + }, + "href": { + "type": "string", + "maxLength": 2000 + }, + "sizing": { + "$ref": "#/$defs/sizing" + } + } + }, + "node_button": { + "type": "object", + "description": "Button leaf.", + "required": [ + "type", + "id", + "name", + "button" + ], + "additionalProperties": false, + "properties": { + "type": { + "const": "button" + }, + "id": { + "type": "string", + "maxLength": 200 + }, + "name": { + "type": "string", + "maxLength": 200 + }, + "button": { + "$ref": "#/$defs/button" + }, + "align": { + "enum": [ + "left", + "center", + "right" + ] + }, + "sizing": { + "$ref": "#/$defs/sizing" + } + } + }, + "node_divider": { + "type": "object", + "description": "Horizontal rule leaf.", + "required": [ + "type", + "id", + "name", + "thickness", + "color", + "style" + ], + "additionalProperties": false, + "properties": { + "type": { + "const": "divider" + }, + "id": { + "type": "string", + "maxLength": 200 + }, + "name": { + "type": "string", + "maxLength": 200 + }, + "thickness": { + "type": "integer", + "minimum": 0 + }, + "color": { + "$ref": "#/$defs/hex6" + }, + "style": { + "enum": [ + "solid", + "dashed", + "dotted" + ] + }, + "width": { + "type": "integer", + "minimum": 0 + } + } + }, + "node_spacer": { + "type": "object", + "description": "Empty-frame spacer leaf.", + "required": [ + "type", + "id", + "name", + "height" + ], + "additionalProperties": false, + "properties": { + "type": { + "const": "spacer" + }, + "id": { + "type": "string", + "maxLength": 200 + }, + "name": { + "type": "string", + "maxLength": 200 + }, + "height": { + "type": "integer", + "minimum": 0 + }, + "background_color": { + "$ref": "#/$defs/hex6" + } + } + }, + "node_raw": { + "type": "object", + "description": "Pass-through table-only HTML. Stored as given: the tree builder sanitises it (sanitize_web_tags_for_email) before constructing the node.", + "required": [ + "type", + "id", + "name", + "html", + "reason" + ], + "additionalProperties": false, + "properties": { + "type": { + "const": "raw" + }, + "id": { + "type": "string", + "maxLength": 200 + }, + "name": { + "type": "string", + "maxLength": 200 + }, + "html": { + "type": "string", + "maxLength": 100000 + }, + "reason": { + "type": "string", + "pattern": "^[a-z][a-z0-9_.]*$", + "maxLength": 64 + } + } + } + } +} From c9e7ab15e533bd5817888710da1d797960e6924f Mon Sep 17 00:00:00 2001 From: Linards Berzins Date: Mon, 5 Oct 2026 13:07:26 +0100 Subject: [PATCH 2/2] fix(design-sync): align v2 loader with schema and point errors at fields (CE-26 review) PR #475 review F1-F3, F5. F1: validate() accepted radius 12.0 (JSON Schema counts a whole float as an integer) but from_json raised; true loaded as 1 although the schema rejects it; float lengths kept floats in int fields. nodes._px now coerces every int length (radius, padding, thickness, width, height). F2: the child unions used oneOf, so any nested failure surfaced at body.0 with the whole subtree's repr as the message (1917 chars for one bad divider colour). They now dispatch on `type` with if/then, so the error names the failing field; validate() keeps the first and last 120 chars of a longer message. The schema accepts the same documents (18 shapes probed against both versions, 0 disagreements). AC 3 amended. F3: from_json maps AttributeError (style: []) to ValueError, so the import service's legacy fallback fires instead of a 500. F5: ledger code_refs gain line numbers; CE-6 entry refs re-pointed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .agents/deferred-items.json | 15 +- .agents/plans/ce-26-document-schema-v2.md | 22 +- app/design_sync/dsl/nodes.py | 35 ++- app/design_sync/email_design_document.py | 16 +- app/design_sync/tests/test_document_v2.py | 129 ++++++++++- data/schemas/email-design-document-v2.json | 256 +++++++++++++++++++-- 6 files changed, 427 insertions(+), 46 deletions(-) diff --git a/.agents/deferred-items.json b/.agents/deferred-items.json index a06ef1ba..a28d4a7e 100644 --- a/.agents/deferred-items.json +++ b/.agents/deferred-items.json @@ -1482,10 +1482,10 @@ "introduced_commit": "pending", "summary": "PR #474 review F1. CE-6 sets sizing_horizontal on ColumnGroup and EmailSection, but production conversion goes through document.to_layout_description(), and DocumentColumn.from_column_group/to_column_group and DocumentSection.from_email_section rebuild both without it (read-confirmed). compute_column_width_fractions then reruns on the rebuilt groups. Keeping the field off DocumentSection was a CE-6 plan decision (GOTCHA :252), so this is a code-shape concession, not a CE-6 regression: nothing reads the field yet.", "code_refs": [ - "app/design_sync/email_design_document.py:936 (DocumentColumn.from_column_group)", - "app/design_sync/email_design_document.py:951 (DocumentColumn.to_column_group)", - "app/design_sync/email_design_document.py:1254 (compute_column_width_fractions on rebuilt groups)", - "app/design_sync/email_design_document.py:1279 (DocumentSection.from_email_section)", + "app/design_sync/email_design_document.py:953 (DocumentColumn.from_column_group)", + "app/design_sync/email_design_document.py:968 (DocumentColumn.to_column_group)", + "app/design_sync/email_design_document.py:1271 (compute_column_width_fractions on rebuilt groups)", + "app/design_sync/email_design_document.py:1296 (DocumentSection.from_email_section)", "app/design_sync/figma/layout_analyzer.py:182 (ColumnGroup.sizing_horizontal)", "app/design_sync/figma/layout_analyzer.py:289 (EmailSection.sizing_horizontal)", "app/design_sync/converter_service.py:352 (document.to_layout_description)" @@ -1505,8 +1505,11 @@ "introduced_commit": "pending", "summary": "The 2 local design_token_snapshots.document_json rows are one whole design-system file (\"The Ultimate Email Design System (Community)\"): 397 sections vs cap 100, section texts 103 vs 50, element_gaps 106 vs 100, content-group texts 73 vs 50 (observed 2026-10-05). They load and round-trip, but EmailDesignDocument.validate() rejects them. Latent because no load path validates. CE-26 raised only the typography cap and declared `dividers` (user decision 2026-10-05).", "code_refs": [ - "data/schemas/email-design-document-v1.json (properties.sections maxItems 100, $defs/section texts/element_gaps, $defs/content_group texts)", - "app/design_sync/email_design_document.py (EmailDesignDocument.validate)", + "data/schemas/email-design-document-v1.json:23 (properties.sections maxItems 100)", + "data/schemas/email-design-document-v1.json:221 ($defs/section texts maxItems 50)", + "data/schemas/email-design-document-v1.json:258 ($defs/section element_gaps maxItems 100)", + "data/schemas/email-design-document-v1.json:306 ($defs/content_group texts maxItems 50)", + "app/design_sync/email_design_document.py:1520 (EmailDesignDocument.validate)", "app/design_sync/import_service.py:239 (persisted-document load, no validate)" ], "symptom_if_broken": "A future import gate, snapshot lint or API contract check that validates persisted documents hard-fails on whole-file imports with maxItems errors, looking like a converter regression.", diff --git a/.agents/plans/ce-26-document-schema-v2.md b/.agents/plans/ce-26-document-schema-v2.md index 76852fe9..3fb424b2 100644 --- a/.agents/plans/ce-26-document-schema-v2.md +++ b/.agents/plans/ce-26-document-schema-v2.md @@ -146,7 +146,7 @@ file below. - `docs/architecture/dsl-compiler.md` § S1 (node schema, loader rule), § S2 (DSL nesting table), § S3 (value contracts) — the decided shapes; do not re-decide. - [JSON Schema 2020-12 `contains`](https://json-schema.org/understanding-json-schema/reference/array#contains) — "at least one section in a wrapper" while `raw` siblings stay allowed. -- [`oneOf` + `const` discriminator](https://json-schema.org/understanding-json-schema/reference/combining#oneOf) — child unions. Errors from a failed `oneOf` report at the parent path (`body.0.children.0`), which is what the unknown-type test asserts. +- [`if`/`then` conditional](https://json-schema.org/understanding-json-schema/reference/conditionals) — child unions dispatch on the `type` tag, so an error reports at the failing field (`body.0.children.0.children.0.children.0.type`). *(Amended after PR #475 F2: the original `oneOf` reported every nested failure at `body.0`, with the whole subtree's repr as the message.)* ### Patterns to Follow @@ -205,15 +205,15 @@ Tasks 6–12 (Task 11 touches `cms/packages/sdk/`). - **IMPLEMENT**: copy the Task-1 v1 file, then change only: - `$id` → `email-design-document/v2`; `title` → `Email Design Document v2`; `description` names the `body` tree. - `properties.version` → `{"const": "2.0", …}`. - - `properties.body` → `{"type": "array", "maxItems": 100, "items": {"oneOf": [{"$ref": "#/$defs/node_wrapper"}, {"$ref": "#/$defs/node_section"}, {"$ref": "#/$defs/node_raw"}]}}`; add `"body"` to root `required`. + - `properties.body` → `{"type": "array", "maxItems": 100, "items": }`; add `"body"` to root `required`. A dispatch is `{"type": "object", "required": ["type"], "properties": {"type": {"enum": [...]}}, "allOf": [{"if": {"type": "object", "required": ["type"], "properties": {"type": {"const": ""}}}, "then": {"$ref": "#/$defs/node_"}}, …]}` (amended after PR #475 F2). - New `$defs` (all `"type": "object"`, `"additionalProperties": false`, `required` includes `type`, `id`, `name`; `id`/`name` are `string` `maxLength: 200`; `type` is `{"const": ""}`): | def | extra properties (required in **bold**) | |---|---| | `container_style` | `background_color` (`#/$defs/hex6`), `radius` (`oneOf`: integer ≥0, or array of exactly 4 integers ≥0), `padding` (array of exactly 4 integers ≥0: top, right, bottom, left), `vertical_align` (enum `top middle bottom`), `sizing` (`#/$defs/sizing`), `full_width` (boolean, default false), `background_image` (`#/$defs/image`) | -| `node_wrapper` | `style` (`container_style`), **`children`**: array, `minItems: 1`, `maxItems: 50`, items `oneOf` [`node_section`, `node_raw`], `contains: {"properties": {"type": {"const": "section"}}}` | -| `node_section` | `style`, **`children`**: same shape, items `oneOf` [`node_column`, `node_raw`], `contains` type `column` | -| `node_column` | `style`, **`children`**: array `maxItems: 100`, items `oneOf` [`node_text`, `node_image`, `node_button`, `node_divider`, `node_spacer`, `node_raw`] | +| `node_wrapper` | `style` (`container_style`), **`children`**: array, `minItems: 1`, `maxItems: 50`, items dispatch [`node_section`, `node_raw`], `contains: {"properties": {"type": {"const": "section"}}}` | +| `node_section` | `style`, **`children`**: same shape, items dispatch [`node_column`, `node_raw`], `contains` type `column` | +| `node_column` | `style`, **`children`**: array `maxItems: 100`, items dispatch [`node_text`, `node_image`, `node_button`, `node_divider`, `node_spacer`, `node_raw`] | | `node_text` | **`text`** (`#/$defs/text`), **`role`** (enum `heading body label cta`), **`font_stack`** (array of strings `maxLength: 200`, `minItems: 1`, `maxItems: 20`) | | `node_image` | **`image`** (`#/$defs/image`), **`alt`** (string `maxLength: 2000`), `href` (string `maxLength: 2000`), `sizing` | | `node_button` | **`button`** (`#/$defs/button`), `align` (enum `left center right`), `sizing` | @@ -246,7 +246,7 @@ Tasks 6–12 (Task 11 touches `cms/packages/sdk/`). - `TextNode.__post_init__`: `font_stack` non-empty and `font_stack[-1] in {c.value for c in FontCategory}`, else `ValueError` (S3). - **PATTERN**: `DocumentButton.to_json`/`from_json` (`email_design_document.py:740-805`); `FontCategory` from `app/design_sync/font_stacks.py:26`. - **IMPORTS**: `from app.design_sync.email_design_document import DocumentButton, DocumentImage, DocumentText`; `from app.design_sync.font_stacks import FontCategory`. -- **GOTCHA**: radius/padding JSON is a list; `from_json` converts to `tuple` (and 4-length check) so round-trip equality and hashing hold; a scalar radius stays `int`. +- **GOTCHA**: radius/padding JSON is a list; `from_json` converts to `tuple` (and 4-length check) so round-trip equality and hashing hold; a scalar radius stays `int`. Every int length (radius, padding, `thickness`, `width`, `height`) goes through `_px`: a whole float loads as `int`, a bool or fraction raises (amended after PR #475 F1). - **GOTCHA**: `from_json` must not use `data.get("type")` defaults; a missing `type` is `KeyError`, which `EmailDesignDocument.from_json` already converts to `ValueError`. - **GOTCHA (strict pyright, prototyped)**: the shared parser returns the node union, which strict pyright will not assign to `tuple[ColumnNode | RawNode, ...]`. Use this shape (prototyped 2026-10-05 on a 4-node cut: `pyright` 0 errors, `mypy` clean, `ruff check` clean, observed): - `_node_from_json(data, allowed: frozenset[NodeType], parent: str) -> AnyNode`: `NodeType(data["type"])` first, then `if node_type not in allowed: raise ValueError(f"node type {node_type.value!r} not allowed in a {parent}")`, **then** `NODE_CLASSES[node_type].from_json(data)`. @@ -307,8 +307,8 @@ Tasks 6–12 (Task 11 touches `cms/packages/sdk/`). - **IMPLEMENT**: - `_all_nine_body()` helper: one `WrapperNode` → `SectionNode` → `ColumnNode` holding one each of text (`font_stack=("Inter", "Helvetica", "sans-serif")`), image, button, divider, spacer, raw; plus a top-level `RawNode` and a top-level `SectionNode`. Leaf payloads (`DocumentText`, `DocumentImage`, `DocumentButton`) are **taken from case 5's `from_legacy` sections** (first text/image/button found), so the v2 fixture carries real payloads, not invented ones. - `test_v2_all_nine_round_trip_and_validate`: `doc = replace(case5_doc, version="2.0", body=_all_nine_body())`; `j = doc.to_json()`; `validate(j) == []`; `json.dumps(EmailDesignDocument.from_json(json.loads(json.dumps(j))).to_json()) == json.dumps(j)`; and `{n.type for n in walk(doc.body)} == set(NodeType)`. - - `test_v2_unknown_node_type_fails_validation`: copy `j`, set the column's first child `type` to `"carousel"`; assert errors non-empty and **every** error path equals `body.0` (control: the unmutated `j` validated clean above). A nested failure surfaces at the outermost `oneOf`, not at the leaf: prototyped, an unknown leaf type, an unknown top-level type, a section with only `raw` children and an upper-case hex colour each report exactly `[("body.0", "oneOf")]` (observed 2026-10-05, jsonschema 4.26.0). Do not assert a deep path such as `body.0.children.0.children.0`. Also `from_json` raises `ValueError`. - - `test_v2_illegal_nesting_fails`: a `node_text` directly under `body` → error path `body.` (`oneOf`); `from_json` raises `ValueError`. + - `test_v2_unknown_node_type_fails_validation`: copy `j`, set the column's first child `type` to `"carousel"`; assert the only error path is `body.0.children.0.children.0.children.0.type` and the message names `'carousel'` (control: the unmutated `j` validated clean above). *(Amended after PR #475 F2: this bullet first pinned every path to `body.0`, which was the `oneOf` behaviour. That instruction is reversed.)* `test_v2_errors_point_at_the_bad_field` pins the other three prototyped shapes at their deep paths: unknown top-level type → `body.0.type`, upper-case hex → `…children.3.color`, section with only `raw` children → `body.0.children.0.children`. Also `from_json` raises `ValueError`. + - `test_v2_illegal_nesting_fails`: a `node_text` directly under `body` → error path `body..type` (`enum`); `from_json` raises `ValueError`. - `test_v2_missing_body_fails_validation`. - Corpus (parametrized over `discover_cases()` that have `structure.json` and `tokens.json`; `pytest.skip` otherwise, as `test_converter_data_regression.py:132`): `test_case_v1_round_trip_byte_identical` (`json.dumps` equality after `from_json`) and `test_case_v1_document_validates` (`validate(doc.to_json()) == []`). This is the ledger's `closes_when` test. - `TestSchemaParity`: v2 `$defs[k] == v1 $defs[k]` for every v1 key; v2 `properties` minus `body`/`version` equals v1's; `set(v2.required) == set(v1.required) | {"body"}`; new defs == `{f"node_{t.value}" for t in NodeType} | {"container_style", "hex6", "sizing"}`. @@ -410,7 +410,7 @@ No DB, network or WebSocket surface. The real-data checks are the corpus tests i - [ ] AC 1 — Every committed v1 document round-trips byte-identically: 7/7 `data/debug` cases (Task 7) and the golden components (Task 9). - [ ] AC 2 — A v2 document using all nine node types validates and round-trips byte-identically (Task 7). -- [ ] AC 3 — A v2 document with an unknown node `type` fails validation with errors at `body.0` only, and fails to load with `ValueError`. +- [ ] AC 3 — A v2 document with an unknown node `type` fails validation with one error at that node's `type` path, and fails to load with `ValueError`. *(Amended after PR #475 F2: was "errors at `body.0` only".)* - [ ] AC 4 — `"1.0"` loads with `body=()` against the v1 file; a v1 document with `body` is rejected by both `validate` and `from_json`; unknown versions raise `ValueError`. - [ ] AC 5 — Converter output byte-identical on all 7 cases (Level 4 step 1); ladder unchanged. - [ ] AC 6 — `validate()` returns `[]` for every case's `from_legacy` document (closes `phase-53.7-typography-maxitems-cap`). @@ -461,3 +461,7 @@ No DB, network or WebSocket surface. The real-data checks are the corpus tests i - Task 4: `schema()` calls `_load_schema("1.0")` so it shares the validator's cache key. - Unknown node types raise `NodeType`'s own `ValueError`, without the `Malformed` prefix. - Level 4 step 3: `POST /validate-document` was not run live (it needs auth). The TestClient test covers it. +- 2026-10-05 (PR #475 review fixes, `.claude/reports/pr-475-review-fixes.md`): + - F1: `nodes._px` coerces every int length field. A whole float loads as `int`; a bool or fraction raises `TypeError` → `ValueError`. The loader and `validate()` now accept the same lengths. + - F2: the four child unions dispatch on `type` with `if`/`then` instead of `oneOf`, so errors report at the failing field. AC 3, the Task 7 bullets and the line-149 reference are amended. The schema accepts the same documents (18 shapes probed against both schemas, 0 disagreements). `validate()` keeps the first and last 120 characters of a longer message. + - F3: `from_json` also turns `AttributeError` into `ValueError`. diff --git a/app/design_sync/dsl/nodes.py b/app/design_sync/dsl/nodes.py index d3069256..13b639c1 100644 --- a/app/design_sync/dsl/nodes.py +++ b/app/design_sync/dsl/nodes.py @@ -14,7 +14,7 @@ from dataclasses import dataclass from enum import StrEnum -from typing import Any, ClassVar +from typing import Any, ClassVar, cast from app.design_sync.email_design_document import DocumentButton, DocumentImage, DocumentText from app.design_sync.font_stacks import FontCategory @@ -76,10 +76,27 @@ class DividerStyle(StrEnum): _GENERIC_FAMILIES = frozenset(c.value for c in FontCategory) -def _four(value: list[int]) -> tuple[int, int, int, int]: +def _px(value: object) -> int: + """Return a px length as ``int``. + + A whole float (``12.0``) is what JSON Schema calls an integer, so it loads as + one; a bool or a fraction raises ``TypeError``, as the schema rejects both. + """ + if isinstance(value, int) and not isinstance(value, bool): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + raise TypeError(f"expected an integer px length, got {value!r}") + + +def _opt_px(value: object) -> int | None: + return _px(value) if value is not None else None + + +def _four(value: list[object]) -> tuple[int, int, int, int]: if len(value) != 4: raise ValueError(f"expected 4 values, got {len(value)}") - return (value[0], value[1], value[2], value[3]) + return (_px(value[0]), _px(value[1]), _px(value[2]), _px(value[3])) def _opt_sizing(value: str | None) -> Sizing | None: @@ -119,12 +136,16 @@ def to_json(self) -> dict[str, Any]: @classmethod def from_json(cls, data: dict[str, Any]) -> ContainerStyle: radius = data.get("radius") + if radius is not None: + radius = ( + _four(cast("list[object]", radius)) if isinstance(radius, list) else _px(radius) + ) padding = data.get("padding") vertical_align = data.get("vertical_align") background_image = data.get("background_image") return cls( background_color=data.get("background_color"), - radius=None if radius is None else radius if isinstance(radius, int) else _four(radius), + radius=radius, padding=_four(padding) if padding is not None else None, vertical_align=VerticalAlign(vertical_align) if vertical_align is not None else None, sizing=_opt_sizing(data.get("sizing")), @@ -268,10 +289,10 @@ def from_json(cls, data: dict[str, Any]) -> DividerNode: return cls( id=data["id"], name=data["name"], - thickness=data["thickness"], + thickness=_px(data["thickness"]), color=data["color"], style=DividerStyle(data["style"]), - width=data.get("width"), + width=_opt_px(data.get("width")), ) @@ -293,7 +314,7 @@ def from_json(cls, data: dict[str, Any]) -> SpacerNode: return cls( id=data["id"], name=data["name"], - height=data["height"], + height=_px(data["height"]), background_color=data.get("background_color"), ) diff --git a/app/design_sync/email_design_document.py b/app/design_sync/email_design_document.py index 95874ba6..b1795725 100644 --- a/app/design_sync/email_design_document.py +++ b/app/design_sync/email_design_document.py @@ -66,6 +66,18 @@ def _get_validator(version: str = "1.0") -> Draft202012Validator: return Draft202012Validator(_load_schema(version)) +# jsonschema messages open with the repr of the failing value, which can be a +# 100 kB raw.html string; keep both ends so the verb ("is too long") survives. +_ERROR_HEAD = 120 +_ERROR_TAIL = 120 + + +def _cap_message(message: str) -> str: + if len(message) <= _ERROR_HEAD + _ERROR_TAIL: + return message + return f"{message[:_ERROR_HEAD]} … {message[-_ERROR_TAIL:]}" + + # ── Document sub-structures ───────────────────────────────────────── @@ -1501,7 +1513,7 @@ def from_json(cls, data: dict[str, Any]) -> EmailDesignDocument: token_warnings=[TokenWarning.from_json(w) for w in data.get("token_warnings", [])], body=body, ) - except (KeyError, TypeError) as exc: + except (KeyError, TypeError, AttributeError) as exc: raise ValueError(f"Malformed EmailDesignDocument: {exc}") from exc @staticmethod @@ -1519,7 +1531,7 @@ def validate(data: dict[str, Any]) -> list[str]: path = ( ".".join(str(p) for p in error.absolute_path) if error.absolute_path else "(root)" ) - errors.append(f"{path}: {error.message}") + errors.append(f"{path}: {_cap_message(error.message)}") return errors @staticmethod diff --git a/app/design_sync/tests/test_document_v2.py b/app/design_sync/tests/test_document_v2.py index da139039..a4209bbc 100644 --- a/app/design_sync/tests/test_document_v2.py +++ b/app/design_sync/tests/test_document_v2.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from collections.abc import Iterator +from collections.abc import Callable, Iterator from dataclasses import replace from pathlib import Path from typing import Any @@ -188,9 +188,9 @@ def test_v2_unknown_node_type_fails_validation() -> None: j = v2_case5_json() j["body"][0]["children"][0]["children"][0]["children"][0]["type"] = "carousel" errors = EmailDesignDocument.validate(j) - assert errors != [] - # A nested oneOf failure surfaces at the outermost body item. - assert _error_paths(errors) == {"body.0"} + # Children dispatch on `type` (if/then), so the error names the bad tag itself. + assert _error_paths(errors) == {"body.0.children.0.children.0.children.0.type"} + assert "'carousel' is not one of" in errors[0] with pytest.raises(ValueError, match="carousel"): EmailDesignDocument.from_json(j) @@ -200,11 +200,130 @@ def test_v2_illegal_nesting_fails() -> None: column_children = j["body"][0]["children"][0]["children"][0]["children"] j["body"].append(column_children[0]) # a text node directly under body errors = EmailDesignDocument.validate(j) - assert _error_paths(errors) == {f"body.{len(j['body']) - 1}"} + assert _error_paths(errors) == {f"body.{len(j['body']) - 1}.type"} with pytest.raises(ValueError, match="not allowed in a body"): EmailDesignDocument.from_json(j) +_Mutation = Callable[[dict[str, Any]], object] +_RAW_ONLY: list[dict[str, Any]] = [ + {"type": "raw", "id": "r", "name": "r", "html": "
", "reason": "x"} +] +_BAD_FIELDS: list[tuple[str, _Mutation, str, str]] = [ + ( + "unknown-top-level-type", + lambda j: j["body"].insert(0, {**j["body"][0], "type": "carousel"}), + "body.0.type", + "is not one of", + ), + ( + "upper-case-hex", + lambda j: j["body"][0]["children"][0]["children"][0]["children"][3].update(color="#CCCCCC"), + "body.0.children.0.children.0.children.3.color", + "does not match", + ), + ( + "section-without-column", + lambda j: j["body"][0]["children"][0].update(children=_RAW_ONLY), + "body.0.children.0.children", + "does not contain", + ), +] + + +@pytest.mark.parametrize( + ("mutate", "path", "phrase"), + [b[1:] for b in _BAD_FIELDS], + ids=[b[0] for b in _BAD_FIELDS], +) +def test_v2_errors_point_at_the_bad_field(mutate: _Mutation, path: str, phrase: str) -> None: + j = v2_case5_json() + mutate(j) + errors = EmailDesignDocument.validate(j) + assert _error_paths(errors) == {path} + assert phrase in errors[0] + + +def test_v2_error_message_is_capped() -> None: + # PR #475 F2: a raw node's html is up to 100 kB; its repr must not reach the response. + j = v2_case5_json() + j["body"][1]["html"] = "" + "x" * 100_000 + "
" + errors = EmailDesignDocument.validate(j) + assert _error_paths(errors) == {"body.1.html"} + assert len(errors[0]) <= 300 + assert errors[0].endswith("is too long") + + +_LENGTH_SITES: list[ + tuple[str, Callable[[dict[str, Any], object], None], Callable[[EmailDesignDocument], object]] +] = [ + ( + "radius", + lambda j, v: j["body"][0]["children"][0]["style"].update(radius=v), + lambda d: d.body[0].children[0].style.radius, # type: ignore[union-attr] + ), + ( + "radius-list", + lambda j, v: j["body"][0]["children"][0]["style"].update(radius=[v, 0, 0, 0]), + lambda d: d.body[0].children[0].style.radius[0], # type: ignore[union-attr,index] + ), + ( + "padding", + lambda j, v: j["body"][0]["children"][0]["style"].update(padding=[v, 0, 0, 0]), + lambda d: d.body[0].children[0].style.padding[0], # type: ignore[union-attr,index] + ), + ( + "thickness", + lambda j, v: j["body"][0]["children"][0]["children"][0]["children"][3].update(thickness=v), + lambda d: d.body[0].children[0].children[0].children[3].thickness, # type: ignore[union-attr] + ), + ( + "width", + lambda j, v: j["body"][0]["children"][0]["children"][0]["children"][3].update(width=v), + lambda d: d.body[0].children[0].children[0].children[3].width, # type: ignore[union-attr] + ), + ( + "height", + lambda j, v: j["body"][0]["children"][0]["children"][0]["children"][4].update(height=v), + lambda d: d.body[0].children[0].children[0].children[4].height, # type: ignore[union-attr] + ), +] + + +@pytest.mark.parametrize( + ("site", "set_value", "read_back"), _LENGTH_SITES, ids=[s[0] for s in _LENGTH_SITES] +) +@pytest.mark.parametrize( + "value", [12, 12.0, 12.5, True], ids=["int", "whole-float", "fraction", "bool"] +) +def test_v2_length_fields_agree_with_schema( + site: str, + set_value: Callable[[dict[str, Any], object], None], + read_back: Callable[[EmailDesignDocument], object], + value: object, +) -> None: + # PR #475 F1: validate() and from_json() accept the same lengths, and a loaded length is an int. + j = v2_case5_json() + set_value(j, value) + valid = EmailDesignDocument.validate(j) == [] + try: + loaded = read_back(EmailDesignDocument.from_json(j)) + except ValueError: + assert not valid, f"{site}={value!r}: schema accepts, loader rejects" + return + assert valid, f"{site}={value!r}: loader accepts, schema rejects" + assert type(loaded) is int and loaded == 12 + + +@pytest.mark.parametrize("style", [[], "x"], ids=["list", "string"]) +def test_v2_malformed_style_raises_value_error(style: object) -> None: + # PR #475 F3: import_service's legacy fallback catches ValueError, not AttributeError. + j = v2_case5_json() + j["body"][0]["children"][0]["style"] = style + with pytest.raises(ValueError, match="Malformed EmailDesignDocument"): + EmailDesignDocument.from_json(j) + + def test_v2_missing_body_fails_validation() -> None: j = v2_case5_json() del j["body"] diff --git a/data/schemas/email-design-document-v2.json b/data/schemas/email-design-document-v2.json index c47be44b..e1262c61 100644 --- a/data/schemas/email-design-document-v2.json +++ b/data/schemas/email-design-document-v2.json @@ -38,15 +38,67 @@ "maxItems": 100, "description": "Typed layout tree: top-level wrapper, section or raw nodes.", "items": { - "oneOf": [ + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "enum": [ + "wrapper", + "section", + "raw" + ] + } + }, + "allOf": [ { - "$ref": "#/$defs/node_wrapper" + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "wrapper" + } + } + }, + "then": { + "$ref": "#/$defs/node_wrapper" + } }, { - "$ref": "#/$defs/node_section" + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "section" + } + } + }, + "then": { + "$ref": "#/$defs/node_section" + } }, { - "$ref": "#/$defs/node_raw" + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "raw" + } + } + }, + "then": { + "$ref": "#/$defs/node_raw" + } } ] } @@ -1499,12 +1551,50 @@ "type": "array", "maxItems": 50, "items": { - "oneOf": [ + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "enum": [ + "section", + "raw" + ] + } + }, + "allOf": [ { - "$ref": "#/$defs/node_section" + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "section" + } + } + }, + "then": { + "$ref": "#/$defs/node_section" + } }, { - "$ref": "#/$defs/node_raw" + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "raw" + } + } + }, + "then": { + "$ref": "#/$defs/node_raw" + } } ] }, @@ -1552,12 +1642,50 @@ "type": "array", "maxItems": 50, "items": { - "oneOf": [ + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "enum": [ + "column", + "raw" + ] + } + }, + "allOf": [ { - "$ref": "#/$defs/node_column" + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "column" + } + } + }, + "then": { + "$ref": "#/$defs/node_column" + } }, { - "$ref": "#/$defs/node_raw" + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "raw" + } + } + }, + "then": { + "$ref": "#/$defs/node_raw" + } } ] }, @@ -1605,24 +1733,118 @@ "type": "array", "maxItems": 100, "items": { - "oneOf": [ + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "enum": [ + "text", + "image", + "button", + "divider", + "spacer", + "raw" + ] + } + }, + "allOf": [ { - "$ref": "#/$defs/node_text" + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "text" + } + } + }, + "then": { + "$ref": "#/$defs/node_text" + } }, { - "$ref": "#/$defs/node_image" + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "image" + } + } + }, + "then": { + "$ref": "#/$defs/node_image" + } }, { - "$ref": "#/$defs/node_button" + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "button" + } + } + }, + "then": { + "$ref": "#/$defs/node_button" + } }, { - "$ref": "#/$defs/node_divider" + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "divider" + } + } + }, + "then": { + "$ref": "#/$defs/node_divider" + } }, { - "$ref": "#/$defs/node_spacer" + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "spacer" + } + } + }, + "then": { + "$ref": "#/$defs/node_spacer" + } }, { - "$ref": "#/$defs/node_raw" + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "raw" + } + } + }, + "then": { + "$ref": "#/$defs/node_raw" + } } ] }