diff --git a/.agents/deferred-items.json b/.agents/deferred-items.json index 9272c3e0..a28d4a7e 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", @@ -1480,17 +1482,38 @@ "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)" ], "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: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.", + "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..3fb424b2 --- /dev/null +++ b/.agents/plans/ce-26-document-schema-v2.md @@ -0,0 +1,467 @@ +# 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. +- [`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 + +**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": }`; 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 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` | +| `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`. 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)`. + - 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 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"}`. + - 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 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`). +- [ ] 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. +- 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/__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..13b639c1 --- /dev/null +++ b/app/design_sync/dsl/nodes.py @@ -0,0 +1,504 @@ +"""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, cast + +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 _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 (_px(value[0]), _px(value[1]), _px(value[2]), _px(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") + 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=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=_px(data["thickness"]), + color=data["color"], + style=DividerStyle(data["style"]), + width=_opt_px(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=_px(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..b1795725 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,40 @@ ) 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=len(_SCHEMA_FILES)) +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 -@lru_cache(maxsize=1) -def _get_validator() -> Draft202012Validator: - return Draft202012Validator(_load_schema()) + +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 ───────────────────────────────────────── @@ -1436,6 +1453,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 +1467,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 +1482,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,26 +1511,33 @@ 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: + except (KeyError, TypeError, AttributeError) 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 = ( ".".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 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..a4209bbc --- /dev/null +++ b/app/design_sync/tests/test_document_v2.py @@ -0,0 +1,375 @@ +"""EmailDesignDocument v2 — version dispatch, v1/v2 schema parity, real-case corpus.""" + +from __future__ import annotations + +import json +from collections.abc import Callable, 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) + # 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) + + +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}.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"] + 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..e1262c61 --- /dev/null +++ b/data/schemas/email-design-document-v2.json @@ -0,0 +1,2086 @@ +{ + "$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": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "enum": [ + "wrapper", + "section", + "raw" + ] + } + }, + "allOf": [ + { + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "wrapper" + } + } + }, + "then": { + "$ref": "#/$defs/node_wrapper" + } + }, + { + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "section" + } + } + }, + "then": { + "$ref": "#/$defs/node_section" + } + }, + { + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "raw" + } + } + }, + "then": { + "$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": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "enum": [ + "section", + "raw" + ] + } + }, + "allOf": [ + { + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "section" + } + } + }, + "then": { + "$ref": "#/$defs/node_section" + } + }, + { + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "raw" + } + } + }, + "then": { + "$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": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "enum": [ + "column", + "raw" + ] + } + }, + "allOf": [ + { + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "column" + } + } + }, + "then": { + "$ref": "#/$defs/node_column" + } + }, + { + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "raw" + } + } + }, + "then": { + "$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": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "enum": [ + "text", + "image", + "button", + "divider", + "spacer", + "raw" + ] + } + }, + "allOf": [ + { + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "text" + } + } + }, + "then": { + "$ref": "#/$defs/node_text" + } + }, + { + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "image" + } + } + }, + "then": { + "$ref": "#/$defs/node_image" + } + }, + { + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "button" + } + } + }, + "then": { + "$ref": "#/$defs/node_button" + } + }, + { + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "divider" + } + } + }, + "then": { + "$ref": "#/$defs/node_divider" + } + }, + { + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "spacer" + } + } + }, + "then": { + "$ref": "#/$defs/node_spacer" + } + }, + { + "if": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "const": "raw" + } + } + }, + "then": { + "$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 + } + } + } + } +}