From 9188f8715b7124f326ff9746b9d170cdd2b730d4 Mon Sep 17 00:00:00 2001 From: Ivo Date: Sat, 27 Jun 2026 15:36:59 +0200 Subject: [PATCH 1/6] fix(elements): accept a factored-out border object on Column without `as const` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canonical ColumnValues.border pins each per-side *Width to `${number}px`, so an inline `border={{ borderBottomWidth: "1px" }}` type-checks but the recommended DRY hairline pattern — a reusable `const HAIRLINE = { ... }` applied across many columns — widens "1px" to `string` and fails strict tsc, even though the runtime already accepts it. The types were stricter than the runtime, the opposite of the DX layer's intent. Add a BorderInput type (derived from the canonical border shape so it tracks the schema instead of duplicating it) that relaxes the per-side *Width fields to SizeInput, and Omit+redeclare `border` on ColumnProps — mirroring the existing FontFamilyInput / SizeInput widenings. Type-only change; build green, 329 tests pass. Button/Table/Divider carry the same canonical border and can reuse BorderInput in a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/react/src/components/Column.tsx | 7 +++++-- packages/react/src/types.ts | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/react/src/components/Column.tsx b/packages/react/src/components/Column.tsx index 5b9984d..25f9c56 100644 --- a/packages/react/src/components/Column.tsx +++ b/packages/react/src/components/Column.tsx @@ -3,7 +3,7 @@ import type { RenderMode, UnlayerConfig, ColumnValues } from "@unlayer-internal/ import { ColumnExporters, ContentExporters } from "@unlayer/exporters"; import { UNLAYER_RENDER_KEY } from "../utils/create-component"; import { mapSemanticProps, type SemanticProps } from "../utils/semantic-props"; -import type { SizeInput } from "../types"; +import type { SizeInput, BorderInput } from "../types"; import { COLUMN_DEFAULTS } from "../utils/container-defaults"; /** Unlayer's default content-block padding when a block sets none. */ @@ -56,7 +56,7 @@ function renderContentToHtml(innerHTML: string, values: any, bodyValues: any, mo // Component // ============================================ -export type ColumnProps = Omit, "padding"> & { +export type ColumnProps = Omit, "padding" | "border"> & { children?: React.ReactNode; // Internal props (provided by Row) index?: number; @@ -68,6 +68,9 @@ export type ColumnProps = Omit, "padding"> & { style?: React.CSSProperties; /** Padding — a CSS string ("0 24px", "10px") or a number (px). */ padding?: SizeInput; + /** Per-side border object (great for hairline dividers). Width fields accept + * a number/px string; reuse it as a factored-out const without `as const`. */ + border?: BorderInput; /** @internal - Unlayer config threaded from UnlayerProvider via Body/Row */ _config?: UnlayerConfig; }; diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index ab2f994..07a6914 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -58,6 +58,7 @@ import type { SocialValues, TableValues, VideoValues, + ColumnValues, SocialIcon, MenuItem, } from "@unlayer-internal/shared-elements"; @@ -92,6 +93,20 @@ export type FontWeightInput = | "bolder"; /** A CSS size: a number (treated as px) or a string ("24px", "50%", "1.5em"). */ export type SizeInput = number | (string & {}); + +/** + * The `border` object, agent-friendly. The canonical type pins each per-side + * `*Width` to `${number}px`, so a literal like "1px" type-checks inline but a + * factored-out hairline object widens "1px" to `string` and stops compiling — + * exactly the reusable-divider pattern authors reach for. Relax the `*Width` + * fields to SizeInput (the runtime already accepts any CSS string). Derived + * from the canonical shape so it tracks the schema instead of duplicating it. + */ +export type BorderInput = { + [K in keyof NonNullable]?: K extends `${string}Width` + ? SizeInput + : NonNullable[K]; +}; /** Heading levels (h1–h6). */ export type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; From 1faf404be3fb1e826a6f1795316966d75496e8d2 Mon Sep 17 00:00:00 2001 From: Ivo Date: Sat, 27 Jun 2026 15:37:12 +0200 Subject: [PATCH 2/6] test(elements): enforce the DX prop-type contract with a tsc gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build only type-checks the index import graph, so the agent-friendly prop types had no CI guard — a relaxed input type could be reverted (like the Column `border` regression) and nothing would catch it; vitest doesn't type-check. Add src/dx-types.test-d.tsx: compile-time assertions that the natural authoring forms type-check (factored-out border hairline incl. numeric width, numeric/ string fontSize+fontWeight, string/object fontFamily, numeric lineHeight, full- width button, percent image width) and that garbage is rejected (@ts-expect-error on a string border, a bogus fontWeight). It imports only TYPES, so it stays out of the render graph and checks in isolation — no storybook/exporters noise. Wire it up with a scoped tsconfig.typecheck.json, a `typecheck` script, and a "Type contract" CI step. Verified the gate goes red when BorderInput is reverted to the strict canonical type. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/test.yml | 7 +++ packages/react/package.json | 1 + packages/react/src/dx-types.test-d.tsx | 59 ++++++++++++++++++++++++++ packages/react/tsconfig.typecheck.json | 10 +++++ 4 files changed, 77 insertions(+) create mode 100644 packages/react/src/dx-types.test-d.tsx create mode 100644 packages/react/tsconfig.typecheck.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6844aa2..f9e4193 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,6 +47,13 @@ jobs: - name: Build packages run: pnpm build + # ── DX type contract ──────────────────────────────────────── + # tsc gate over the agent-friendly prop types (src/dx-types.test-d.tsx). + # The build only type-checks the index import graph, so this catches a + # relaxed input type being reverted (e.g. the Column `border` regression). + - name: Type contract + run: pnpm --filter @unlayer/react-elements typecheck + # ── Bundle size budget ────────────────────────────────────── # Fail if the react-elements ESM bundle exceeds 60KB. # Current size: ~49KB (14+ components). Budget gives room for diff --git a/packages/react/package.json b/packages/react/package.json index 989493e..0daf0b4 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -53,6 +53,7 @@ }, "scripts": { "build": "tsup", + "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "test": "vitest", diff --git a/packages/react/src/dx-types.test-d.tsx b/packages/react/src/dx-types.test-d.tsx new file mode 100644 index 0000000..a247a53 --- /dev/null +++ b/packages/react/src/dx-types.test-d.tsx @@ -0,0 +1,59 @@ +/** + * Type-level regression guard for the agent-friendly DX surface. + * + * These are NOT vitest tests — they are compile-time assertions checked by + * `pnpm typecheck` (tsc --noEmit -p tsconfig.typecheck.json), which CI runs. + * The file imports only TYPES, so it stays out of the render graph (no + * storybook / @unlayer/exporters resolution noise) and type-checks in isolation. + * + * Each `const _x: SomeType = value` asserts a natural authoring form compiles; + * each `@ts-expect-error` asserts garbage is still rejected (tsc fails if the + * directive becomes unused — i.e. the bad form started compiling). If a relaxed + * input type is reverted, the matching assertion below stops compiling. + */ +import type { ColumnProps } from "./components/Column"; +import type { + BorderInput, + HeadingProps, + ButtonProps, + ParagraphProps, + ImageProps, +} from "./types"; + +// ── border: THE regression this guard exists for ──────────────────────────── +// A reusable hairline object factored into a `const` (no `as const`) must satisfy +// the Column `border` type. Before BorderInput, the per-side *Width was pinned to +// `${number}px`, so the widened `string` failed strict tsc — see the fix. +const HAIRLINE = { + borderBottomWidth: "1px", + borderBottomStyle: "solid", + borderBottomColor: "#E3E8EE", +}; +export const _border_factored_const: BorderInput = HAIRLINE; +export const _border_on_column: ColumnProps["border"] = HAIRLINE; +export const _border_numeric_width: BorderInput = { + borderTopWidth: 2, + borderTopStyle: "solid", + borderTopColor: "#222222", +}; +// @ts-expect-error a border is an object of per-side props, never a bare CSS string +export const _border_reject_string: ColumnProps["border"] = "1px solid #ccc"; + +// ── the rest of the natural DX surface (broader contract) ─────────────────── +export const _fontSize_number: HeadingProps["fontSize"] = 28; +export const _fontSize_string: HeadingProps["fontSize"] = "28px"; +export const _fontWeight_number: HeadingProps["fontWeight"] = 700; +export const _fontWeight_numeric_string: HeadingProps["fontWeight"] = "700"; +export const _fontWeight_keyword: HeadingProps["fontWeight"] = "bold"; +export const _fontFamily_string: HeadingProps["fontFamily"] = "Arial"; +export const _fontFamily_object: HeadingProps["fontFamily"] = { + label: "Arial", + value: "arial,sans-serif", +}; +export const _lineHeight_number: ParagraphProps["lineHeight"] = 1.4; +export const _button_full_width: ButtonProps["width"] = "100%"; +export const _button_px: ButtonProps["width"] = 200; +export const _image_percent: ImageProps["maxWidth"] = "50%"; + +// @ts-expect-error fontWeight does not accept arbitrary words +export const _fontWeight_reject: HeadingProps["fontWeight"] = "heavy"; diff --git a/packages/react/tsconfig.typecheck.json b/packages/react/tsconfig.typecheck.json new file mode 100644 index 0000000..b04994f --- /dev/null +++ b/packages/react/tsconfig.typecheck.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ES2020", "DOM"], + "types": ["react", "react-dom"], + "noEmit": true + }, + "include": ["src/dx-types.test-d.tsx"] +} From 5466d2b7c394d04d1d57d05eb04d50a4789c11a3 Mon Sep 17 00:00:00 2001 From: Ivo Date: Sat, 27 Jun 2026 16:13:26 +0200 Subject: [PATCH 3/6] fix(elements): relax box-model dimension types across all components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A type stress test of every component showed the Column `border` fix was one instance of a broader hazard: the canonical @unlayer/types pins every scalar dimension field to a `${number}px`-style template, so natural forms an agent writes — a bare number (`borderRadius={8}`, `padding={14}`), a factored-out border object, a computed/widened string — fail strict tsc even though the runtime already normalizes them to px (verified: no unitless output, no [object Object], no NaN). The types were stricter than the runtime. Relax to SizeInput / BorderInput, mirroring the existing FontFamilyInput / SizeInput widenings, at BOTH layers: - component-local *SemanticProps (Button, Menu, Table, Divider) — these are what the factory types the components with (what JSX checks); - the exported *Props aliases in types.ts (Button/Menu/Table/Divider) and the container props (Column/Body borderRadius). Fields: borderRadius (Button/Column/Body), padding (Button/Menu/Table), border object (Button/Table/Divider). Divider previously passed the raw strict SemanticProps to the factory; it now has a relaxed DividerSemanticProps. Body and Divider gained the `as SemanticProps` cast at the mapSemanticProps call that Column/Row already use. Type-only; build green, 329 tests pass, render output unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/react/src/components/Body.tsx | 6 ++++-- packages/react/src/components/Button.tsx | 10 ++++++++-- packages/react/src/components/Column.tsx | 4 +++- packages/react/src/components/Divider.tsx | 13 +++++++++---- packages/react/src/components/Menu.tsx | 10 +++++----- packages/react/src/components/Table.tsx | 15 ++++++++++++--- packages/react/src/types.ts | 23 +++++++++++++++++++---- 7 files changed, 60 insertions(+), 21 deletions(-) diff --git a/packages/react/src/components/Body.tsx b/packages/react/src/components/Body.tsx index 152854a..a36508a 100644 --- a/packages/react/src/components/Body.tsx +++ b/packages/react/src/components/Body.tsx @@ -7,7 +7,7 @@ import { mapSemanticProps, type SemanticProps } from "../utils/semantic-props"; import type { SizeInput } from "../types"; import { BODY_DEFAULTS } from "../utils/container-defaults"; -export type BodyProps = Omit, "padding"> & { +export type BodyProps = Omit, "padding" | "borderRadius"> & { children?: React.ReactNode; mode?: RenderMode; className?: string; @@ -19,6 +19,8 @@ export type BodyProps = Omit, "padding"> & { previewText?: string; /** Padding — a CSS string ("0 48px", "20px") or a number (px). */ padding?: SizeInput; + /** Corner radius — a number (→ px) or CSS string ("8px"). */ + borderRadius?: SizeInput; }; const DEFAULT_VALUES = BODY_DEFAULTS; @@ -137,7 +139,7 @@ const Body: React.FC = (props) => { // Outlook table, container, and grid CSS disagree. Mapped values win. const values = mergeValues( DEFAULT_VALUES, - mapSemanticProps(semanticProps, DEFAULT_VALUES, "Body") + mapSemanticProps(semanticProps as SemanticProps, DEFAULT_VALUES, "Body") ); // Ensure _meta diff --git a/packages/react/src/components/Button.tsx b/packages/react/src/components/Button.tsx index 2948672..70cdfd6 100644 --- a/packages/react/src/components/Button.tsx +++ b/packages/react/src/components/Button.tsx @@ -1,5 +1,5 @@ import { ButtonExporters, ButtonDefaults } from "@unlayer/exporters"; -import type { ButtonValues, TextStyleProps, SizeInput } from "../types"; +import type { ButtonValues, TextStyleProps, SizeInput, BorderInput } from "../types"; import { createItemComponent, type ItemComponentProps } from "../utils/create-component"; import { mapSemanticProps, type SemanticProps } from "../utils/semantic-props"; @@ -9,11 +9,17 @@ import { mapSemanticProps, type SemanticProps } from "../utils/semantic-props"; */ type ButtonSemanticProps = Omit< SemanticProps, - keyof TextStyleProps | "width" + keyof TextStyleProps | "width" | "padding" | "borderRadius" | "border" > & TextStyleProps & { /** Display width — a number/px pins the button; "100%" makes it full-width. */ width?: SizeInput; + /** Inner padding — a number (→ px) or CSS string ("14px 28px"). */ + padding?: SizeInput; + /** Corner radius — a number (→ px) or CSS string ("8px", "500px"). */ + borderRadius?: SizeInput; + /** Per-side border object (width fields accept a number/px string). */ + border?: BorderInput; }; /** diff --git a/packages/react/src/components/Column.tsx b/packages/react/src/components/Column.tsx index 25f9c56..0075acf 100644 --- a/packages/react/src/components/Column.tsx +++ b/packages/react/src/components/Column.tsx @@ -56,7 +56,7 @@ function renderContentToHtml(innerHTML: string, values: any, bodyValues: any, mo // Component // ============================================ -export type ColumnProps = Omit, "padding" | "border"> & { +export type ColumnProps = Omit, "padding" | "border" | "borderRadius"> & { children?: React.ReactNode; // Internal props (provided by Row) index?: number; @@ -68,6 +68,8 @@ export type ColumnProps = Omit, "padding" | "border" style?: React.CSSProperties; /** Padding — a CSS string ("0 24px", "10px") or a number (px). */ padding?: SizeInput; + /** Corner radius — a number (→ px) or CSS string ("8px"). */ + borderRadius?: SizeInput; /** Per-side border object (great for hairline dividers). Width fields accept * a number/px string; reuse it as a factored-out const without `as const`. */ border?: BorderInput; diff --git a/packages/react/src/components/Divider.tsx b/packages/react/src/components/Divider.tsx index 47e217c..f78d88e 100644 --- a/packages/react/src/components/Divider.tsx +++ b/packages/react/src/components/Divider.tsx @@ -1,9 +1,14 @@ import { DividerExporters, DividerDefaults } from "@unlayer/exporters"; -import type { DividerValues } from "../types"; +import type { DividerValues, BorderInput } from "../types"; import { createItemComponent, type ItemComponentProps } from "../utils/create-component"; import { mapSemanticProps, type SemanticProps } from "../utils/semantic-props"; -export interface DividerProps extends ItemComponentProps> {} +type DividerSemanticProps = Omit, "border"> & { + /** Per-side border object (width fields accept a number/px string). */ + border?: BorderInput; +}; + +export interface DividerProps extends ItemComponentProps {} // Defaults from the editor schema const DEFAULT_VALUES = { @@ -26,10 +31,10 @@ const DEFAULT_VALUES = { * }} /> * ``` */ -const Divider = createItemComponent>({ +const Divider = createItemComponent({ name: "Divider", defaultValues: DEFAULT_VALUES, - propMapper: (props) => mapSemanticProps(props, DEFAULT_VALUES, "Divider"), + propMapper: (props) => mapSemanticProps(props as SemanticProps, DEFAULT_VALUES, "Divider"), displayName: "Divider", exporters: DividerExporters, }); diff --git a/packages/react/src/components/Menu.tsx b/packages/react/src/components/Menu.tsx index 3d4bd0b..e1f2af1 100644 --- a/packages/react/src/components/Menu.tsx +++ b/packages/react/src/components/Menu.tsx @@ -1,16 +1,16 @@ import { MenuExporters, MenuDefaults } from "@unlayer/exporters"; -import type { MenuValues, MenuItem } from "../types"; +import type { MenuValues, MenuItem, SizeInput } from "../types"; import { createItemComponent, type ItemComponentProps } from "../utils/create-component"; import { mapSemanticProps, type SemanticProps } from "../utils/semantic-props"; -type MenuSemanticProps = SemanticProps & { +type MenuSemanticProps = Omit, "padding"> & { /** Menu items shorthand */ items?: MenuItem[]; + /** Inner padding — a number (→ px) or CSS string. */ + padding?: SizeInput; }; -export interface MenuProps extends ItemComponentProps> { - items?: MenuItem[]; -} +export interface MenuProps extends ItemComponentProps {} // Defaults from the editor schema const DEFAULT_MENU: NonNullable = MenuDefaults.menu ?? { items: [] }; diff --git a/packages/react/src/components/Table.tsx b/packages/react/src/components/Table.tsx index 6f4da58..0acc140 100644 --- a/packages/react/src/components/Table.tsx +++ b/packages/react/src/components/Table.tsx @@ -1,18 +1,27 @@ import { TableExporters, TableDefaults } from "@unlayer/exporters"; -import type { TableValues } from "../types"; +import type { TableValues, SizeInput, BorderInput } from "../types"; import { createItemComponent, type ItemComponentProps } from "../utils/create-component"; import { mapSemanticProps, type SemanticProps } from "../utils/semantic-props"; -type TableSemanticProps = SemanticProps & { +type TableSemanticProps = Omit, "padding" | "border"> & { /** Column headers as string[] */ headers?: string[]; /** Row data as 2D string array */ data?: string[][]; + /** Inner padding — a number (→ px) or CSS string. */ + padding?: SizeInput; + /** Per-side border object (width fields accept a number/px string). */ + border?: BorderInput; }; -export interface TableProps extends Omit>, "headers" | "data"> { +export interface TableProps + extends Omit, "padding" | "border">>, "headers" | "data"> { headers?: string[]; data?: string[][]; + /** Inner padding — a number (→ px) or CSS string. */ + padding?: SizeInput; + /** Per-side border object (width fields accept a number/px string). */ + border?: BorderInput; } // Defaults from the editor schema, plus table data structure diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 07a6914..5e55563 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -136,11 +136,17 @@ export type ImageSrcInput = /** Button component props */ export type ButtonProps = Omit< ItemProps, - keyof TextStyleProps | "width" + keyof TextStyleProps | "width" | "padding" | "borderRadius" | "border" > & TextStyleProps & { /** Display width — a number/px pins the button; "100%" makes it full-width. */ width?: SizeInput; + /** Inner padding — a number (→ px) or CSS string ("14px 28px"). */ + padding?: SizeInput; + /** Corner radius — a number (→ px) or CSS string ("8px", "500px"). */ + borderRadius?: SizeInput; + /** Per-side border object (width fields accept a number/px string). */ + border?: BorderInput; }; /** Heading component props */ export type HeadingProps = Omit< @@ -156,7 +162,10 @@ export type HeadingProps = Omit< text?: string; }; /** Divider component props */ -export type DividerProps = ItemProps; +export type DividerProps = Omit, "border"> & { + /** Per-side border object (width fields accept a number/px string). */ + border?: BorderInput; +}; /** HTML component props */ export type HtmlProps = ItemProps; /** Paragraph component props */ @@ -190,17 +199,23 @@ export type SocialProps = ItemProps & { }; /** Menu component props — supports `items` shorthand array. */ -export type MenuProps = ItemProps & { +export type MenuProps = Omit, "padding"> & { /** Menu items shorthand */ items?: MenuItem[]; + /** Inner padding — a number (→ px) or CSS string. */ + padding?: SizeInput; }; /** Table component props — supports `headers` + `data` shorthands. */ -export type TableProps = ItemProps & { +export type TableProps = Omit, "padding" | "border"> & { /** Column headers */ headers?: string[]; /** Row data as 2D array */ data?: string[][]; + /** Inner padding — a number (→ px) or CSS string. */ + padding?: SizeInput; + /** Per-side border object (width fields accept a number/px string). */ + border?: BorderInput; }; /** Video component props — supports `videoUrl` shorthand. */ From 36e04113a534d7f5c7cb872b34df5e2eae32a0f4 Mon Sep 17 00:00:00 2001 From: Ivo Date: Sat, 27 Jun 2026 16:13:37 +0200 Subject: [PATCH 4/6] test(elements): extend the type-contract gate to the box-model relaxations Guard the broader relaxation against the ACTUAL component prop types (imported from the component files, not just the exported aliases): borderRadius as a number on Column/Button, item padding as a number on Button/Menu/Table, and a factored-out border object on Button/Table/Divider. Runs in the existing `typecheck` CI gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/react/src/dx-types.test-d.tsx | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/react/src/dx-types.test-d.tsx b/packages/react/src/dx-types.test-d.tsx index a247a53..5fedce1 100644 --- a/packages/react/src/dx-types.test-d.tsx +++ b/packages/react/src/dx-types.test-d.tsx @@ -11,14 +11,14 @@ * directive becomes unused — i.e. the bad form started compiling). If a relaxed * input type is reverted, the matching assertion below stops compiling. */ +// Import the ACTUAL component prop types (what `` accepts in JSX), not just +// the exported aliases — these are the types that must stay agent-friendly. import type { ColumnProps } from "./components/Column"; -import type { - BorderInput, - HeadingProps, - ButtonProps, - ParagraphProps, - ImageProps, -} from "./types"; +import type { ButtonProps } from "./components/Button"; +import type { MenuProps } from "./components/Menu"; +import type { TableProps } from "./components/Table"; +import type { DividerProps } from "./components/Divider"; +import type { BorderInput, HeadingProps, ParagraphProps, ImageProps } from "./types"; // ── border: THE regression this guard exists for ──────────────────────────── // A reusable hairline object factored into a `const` (no `as const`) must satisfy @@ -39,6 +39,17 @@ export const _border_numeric_width: BorderInput = { // @ts-expect-error a border is an object of per-side props, never a bare CSS string export const _border_reject_string: ColumnProps["border"] = "1px solid #ccc"; +// ── box-model dimensions: bare number (→ px) + factored border, across the +// ACTUAL component types (the canonical schema pins these to `${number}px`). +export const _col_radius_num: ColumnProps["borderRadius"] = 8; +export const _btn_radius_num: ButtonProps["borderRadius"] = 8; +export const _btn_padding_num: ButtonProps["padding"] = 14; +export const _btn_border_factored: ButtonProps["border"] = HAIRLINE; +export const _menu_padding_num: MenuProps["padding"] = 10; +export const _table_padding_num: TableProps["padding"] = 12; +export const _table_border_factored: TableProps["border"] = HAIRLINE; +export const _divider_border_factored: DividerProps["border"] = HAIRLINE; + // ── the rest of the natural DX surface (broader contract) ─────────────────── export const _fontSize_number: HeadingProps["fontSize"] = 28; export const _fontSize_string: HeadingProps["fontSize"] = "28px"; From 593e4998c0328cb20a3d241a7ccd9baba0ba8f4f Mon Sep 17 00:00:00 2001 From: Ivo Date: Sat, 27 Jun 2026 16:21:56 +0200 Subject: [PATCH 5/6] refactor(elements): one source of truth for component prop types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The item prop types (ButtonProps, HeadingProps, …) were defined twice — a parallel set in types.ts (the exported ones) and the real ones next to each component (what the components are actually typed with). The two could drift, so the type you imported didn't always match what the component accepted. Drop the types.ts duplicates and export each component's own prop type from its file, matching how RowProps / EmailProps already work. types.ts is now just the shared value-type re-exports plus the agent-friendly input building blocks (SizeInput, BorderInput, TextStyleProps, …) that components import, and the unused ItemProps helper is gone. No behavior change; build green, 329 tests pass, and the exported type now equals the component's accepted props. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/react/src/dx-types.test-d.tsx | 5 +- packages/react/src/index.ts | 25 ++-- packages/react/src/types.ts | 159 ++++--------------------- 3 files changed, 37 insertions(+), 152 deletions(-) diff --git a/packages/react/src/dx-types.test-d.tsx b/packages/react/src/dx-types.test-d.tsx index 5fedce1..951a196 100644 --- a/packages/react/src/dx-types.test-d.tsx +++ b/packages/react/src/dx-types.test-d.tsx @@ -18,7 +18,10 @@ import type { ButtonProps } from "./components/Button"; import type { MenuProps } from "./components/Menu"; import type { TableProps } from "./components/Table"; import type { DividerProps } from "./components/Divider"; -import type { BorderInput, HeadingProps, ParagraphProps, ImageProps } from "./types"; +import type { HeadingProps } from "./components/Heading"; +import type { ParagraphProps } from "./components/Paragraph"; +import type { ImageProps } from "./components/Image"; +import type { BorderInput } from "./types"; // ── border: THE regression this guard exists for ──────────────────────────── // A reusable hairline object factored into a `const` (no `as const`) must satisfy diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 3156054..6dfe7e6 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -29,17 +29,6 @@ import { htmlToTextJson } from "@unlayer-internal/shared-elements"; // 🎯 Export clean public types (hiding internal implementation details) export type { - // Clean component prop types - ButtonProps, - DividerProps, - HeadingProps, - HtmlProps, - ImageProps, - MenuProps, - ParagraphProps, - SocialProps, - TableProps, - VideoProps, // Value types for configuration ButtonValues, DividerValues, @@ -76,10 +65,18 @@ export type { DesignContent, } from "@unlayer-internal/shared-elements"; -// Export Row props separately since it has a custom interface +// Component prop types — each lives with its component (single source of truth). +export type { ButtonProps } from "./components/Button"; +export type { DividerProps } from "./components/Divider"; +export type { HeadingProps } from "./components/Heading"; +export type { HtmlProps } from "./components/Html"; +export type { ImageProps } from "./components/Image"; +export type { MenuProps } from "./components/Menu"; +export type { ParagraphProps } from "./components/Paragraph"; +export type { SocialProps } from "./components/Social"; +export type { TableProps } from "./components/Table"; +export type { VideoProps } from "./components/Video"; export type { RowProps } from "./components/Row"; - -// Export semantic wrapper prop types export type { EmailProps } from "./components/Email"; export type { PageProps } from "./components/Page"; export type { DocumentProps } from "./components/Document"; diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 5e55563..0a7e839 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -1,14 +1,15 @@ /** * Type Definitions * - * Re-exports shared types from @unlayer-internal/shared-elements - * and defines React-specific component prop types. + * Re-exports the canonical value types from @unlayer-internal/shared-elements + * and defines the agent-friendly input types components build their props from. + * + * Each component's prop type (ButtonProps, HeadingProps, …) lives next to its + * component — these are just the shared building blocks they share. */ -import type { SemanticProps } from "@unlayer-internal/shared-elements"; - // ============================================ -// RE-EXPORT ALL SHARED TYPES +// RE-EXPORT SHARED VALUE TYPES // ============================================ export type { @@ -43,46 +44,20 @@ export type { } from "@unlayer-internal/shared-elements"; // ============================================ -// COMPONENT PROP TYPES +// AGENT-FRIENDLY INPUT TYPES // ============================================ - -// Import value types for prop definitions -import type { - ButtonValues, - ImageValues, - HeadingValues, - DividerValues, - HtmlValues, - MenuValues, - ParagraphValues, - SocialValues, - TableValues, - VideoValues, - ColumnValues, - SocialIcon, - MenuItem, -} from "@unlayer-internal/shared-elements"; - -/** - * Public props for item components. - * Includes semantic flat props, children, values escape hatch, - * className, style, and mode. Excludes internal threading props. - */ -type ItemProps = SemanticProps & { - className?: string; - style?: React.CSSProperties; - mode?: "web" | "email" | "document"; -}; - -// ── Agent-friendly prop inputs ─────────────────────────────────────────────── // The canonical value types are stricter than the forms authors (human and AI) -// naturally write — and the flattened semantic props are typed `any`, so the -// wrong form type-checks and renders broken. These widen the public surface to -// the natural forms and replace the `any`; mapSemanticProps normalizes them at -// runtime (see normalizeCssProps / Image propMapper). +// naturally write. These widen the public surface to the natural forms; the +// runtime normalizes them at render time. Components import these to build their +// own prop types. + +// Imported locally (not just re-exported) so BorderInput can be derived from the +// canonical border shape. +import type { ColumnValues } from "@unlayer-internal/shared-elements"; /** fontFamily accepts a ready stack object or a bare family-name string. */ export type FontFamilyInput = { label: string; value: string } | string; + /** fontWeight accepts a number, a numeric string, or a CSS keyword. */ export type FontWeightInput = | number @@ -91,26 +66,28 @@ export type FontWeightInput = | "bold" | "lighter" | "bolder"; + /** A CSS size: a number (treated as px) or a string ("24px", "50%", "1.5em"). */ export type SizeInput = number | (string & {}); /** * The `border` object, agent-friendly. The canonical type pins each per-side * `*Width` to `${number}px`, so a literal like "1px" type-checks inline but a - * factored-out hairline object widens "1px" to `string` and stops compiling — - * exactly the reusable-divider pattern authors reach for. Relax the `*Width` - * fields to SizeInput (the runtime already accepts any CSS string). Derived - * from the canonical shape so it tracks the schema instead of duplicating it. + * factored-out object widens "1px" to `string` and stops compiling — exactly the + * reusable-divider pattern authors reach for. Relax the `*Width` fields to + * SizeInput (the runtime accepts any CSS string), derived from the canonical + * shape so it tracks the schema instead of duplicating it. */ export type BorderInput = { [K in keyof NonNullable]?: K extends `${string}Width` ? SizeInput : NonNullable[K]; }; + /** Heading levels (h1–h6). */ export type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; -/** Shared text/style props, agent-friendly (replace the loose `any` flat keys). */ +/** Shared text/style inputs, agent-friendly. */ export type TextStyleProps = { fontFamily?: FontFamilyInput; fontWeight?: FontWeightInput; @@ -132,95 +109,3 @@ export type ImageSrcInput = maxWidth?: SizeInput; [key: string]: unknown; }; - -/** Button component props */ -export type ButtonProps = Omit< - ItemProps, - keyof TextStyleProps | "width" | "padding" | "borderRadius" | "border" -> & - TextStyleProps & { - /** Display width — a number/px pins the button; "100%" makes it full-width. */ - width?: SizeInput; - /** Inner padding — a number (→ px) or CSS string ("14px 28px"). */ - padding?: SizeInput; - /** Corner radius — a number (→ px) or CSS string ("8px", "500px"). */ - borderRadius?: SizeInput; - /** Per-side border object (width fields accept a number/px string). */ - border?: BorderInput; - }; -/** Heading component props */ -export type HeadingProps = Omit< - ItemProps, - keyof TextStyleProps | "headingType" -> & - TextStyleProps & { - /** Heading level h1–h6. */ - headingType?: HeadingLevel; - /** Alias for `headingType`. */ - level?: HeadingLevel; - /** Heading text (or use children). */ - text?: string; - }; -/** Divider component props */ -export type DividerProps = Omit, "border"> & { - /** Per-side border object (width fields accept a number/px string). */ - border?: BorderInput; -}; -/** HTML component props */ -export type HtmlProps = ItemProps; -/** Paragraph component props */ -export type ParagraphProps = Omit, keyof TextStyleProps> & - TextStyleProps & { - /** Plain-text content (or use `html` for inline formatting, or children). */ - text?: string; - }; - -/** Image component props — supports `alt` shorthand for `altText`. */ -export type ImageProps = Omit< - ItemProps, - "src" | "width" | "maxWidth" -> & { - /** Alt text (alias for altText) */ - alt?: string; - /** Image URL string, or the value object `{ url, width?, maxWidth?, ... }`. */ - src?: ImageSrcInput; - /** Display width — number/px pins the image; "50%" sets a percentage width. */ - width?: SizeInput; - /** Display width as a CSS value ("50%", "300px"). */ - maxWidth?: SizeInput; -}; - -/** Social component props — supports `icons` shorthand array. */ -export type SocialProps = ItemProps & { - /** Social icons shorthand */ - icons?: SocialIcon[]; - /** Icon shape */ - iconType?: "circle" | "rounded" | "squared"; -}; - -/** Menu component props — supports `items` shorthand array. */ -export type MenuProps = Omit, "padding"> & { - /** Menu items shorthand */ - items?: MenuItem[]; - /** Inner padding — a number (→ px) or CSS string. */ - padding?: SizeInput; -}; - -/** Table component props — supports `headers` + `data` shorthands. */ -export type TableProps = Omit, "padding" | "border"> & { - /** Column headers */ - headers?: string[]; - /** Row data as 2D array */ - data?: string[][]; - /** Inner padding — a number (→ px) or CSS string. */ - padding?: SizeInput; - /** Per-side border object (width fields accept a number/px string). */ - border?: BorderInput; -}; - -/** Video component props — supports `videoUrl` shorthand. */ -export type VideoProps = ItemProps & { - /** YouTube/Vimeo URL (auto-parsed) */ - videoUrl?: string; -}; - From db8c662b599679c2929fdd97f60b30437354175a Mon Sep 17 00:00:00 2001 From: Ivo Date: Sat, 27 Jun 2026 16:30:02 +0200 Subject: [PATCH 6/6] fix(elements): type extract-head's head shape locally; gate the full contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extract-head.ts imported `type ComponentHead` from @unlayer/exporters, but that package only exports `heads` — an untyped Record registry — and neither @unlayer/exporters nor @unlayer/types defines a head type. The phantom import was a latent type error the build happened to tolerate. Replace it with a local ComponentHead type describing the css/js/tags builders this file calls. The index import graph is now type-clean, so add dx-contract.test.tsx to the typecheck gate — its @ts-expect-error garbage-rejection assertions are now enforced in CI alongside dx-types.test-d.tsx. Type-only; build green, 329 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/react/src/utils/extract-head.ts | 19 +++++++++++++++---- packages/react/tsconfig.typecheck.json | 2 +- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/react/src/utils/extract-head.ts b/packages/react/src/utils/extract-head.ts index b17bb12..730f368 100644 --- a/packages/react/src/utils/extract-head.ts +++ b/packages/react/src/utils/extract-head.ts @@ -8,16 +8,27 @@ */ import React from "react"; -import { - heads, - type ComponentHead, -} from "@unlayer/exporters"; +import { heads } from "@unlayer/exporters"; import { mergeValues } from "@unlayer-internal/shared-elements"; import type { RenderMode, HeadConfig } from "@unlayer-internal/shared-elements"; import { mapSemanticProps } from "./semantic-props"; import { UNLAYER_CONFIG_KEY } from "./create-component"; import { BODY_DEFAULTS, ROW_DEFAULTS, COLUMN_DEFAULTS } from "./container-defaults"; +/** Args every head builder receives: (values, bodyValues, meta). */ +type HeadArgs = [Record, Record, Record]; + +/** + * The head contributions a component can emit — optional css/js/tags builders. + * The exporters' `heads` registry is untyped (Record), so describe + * the shape this file calls. + */ +type ComponentHead = { + css?: (...args: HeadArgs) => string | undefined; + js?: (...args: HeadArgs) => string | undefined; + tags?: (...args: HeadArgs) => string[] | undefined; +}; + // ============================================ // Inlined helpers // ============================================ diff --git a/packages/react/tsconfig.typecheck.json b/packages/react/tsconfig.typecheck.json index b04994f..fb1ebaa 100644 --- a/packages/react/tsconfig.typecheck.json +++ b/packages/react/tsconfig.typecheck.json @@ -6,5 +6,5 @@ "types": ["react", "react-dom"], "noEmit": true }, - "include": ["src/dx-types.test-d.tsx"] + "include": ["src/dx-types.test-d.tsx", "src/dx-contract.test.tsx"] }