Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions lib/mcp/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,12 @@ Each layer has:
- \`label\` — Form label

**Utility**:
- \`htmlEmbed\` — Custom HTML/CSS/JS code block. Set code via update_layer_settings.
- \`htmlEmbed\` — Custom HTML/CSS/JS code block. Set code via update_layer_settings. Renders inside a
sandboxed, auto-resizing iframe on the published site: position absolute/fixed cannot escape the
embed's own box, its CSS never cascades to the page, and its scripts cannot reach the parent DOM.
For overlays/scrims/textures build native \`div\` layers instead (positioning + effects.mixBlendMode +
backgrounds.backgroundImage); for scripts that must touch the page, inject them via page custom code
(update_page_settings custom_code) rather than an embed.
- \`slider\` — Image/content carousel. Configure via update_layer_settings.
- \`lightbox\` — Fullscreen image gallery. Configure via update_layer_settings.
- \`map\` — Interactive map element. Configure via update_layer_settings.
Expand Down Expand Up @@ -142,16 +147,24 @@ Each layer's \`design\` object controls its appearance. Use update_layer_design
- borderColor: "#e5e7eb", "rgba(0,0,0,0.1)"
- borderRadius: "12px", "9999px" (pill), "0"

**backgrounds** — Background colors and gradients
**backgrounds** — Background colors, images and gradients
- backgroundColor: "#ffffff", "#0a0a0a", "transparent"
- backgroundImage: any CSS background-image value — "url(https://…)" or an inline data URI
(e.g. an SVG fractalNoise texture for film-grain overlays). URL-encode spaces inside data URIs.
- backgroundSize / backgroundPosition / backgroundRepeat: "cover", "center", "no-repeat"
- backgroundClip: "text" (for gradient text effect — also set typography color "transparent")
- bgGradientVars: { "--bg-img": "linear-gradient(135deg, #667eea 0%, #764ba2 100%)" } — CSS gradient values

**effects** — Shadows, opacity, blur
**effects** — Shadows, opacity, blur, filters, blend modes
- opacity: "0" to "1"
- boxShadow: "0 4px 6px -1px rgb(0 0 0 / 0.1)"
- blur: "4px"
- backdropBlur: "8px"
- filter: any CSS filter, e.g. "grayscale(1)", "brightness(0.5)"
- backdropFilter: any CSS backdrop-filter, e.g. "saturate(180%)"
- mixBlendMode: "multiply" | "screen" | "overlay" | "darken" | "lighten" | … — blends a layer into
whatever is beneath it. Combine with an absolutely-positioned div for color tints (multiply) and
grain/texture overlays (overlay) — no htmlEmbed needed.

**positioning** — Position, z-index
- position: "relative" | "absolute" | "fixed" | "sticky"
Expand All @@ -167,6 +180,9 @@ Each layer's \`design\` object controls its appearance. Use update_layer_design
link pointing at the page being viewed — use it for active nav-link and pagination styling.
**Breakpoints:** pass \`breakpoint\` ("desktop" default, "tablet", "mobile"). Both work in
update_layer_design and in batch_operations update_design operations.
**Beyond these properties:** CSS the design schema doesn't cover (e.g. pointer-events) can be set
per-layer with update_layer_settings custom_attributes, e.g. { "style": "pointer-events:none" } for
decorative overlays that must not block clicks or text selection.

### Rich Text

Expand Down
20 changes: 19 additions & 1 deletion lib/mcp/tools/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
replaceLayerWithComponentInstance,
} from '@/lib/layer-utils';
import { EMPTY_OVERRIDES, createTextComponentVariableValue } from '@/lib/variable-utils';
import { stringToTiptapContent } from '@/lib/text-format-utils';
import { collectFontFamiliesFromDesign, ensureFontsInstalled, fontWarnings } from '@/lib/mcp/font-install';
import { getCachedLayers as getPageLayers, saveCachedLayers } from '@/lib/mcp/page-layers';
import { getAllPages } from '@/lib/repositories/pageRepository';
Expand Down Expand Up @@ -60,13 +61,30 @@ const variableUpdateSchema = z.object({
default_value: z.unknown().optional(),
});

/**
* Coerce a `default_value` into the object shape the renderer expects.
*
* `default_value` is intentionally untyped in the tool schema (its shape
* depends on the variable type), so callers routinely pass a bare string for
* text variables. Storing that verbatim breaks rendering for every page using
* the component, so wrap it in the matching variable value here.
*/
function normalizeDefaultValue(value: unknown, type: z.infer<typeof variableTypeEnum>): ComponentVariableValue {
if (typeof value === 'string') {
return type === 'rich_text'
? createTextComponentVariableValue(stringToTiptapContent(value))
: { type: 'dynamic_text', data: { content: value } };
}
return value as ComponentVariableValue;
}

function normalizeVariables(input: Array<z.infer<typeof variableUpdateSchema>>): ComponentVariable[] {
return input.map((v) => ({
id: v.id || generateId(),
name: v.name,
type: v.type,
...(v.placeholder !== undefined && { placeholder: v.placeholder }),
...(v.default_value !== undefined && { default_value: v.default_value as ComponentVariableValue }),
...(v.default_value !== undefined && { default_value: normalizeDefaultValue(v.default_value, v.type) }),
}));
}

Expand Down
2 changes: 1 addition & 1 deletion lib/mcp/tools/layers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,7 @@ COMMON USES:
layer_id: z.string().describe('The layer ID'),
tag: z.string().optional().describe('HTML tag override: h1, h2, h3, h4, h5, h6, p, span, div, section, nav, footer, header, main, aside, article'),
html_id: z.string().optional().describe('Custom HTML element ID (for anchor links, CSS targeting)'),
html_embed_code: z.string().optional().describe('For htmlEmbed layers: the HTML/CSS/JS code to embed'),
html_embed_code: z.string().optional().describe('For htmlEmbed layers: the HTML/CSS/JS code to embed. Runs in a sandboxed iframe on the published site — it cannot overlay the page or access the parent DOM'),
custom_attributes: z.record(z.string(), z.string()).optional().describe('Custom HTML attributes as { name: value } pairs'),
custom_name: z.string().optional().describe('Display name for the layer in the builder'),
hidden: z.boolean().optional().describe('Hide the layer on the canvas (still renders on the published site).'),
Expand Down
5 changes: 3 additions & 2 deletions lib/page-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4228,9 +4228,10 @@ function resolveLayerAssets(
}
}

// Resolve richTextImage src URLs inside Tiptap content
// Resolve richTextImage src URLs inside Tiptap content. The value is read
// from persisted layer JSON, so guard against a primitive before probing it.
const textVar = layer.variables?.text;
if (textVar && 'type' in textVar && textVar.type === 'dynamic_rich_text') {
if (textVar && typeof textVar === 'object' && 'type' in textVar && textVar.type === 'dynamic_rich_text') {
const resolvedContent = resolveRichTextImageAssets((textVar as any).data?.content, assetMap);
if (resolvedContent !== (textVar as any).data?.content) {
variableUpdates.text = {
Expand Down
3 changes: 2 additions & 1 deletion lib/resolve-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import type { Layer, Component, ComponentVariable, ComponentVariableValue, LayerVariables, VariantSettingsValue } from '@/types';
import { getComponentVariantLayers } from './component-variant-utils';
import { normalizeComponentVariableValue } from './variable-utils';

/**
* Remap collection_layer_id in a FieldVariable using the ID map.
Expand Down Expand Up @@ -386,7 +387,7 @@ export function applyComponentOverrides(
const variableDef = componentVariables?.find(v => v.id === linkedTextVariableId);
const overrideCategory = (variableDef?.type === 'rich_text' ? 'rich_text' : 'text') as OverrideCategory;
const overrideValue = overrides?.[overrideCategory]?.[linkedTextVariableId];
const valueToApply = overrideValue ?? variableDef?.default_value;
const valueToApply = normalizeComponentVariableValue(overrideValue ?? variableDef?.default_value);

// Only apply if it's a text variable (has 'type' property, not ImageSettingsValue)
if (valueToApply && 'type' in valueToApply) {
Expand Down
30 changes: 25 additions & 5 deletions lib/variable-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,25 @@ export function createTextComponentVariableValue(tiptapContent: object): Compone
};
}

/**
* Coerce a persisted component variable value into its object form.
*
* Text values are expected to be `{ type, data }` objects, but a bare string
* can reach the database — the MCP component tools accept an unvalidated
* `default_value`, so an agent can store `"Accessories"` instead of
* `{ type: 'dynamic_text', data: { content: 'Accessories' } }`. Callers probe
* these values with the `in` operator, which throws on a primitive, so
* normalize before inspecting. Returns undefined for values that can never
* be a variable value.
*/
export function normalizeComponentVariableValue(value: unknown): ComponentVariableValue | undefined {
if (typeof value === 'string') {
return { type: 'dynamic_text', data: { content: value } };
}
if (typeof value !== 'object' || value === null) return undefined;
return value as ComponentVariableValue;
}

/**
* Extract Tiptap JSON content from text ComponentVariableValue
* Returns the Tiptap content object or a default empty document
Expand All @@ -123,16 +142,17 @@ export function createTextComponentVariableValue(tiptapContent: object): Compone
export function extractTiptapFromComponentVariable(value?: ComponentVariableValue): object {
const emptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };

if (!value) return emptyDoc;
const normalized = normalizeComponentVariableValue(value);
if (!normalized) return emptyDoc;

// Check if value is a text variable (has 'type' property) vs ImageSettingsValue (has 'src' property)
if ('type' in value && value.type === 'dynamic_rich_text') {
return (value as DynamicRichTextVariable).data.content;
if ('type' in normalized && normalized.type === 'dynamic_rich_text') {
return (normalized as DynamicRichTextVariable).data.content;
}

if ('type' in value && value.type === 'dynamic_text') {
if ('type' in normalized && normalized.type === 'dynamic_text') {
// Convert plain text to Tiptap format
return stringToTiptapContent((value as DynamicTextVariable).data.content);
return stringToTiptapContent((normalized as DynamicTextVariable).data.content);
}

return emptyDoc;
Expand Down