export type DocumentBlockType = 'DOCUMENT_BLOCK_TYPE_UNSPECIFIED' | 'DOCUMENT_BLOCK_TYPE_CARD' | 'DOCUMENT_BLOCK_TYPE_COLUMNS' /** * Top-level free-form Content block (text + buttons), no card * framing or image slot. Allowed in WEB_POPUP only. */ | 'DOCUMENT_BLOCK_TYPE_CONTENT' /** * Structural stack container — a "group" block in the kind registry. * Wire-shape is AiBuilderBlock.group. Allowed in EMAIL and WEB_POPUP. */ | 'DOCUMENT_BLOCK_TYPE_GROUP' /** * Client-created empty slot meant to be materialised into a real * block by the AI on a follow-up edit. Wire-shape is * AiBuilderBlock.placeholder. */ | 'DOCUMENT_BLOCK_TYPE_PLACEHOLDER'; /** * GroupDirection picks which axis an AiBuilderGroup lays its children * along. * * Wire contract: * UNSPECIFIED — "inherit / use the document default" (currently * rendered as a vertical stack). Render-time fallback * only; NOT wire-equivalent to VERTICAL — downstream * consumers that diff documents see them as different * values. * VERTICAL — explicit vertical stack. Reserved for an "Explicit * override" UX where the client wants to pin the value * against a future default change. The backend never * emits this on freshly generated groups; that case * stays UNSPECIFIED. * HORIZONTAL — explicit side-by-side layout. Emitted only by the * client (the LLM never picks a direction). * * Recommended client flow: send UNSPECIFIED for the default and switch * to a concrete value only when the user explicitly chose a non-default * direction; revert to UNSPECIFIED when the user resets it. * * Server contract: the backend preserves whatever value the client * sent across edits — direction is in the client-managed set on * AiBuilderGroup. The enum lives at file scope so the same shape is * shared between the document model and the AiGen wire types. */ export type GroupDirection = 'GROUP_DIRECTION_UNSPECIFIED' | 'GROUP_DIRECTION_VERTICAL' | 'GROUP_DIRECTION_HORIZONTAL'; export type CardLayout = 'CARD_LAYOUT_UNSPECIFIED' | 'CARD_LAYOUT_RIGHT' | 'CARD_LAYOUT_LEFT' | 'CARD_LAYOUT_TOP' | 'CARD_LAYOUT_NO' /** * The card image renders as the card backdrop (dark overlay + light text). * Takes precedence over BlockStyles.background_image on the same block. */ | 'CARD_LAYOUT_BACKGROUND'; export type ButtonGroupAlign = 'BUTTON_GROUP_ALIGN_UNSPECIFIED' | 'BUTTON_GROUP_ALIGN_LEFT' | 'BUTTON_GROUP_ALIGN_CENTER' | 'BUTTON_GROUP_ALIGN_RIGHT'; /** * TextAlign is the horizontal alignment of a single TextBlock's copy. * UNSPECIFIED = inherit (renders left). LLM-managed: the content generator * sets it for headline/caption centring; preserved verbatim on edit. */ export type TextAlign = 'TEXT_ALIGN_UNSPECIFIED' | 'TEXT_ALIGN_LEFT' | 'TEXT_ALIGN_CENTER' | 'TEXT_ALIGN_RIGHT'; /** * GroupAlign controls alignment of an AiBuilderGroup's children along * the perpendicular axis (for direction=VERTICAL → horizontal * alignment of children; for direction=HORIZONTAL → vertical * alignment). Named in start/center/end terms (not left/right) * because the concrete axis depends on direction. * * Wire contract: * UNSPECIFIED — "inherit / use the document default" (currently * rendered as START). Render-time fallback only; NOT * wire-equivalent to START. * START — explicit start alignment. Reserved for the same * "Explicit override" UX as GroupDirection.VERTICAL. * The backend never emits this on freshly generated * groups; that case stays UNSPECIFIED. * CENTER/END — explicit center / end alignment. * * Recommended client flow: same as GroupDirection — UNSPECIFIED for the * default, a concrete value only when the user explicitly chose one, * back to UNSPECIFIED on reset. */ export type GroupAlign = 'GROUP_ALIGN_UNSPECIFIED' | 'GROUP_ALIGN_START' | 'GROUP_ALIGN_CENTER' | 'GROUP_ALIGN_END'; /** * CardImageFit controls how the card's image fills its image slot. * Why it exists: card slots have fixed proportions while images arrive * with arbitrary aspect ratios. Without this knob the client would have * to pick a single global default and some cards would always look bad. * A product photo is fine with COVER, a company logo must be CONTAIN * or it gets cropped. */ export type CardImageFit = 'CARD_IMAGE_FIT_UNSPECIFIED' /** Fill the slot, cropping overflow. Good default for photos. */ | 'CARD_IMAGE_FIT_COVER' /** * Fit entirely inside the slot, may leave padding. Required for logos * and other assets that must not be cropped. */ | 'CARD_IMAGE_FIT_CONTAIN' /** * Stretch to fit, distorting aspect ratio. Deprecated — no longer * offered in the editor and dropped from the LLM schema; kept only for * wire compatibility. */ | 'CARD_IMAGE_FIT_FILL'; /** Unit for CardImageSize.value. */ export type CardImageSizeUnit = /** value ignored → client default */ 'CARD_IMAGE_SIZE_UNIT_UNSPECIFIED' /** absolute pixels */ | 'CARD_IMAGE_SIZE_UNIT_PX' /** % of the card content-box width */ | 'CARD_IMAGE_SIZE_UNIT_PERCENT'; /** * CardImageSize sizes the image slot. Which dimension it drives depends on * AiBuilderCard.layout: * LEFT / RIGHT → width of the image column * TOP → height of the image * BACKGROUND → height of the card (width is always 100%) * NO → no image, ignored * PERCENT is always measured against the card content-box WIDTH, so a * percentage height yields a stable aspect ratio regardless of card width. * Client-managed: the LLM never sets this; the backend round-trips it * verbatim. value <= 0 (or unit UNSPECIFIED) is treated as unset → the * client falls back to its default. */ export type CardImageSize = { value: number; unit: CardImageSizeUnit; }; /** * CardImagePosition is the focus / anchor of the image within its slot * (CSS object-position). One field serves both UI modes: * CONTAIN → "set position" (where the fitted image sits) * COVER → "focal point" (which point stays in frame when cropped) * Percentages 0..100; (0,0) = top-left, (50,50) = centre. Client-managed: * the LLM never sets this; the backend round-trips it verbatim. */ export type CardImagePosition = { /** horizontal, % (left) */ x: number; /** vertical, % (top) */ y: number; }; export type TextVariantStyle = { fontSize: number; fontWeight: number; lineHeight: number; /** * Font-family override for this text variant (e.g. "headings in Inter, * body in Roboto"). * * Wire contract: * - CSS font-family syntax; see DocumentSettings.font_family. * - Unset (or empty string) = inherit from * DocumentSettings.font_family, which itself falls back to the * renderer default. Backend should emit unset rather than "". * - Client-managed: the LLM never selects fonts. */ fontFamily?: string; /** * Colour for text rendered with this variant (e.g. one colour for * headings, another for body copy). * * Wire contract: * - #RGB or #RRGGBB hex. * - Unset (or empty string) = inherit. Cascade: TextBlock.color → * this field → DocumentSettings.text_color → renderer default. * - Client-managed: the LLM picks per-item colours via TextBlock.color; * variant colours are user preferences, round-tripped verbatim. */ color?: string; /** * Letter spacing (CSS letter-spacing) in px; fractional and negative * values are meaningful ("-0.2" tightens display type). Unset = normal. * Client-managed typography, round-tripped verbatim like font_family. */ letterSpacing?: number; }; export type ColorSchemeItem = { color: string; description: string; }; /** * Authored dark palette. Blocks store resolved hex, not slot references, so * dark mode works by value: the renderer builds light-hex → dark-hex maps from * the two palettes and rewrites the emitted HTML, pairing `pwd-N` class hooks * with @media (prefers-color-scheme: dark) overrides. * * Absent is NOT the same as disabled-with-a-palette: without one the document * declares `color-scheme: only light`, opting out of client recolouring * entirely, because one light palette recoloured by a mail client collapses * dark text onto a dark canvas. */ export type DarkColorScheme = { enabled: boolean; colors: Record; }; export type EdgeInsets = { top: number; right: number; bottom: number; left: number; }; /** * BlockStyles is the universal per-block visual override applied at * the AiBuilderBlock level — independent of the concrete block kind. * Every block kind (card, columns, group, and any future kind we add) * carries it through AiBuilderBlock.styles; the client renders the * frame, the LLM emits the colour overrides, and the user adjusts the * rest in the editor. Per-kind layout knobs (CardStyles padding, * ColumnsStyles gap, …) live on their own messages and stay * independent of this one. * * All fields are optional. An unset field means "inherit / no * override" — the client falls back to DocumentSettings.default_styles * and then to its renderer default. * * Tri-state semantics differ between string and numeric fields: * - String fields (background_color, border_color): empty string "" * is equivalent to unset — clients collapse both to "inherit". * Backend should emit unset rather than ""; do not rely on "" as * an "explicit reset" state for colours. * - Numeric fields (border_width, border_radius): explicit 0 is * distinct from unset (means "no border" / "sharp corners") and * is preserved across edits. * * LLM-managed: background_color, border_color, border_radius — the * block generator may emit these via AiGenBlockStyles and the * pipeline lifts them into BlockStyles. * Client-managed: border_width, border_radius_css, background_image, * hide_on_mobile and hide_on_desktop — the LLM never emits them (absent * from its schema), the backend preserves them verbatim across edits. */ export type BlockStyles = { /** * Empty string "" is treated as unset by clients. Prefer emitting * unset. */ backgroundColor?: string; /** * Empty string "" is treated as unset by clients. Prefer emitting * unset. */ borderColor?: string; /** Explicit 0 is distinct from unset and preserved. */ borderWidth?: number; /** Explicit 0 is distinct from unset and preserved. */ borderRadius?: number; /** * CSS border-radius shorthand for per-corner rounding ("20px 20px 0px * 0px"). Written by Unlayer conversion; clients render border_radius * when set (user-edited uniform value wins), else this. */ borderRadiusCss?: string; /** * Block backdrop image URL over background_color (CSS cover/center/no-repeat). * Reintroduces a block backdrop the old field (tag 5) removed, now client-managed * (user-set, LLM never touches it). "" ≡ unset. Ignored on a CARD_LAYOUT_BACKGROUND * card — that layout's own image is the backdrop. */ backgroundImage?: string; /** * Responsive visibility (Unlayer's hideMobile / hideDesktop): the emitted * email hides the block via media-query classes. Client-managed. */ hideOnMobile?: boolean; hideOnDesktop?: boolean; }; /** Two-stop CSS linear gradient: `linear-gradient(deg, from, to)`. */ export type BackgroundGradient = { /** #RRGGBB */ from: string; to: string; /** CSS angle in degrees (180 = top→bottom). */ angle: number; }; /** * How a columns block (or a button group) stacks on mobile (max-width media * query). Unset / UNSPECIFIED = stack in DOM order (the historical behaviour). */ export type MobileStack = 'MOBILE_STACK_UNSPECIFIED' /** Keep the items side by side on mobile (desktop widths). */ | 'MOBILE_STACK_NONE' /** Stack in reverse order (last item on top). */ | 'MOBILE_STACK_REVERSE'; export type ColumnsStyles = { /** Padding applied to each column in the columns block. */ columnPadding?: EdgeInsets; gap?: number; /** Padding applied around the columns block (outside the columns row). */ padding?: EdgeInsets; /** * Mobile (media-query) overrides of column_padding / padding. Unset = * desktop value with the client's automatic mobile clamp. Client-managed, * like the whole message (the LLM never sees ColumnsStyles). */ mobileColumnPadding?: EdgeInsets; mobilePadding?: EdgeInsets; mobileStack?: MobileStack; }; export type CardStyles = { /** * Padding applied to the card's content (text) area on top of any * document-level default. Applies to all layouts that have a content * area (i.e. every layout, including NO). */ contentPadding?: EdgeInsets; /** * Mobile (media-query) override of content_padding; same contract as * ColumnsStyles.mobile_* — unset = desktop value + automatic clamp. */ mobileContentPadding?: EdgeInsets; }; /** Document-level image defaults; per-image fields override these. Client-managed. */ export type ImageStyles = { /** Corner rounding, px. Unset (or 0) = square. */ borderRadius?: number; }; export type DefaultStyles = { common: BlockStyles; columns: ColumnsStyles; card: CardStyles; buttonGroup: ButtonGroupStyles; button: ButtonStyles; /** * Document-level default divider line config. Per-divider fields override * these; unset here falls back to the renderer defaults (1px, scheme * border, solid, 100%). */ divider: Divider; image: ImageStyles; }; export type DocumentSettings = { width: number; blockGap: number; padding: EdgeInsets; backgroundColor: string; textColor: string; defaultStyles: DefaultStyles; /** * Document-wide font-family override. * * Wire contract: * - CSS font-family syntax: a comma-separated fallback chain, e.g. * "Inter, system-ui, sans-serif". The backend stores it verbatim * and never parses or rewrites it. * - Unset (or empty string) = "no override" — the client falls * back to its renderer default. Backend should emit unset rather * than ""; do not rely on "" as an "explicit reset" state. * - Client-managed: the LLM never sees this field and never * selects fonts. * - Overridden per-variant by TextVariantStyle.font_family and * per-button by ButtonStyles.font_family. Cascade resolves * button → variant → document → renderer default. */ fontFamily?: string; /** * Document-wide backdrop behind all blocks (email body background). * Same semantics as the BlockStyles pair. Client-managed. */ backgroundImage?: string; backgroundGradient?: BackgroundGradient; /** * Soft drop shadow around the content column, rendered only when a painted * backdrop (background_color / gradient / image) is present. * * Wire contract: * - Unset = shown (the default look); false is the explicit opt-out. * - Client-managed: render-only chrome, the LLM never sees this field. */ showShadow?: boolean; /** * Corner rounding of the content column, px. Unset (or 0) = square. * Client-managed render-only chrome, like show_shadow. */ borderRadius?: number; }; export type TextBlock = { variant: string; text: string; /** * Per-text-item colour override. * * Wire contract: * - #RGB or #RRGGBB hex. * - Unset (or empty string) = inherit. Cascade: this field → * DocumentSettings.text_color → renderer default. Backend should * emit unset rather than "". * - LLM-managed: the block generator picks an accent colour from the * document's color_scheme for CTA-relevant / primary copy and leaves * it empty for body text so the document default applies. */ color?: string; /** * Horizontal alignment of this text item. UNSPECIFIED = inherit (left). * LLM-managed: the content generator centres headlines/captions where it * fits the design; preserved verbatim on edit. */ align: TextAlign; /** * Per-language overrides of `text`, keyed by language code ("es", "de"). * The default language lives in `text` itself. Client-managed: the LLM * writes only `text`; translations are round-tripped verbatim. */ translations: Record; /** * Per-item font-size override in px. Unset (or 0) = inherit the variant's * font_size. Client-managed: typography stays user-owned; the LLM sizes * text by picking a variant. NOTE: like translations, this is lost when * the LLM regenerates the block (items are rewritten wholesale). */ fontSize?: number; /** * Per-item font-family override. CSS font-family syntax (see * DocumentSettings.font_family). Unset (or empty string) = inherit the * variant's font_family → DocumentSettings.font_family. Client-managed. */ fontFamily?: string; /** * Background colour of this text item's box (#RGB / #RRGGBB). Unset (or * empty string) = transparent — the block backdrop shows through. * Client-managed: user-owned styling; like font_size, it is lost when the * LLM regenerates the block (items are rewritten wholesale). */ backgroundColor?: string; /** * Mobile (media-query) font-size override in px. Unset (or 0) = the * client's automatic mobile clamp of the desktop size. Client-managed; * like font_size, lost when the LLM regenerates the block. */ mobileFontSize?: number; /** Mobile alignment override. UNSPECIFIED = inherit `align` (explicit LEFT survives). Client-managed. */ mobileAlign: TextAlign; /** * Per-item letter-spacing override in px (fractional/negative meaningful; * explicit 0 = "normal", overriding a variant's tracking). Unset = inherit * TextVariantStyle.letter_spacing. Client-managed, like font_size. */ letterSpacing?: number; }; export type ButtonActionHref = { url: string; }; /** * Web-popup only: the button runs an inline JS handler instead of * navigating (Unlayer's "Custom Javascript" link type). Never emitted for * EMAIL documents — mail clients strip script handlers. */ export type ButtonActionJs = { onClick: string; }; export type ButtonAction_action_href = { type: 'href'; data: ButtonActionHref; }; export type ButtonAction_action_js = { type: 'js'; data: ButtonActionJs; }; export type ButtonAction_action = ButtonAction_action_href | ButtonAction_action_js; export type ButtonAction = { action: ButtonAction_action; }; export type Button = { /** Label; TextBlock.text's inline-HTML subset minus links/
(plain text also valid). */ text: string; action: ButtonAction; styles?: ButtonStyles; /** Per-language overrides of `text`; same contract as TextBlock.translations. */ translations: Record; }; export type ButtonStyles = { backgroundColor?: string; textColor?: string; borderRadius?: number; borderColor?: string; borderWidth?: number; fontSize?: number; /** * Font-family override for button labels. * * Wire contract: * - CSS font-family syntax; see DocumentSettings.font_family. * - Unset (or empty string) = inherit. Cascade: this field → * TextVariantStyle.font_family of the button's variant (if any) → * DocumentSettings.font_family → renderer default. Backend * should emit unset rather than "". * - Client-managed: the LLM never emits this field. Button styles * aren't part of the block generator's JSON schema, so this is * enforced by construction. */ fontFamily?: string; /** Label padding, px. Client-managed; unset = renderer default (8 / 16). */ padding?: EdgeInsets; /** Width as % of the block row (1-100). Client-managed; unset = auto (fit the label). */ widthPercent?: number; /** * Mobile (media-query) override of width_percent (1-100). Client-managed; * unset = the group's mobile layout decides (full width when stacked, * width_percent / auto when the desktop row is kept). */ mobileWidthPercent?: number; /** Mobile (media-query) override of padding. Client-managed; unset = desktop value. */ mobilePadding?: EdgeInsets; /** * Gradient fill over background_color, the flat fallback for clients without * gradient support (Outlook's Word engine). Client-managed. */ backgroundGradient?: BackgroundGradient; }; export type ButtonGroup = { buttons: Button[]; styles?: ButtonGroupStyles; /** * Horizontal alignment of the buttons within the group container. * UNSPECIFIED = inherit from CSS / client default. */ align: ButtonGroupAlign; }; export type ButtonGroupStyles = { padding?: EdgeInsets; backgroundColor?: string; gap?: number; /** * How the group lays out on mobile. Unset / UNSPECIFIED = stack the * buttons full-width in DOM order (the historical behaviour); NONE keeps * the desktop row. Client-managed, like the whole message. */ mobileStack?: MobileStack; /** * Mobile overrides of gap / padding. Client-managed; unset = the mobile * default (stacked: fixed stack gap below each button; row kept: the * desktop values). */ mobileGap?: number; mobilePadding?: EdgeInsets; }; /** * ListItem is one entry of a List. `text` carries the same inline-HTML * subset as TextBlock.text (sanitised on render) so list entries can hold * links / emphasis. */ export type ListItem = { text: string; /** Per-language overrides of `text`; same contract as TextBlock.translations. */ translations: Record; }; /** * List is a simple list of short entries — features, benefits, steps, * FAQ-style runs, terms & conditions. */ export type List = { items: ListItem[]; /** * true = numbered (
    ), false/unset = bulleted (
      ). * CONTEXT for now to keep the generated schema byte-compatible with the * legacy hand-written one; candidate for MODEL (the block-gen prompt * already promises numbered lists). */ ordered: boolean; /** * Text variant of the entries (same registry as TextBlock.variant); * unset = "normal". */ variant: string; /** * Background colour of the list's box; same contract as * TextBlock.background_color. Client-managed. */ backgroundColor?: string; }; export type FormFieldType = /** treated as TEXT */ 'FORM_FIELD_TYPE_UNSPECIFIED' | 'FORM_FIELD_TYPE_TEXT' | 'FORM_FIELD_TYPE_EMAIL' | 'FORM_FIELD_TYPE_PHONE' | 'FORM_FIELD_TYPE_NUMBER' | 'FORM_FIELD_TYPE_DATE' | 'FORM_FIELD_TYPE_CHECKBOX' | 'FORM_FIELD_TYPE_SELECT' | 'FORM_FIELD_TYPE_TEXTAREA' | 'FORM_FIELD_TYPE_HIDDEN' | 'FORM_FIELD_TYPE_RADIO'; export type FormField = { /** * Input name, submitted verbatim; the cloud-pages backend turns every * field into a subscriber tag of the same name. Two names are special * there: "email" registers an Email-platform device, "phone_number" an * SMS one. */ name: string; label: string; type: FormFieldType; required: boolean; placeholder: string; /** Choices for SELECT / RADIO / CHECKBOX-group fields. */ options: string[]; }; /** * A subscription form (WEB_PAGE documents). Client-managed end to end in * the MVP: the LLM neither sees nor generates forms; the converter and the * editor own them. The emitted HTML must follow the cloud-pages submit * conventions:
      with NO action — the hosting service * rewrites the method to POST and the form posts back to the page's own * URL. */ export type Form = { fields: FormField[]; /** Submit button. */ submitLabel: string; submitStyles?: ButtonStyles; }; /** * Countdown timer. Renders in email HTML as an animated GIF served by this * service's public /timer/v1/countdown.gif endpoint (regenerated per open). * Client-managed for the MVP: the LLM neither sees nor generates timers. */ export type Timer = { /** * Countdown target as local wall-clock time in `timezone` * ("2026-12-01T00:00:00") — mirrors the Unlayer widget's * endTime+timezone pair. */ endTime: string; /** IANA zone name; empty = UTC. */ timezone: string; /** Unit-label language (ISO 639-1); empty/unknown = "en". */ language: string; showLabels: boolean; /** * Hex "#rrggbb"; empty = renderer defaults (white bg, dark digits, * gray labels). */ backgroundColor: string; digitColor: string; labelColor: string; /** px; 0 = 40 */ digitFontSize: number; /** px; 0 = 16 */ labelFontSize: number; /** Canvas size in px; 0 = derived from the font sizes. */ width: number; height: number; /** * Font families carried for fidelity/round-trip; the GIF renderer * currently always uses Open Sans. */ digitFontFamily: string; labelFontFamily: string; /** * CSS numeric font weights. The GIF renderer maps >=600 to Open Sans * Bold, anything else to Regular (only those two faces are embedded). * 0 keeps the historical defaults: digits bold (700), labels regular (400). */ digitFontWeight: number; labelFontWeight: number; }; /** * An Unlayer HTML widget. The markup is deliberately kept verbatim: unlike * TextBlock.text this is a complete, user-authored HTML fragment and flattening * or sanitising it would change the email. The editor previews it in a sandbox; * the final email renderer emits it as-is. */ export type RawHtml = { html: string; /** * Per-language overrides of `html`; same client-managed contract as * TextBlock.translations. */ translations: Record; }; export type MenuItem = { text: string; url: string; /** Unlayer stores browser targets as "_self" / "_blank". */ target: string; translations: Record; }; export type MenuLayout = /** renders as HORIZONTAL */ 'MENU_LAYOUT_UNSPECIFIED' | 'MENU_LAYOUT_HORIZONTAL' | 'MENU_LAYOUT_VERTICAL'; /** * Unlayer's stock Menu widget. These are the presentation fields exported by * Unlayer itself; keeping them together avoids approximating a menu as a text * run and preserves each link as a distinct editable item. */ export type Menu = { items: MenuItem[]; linkColor: string; textColor: string; fontFamily: string; fontWeight: number; fontSize: number; letterSpacing: number; layout: MenuLayout; align: TextAlign; separator: string; itemPadding?: EdgeInsets; /** Mobile overrides; UNSPECIFIED = inherit the desktop value. Client-managed. */ mobileAlign: TextAlign; mobileLayout: MenuLayout; }; /** * Video teaser in the content flow. Email clients can't embed players, so * the HTML renders the poster (thumbnail + play-button overlay composited by * this service's public /video/v1/poster.jpg endpoint) as an image linking to * `url`. Client-managed for the MVP: the LLM neither sees nor generates * videos; the editor derives the thumbnail from the video URL. */ export type Video = { /** Watch-page URL the poster links to (YouTube / Vimeo / any https link). */ url: string; /** * Poster source image URL. Derived by the editor for YouTube/Vimeo links, * user-replaceable. Empty = nothing is rendered in the email. */ thumbnailUrl: string; alt: string; /** * Rendered width; the client Image.width idiom: "560px", or ""/"auto" = * natural poster size (always capped to the content width). */ width: string; /** UNSPECIFIED renders center (matches the image widget default). */ align: TextAlign; /** Corner rounding, px. Unset = inherit DefaultStyles.image; explicit 0 = square. */ borderRadius?: number; /** * Composite the play button onto the poster. Unset = on; false is the * explicit opt-out (same idiom as DocumentSettings.show_shadow). */ showPlayButton?: boolean; /** Mobile alignment override. UNSPECIFIED = inherit `align`. */ mobileAlign: TextAlign; }; /** * Row of social-network icon links in the content flow (Unlayer's "social" * widget; same SocialLink shape as the footer row). Client-managed — the * LLM never emits it; icons come from the client's stock icon set. */ export type SocialRow = { links: SocialLink[]; /** UNSPECIFIED renders center (Unlayer's social default). */ align: TextAlign; /** px; 0 = client default (24) */ iconSize: number; /** px between icons; 0 = client default (12) */ gap: number; /** Mobile alignment override. UNSPECIFIED = inherit `align`. Client-managed. */ mobileAlign: TextAlign; }; export type ContentItem_item_text = { type: 'text'; data: TextBlock; }; export type ContentItem_item_buttonGroup = { type: 'buttonGroup'; data: ButtonGroup; }; export type ContentItem_item_list = { type: 'list'; data: List; }; export type ContentItem_item_image = { type: 'image'; data: Image; }; export type ContentItem_item_form = { type: 'form'; data: Form; }; export type ContentItem_item_timer = { type: 'timer'; data: Timer; }; export type ContentItem_item_rawHtml = { type: 'rawHtml'; data: RawHtml; }; export type ContentItem_item_menu = { type: 'menu'; data: Menu; }; export type ContentItem_item_social = { type: 'social'; data: SocialRow; }; export type ContentItem_item_video = { type: 'video'; data: Video; }; export type ContentItem_item = ContentItem_item_text | ContentItem_item_buttonGroup | ContentItem_item_list | ContentItem_item_image | ContentItem_item_form | ContentItem_item_timer | ContentItem_item_rawHtml | ContentItem_item_menu | ContentItem_item_social | ContentItem_item_video; export type ContentItem = { /** * Container padding of the item. Unset = client's default inter-item gap; * set (even zeros) = used verbatim. Client-managed, hidden from the LLM. */ padding?: EdgeInsets; /** * Responsive visibility of this item (Unlayer's hideMobile / hideDesktop); * same media-query contract as BlockStyles. Client-managed. */ hideOnMobile?: boolean; hideOnDesktop?: boolean; /** * Mobile (media-query) override of `padding`. Unset = desktop value with * the client's automatic mobile clamp. Client-managed, hidden from the LLM. */ mobilePadding?: EdgeInsets; /** * Per-recipient visibility of this item, compiled to Liquid on save. * Client-managed: the model must never invent targeting rules. */ displayCondition?: DisplayCondition; item: ContentItem_item; }; export type Content = { items: ContentItem[]; textPrompt?: string; }; export type Image = { prompt: string; width: string; height: string; src: string; alt: string; mediaUuid: string; origWidth: number; origHeight: number; /** * Container padding of the image within its slot (card TOP image, column * image). Same semantics as ContentItem.padding: client-managed, written * by importers / the editor, never exposed to the LLM, round-tripped * verbatim. Layouts without a discrete image box (BACKGROUND, LEFT/RIGHT * fills) may ignore it. */ padding?: EdgeInsets; /** * Horizontal alignment when the image is a content item; slots ignore it. * UNSPECIFIED renders as center (Unlayer's image default). */ align: TextAlign; /** * Optional click-through carried by Unlayer image widgets. Empty href = * not clickable. Target is normalised to "_self" or "_blank". */ href: string; target: string; /** * Per-language overrides of src / alt (localized banners); same * client-managed contract as TextBlock.translations. */ srcTranslations: Record; altTranslations: Record; /** Corner rounding, px. Unset = inherit DefaultStyles.image; explicit 0 = square. Client-managed. */ borderRadius?: number; /** Mobile alignment override (content items only). UNSPECIFIED = inherit `align`. Client-managed. */ mobileAlign: TextAlign; /** * Mobile width override as a CSS length ("200px" / "60%"). Unset or empty = * the desktop width, scaled by the client. Only these two units are emitted; * anything else is ignored rather than written into a rule. Client-managed. */ mobileWidth?: string; /** * Content-item geometry, same contract as AiBuilderColumn's slot; unset size * = the item's own width. Ignored in a slot, whose container owns it. */ imageSize?: CardImageSize; imageFit?: CardImageFit; imagePosition?: CardImagePosition; /** Crop box: image height as a percentage of the rendered width. Unset = natural. */ imageRatio?: number; }; export type AiBuilderCard = { layout: CardLayout; image?: Image; content: Content; styles?: CardStyles; /** * How the image fills its slot. Relevant for layouts that have an * image slot: LEFT, RIGHT, TOP, BACKGROUND. Ignored for layout NO. * UNSPECIFIED lets the client fall back to its default fit. */ imageFit: CardImageFit; /** * Size of the image slot (width for LEFT/RIGHT, height for TOP, card * height for BACKGROUND; ignored for NO). Client-managed: the LLM never * sets it, the backend round-trips it. Unset → client default. */ imageSize?: CardImageSize; /** * Focus / anchor of the image within its slot (object-position). * Client-managed: the LLM never sets it, the backend round-trips it. * Unset → centre (50/50). Applies to the image layouts only. */ imagePosition?: CardImagePosition; /** * LEFT/RIGHT only: float the image so copy flows around it instead of * sitting in a separate column. Unset = off, which keeps pre-wrap documents * on their two-column look. Client-managed. */ imageWrap?: boolean; /** * Mobile override of image.padding for the TOP/LEFT/RIGHT slots. * Client-managed. */ imageMobilePadding?: EdgeInsets; /** * LEFT/RIGHT crop box: image height as a percentage of the image column's * width. Unset = natural height. Client-managed. */ sideImageRatio?: number; }; export type AiBuilderColumns = { columns: AiBuilderColumn[]; styles?: ColumnsStyles; withImages: boolean; }; export type AiBuilderColumn = { image?: Image; content: Content; /** no_null: empty string (not JSON null) is this field's "unset" idiom. */ width?: string; /** * Container padding of this column, wrapping BOTH the image slot and the * content (Unlayer column padding). Overrides ColumnsStyles.column_padding * for this column when set. Client-managed; the LLM never sets it. */ padding?: EdgeInsets; /** * Slot-image geometry, same contract as the card's: size drives the WIDTH * (unset = spans the cell), an explicit height or a crop ratio fixes the * box, and the fit crops into it. CLIENT even though the card's image_fit is * MODEL — for columns these have always been the editor's, and widening a * policy later is safe while narrowing one is not. */ imageSize?: CardImageSize; imageFit?: CardImageFit; imagePosition?: CardImagePosition; /** Crop box: image height as a percentage of the cell width. Unset = natural. */ imageRatio?: number; }; /** * AiBuilderGroup is a structural stack container. Children live as * siblings in the same AiBuilderPage with their parent_id pointing at * this block's id; the group itself has no copy and no image slot. * Allowed in EMAIL and WEB_POPUP. * * Wire contract: * - All fields (direction, align, margin, padding, gap) are * client-managed. The backend preserves whatever the client sent * verbatim across edits and emits proto-zero / unset for them on * freshly generated groups. * - UNSPECIFIED enums and unset optionals mean "inherit / use the * document default" — see GroupDirection / GroupAlign for the * render-time fallback. UNSPECIFIED is NOT wire-equivalent to the * concrete default value; downstream consumers that diff documents * see them as different states. * - The LLM never sees these fields and never sets them; they round- * trip through the backend untouched. * * Recommended client flow: * - Default rendering (vertical stack, START alignment, document * defaults for margin/padding/gap): leave UNSPECIFIED / unset. * Don't normalise to a concrete enum just because that's what the * user currently sees. * - Send a concrete enum (VERTICAL, START, HORIZONTAL, CENTER, END) * only when the user explicitly chose a non-default value, or when * the user wants to pin the value against a future default change. * - On reset, write back UNSPECIFIED / unset rather than the concrete * default — that way the document still diffs cleanly against a * freshly generated group. */ export type AiBuilderGroup = { direction: GroupDirection; /** Outer spacing around the group block. */ margin?: EdgeInsets; /** Inner spacing between the group's frame and its children. */ padding?: EdgeInsets; /** * Spacing between adjacent children. UNSPECIFIED (0) lets the * client fall back to its default. Use a non-zero value to override. */ gap?: number; /** * Alignment of children along the axis perpendicular to direction. * UNSPECIFIED → render as START. */ align: GroupAlign; /** * Mobile direction override. UNSPECIFIED = inherit `direction`. Only * HORIZONTAL→VERTICAL is honored: a vertical group is table rows in the * email, which CSS cannot re-flow into a row. Client-managed. */ mobileDirection: GroupDirection; }; /** * Placeholder is a client-created empty slot meant to be replaced by * the AI on a follow-up edit. The frontend builder inserts a * Placeholder when the user adds a new block to the canvas without * yet describing what it should be; on the next AI-edit the model * replaces it with a real block kind (card / columns / content / * group). Carries no rendered content of its own. */ export type Placeholder = { /** * Optional user-supplied hint about what the slot should become, * e.g. "CTA section with a button" or "product card". The AI uses * it as an instruction on the next edit; empty string lets the * model pick freely from surrounding context. */ prompt?: string; }; /** Line style for a Divider. UNSPECIFIED renders as solid. */ export type DividerStyle = 'DIVIDER_STYLE_UNSPECIFIED' | 'DIVIDER_STYLE_SOLID' | 'DIVIDER_STYLE_DASHED' | 'DIVIDER_STYLE_DOTTED'; /** * Divider is a horizontal separator line between sections. It carries no copy * — only line config. All fields are optional with sensible render defaults: * thickness 0 → 1px, empty color → the scheme border colour, style * UNSPECIFIED → solid, width_percent 0 → 100. */ export type Divider = { thickness: number; color: string; style: DividerStyle; widthPercent: number; }; /** * SyncedBlockRef links a block subtree root back to the saved-block template it was inserted from. * Client-managed end to end: the client refreshes the subtree from the template on load, * pushes edits back via TemplatesService, and re-applies the link after AI edits. */ export type SyncedBlockRef = { /** TemplatesService code of the source template. */ templateCode: string; /** * RFC3339 updated_at of the source template at last sync — the staleness check on load. * A string, not google.protobuf.Timestamp, on purpose: the value is client-opaque and lives in * documents stored as raw JSON (rpc-v2 smartcards content), where a Timestamp's generated * Date type would lie after a plain JSON revival; only the client ever parses it. */ sourceUpdatedAt: string; /** * Companion email template holding the block's rendered fragment. The email * emitter writes `{% email_content "" %}` instead of the baked block, * so the sending pipeline inlines the CURRENT fragment at send time. */ fragmentCode: string; }; export type DisplayConditionOp = /** Invalid: the rule is skipped at compile, as a malformed rule is today. */ 'DISPLAY_CONDITION_OP_UNSPECIFIED' | 'DISPLAY_CONDITION_OP_EQ' | 'DISPLAY_CONDITION_OP_NEQ' | 'DISPLAY_CONDITION_OP_GT' | 'DISPLAY_CONDITION_OP_GTE' | 'DISPLAY_CONDITION_OP_LT' | 'DISPLAY_CONDITION_OP_LTE' | 'DISPLAY_CONDITION_OP_CONTAINS' | 'DISPLAY_CONDITION_OP_NOT_CONTAINS' /** Presence checks; `value` is ignored. */ | 'DISPLAY_CONDITION_OP_EXISTS' | 'DISPLAY_CONDITION_OP_NOT_EXISTS'; export type DisplayConditionValueType = /** Renders as STRING. */ 'DISPLAY_CONDITION_VALUE_TYPE_UNSPECIFIED' | 'DISPLAY_CONDITION_VALUE_TYPE_STRING' | 'DISPLAY_CONDITION_VALUE_TYPE_NUMBER' | 'DISPLAY_CONDITION_VALUE_TYPE_BOOLEAN'; export type DisplayConditionCombinator = /** Renders as AND. */ 'DISPLAY_CONDITION_COMBINATOR_UNSPECIFIED' | 'DISPLAY_CONDITION_COMBINATOR_AND' | 'DISPLAY_CONDITION_COMBINATOR_OR'; /** What to do for a recipient missing a referenced value. UNSPECIFIED = HIDE. */ export type DisplayConditionUnknown = 'DISPLAY_CONDITION_UNKNOWN_UNSPECIFIED' | 'DISPLAY_CONDITION_UNKNOWN_HIDE' | 'DISPLAY_CONDITION_UNKNOWN_SHOW'; /** * Branch role in an if/elsif/else chain of ADJACENT siblings; exactly one * branch renders. UNSPECIFIED = a standalone {% if %}. */ export type DisplayConditionChain = 'DISPLAY_CONDITION_CHAIN_UNSPECIFIED' | 'DISPLAY_CONDITION_CHAIN_ELSIF' | 'DISPLAY_CONDITION_CHAIN_ELSE'; export type DisplayConditionRule = { /** * Tag / dynamic-content variable exactly as the user typed it; the emitter * strips spaces to reach the Liquid binding name. */ name: string; op: DisplayConditionOp; /** Raw input, parsed per value_type at compile. Ignored for EXISTS/NOT_EXISTS. */ value: string; valueType: DisplayConditionValueType; }; export type DisplayCondition = { /** False keeps the rules stored without emitting anything. */ enabled: boolean; combinator: DisplayConditionCombinator; rules: DisplayConditionRule[]; unknown: DisplayConditionUnknown; /** * Advanced mode: a raw Liquid boolean expression. Non-empty REPLACES `rules` * and is emitted verbatim — never escaped, never validated server-side. * "" = unset. */ liquidExpr: string; chain: DisplayConditionChain; }; export type ProductsSource = /** Renders as MANUAL. */ 'PRODUCTS_SOURCE_UNSPECIFIED' | 'PRODUCTS_SOURCE_MANUAL' | 'PRODUCTS_SOURCE_RULE' | 'PRODUCTS_SOURCE_CART'; export type ProductsRuleSort = /** Invalid: the rule degrades to MANUAL, as a malformed payload does today. */ 'PRODUCTS_RULE_SORT_UNSPECIFIED' | 'PRODUCTS_RULE_SORT_NEWEST' | 'PRODUCTS_RULE_SORT_PRICE_ASC' | 'PRODUCTS_RULE_SORT_PRICE_DESC' | 'PRODUCTS_RULE_SORT_TITLE'; /** * Catalog feed. A payload missing feed_origin, sort or a positive limit * degrades to MANUAL rather than emitting a call to a garbage URL. */ export type ProductsRule = { application: string; /** Environment-resolved feed host. "" = unset → degrades to MANUAL. */ feedOrigin: string; /** "" = unset (the query parameter is omitted). */ category: string; sort: ProductsRuleSort; /** Grid cells emitted, and the feed limit. <= 0 → degrades to MANUAL. */ limit: number; /** * How the feed RANKS the products, in the feed's own vocabulary (hence not an * enum); `sort` stays the fallback order, and an unknown value degrades to it. */ strategy: string; /** * Device tag holding the anchor product id of "also_bought". "" = unset, and a * name unreachable in Liquid is dropped — the block falls back to the rule. */ anchorTag: string; }; /** Abandoned cart of the recipient. Same degradation contract as ProductsRule. */ export type ProductsCart = { application: string; feedOrigin: string; limit: number; }; /** * One product row. The card design lives in ProductsData.template; elements * whose plain text is exactly "$title" / "$description" / "$price" / * "$oldPrice" bind to these fields, as do an image src of "$image" and a * button url of "$url". */ export type ProductItem = { /** Account catalog id when the row was picked from the catalog. "" = unset. */ catalogId: string; image?: Image; title: string; titleTranslations: Record; description: string; descriptionTranslations: Record; price: string; oldPrice: string; url: string; }; export type ProductsData = { source: ProductsSource; rule?: ProductsRule; cart?: ProductsCart; /** * Rendered-description cap in characters. Unset = renderer default, * explicit 0 = off. */ descriptionLimit?: number; /** * Rows for MANUAL, and the baked preview rows shown for RULE / CART outside * the save path. */ products: ProductItem[]; /** * The card rendered once per row. Absent = the block falls back to the * stand-in card in the oneof. */ template?: AiBuilderCard; templateStyles?: BlockStyles; /** Grid columns, clamped 1..3 by the renderer. Unset = renderer default. */ columns?: number; /** Gap between cells in px. Unset = renderer default. */ gap?: number; }; /** * One article row of a news repeater; the card design lives in * NewsData.template and binds these fields through "$field" refs. */ export type NewsItem = { /** Feed guid / atom id — identity for "already in the block" checks. "" = unset. */ id: string; image?: Image; title: string; titleTranslations: Record; summary: string; summaryTranslations: Record; /** * Display string as baked ("11 Aug 2026"); published_at keeps the ISO one * so a language switch can reformat it. */ date: string; publishedAt: string; author: string; category: string; url: string; }; export type NewsSource = /** Renders as MANUAL. */ 'NEWS_SOURCE_UNSPECIFIED' | 'NEWS_SOURCE_MANUAL' | 'NEWS_SOURCE_FEED'; /** * Registered news feed the block reads per send. Same degradation contract as * ProductsRule: anything unusable here falls back to the baked articles. */ export type NewsFeedSource = { application: string; /** Environment-resolved feed host. "" = unset → degrades to MANUAL. */ feedOrigin: string; /** Code of the feed in the news_feeds registry. "" = every feed of the app. */ feedCode: string; /** "" = unset (the query parameter is omitted). */ category: string; /** Grid cells emitted, and the feed limit. <= 0 → degrades to MANUAL. */ limit: number; /** * How the feed RANKS the articles, in the feed's own vocabulary (hence not an * enum); "" and an unknown value leave the feed on its recency default. */ strategy: string; }; /** * News repeater: one card template per article — baked at edit time, or fetched * per send when source is FEED. */ export type NewsData = { /** Feed the articles were taken from, kept for the panel's Refresh. */ feedUrl: string; /** * Rendered-summary cap in characters. Unset = renderer default, * explicit 0 = off. */ summaryLimit?: number; items: NewsItem[]; /** * The card rendered once per article. Absent = the block falls back to the * stand-in card in the oneof. */ template?: AiBuilderCard; templateStyles?: BlockStyles; /** Grid columns, clamped 1..3 by the renderer. Unset = renderer default. */ columns?: number; /** Gap between cells in px. Unset = renderer default. */ gap?: number; source: NewsSource; feed?: NewsFeedSource; /** * Leading cells taken from items[] instead of the feed — the editorial pin. * Clamped to leave at least one feed cell; ignored by a MANUAL block. */ pinned: number; }; export type AiBuilderBlock_block_card = { type: 'card'; data: AiBuilderCard; }; export type AiBuilderBlock_block_columns = { type: 'columns'; data: AiBuilderColumns; }; export type AiBuilderBlock_block_content = { type: 'content'; data: Content; }; export type AiBuilderBlock_block_group = { type: 'group'; data: AiBuilderGroup; }; export type AiBuilderBlock_block_placeholder = { type: 'placeholder'; data: Placeholder; }; export type AiBuilderBlock_block_divider = { type: 'divider'; data: Divider; }; export type AiBuilderBlock_block = AiBuilderBlock_block_card | AiBuilderBlock_block_columns | AiBuilderBlock_block_content | AiBuilderBlock_block_group | AiBuilderBlock_block_placeholder | AiBuilderBlock_block_divider; export type AiBuilderBlock = { id: string; styles?: BlockStyles; /** Synced-block link; present only on the root of an instance's subtree. */ syncedRef?: SyncedBlockRef; /** * Id of the block-of-this-page that contains this one. Empty string * marks a top-level block. The parent (if set) must be a container * kind in the same AiBuilderPage. Containers may nest to arbitrary * depth; the server rejects trees deeper than 16 levels with * InvalidArgument (a sanity cap, not a product limit). Per-mode * soft hints in the planner prompt steer the model toward shallower * structures appropriate for each channel. */ parentId: string; /** Per-recipient visibility of this block, compiled to Liquid on save. */ displayCondition?: DisplayCondition; /** * Products repeater. When set it OWNS the block's rendering and the oneof * holds a stand-in card, so the model (and an older editor) still sees a * valid block. */ products?: ProductsData; /** * News repeater. Same ownership contract as products: when set it OWNS the * block's rendering and the oneof holds a stand-in card. */ news?: NewsData; block: AiBuilderBlock_block; }; /** * Meta carries channel-agnostic document-level metadata generated by * the meta generator. Channel-specific fields (email subject / * preheader, future popup-only / inapp-only fields) live in their own * optional sub-messages on AiBuilderDocument (EmailSettings, * PopupSettings, ...) so this message stays small and uniform across * all modes. */ export type Meta = { templateName: string; }; /** * EmailType classifies a document as a commercial (marketing) message or a * transactional / relationship message. It drives footer compliance: marketing * emails must carry an unsubscribe link + postal address (CAN-SPAM), while * transactional ones (receipts, password resets, policy updates) are exempt. * The content generator classifies this from the campaign intent (see the * content-fill _meta); the client may override. */ export type EmailType = /** treated as MARKETING (safe default) */ 'EMAIL_TYPE_UNSPECIFIED' | 'EMAIL_TYPE_MARKETING' | 'EMAIL_TYPE_TRANSACTIONAL'; export type SocialPlatform = 'SOCIAL_PLATFORM_UNSPECIFIED' | 'SOCIAL_PLATFORM_FACEBOOK' | 'SOCIAL_PLATFORM_X' | 'SOCIAL_PLATFORM_INSTAGRAM' | 'SOCIAL_PLATFORM_LINKEDIN' | 'SOCIAL_PLATFORM_YOUTUBE' | 'SOCIAL_PLATFORM_TIKTOK' /** The hosted icon set (pushwoosh.com/social-icons) already covers these. */ | 'SOCIAL_PLATFORM_DISCORD' | 'SOCIAL_PLATFORM_GITHUB' | 'SOCIAL_PLATFORM_TELEGRAM' | 'SOCIAL_PLATFORM_THREADS' | 'SOCIAL_PLATFORM_WHATSAPP'; export type AppStorePlatform = 'APP_STORE_PLATFORM_UNSPECIFIED' | 'APP_STORE_PLATFORM_APP_STORE' | 'APP_STORE_PLATFORM_GOOGLE_PLAY'; export type NavLink = { label: string; url: string; /** Per-language overrides of `label`; same contract as MenuItem.translations. */ translations: Record; /** Per-language overrides of `url` (e.g. locale-specific Privacy Policy). */ urlTranslations: Record; }; export type SocialLink = { platform: SocialPlatform; url: string; }; export type AppBadge = { platform: AppStorePlatform; url: string; }; /** Reusable brand footer data; never LLM-generated. */ export type FooterContent = { companyName: string; address: string; copyright: string; unsubscribeLabel: string; links: NavLink[]; social: SocialLink[]; appBadges: AppBadge[]; /** * Per-language overrides; same contract as EmailSettings.subject_translations. * URLs (links/social/badges) are the only fields shared across languages. */ unsubscribeLabelTranslations: Record; copyrightTranslations: Record; companyNameTranslations: Record; addressTranslations: Record; /** * Brand logo shown at the top of the footer. Absolute image URL (email * clients need hosted raster assets); empty = no logo. */ logoUrl?: string; /** Rendered logo width in px; 0/unset = the renderer default. */ logoWidth?: number; /** Click-through URL for the logo; empty = plain image. */ logoLink?: string; }; /** * Per-template footer presentation. Deliberately separate from * FooterContent: FooterContent is reusable brand DATA (mirrored by the * account-level EmailFooter in rpc-v2), while these are render * preferences of one template. Client-managed; unset fields keep the * renderer defaults (muted gray, 12px, centered, underlined links). */ export type FooterStyle = { /** #RGB/#RRGGBB hex; empty = renderer default (#8A94A6). */ textColor?: string; /** px; 0/unset = renderer default (12). */ fontSize?: number; /** UNSPECIFIED = renderer default (center). */ align: TextAlign; /** * Underline the footer's text links (nav links, unsubscribe, web-version). * Optional so absent keeps the client default (underlined). */ underlineLinks?: boolean; /** * Look of the social icons; both UNSPECIFIED reproduce today's defaults * (circle / black), so an absent value renders as it always has. */ socialIconShape: SocialIconShape; socialIconVariant: SocialIconVariant; }; /** * Renderer picks a hosted icon set from these two. The value names are not * cosmetic: they are the asset path segment and filename suffix * (//--.png), so an invented value is a * 404, not a fallback. */ export type SocialIconShape = /** Renders as CIRCLE. */ 'SOCIAL_ICON_SHAPE_UNSPECIFIED' | 'SOCIAL_ICON_SHAPE_CIRCLE' | 'SOCIAL_ICON_SHAPE_SQUARE' | 'SOCIAL_ICON_SHAPE_REGULAR'; export type SocialIconVariant = /** Renders as BLACK. */ 'SOCIAL_ICON_VARIANT_UNSPECIFIED' | 'SOCIAL_ICON_VARIANT_BLACK' | 'SOCIAL_ICON_VARIANT_WHITE' | 'SOCIAL_ICON_VARIANT_COLOR'; /** * EmailSettings holds the email-only document-level fields produced by * the meta generator under mode=EMAIL. Populated only when the * document was generated for the email channel; absent otherwise. */ export type EmailSettings = { subject?: string; preheader?: string; /** * Marketing vs transactional. Classified by the content generator from the * campaign intent; drives footer compliance (see EmailType). The client may * override. */ emailType: EmailType; /** * Reusable brand footer (company / address / links / …). Client-managed — * never LLM-generated. Absent = no footer configured yet. */ footer?: FooterContent; /** * Thin "View in browser" strip at the top, linking %%PW_EMAIL_WEB_LINK%%. * optional: needs presence so an unset value stays absent (client default on) * instead of collapsing to proto3 false on save. */ showWebVersion?: boolean; /** * Per-language overrides of subject / preheader; the default language * lives in the scalar fields. Client-managed, hidden from the LLM. */ subjectTranslations: Record; preheaderTranslations: Record; /** * Managed unsubscribe link in the document footer. It is rendered only for * marketing email. Optional so absent keeps the client default (on). */ showUnsubscribe?: boolean; /** * Footer presentation overrides for this template. Absent = renderer * defaults. Client-managed. */ footerStyle?: FooterStyle; }; /** * PopupAnimation chooses how a web-popup enters / exits the * viewport. UNSPECIFIED leaves the choice to the client default * (typically NONE or FADE). Client-managed: the LLM never selects an * animation. */ export type PopupAnimation = 'POPUP_ANIMATION_UNSPECIFIED' | 'POPUP_ANIMATION_NONE' | 'POPUP_ANIMATION_FADE' | 'POPUP_ANIMATION_SLIDE'; /** * PopupOverlay describes the backdrop drawn behind the popup. Both * fields are client-managed; the LLM does not see them. */ export type PopupOverlay = { /** Hex color, e.g. "#000000". Empty string = no overlay. */ color: string; /** 0.0 (fully transparent) to 1.0 (opaque). */ opacity: number; }; /** * PopupSettings holds the web-popup channel's client-managed UI * configuration on AiBuilderDocument. Populated by the client (the * editor) and preserved across edits by the server through the same * flow as AiBuilderGroup's client-managed fields: the LLM never sees * these values, server-side conversion never writes to them, the * client owns them end-to-end. */ export type PopupSettings = { /** Corner radius of the popup container, in px. 0 = sharp corners. */ borderRadius: number; /** * CSS box-shadow string, e.g. "0 4px 24px rgba(0,0,0,0.2)". Empty * string = no shadow. */ boxShadow: string; /** Backdrop overlay. Absent = no overlay. */ overlay?: PopupOverlay; /** Enter / exit animation. UNSPECIFIED = client default. */ animation: PopupAnimation; /** * Edge offsets in px (CSS-style positioning). Any subset of * top / bottom / left / right may be set. When none are set the * popup is centered in the viewport. Typical combinations: * - centered: leave all unset * - bottom-left: set bottom + left (with the desired gutter) * - top-right: set top + right * - full-bleed * overlay band: set both top + bottom (and/or left + right); * the client decides how to fill the gap. * Conflicting pairs (top + bottom, left + right) are permitted on * the wire — the client decides whether to stretch the popup * between the two edges or to honour one of them. */ top?: number; bottom?: number; left?: number; right?: number; }; /** * AiBuilderPage groups a list of blocks under a single logical page. For * now every document has exactly one page; the structure is in place so * multi-page documents (landing pages with several screens, email digests * with tabs, etc.) can be added without a further schema break. */ export type AiBuilderPage = { id: string; blocks: AiBuilderBlock[]; }; export type AiBuilderDocument = { /** MVP: always one page. Consumers may assume pages[0] exists. */ pages: AiBuilderPage[]; settings: DocumentSettings; textVariants: Record; colorScheme: Record; meta: Meta; /** * Email channel-specific fields. Populated only when the document * was generated for mode=EMAIL. */ emailSettings?: EmailSettings; /** * Web popup channel-specific fields. Populated only when the * document was generated for mode=WEB_POPUP. */ popupSettings?: PopupSettings; /** * Languages this document is edited in, "default" first, then language * codes in a stable order. Drives the editor's language switcher; a * language may be listed before any translation exists. Client-managed. */ languages: string[]; /** * Authored dark palette, paired with color_scheme. Absent = the document * opts out of dark mode; see DarkColorScheme. Client-managed. */ darkColorScheme?: DarkColorScheme; };