add forms tool, wip

This commit is contained in:
Michael Kret 2026-08-12 13:17:09 +03:00
parent 28cff712df
commit 6e7fc7ea99
30 changed files with 2292 additions and 468 deletions

View File

@ -316,6 +316,7 @@ export {
confirmationInputTypeSchema,
instanceAiTargetApprovalSchema,
channelConfigSchema,
formAppearanceConfirmationSchema,
credentialPlaceholderDefSchema,
credentialRequestSchema,
credentialSetupHintSchema,
@ -410,6 +411,7 @@ export type {
InstanceAiConfirmation,
InstanceAiConfirmationInputType,
InstanceAiChannelConfig,
InstanceAiFormAppearance,
InstanceAiConfirmationRequestPayload,
InstanceAiConfirmationSeverity,
InstanceAiCredentialPlaceholderDef,

View File

@ -555,6 +555,18 @@ export const channelConfigSchema = z.object({
});
export type InstanceAiChannelConfig = z.infer<typeof channelConfigSchema>;
/** Rendered form-appearance confirmation card (Instance AI `forms` apply-theme). */
export const formAppearanceConfirmationSchema = z.object({
workflowId: z.string(),
nodeName: z.string().optional(),
scope: z.enum(['node', 'workflow']),
/** Server-rendered form HTML shown read-only in a sandboxed iframe. */
previewHtml: z.string(),
preset: z.string().optional(),
themeLabel: z.string().optional(),
});
export type InstanceAiFormAppearance = z.infer<typeof formAppearanceConfirmationSchema>;
export const confirmationInputTypeSchema = z.enum([
'approval',
'text',
@ -649,6 +661,11 @@ export const confirmationRequestPayloadSchema = z.object({
.describe(
'When present, renders agent chat-channel setup UI for this integration type and agent',
),
formAppearance: formAppearanceConfirmationSchema
.optional()
.describe(
'When present, renders the form appearance confirmation card (rendered form preview + approve/deny)',
),
});
export type InstanceAiConfirmationRequestPayload = z.infer<typeof confirmationRequestPayloadSchema>;
@ -682,6 +699,7 @@ export function isDisplayableConfirmationRequest(
if (hasItems(payload.credentialRequests)) return true;
if (payload.domainAccess) return true;
if (payload.channelConfig) return true;
if (payload.formAppearance) return true;
const inputType = payload.inputType ?? 'approval';
switch (inputType) {
@ -1189,6 +1207,7 @@ export interface InstanceAiConfirmation {
tasks?: TaskList;
resourceDecision?: GatewayConfirmationRequiredPayload;
channelConfig?: InstanceAiChannelConfig;
formAppearance?: InstanceAiFormAppearance;
expired?: boolean;
}

View File

@ -0,0 +1,62 @@
---
name: forms
description: >-
Preview and restyle/theme form-trigger workflows (Form Trigger + Form nodes)
through the forms tool. Load when the user asks how a form looks, or to
rebrand, recolor, restyle, theme, or "make prettier / match X" a form, or
when they just built or are editing a form workflow and appearance is
relevant. Appearance only (colors, fonts, spacing, themes) — not field or
logic editing.
recommended_tools:
- forms
---
# Forms — Appearance & Theming
Use the `forms` tool to preview and restyle the appearance of form-trigger
workflows. This is **appearance only** (colors, fonts, spacing, themes). Field
and logic editing is not supported here yet — for that, point the user to the
form node in the editor.
## When to use
- The user asks how a form **looks**, or wants to preview it.
- The user wants to rebrand / recolor / restyle / theme a form, or "make it
match our brand", "make it dark", "make it prettier".
- The user just built or is editing a form workflow — proactively offer to
preview it and to apply a theme. Keep the offer short and only when relevant.
## Actions
- `describe` — read a form node's current theme/appearance (resolved overrides,
matched preset, attribution) and list the workflow's form nodes. Do this
before changing a theme.
- `list-appearance-options` — list built-in themes and the editable CSS-variable
catalog. **Call this before composing a custom theme** so you only use valid
variable names and values.
- `preview` — render a single form (non-interactive) so the user can see it,
e.g. before/after a restyle. Optionally pass a `preset` or `overrides` to
preview a proposed look.
- `apply-theme` — apply a built-in `preset` or a custom `overrides` map to one
node (`scope: 'node'`) or every form node (`scope: 'workflow'`). Requires
user confirmation (shows a rendered preview card).
## Apply a built-in theme
1. `forms(describe)` to see the current look and the form nodes.
2. `forms(apply-theme, { preset: 'dark', scope: 'workflow' })` — the user
approves the preview card, then the theme is written.
## Create a theme from a vague description
For a mood/brand/style request ("calm, minimal, spa-like", "match our green
brand"):
1. `forms(list-appearance-options)` — learn the presets and the CSS-variable
catalog (each variable has a type, group, default, and description).
2. Compose an `overrides` map from that catalog. You may start from the closest
preset and adjust.
3. Optionally `forms(preview, { overrides, nodeName })` to check the look.
4. `forms(apply-theme, { overrides, scope })`. If the tool returns validation
`errors` (unknown variable or wrong value type), fix those values and retry —
never write junk to the node.

View File

@ -95,6 +95,17 @@ ${licenseHints.map((hint) => `- ${hint}`).join('\n')}
`;
}
function getFormsSection(): string {
return `
## Form Appearance
When the user is working with a form-trigger workflow (Form Trigger / Form nodes), or asks how a form **looks** — branding, colors, fonts, spacing, theme, "make it match X", "make it prettier" — use the \`forms\` tool.
- Be proactive: if the user just built or is editing a form workflow, briefly offer to preview it and to restyle or apply a theme. Keep the offer short and only when relevant.
- To restyle from a vague description, first \`forms(list-appearance-options)\` to learn the themes and CSS variables, compose overrides, optionally \`preview\`, then \`apply-theme\` (which asks for confirmation).
- Appearance only for now: if the user asks to change form **fields or logic**, say that isn't supported through this tool yet and point them to the form node in the editor.`;
}
function getReadOnlySection(branchReadOnly?: boolean): string {
if (!branchReadOnly) return '';
return `
@ -173,6 +184,7 @@ Don't fabricate provider setup mechanics (credential field names, secret values,
${UNTRUSTED_CONTENT_DOCTRINE}
${getComputerUsePrompt({ browserAvailable, localGateway })}
${getFormsSection()}
${getLicenseLimitationsSection(licenseHints)}
${getReadOnlySection(branchReadOnly)}`;
}

View File

@ -601,6 +601,9 @@ export declare const PLANNED_TASK_PERMISSION_OVERRIDES: typeof PlannedTaskPermis
export type {
InstanceAiContext,
InstanceAiWorkflowService,
InstanceAiFormService,
FormNodeSummary,
FormNodeAppearance,
InstanceAiExecutionService,
InstanceAiCredentialService,
InstanceAiNodeService,

View File

@ -7,6 +7,7 @@ import {
gatewayConfirmationRequiredPayloadSchema,
webSearchMetaSchema,
channelConfigSchema,
formAppearanceConfirmationSchema,
} from '@n8n/api-types';
import type { InstanceAiEvent } from '@n8n/api-types';
import { isRecord } from '@n8n/utils/is-record';
@ -344,6 +345,10 @@ function mapSuspendedChunk(
gatewayConfirmationRequiredPayloadSchema,
);
const channelConfig = parseSchemaRecord(suspendPayload.channelConfig, channelConfigSchema);
const formAppearance = parseSchemaRecord(
suspendPayload.formAppearance,
formAppearanceConfirmationSchema,
);
const targetApprovalResult = isRecord(suspendPayload.builderCheckpoint)
? APPROVAL_SUSPEND_SCHEMA.safeParse(suspendPayload)
: undefined;
@ -385,6 +390,7 @@ function mapSuspendedChunk(
...(planItems ? { planItems } : {}),
...(resourceDecision ? { resourceDecision } : {}),
...(channelConfig ? { channelConfig } : {}),
...(formAppearance ? { formAppearance } : {}),
},
};
}

View File

@ -0,0 +1,189 @@
import { executeTool } from '../../__tests__/tool-test-utils';
import type { InstanceAiContext, InstanceAiFormService } from '../../types';
import { createFormsTool } from '../forms.tool';
function createFormService(overrides: Partial<InstanceAiFormService> = {}): InstanceAiFormService {
return {
getFormNode: vi.fn().mockResolvedValue({
nodeName: 'My Form',
nodeType: 'n8n-nodes-base.formTrigger',
overrides: {},
appendAttribution: true,
preset: 'light',
}),
listFormNodes: vi.fn().mockResolvedValue([
{
nodeName: 'My Form',
nodeType: 'n8n-nodes-base.formTrigger',
isTrigger: true,
preset: 'light',
},
]),
getWorkflowName: vi.fn().mockResolvedValue('My Workflow'),
applyAppearance: vi.fn().mockResolvedValue({ updatedNodeNames: ['My Form'] }),
renderPreview: vi.fn().mockResolvedValue('<html>preview</html>'),
...overrides,
};
}
function createMockContext(formService: InstanceAiFormService): InstanceAiContext {
return {
userId: 'user-1',
formService,
permissions: {},
} as unknown as InstanceAiContext;
}
describe('forms tool', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('describe', () => {
it("returns the node's resolved appearance and the workflow's form nodes", async () => {
const formService = createFormService();
const tool = createFormsTool(createMockContext(formService));
const result = await executeTool<{
node?: { nodeName: string; preset: string };
formNodes: Array<{ nodeName: string }>;
}>(tool, { action: 'describe', workflowId: 'wf1', nodeName: 'My Form' }, {} as never);
expect(formService.getFormNode).toHaveBeenCalledWith('wf1', 'My Form');
expect(result.node?.nodeName).toBe('My Form');
expect(result.node?.preset).toBe('light');
expect(result.formNodes).toHaveLength(1);
});
it('reports not-found when the workflow has no form node', async () => {
const formService = createFormService({
getFormNode: vi.fn().mockResolvedValue(null),
listFormNodes: vi.fn().mockResolvedValue([]),
});
const tool = createFormsTool(createMockContext(formService));
const result = await executeTool<{ found: boolean }>(
tool,
{ action: 'describe', workflowId: 'wf1' },
{} as never,
);
expect(result.found).toBe(false);
});
});
describe('list-appearance-options', () => {
it('returns preset ids with descriptions and the CSS-variable catalog', async () => {
const formService = createFormService();
const tool = createFormsTool(createMockContext(formService));
const result = await executeTool<{
presets: Array<{ id: string; description: string }>;
variables: Array<{ variable: string; type: string }>;
}>(tool, { action: 'list-appearance-options' }, {} as never);
expect(result.presets.some((p) => p.id === 'dark')).toBe(true);
expect(result.variables.some((v) => v.variable === '--color-background')).toBe(true);
expect(formService.getFormNode).not.toHaveBeenCalled();
});
});
describe('apply-theme', () => {
it('suspends with a preview then applies the preset on approval', async () => {
const formService = createFormService();
const tool = createFormsTool(createMockContext(formService));
const suspend = vi.fn();
// First call — no resume data: renders a preview and suspends.
await executeTool(
tool,
{
action: 'apply-theme',
workflowId: 'wf1',
scope: 'node',
nodeName: 'My Form',
preset: 'dark',
},
{ suspend, resumeData: undefined } as never,
);
expect(formService.renderPreview).toHaveBeenCalledWith('wf1', {
nodeName: 'My Form',
customCss: expect.stringContaining(':root'),
});
expect(suspend).toHaveBeenCalledTimes(1);
expect(suspend.mock.calls[0][0]).toMatchObject({
severity: 'info',
formAppearance: {
workflowId: 'wf1',
nodeName: 'My Form',
scope: 'node',
previewHtml: '<html>preview</html>',
preset: 'dark',
},
});
expect(formService.applyAppearance).not.toHaveBeenCalled();
// Second call — approved resume data: writes the CSS.
const result = await executeTool<{ success: boolean; updatedNodeNames: string[] }>(
tool,
{
action: 'apply-theme',
workflowId: 'wf1',
scope: 'node',
nodeName: 'My Form',
preset: 'dark',
},
{ suspend, resumeData: { approved: true } } as never,
);
expect(formService.applyAppearance).toHaveBeenCalledWith('wf1', {
nodeNames: ['My Form'],
customCss: expect.stringContaining(':root'),
appendAttribution: true,
});
expect(result.success).toBe(true);
expect(result.updatedNodeNames).toEqual(['My Form']);
});
it('does not apply when the user denies', async () => {
const formService = createFormService();
const tool = createFormsTool(createMockContext(formService));
const result = await executeTool<{ success: boolean; denied: boolean }>(
tool,
{ action: 'apply-theme', workflowId: 'wf1', scope: 'node', preset: 'dark' },
{ suspend: vi.fn(), resumeData: { approved: false } } as never,
);
expect(result.denied).toBe(true);
expect(formService.applyAppearance).not.toHaveBeenCalled();
});
it('returns validation errors for invalid overrides without suspending', async () => {
const formService = createFormService();
const tool = createFormsTool(createMockContext(formService));
const suspend = vi.fn();
const result = await executeTool<{
success: boolean;
errors?: Array<{ variable: string; reason: string }>;
}>(
tool,
{
action: 'apply-theme',
workflowId: 'wf1',
scope: 'node',
overrides: { '--not-a-real-variable': 'nope' },
},
{ suspend, resumeData: undefined } as never,
);
expect(result.success).toBe(false);
expect(result.errors?.[0]?.variable).toBe('--not-a-real-variable');
expect(suspend).not.toHaveBeenCalled();
expect(formService.applyAppearance).not.toHaveBeenCalled();
expect(formService.renderPreview).not.toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,457 @@
/**
* Forms tool — appearance/theming of form-trigger workflows.
*
* v1 is appearance-only: read a form node's current look (`describe`), learn the
* theming surface (`list-appearance-options`), render a non-interactive preview
* (`preview`), and apply a preset or custom overrides to node(s) behind an HITL
* confirmation card (`apply-theme`). The action union and `InstanceAiFormService`
* are shaped so field/structure editing can be added later without rework.
*
* Thin-tool rule: no business logic here — every read/write goes through
* `context.formService`.
*/
import { Tool } from '@n8n/agents';
import {
formAppearanceConfirmationSchema,
instanceAiConfirmationSeveritySchema,
} from '@n8n/api-types';
import {
applyFormThemePreset,
assembleFormCss,
FORM_CSS_VARIABLE_CONTROLS,
FORM_CSS_VARIABLE_GROUPS,
FORM_THEMES,
resolveFormTheme,
validateThemeOverrides,
} from 'n8n-workflow';
import { nanoid } from 'nanoid';
import { z } from 'zod';
import { sanitizeInputSchema } from '../agent/sanitize-mcp-schemas';
import type { FormNodeSummary, InstanceAiContext } from '../types';
// ── Preset descriptions (LLM-facing) ─────────────────────────────────────────
// One-line human descriptions per built-in preset id, so `list-appearance-options`
// teaches the model what each preset looks like.
const PRESET_DESCRIPTIONS: Record<string, string> = {
light: 'Default light theme — white card on a near-white page',
dark: 'Dark background with light text',
dense: 'Reduced spacing and smaller headings, light background',
compact: 'Narrow, tightly-spaced layout',
compactDark: 'Compact layout on a dark background',
enterprise: 'Formal corporate blue with near-square corners',
enterpriseDark: 'Formal corporate look on a dark background',
fun: 'Playful pink/purple with heavily rounded corners',
funColorful: 'Bright, colorful and highly rounded',
};
// Human display names for preset ids (the id is a property name; the UI shows this).
const PRESET_LABELS: Record<string, string> = {
light: 'Light',
dark: 'Dark',
dense: 'Dense',
compact: 'Compact',
compactDark: 'Compact Dark',
enterprise: 'Enterprise',
enterpriseDark: 'Enterprise Dark',
fun: 'Fun',
funColorful: 'Fun Colorful',
};
/** Display label for a preset id: proper name, "Custom theme", or the id as a fallback. */
function presetLabel(id: string): string {
if (id === 'custom') return 'Custom theme';
return PRESET_LABELS[id] ?? id;
}
// Shared field descriptions must be identical across the discriminated union
// (the MCP-schema sanitizer merges same-named fields and rejects conflicts).
const WORKFLOW_ID_DESC = 'ID of the workflow containing the form node(s)';
const NODE_NAME_DESC =
"Form node name. Omit to use the Form Trigger (or first form node). For apply-theme this is the target when scope='node' and is ignored when scope='workflow'.";
const PRESET_DESC = 'Built-in theme preset id (e.g. "dark"). Mutually exclusive with overrides.';
const OVERRIDES_DESC =
'Custom CSS-variable overrides map (e.g. {"--color-background":"#101010"}). Mutually exclusive with preset.';
// ── Action schemas ───────────────────────────────────────────────────────────
const describeAction = z.object({
action: z
.literal('describe')
.describe(
"Read a form node's current theme/appearance (resolved CSS overrides, matched preset, attribution) before changing it. Also lists the workflow's form nodes.",
),
workflowId: z.string().describe(WORKFLOW_ID_DESC),
nodeName: z.string().optional().describe(NODE_NAME_DESC),
});
const listAppearanceOptionsAction = z.object({
action: z
.literal('list-appearance-options')
.describe(
'List built-in themes and the editable CSS-variable catalog (variable, type, group, default, description). Call this before creating a custom theme so you compose overrides from valid variable names and values.',
),
});
const previewAction = z.object({
action: z
.literal('preview')
.describe(
'Show the user what a form looks like, non-interactively (e.g. before/after a restyle). Omit nodeName to preview EVERY form step (the whole form); pass nodeName to preview a single step. Optionally pass a preset or overrides to preview a proposed look before applying it. Does not modify the workflow.',
),
workflowId: z.string().describe(WORKFLOW_ID_DESC),
nodeName: z.string().optional().describe(NODE_NAME_DESC),
preset: z.string().optional().describe(PRESET_DESC),
overrides: z.record(z.string()).optional().describe(OVERRIDES_DESC),
});
const applyThemeAction = z.object({
action: z
.literal('apply-theme')
.describe(
'Apply a built-in theme or a custom set of CSS overrides to form node(s). Requires user confirmation: suspends an approval card showing the rendered preview, then writes the CSS on approval. Provide EXACTLY ONE of `preset` or `overrides`. To create a theme from a vague description (mood/brand/style), FIRST call list-appearance-options, compose `overrides` from that catalog, optionally `preview`, then apply. If the overrides are rejected, fix the reported values and retry.',
),
workflowId: z.string().describe(WORKFLOW_ID_DESC),
scope: z
.enum(['node', 'workflow'])
.describe("'node' targets a single form node; 'workflow' targets every form node."),
nodeName: z.string().optional().describe(NODE_NAME_DESC),
preset: z.string().optional().describe(PRESET_DESC),
overrides: z.record(z.string()).optional().describe(OVERRIDES_DESC),
});
const actionUnion = z.discriminatedUnion('action', [
describeAction,
listAppearanceOptionsAction,
previewAction,
applyThemeAction,
]);
const inputSchema = sanitizeInputSchema(actionUnion);
type Input = z.infer<typeof actionUnion>;
// ── Suspend / resume schemas ─────────────────────────────────────────────────
const suspendSchema = z.object({
requestId: z.string(),
message: z.string(),
severity: instanceAiConfirmationSeveritySchema,
formAppearance: formAppearanceConfirmationSchema,
});
const resumeSchema = z.object({
approved: z.boolean(),
});
interface FormsToolContext {
resumeData: z.infer<typeof resumeSchema> | undefined;
suspend: (payload: z.infer<typeof suspendSchema>) => Promise<never>;
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/** Resolve the CSS-variable overrides an apply/preview request describes. */
function resolveOverrides(input: {
preset?: string;
overrides?: Record<string, string>;
}):
| { ok: true; overrides: Record<string, string>; preset?: string; themeLabel: string }
| {
ok: false;
error: string;
errors?: Array<{ variable: string; value: string; reason: string }>;
} {
const hasPreset = typeof input.preset === 'string' && input.preset.length > 0;
const hasOverrides = input.overrides !== undefined && Object.keys(input.overrides).length > 0;
if (hasPreset && hasOverrides) {
return { ok: false, error: 'Provide exactly one of `preset` or `overrides`, not both.' };
}
if (hasPreset) {
const overrides = applyFormThemePreset(input.preset!);
if (!overrides) {
return {
ok: false,
error: `Unknown preset "${input.preset!}". Call list-appearance-options for valid preset ids.`,
};
}
return { ok: true, overrides, preset: input.preset, themeLabel: presetLabel(input.preset!) };
}
if (hasOverrides) {
const result = validateThemeOverrides(input.overrides!);
if (!result.valid) {
return {
ok: false,
error:
'Some overrides are invalid. Fix the values below and retry — do not write them to the node.',
errors: result.errors,
};
}
const matched = resolveFormTheme(result.overrides);
return {
ok: true,
overrides: result.overrides,
themeLabel: presetLabel(matched),
};
}
return { ok: false, error: 'Provide either `preset` or `overrides`.' };
}
// ── Handlers ─────────────────────────────────────────────────────────────────
async function handleDescribe(
context: InstanceAiContext,
input: Extract<Input, { action: 'describe' }>,
) {
const [node, formNodes, workflowName] = await Promise.all([
context.formService.getFormNode(input.workflowId, input.nodeName),
context.formService.listFormNodes(input.workflowId),
context.formService.getWorkflowName(input.workflowId),
]);
if (!node) {
return {
workflowId: input.workflowId,
workflowName,
found: false as const,
formNodes,
hint:
formNodes.length > 0
? 'That node was not found. Pick a form node from `formNodes`.'
: 'This workflow has no Form Trigger or Form nodes.',
};
}
return {
workflowId: input.workflowId,
workflowName,
node: {
nodeName: node.nodeName,
nodeType: node.nodeType,
preset: node.preset,
overrides: node.overrides,
appendAttribution: node.appendAttribution,
},
formNodes,
};
}
function handleListAppearanceOptions() {
return {
presets: FORM_THEMES.map((theme) => ({
id: theme.id,
description: PRESET_DESCRIPTIONS[theme.id] ?? '',
})),
groups: FORM_CSS_VARIABLE_GROUPS,
variables: FORM_CSS_VARIABLE_CONTROLS.map((control) => ({
variable: control.variable,
type: control.type,
group: control.group,
default: control.default,
description: control.description,
})),
};
}
async function handlePreview(
context: InstanceAiContext,
input: Extract<Input, { action: 'preview' }>,
) {
const [formNodes, workflowName] = await Promise.all([
context.formService.listFormNodes(input.workflowId),
context.formService.getWorkflowName(input.workflowId),
]);
if (formNodes.length === 0) {
return {
workflowId: input.workflowId,
workflowName,
found: false as const,
formNodes,
hint: 'This workflow has no Form Trigger or Form nodes.',
};
}
// A proposed preset/overrides is optional — without one we render the saved
// look. When present it applies to every previewed step.
let customCss: string | undefined;
let themeLabel: string | undefined;
let preset: string | undefined;
if (input.preset !== undefined || input.overrides !== undefined) {
const resolved = resolveOverrides(input);
if (!resolved.ok) {
return { workflowId: input.workflowId, ...resolved };
}
customCss = assembleFormCss(resolved.overrides);
themeLabel = resolved.themeLabel;
preset = resolved.preset;
}
// A named node previews just that step; otherwise preview EVERY form step.
let targets: FormNodeSummary[];
if (input.nodeName) {
const target = formNodes.find((n) => n.nodeName === input.nodeName);
if (!target) {
return {
workflowId: input.workflowId,
workflowName,
found: false as const,
formNodes,
hint: 'That node was not found. Pick a form node from `formNodes`.',
};
}
targets = [target];
} else {
targets = formNodes;
}
const previews = await Promise.all(
targets.map(async (n) => ({
nodeName: n.nodeName,
// A proposed theme labels every step the same; otherwise show each
// step's own current theme (display name).
themeLabel: themeLabel ?? presetLabel(n.preset),
previewHtml: await context.formService.renderPreview(input.workflowId, {
nodeName: n.nodeName,
...(customCss !== undefined ? { customCss } : {}),
}),
})),
);
return { workflowId: input.workflowId, workflowName, ...(preset ? { preset } : {}), previews };
}
async function handleApplyTheme(
context: InstanceAiContext,
input: Extract<Input, { action: 'apply-theme' }>,
ctx: FormsToolContext,
) {
if (context.permissions?.updateWorkflow === 'blocked') {
return { success: false, denied: true, reason: 'Action blocked by admin' };
}
const resolved = resolveOverrides(input);
if (!resolved.ok) {
// Validation failures return to the model so it can self-correct — no suspend.
return { success: false, workflowId: input.workflowId, ...resolved };
}
// Resolve the target node(s).
const [formNodes, workflowName] = await Promise.all([
context.formService.listFormNodes(input.workflowId),
context.formService.getWorkflowName(input.workflowId),
]);
if (formNodes.length === 0) {
return {
success: false,
workflowId: input.workflowId,
error: 'This workflow has no Form Trigger or Form nodes.',
};
}
let targetNodeNames: string[];
if (input.scope === 'workflow') {
targetNodeNames = formNodes.map((n) => n.nodeName);
} else {
const target = input.nodeName
? formNodes.find((n) => n.nodeName === input.nodeName)
: (formNodes.find((n) => n.isTrigger) ?? formNodes[0]);
if (!target) {
return {
success: false,
workflowId: input.workflowId,
error: `Form node "${input.nodeName ?? ''}" not found.`,
formNodes,
};
}
targetNodeNames = [target.nodeName];
}
const customCss = assembleFormCss(resolved.overrides);
const previewNodeName = targetNodeNames[0];
// First call — render preview and suspend the confirmation card.
const { resumeData } = ctx;
if (resumeData === undefined || resumeData === null) {
const previewHtml = await context.formService.renderPreview(input.workflowId, {
nodeName: previewNodeName,
customCss,
});
const scopeLabel =
input.scope === 'workflow'
? `all ${targetNodeNames.length} form node(s)`
: `"${previewNodeName}"`;
return await ctx.suspend({
requestId: nanoid(),
message: `Apply ${resolved.themeLabel} to ${scopeLabel}?`,
severity: 'info',
formAppearance: {
workflowId: input.workflowId,
...(input.scope === 'node' ? { nodeName: previewNodeName } : {}),
scope: input.scope,
previewHtml,
...(resolved.preset ? { preset: resolved.preset } : {}),
themeLabel: resolved.themeLabel,
},
});
}
// Denied.
if (!resumeData.approved) {
return { success: false, denied: true, reason: 'User denied the action' };
}
// Approved — write to the target node(s).
try {
const { updatedNodeNames } = await context.formService.applyAppearance(input.workflowId, {
nodeNames: targetNodeNames,
customCss,
appendAttribution: true,
});
return {
success: true,
workflowId: input.workflowId,
workflowName,
scope: input.scope,
themeLabel: resolved.themeLabel,
updatedNodeNames,
};
} catch (error) {
return {
success: false,
workflowId: input.workflowId,
error: error instanceof Error ? error.message : 'Failed to apply appearance',
};
}
}
// ── Tool factory ─────────────────────────────────────────────────────────────
export function createFormsTool(context: InstanceAiContext) {
return new Tool('forms')
.description(
'Preview and restyle/theme form-trigger workflows (Form Trigger + Form nodes). Appearance only — colors, fonts, spacing and themes — not field or logic editing (yet). Actions: describe (read current theme), list-appearance-options (themes + CSS variable catalog), preview (show a form, non-interactive), apply-theme (apply a preset or custom overrides, with approval).',
)
.input(inputSchema)
.suspend(suspendSchema)
.resume(resumeSchema)
.handler(async (input, ctx) => {
const formInput = input as Input;
switch (formInput.action) {
case 'describe':
return await handleDescribe(context, formInput);
case 'list-appearance-options':
return handleListAppearanceOptions();
case 'preview':
return await handlePreview(context, formInput);
case 'apply-theme':
return await handleApplyTheme(context, formInput, ctx);
default:
return { error: `Unknown action: ${(formInput as { action: string }).action}` };
}
})
.build();
}

View File

@ -29,6 +29,7 @@ const loadEvalConfigTool = lazyMod(
const loadExecutionsTool = lazyMod(
() => require('./executions.tool') as typeof import('./executions.tool'),
);
const loadFormsTool = lazyMod(() => require('./forms.tool') as typeof import('./forms.tool'));
const loadNodesTool = lazyMod(() => require('./nodes.tool') as typeof import('./nodes.tool'));
const loadMcpServersTool = lazyMod(
() => require('./mcp-servers.tool') as typeof import('./mcp-servers.tool'),
@ -109,6 +110,7 @@ export function createAllTools(context: InstanceAiContext): InstanceAiToolRegist
[DOMAIN_TOOL_IDS.NODES, loadNodesTool().createNodesTool(context)],
[DOMAIN_TOOL_IDS.ASK_USER, loadAskUserTool().createAskUserTool()],
[DOMAIN_TOOL_IDS.BUILD_WORKFLOW, loadBuildWorkflowTool().createBuildWorkflowTool(context)],
[DOMAIN_TOOL_IDS.FORMS, loadFormsTool().createFormsTool(context)],
];
// eval-config is flag-gated: the adapter only wires evaluationConfigService
@ -142,6 +144,7 @@ export function createOrchestratorDomainTools(context: InstanceAiContext): Insta
[DOMAIN_TOOL_IDS.NODES, loadNodesTool().createNodesTool(context)],
[DOMAIN_TOOL_IDS.ASK_USER, loadAskUserTool().createAskUserTool()],
[DOMAIN_TOOL_IDS.BUILD_WORKFLOW, loadBuildWorkflowTool().createBuildWorkflowTool(context)],
[DOMAIN_TOOL_IDS.FORMS, loadFormsTool().createFormsTool(context)],
];
// eval-config is flag-gated: the adapter only wires evaluationConfigService

View File

@ -16,6 +16,7 @@ export const DOMAIN_TOOL_IDS = {
PARSE_FILE: 'parse-file',
AGENTS: 'agents',
MCP_SERVERS: 'mcp-servers',
FORMS: 'forms',
} as const;
/** Trace-only chain-typed child run emitted by `build-workflow` with the
@ -62,6 +63,8 @@ export const ALWAYS_LOADED_TOOL_NAMES = new Set<string>([
DOMAIN_TOOL_IDS.PARSE_FILE,
DOMAIN_TOOL_IDS.BUILD_WORKFLOW,
DOMAIN_TOOL_IDS.NODES,
// Core appearance/theming tool for form-trigger workflows — always loaded (no gating).
DOMAIN_TOOL_IDS.FORMS,
ORCHESTRATION_TOOL_IDS.VERIFY_BUILT_WORKFLOW,
DOMAIN_TOOL_IDS.RESEARCH,
// Paired with RESEARCH on purpose: the research tool tells the agent to prefer

View File

@ -365,6 +365,52 @@ export interface InstanceAiWorkflowService {
getLatestRunData?(workflowId: string): Promise<Record<string, ITaskData[]> | null>;
}
/** A form-capable node in a workflow (Form Trigger or Form node). */
export interface FormNodeSummary {
nodeName: string;
nodeType: string;
isTrigger: boolean;
/** Matched preset id for the node's current appearance (via `resolveFormTheme`), or `'custom'`. */
preset: string;
}
/** Current appearance of a single form node, resolved for the LLM. */
export interface FormNodeAppearance {
nodeName: string;
nodeType: string;
/** Parsed CSS-variable overrides from the node's `options.customCss`. */
overrides: Record<string, string>;
appendAttribution: boolean;
/** Matched preset id (via `resolveFormTheme`), or `'custom'`. */
preset: string;
}
/**
* Reads and mutates the appearance/theming of form-trigger workflows for the
* Instance AI `forms` tool. Shaped so field/structure editing can be added
* later without reworking the surface — appearance-only for v1.
*/
export interface InstanceAiFormService {
/** Resolve a single form node's current appearance. When `nodeName` is
* omitted, the Form Trigger (or first form node) is used. `null` when the
* workflow has no form node. */
getFormNode(workflowId: string, nodeName?: string): Promise<FormNodeAppearance | null>;
/** List every form-capable node in the workflow. */
listFormNodes(workflowId: string): Promise<FormNodeSummary[]>;
/** The workflow's display name — used to surface it as an artifact. `null` if not found. */
getWorkflowName(workflowId: string): Promise<string | null>;
/** Write assembled `customCss` + `appendAttribution` onto the target node(s). */
applyAppearance(
workflowId: string,
params: { nodeNames: string[]; customCss: string; appendAttribution: boolean },
): Promise<{ updatedNodeNames: string[] }>;
/** Render a form node's appearance to HTML (optionally with proposed CSS). */
renderPreview(
workflowId: string,
params: { nodeName?: string; customCss?: string; isCompletion?: boolean },
): Promise<string>;
}
export interface ExecutionSummary {
id: string;
workflowId: string;
@ -1049,6 +1095,7 @@ export interface InstanceAiContext {
*/
modelId?: ModelConfig;
workflowService: InstanceAiWorkflowService;
formService: InstanceAiFormService;
executionService: InstanceAiExecutionService;
credentialService: InstanceAiCredentialService;
nodeService: InstanceAiNodeService;

View File

@ -1,56 +1,23 @@
import type { Response } from 'express';
import { InstanceSettings } from 'n8n-core';
import { prepareFormData, prepareFormFields, type FormFieldsParameter } from 'n8n-workflow';
import { type FormFieldsParameter } from 'n8n-workflow';
import { FormPreviewRequestDto } from '@n8n/api-types';
import type { AuthenticatedRequest } from '@n8n/db';
import { Body, Post, RestController } from '@n8n/decorators';
import { FormPreviewService } from '@/services/form-preview.service';
@RestController('/form-preview')
export class FormPreviewController {
constructor(private readonly instanceSettings: InstanceSettings) {}
constructor(private readonly formPreviewService: FormPreviewService) {}
// Render markup is built by the shared FormPreviewService (reused by the Instance AI forms tool).
@Post('/', { usesTemplates: true })
preview(_req: AuthenticatedRequest, res: Response, @Body body: FormPreviewRequestDto) {
if (body.isCompletion) {
const instanceId = this.instanceSettings.instanceId;
const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : '';
const completionCss = [
body.customCss,
'.container { padding-bottom: var(--padding-container-top); }',
]
.filter(Boolean)
.join('\n');
const respondWith = body.respondWith ?? 'text';
res.render('form-trigger-completion', {
title: body.formTitle,
message: body.formDescription,
formTitle: body.formTitle,
appendAttribution: body.appendAttribution ?? true,
n8nWebsiteLink: `https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger${utm_campaign}`,
responseText: respondWith === 'showText' ? (body.responseText ?? '') : '',
responseBinary: encodeURIComponent(JSON.stringify('')),
dangerousCustomCss: completionCss,
redirectUrl: respondWith === 'redirect' ? body.redirectUrl : undefined,
});
return;
}
const formFields = prepareFormFields(body.formFields as FormFieldsParameter);
const data = prepareFormData({
formTitle: body.formTitle,
formDescription: body.formDescription,
formFields,
testRun: false,
query: {},
instanceId: this.instanceSettings.instanceId,
buttonLabel: body.buttonLabel,
nodeVersion: body.nodeVersion,
customCss: body.customCss,
appendAttribution: body.appendAttribution ?? true,
formSubmittedText: undefined,
redirectUrl: undefined,
const { template, data } = this.formPreviewService.buildRenderInstruction({
...body,
formFields: body.formFields as FormFieldsParameter,
});
res.render('form-trigger', data);
res.render(template, data);
}
}

View File

@ -4,6 +4,9 @@ import path from 'node:path';
import type {
InstanceAiContext,
InstanceAiWorkflowService,
InstanceAiFormService,
FormNodeSummary,
FormNodeAppearance,
InstanceAiExecutionService,
InstanceAiCredentialService,
InstanceAiNodeService,
@ -95,6 +98,7 @@ import { hasGlobalScope, type Scope } from '@n8n/permissions';
import { LessThan } from '@n8n/typeorm';
import {
type ICredentialsDecrypted,
type FormFieldsParameter,
type INode,
type INodeParameters,
type INodeProperties,
@ -113,6 +117,7 @@ import {
Workflow,
CHAT_TRIGGER_NODE_TYPE,
FORM_TRIGGER_NODE_TYPE,
FORM_NODE_TYPE,
WEBHOOK_NODE_TYPE,
SCHEDULE_TRIGGER_NODE_TYPE,
ManualExecutionCancelledError,
@ -123,6 +128,8 @@ import {
jsonParse,
createRunExecutionData,
calculateWorkflowChecksum,
parseCssVariables,
resolveFormTheme,
} from 'n8n-workflow';
import { ActiveExecutions } from '@/active-executions';
@ -151,6 +158,7 @@ import { resolveBuiltinNodeDefinitionDirs } from '@/utils/node-definition-dirs';
import type { AiGatewayConfigDto } from '@n8n/api-types';
import { AiGatewayService } from '@/services/ai-gateway.service';
import { FolderService } from '@/services/folder.service';
import { FormPreviewService, type FormPreviewInput } from '@/services/form-preview.service';
import { NodeResourceExplorerService } from '@/services/node-resource-explorer.service';
import { ProjectService } from '@/services/project.service.ee';
import { RoleService } from '@/services/role.service';
@ -340,6 +348,7 @@ export class InstanceAiAdapterService {
projectId,
modelId,
workflowService: this.createWorkflowAdapter(user, threadId, projectId),
formService: this.createFormAdapter(user, threadId, projectId),
executionService: this.createExecutionAdapter(user, pushRef, threadId),
credentialService: this.createCredentialAdapter(
user,
@ -586,6 +595,212 @@ export class InstanceAiAdapterService {
return { getPersonalProjectId, assertProjectScope, resolveProjectId, resolveBoundProjectId };
}
/**
* Appearance/theming adapter for the Instance AI `forms` tool. Reads and
* mutates form-node `options.customCss` (RBAC-scoped to `user`) and renders
* previews via the shared `FormPreviewService` — the same render path the
* `/form-preview` controller uses.
*/
private createFormAdapter(
user: User,
threadId?: string,
_boundProjectId?: string,
): InstanceAiFormService {
const { workflowFinderService, workflowService, workflowRepository, telemetry, nodeTypes } =
this;
const assertNotReadOnly = () => this.assertInstanceNotReadOnly('workflows');
const FORM_TYPES = new Set<string>([FORM_TRIGGER_NODE_TYPE, FORM_NODE_TYPE]);
// Defaults declared on the node type's `options` collection (e.g. the
// submit button's "Submit" label). The editor resolves these, so the
// preview must too — otherwise unset defaults render blank.
const optionDefaultsFor = (node: INode): Record<string, unknown> => {
try {
const desc = nodeTypes.getByNameAndVersion(node.type, node.typeVersion).description;
const collection = desc.properties.find(
(p): p is INodeProperties => p.name === 'options' && p.type === 'collection',
);
const items = collection?.options as INodeProperties[] | undefined;
return Object.fromEntries(items?.map((p) => [p.name, p.default]) ?? []);
} catch {
return {};
}
};
const loadFormNodes = async (
workflowId: string,
scopes: Scope[],
): Promise<{ workflow: WorkflowEntity; formNodes: INode[] }> => {
const workflow = await workflowFinderService.findWorkflowForUser(workflowId, user, scopes);
if (!workflow) throw new WorkflowNotFoundError(workflowId);
return { workflow, formNodes: workflow.nodes.filter((n) => FORM_TYPES.has(n.type)) };
};
const nodeOptions = (node: INode): INodeParameters =>
(node.parameters?.options as INodeParameters | undefined) ?? {};
// Resolve the target node: explicit name, else the Form Trigger, else first form node.
const resolveTargetNode = (formNodes: INode[], nodeName?: string): INode | undefined =>
nodeName
? formNodes.find((n) => n.name === nodeName)
: (formNodes.find((n) => n.type === FORM_TRIGGER_NODE_TYPE) ?? formNodes[0]);
// Build the preview render input from a node's raw saved parameters,
// mirroring the editor's FormStepCard (options fall back to the trigger's).
const buildPreviewInput = (
node: INode,
triggerNode: INode | undefined,
customCssOverride?: string,
): FormPreviewInput => {
const params = (node.parameters ?? {}) as INodeParameters;
const options = nodeOptions(node);
const triggerParams = (triggerNode?.parameters ?? {}) as INodeParameters;
const triggerOptions = triggerNode ? nodeOptions(triggerNode) : {};
const defaults = optionDefaultsFor(node);
const triggerDefaults = triggerNode ? optionDefaultsFor(triggerNode) : {};
const customCss = customCssOverride ?? (options.customCss as string | undefined);
const appendAttribution =
(options.appendAttribution as boolean | undefined) ??
(triggerOptions.appendAttribution as boolean | undefined) ??
true;
if (params.operation === 'completion') {
return {
isCompletion: true,
formTitle: (params.completionTitle as string) || '',
formDescription: (params.completionMessage as string) ?? '',
respondWith: (params.respondWith as string) || 'text',
responseText: (params.responseText as string) || '',
redirectUrl: (params.redirectUrl as string) || '',
nodeVersion: node.typeVersion,
customCss,
appendAttribution,
};
}
const isTrigger = node.type === FORM_TRIGGER_NODE_TYPE;
const formFields =
(params.formFields as { values?: INodeParameters[] } | undefined)?.values ?? [];
return {
formTitle: isTrigger
? ((params.formTitle as string) ?? '')
: (options.formTitle as string) || (triggerParams.formTitle as string) || '',
formDescription: isTrigger
? ((params.formDescription as string) ?? '')
: (options.formDescription as string) || (triggerParams.formDescription as string) || '',
buttonLabel:
(options.buttonLabel as string) ||
(triggerOptions.buttonLabel as string) ||
(defaults.buttonLabel as string) ||
(triggerDefaults.buttonLabel as string) ||
undefined,
formFields: formFields as unknown as FormFieldsParameter,
nodeVersion: node.typeVersion,
customCss,
appendAttribution,
};
};
return {
async listFormNodes(workflowId: string): Promise<FormNodeSummary[]> {
const { formNodes } = await loadFormNodes(workflowId, ['workflow:read']);
return formNodes.map((n) => {
const css = (nodeOptions(n).customCss as string | undefined) ?? '';
return {
nodeName: n.name,
nodeType: n.type,
isTrigger: n.type === FORM_TRIGGER_NODE_TYPE,
preset: resolveFormTheme(css ? parseCssVariables(css) : {}),
};
});
},
async getWorkflowName(workflowId: string): Promise<string | null> {
try {
const { workflow } = await loadFormNodes(workflowId, ['workflow:read']);
return workflow.name ?? null;
} catch {
return null;
}
},
async getFormNode(workflowId: string, nodeName?: string): Promise<FormNodeAppearance | null> {
const { formNodes } = await loadFormNodes(workflowId, ['workflow:read']);
const node = resolveTargetNode(formNodes, nodeName);
if (!node) return null;
const options = nodeOptions(node);
const css = (options.customCss as string | undefined) ?? '';
const overrides = css ? parseCssVariables(css) : {};
const trigger = formNodes.find((n) => n.type === FORM_TRIGGER_NODE_TYPE);
const triggerOptions = trigger ? nodeOptions(trigger) : {};
const appendAttribution =
(options.appendAttribution as boolean | undefined) ??
(triggerOptions.appendAttribution as boolean | undefined) ??
true;
return {
nodeName: node.name,
nodeType: node.type,
overrides,
appendAttribution,
preset: resolveFormTheme(overrides),
};
},
async applyAppearance(
workflowId: string,
params: { nodeNames: string[]; customCss: string; appendAttribution: boolean },
): Promise<{ updatedNodeNames: string[] }> {
assertNotReadOnly();
const { workflow } = await loadFormNodes(workflowId, ['workflow:update']);
const targets = new Set(params.nodeNames);
const updatedNodeNames: string[] = [];
const nodes = workflow.nodes.map((n) => {
if (!targets.has(n.name) || !FORM_TYPES.has(n.type)) return n;
const opts = {
...((n.parameters?.options as Record<string, unknown> | undefined) ?? {}),
};
if (params.customCss) opts.customCss = params.customCss;
else delete opts.customCss;
// Mirror the editor: only persist appendAttribution when disabled (default true).
if (!params.appendAttribution) opts.appendAttribution = false;
else delete opts.appendAttribution;
updatedNodeNames.push(n.name);
return { ...n, parameters: { ...n.parameters, options: opts as INodeParameters } };
});
const updateData = workflowRepository.create({ nodes } as Partial<WorkflowEntity>);
await workflowService.update(user, updateData, workflowId, { source: 'n8n-ai' });
if (threadId) {
telemetry.track('Builder modified workflow', {
user_id: user.id,
thread_id: threadId,
workflow_id: workflowId,
});
}
return { updatedNodeNames };
},
async renderPreview(
workflowId: string,
params: { nodeName?: string; customCss?: string; isCompletion?: boolean },
): Promise<string> {
const { formNodes } = await loadFormNodes(workflowId, ['workflow:read']);
const node = resolveTargetNode(formNodes, params.nodeName);
if (!node) {
throw new UserError(`No form node found in workflow ${workflowId}`);
}
const trigger = formNodes.find((n) => n.type === FORM_TRIGGER_NODE_TYPE);
const input = buildPreviewInput(node, trigger, params.customCss);
return await Container.get(FormPreviewService).render(input);
},
};
}
private createWorkflowAdapter(
user: User,
threadId?: string,

View File

@ -0,0 +1,111 @@
import { Service } from '@n8n/di';
import { InstanceSettings } from 'n8n-core';
import { prepareFormData, prepareFormFields, type FormFieldsParameter } from 'n8n-workflow';
import { join } from 'path';
import { TEMPLATES_DIR } from '@/constants';
import { createHandlebarsEngine } from '@/utils/handlebars.util';
/** Normalized inputs for a form-trigger / form-completion preview render. */
export interface FormPreviewInput {
formTitle?: string;
formDescription?: string;
formFields?: FormFieldsParameter;
buttonLabel?: string;
nodeVersion?: number;
customCss?: string;
appendAttribution?: boolean;
isCompletion?: boolean;
respondWith?: string;
responseText?: string;
redirectUrl?: string;
}
type RenderInstruction = { template: string; data: Record<string, unknown> };
/**
* The express-handlebars engine's published type describes its `options` arg as
* `ConfigOptions`, but at runtime it receives the template locals (exactly what
* express passes from `res.render`). We type the standalone call by its real
* runtime contract.
*/
type RenderEngine = (
viewPath: string,
locals: Record<string, unknown>,
callback: (err: unknown, rendered?: string) => void,
) => void;
/**
* Renders the `form-trigger` / `form-trigger-completion` handlebars templates
* from unsaved node parameters. Shared between the REST `form-preview`
* controller (renders to the HTTP response) and the Instance AI forms adapter
* (renders to an HTML string) — the single source of truth for preview markup,
* mirroring how `NodeResourceExplorerService` backs both a REST path and a tool.
*/
@Service()
export class FormPreviewService {
private readonly engine = createHandlebarsEngine() as unknown as RenderEngine;
constructor(private readonly instanceSettings: InstanceSettings) {}
/** Build the template name + view data for a preview. Consumed by the
* controller (via `res.render`) and by `render()` below. */
buildRenderInstruction(body: FormPreviewInput): RenderInstruction {
if (body.isCompletion) {
const instanceId = this.instanceSettings.instanceId;
const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : '';
const completionCss = [
body.customCss,
'.container { padding-bottom: var(--padding-container-top); }',
]
.filter(Boolean)
.join('\n');
const respondWith = body.respondWith ?? 'text';
return {
template: 'form-trigger-completion',
data: {
title: body.formTitle,
message: body.formDescription,
formTitle: body.formTitle,
appendAttribution: body.appendAttribution ?? true,
n8nWebsiteLink: `https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger${utm_campaign}`,
responseText: respondWith === 'showText' ? (body.responseText ?? '') : '',
responseBinary: encodeURIComponent(JSON.stringify('')),
dangerousCustomCss: completionCss,
redirectUrl: respondWith === 'redirect' ? body.redirectUrl : undefined,
},
};
}
const formFields = prepareFormFields(body.formFields ?? []);
const data = prepareFormData({
formTitle: body.formTitle ?? '',
formDescription: body.formDescription ?? '',
formFields,
testRun: false,
query: {},
instanceId: this.instanceSettings.instanceId,
buttonLabel: body.buttonLabel,
nodeVersion: body.nodeVersion,
customCss: body.customCss,
appendAttribution: body.appendAttribution ?? true,
formSubmittedText: undefined,
redirectUrl: undefined,
});
return { template: 'form-trigger', data: data as unknown as Record<string, unknown> };
}
/** Render a preview to an HTML string (no HTTP response involved). */
async render(body: FormPreviewInput): Promise<string> {
const { template, data } = this.buildRenderInstruction(body);
const filePath = join(TEMPLATES_DIR, `${template}.handlebars`);
return await new Promise<string>((resolve, reject) => {
// express-handlebars' engine is callable standalone; `settings.views`
// lets it resolve the templates dir without an express app.
this.engine(filePath, { ...data, settings: { views: TEMPLATES_DIR } }, (err, rendered) => {
if (err) reject(err instanceof Error ? err : new Error(String(err)));
else resolve(rendered ?? '');
});
});
}
}

View File

@ -7053,6 +7053,10 @@
"instanceAi.workflowPreview.title": "Workflow preview",
"instanceAi.workflowPreview.openInEditor": "Open in editor",
"instanceAi.workflowPreview.fetchError": "Could not load workflow",
"instanceAi.formAppearance.title": "Apply form appearance?",
"instanceAi.formAppearance.summary": "{theme} · {scope}",
"instanceAi.formAppearance.scope.node": "{nodeName}",
"instanceAi.formAppearance.scope.workflow": "All form nodes",
"instanceAi.dataTablePreview.title": "Data table preview",
"instanceAi.dataTablePreview.open": "Open",
"instanceAi.dataTablePreview.fetchError": "Could not load data table",

View File

@ -306,6 +306,83 @@ describe('buildTimelineBlocks', () => {
expect(blocks[0].type === 'thinking' && blocks[0].entries).toHaveLength(4);
});
test('promotes a completed forms preview to its own standalone block', () => {
const blocks = blocksOf(
[reasoning('r1'), toolEntry('tc-1', 'r1')],
[
makeToolCall({
toolCallId: 'tc-1',
toolName: 'forms',
args: { action: 'preview' },
result: {
previews: [{ previewHtml: '<html></html>', nodeName: 'Attendee Details' }],
},
}),
],
);
// reasoning stays in a thinking block; the preview becomes its own block
expect(blocks.map((b) => b.type)).toEqual(['thinking', 'form-preview']);
expect(blocks[1].type === 'form-preview' && blocks[1].toolCall.toolName).toBe('forms');
});
test('keeps a forms preview inside the thinking block while it is still loading', () => {
const blocks = blocksOf(
[toolEntry('tc-1', 'r1')],
[
makeToolCall({
toolCallId: 'tc-1',
toolName: 'forms',
args: { action: 'preview' },
isLoading: true,
result: undefined,
}),
],
);
// No result yet → renders as a normal trace row, not promoted.
expect(blocks).toHaveLength(1);
expect(blocks[0].type).toBe('thinking');
});
test('does not promote non-preview forms actions', () => {
const blocks = blocksOf(
[toolEntry('tc-1', 'r1')],
[
makeToolCall({
toolCallId: 'tc-1',
toolName: 'forms',
args: { action: 'describe' },
result: { node: { nodeName: 'x' } },
}),
],
);
expect(blocks).toHaveLength(1);
expect(blocks[0].type).toBe('thinking');
});
test('promotes a multi-step forms preview (one block, rendered per step downstream)', () => {
const blocks = blocksOf(
[toolEntry('tc-1', 'r1')],
[
makeToolCall({
toolCallId: 'tc-1',
toolName: 'forms',
args: { action: 'preview' },
result: {
previews: [
{ previewHtml: '<html>1</html>', nodeName: 'Attendee Details' },
{ previewHtml: '<html>2</html>', nodeName: 'Trip Preferences' },
],
},
}),
],
);
expect(blocks.map((b) => b.type)).toEqual(['form-preview']);
});
test('text followed by same-response trace content joins the thinking block', () => {
const blocks = blocksOf(
[reasoning('r1'), text('Let me check the schema.', 'r1'), toolEntry('tc-1', 'r1')],

View File

@ -34,6 +34,7 @@ export type TimelineBlock =
| { type: 'tasks'; key: string; toolCall: InstanceAiToolCallState }
| { type: 'plan-review'; key: string; toolCall: InstanceAiToolCallState }
| { type: 'questions'; key: string; toolCall: InstanceAiToolCallState }
| { type: 'form-preview'; key: string; toolCall: InstanceAiToolCallState }
| { type: 'child'; key: string; child: InstanceAiAgentNode };
type ToolCallKind =
@ -42,8 +43,21 @@ type ToolCallKind =
| 'plan-review'
| 'questions'
| 'questions-pending'
| 'form-preview'
| 'trace';
/** A completed `forms` preview carries rendered HTML (one entry per form step)
* we surface as its own prominent block instead of burying it in the collapsed
* thinking trace. */
function isFormPreview(tc: InstanceAiToolCallState): boolean {
if (tc.toolName !== 'forms') return false;
if ((tc.args as Record<string, unknown> | undefined)?.action !== 'preview') return false;
const result = tc.result;
if (!result || typeof result !== 'object') return false;
const previews = (result as Record<string, unknown>).previews;
return Array.isArray(previews) && previews.length > 0;
}
/**
* How a tool call renders in the timeline. `trace` rows join thinking blocks;
* `tasks`/`plan-review`/`questions` render standalone UI; `hidden` calls are
@ -55,6 +69,7 @@ type ToolCallKind =
*/
function classifyToolCall(tc: InstanceAiToolCallState): ToolCallKind {
if (HIDDEN_TOOLS.has(tc.toolName)) return 'hidden';
if (isFormPreview(tc)) return 'form-preview';
if (tc.renderHint === 'tasks') return 'tasks';
if (tc.renderHint === 'builder' && tc.toolName.endsWith('-with-agent')) return 'hidden';
if (tc.renderHint && INVISIBLE_RENDER_HINTS.has(tc.renderHint)) return 'hidden';
@ -192,6 +207,9 @@ export function buildTimelineBlocks(
case 'questions':
pushStandalone({ type: 'questions', key: `questions-${idx}`, toolCall: tc });
return;
case 'form-preview':
pushStandalone({ type: 'form-preview', key: `form-preview-${idx}`, toolCall: tc });
return;
case 'trace':
pushTrace(entry, idx);
return;

View File

@ -23,6 +23,7 @@ import PlanReviewPanel, { type PlannedTaskArg, type PlanReviewStatus } from './P
import TaskChecklist from './TaskChecklist.vue';
import ThinkingBlock from './ThinkingBlock.vue';
import TimelineTextSegment from './TimelineTextSegment.vue';
import InstanceAiFormPreview from './InstanceAiFormPreview.vue';
const i18n = useI18n();
const thread = useThread();
@ -232,6 +233,27 @@ function handlePlanDeny(tc: InstanceAiToolCallState) {
void thread.confirmAction(requestId, { kind: 'planDeny' });
}
/** Map simplified TaskList items to PlannedTaskArg shape for loading preview */
/** Extract the rendered previews (one per form step) from a `forms` preview call. */
function getFormPreviews(
tc: InstanceAiToolCallState,
): Array<{ previewHtml: string; themeLabel?: string; nodeName?: string }> {
const result = (tc.result ?? {}) as Record<string, unknown>;
const previews = Array.isArray(result.previews) ? result.previews : [];
return previews.flatMap((p) => {
if (!p || typeof p !== 'object') return [];
const r = p as Record<string, unknown>;
if (typeof r.previewHtml !== 'string') return [];
return [
{
previewHtml: r.previewHtml,
...(typeof r.themeLabel === 'string' ? { themeLabel: r.themeLabel } : {}),
...(typeof r.nodeName === 'string' ? { nodeName: r.nodeName } : {}),
},
];
});
}
/** Map simplified TaskList items to PlannedTaskArg shape for loading preview */
function mapTaskItemsToPlannedTasks(tasks?: TaskList): PlannedTaskArg[] | undefined {
if (!tasks?.tasks?.length) return undefined;
@ -284,6 +306,17 @@ function mapTaskItemsToPlannedTasks(tasks?: TaskList): PlannedTaskArg[] | undefi
<!-- Answered questions (read-only after resolution) -->
<AnsweredQuestions v-else-if="block.type === 'questions'" :tool-call="block.toolCall" />
<!-- Rendered form preview(s) (forms tool) — surfaced prominently, not buried.
One card per form step. -->
<template v-else-if="block.type === 'form-preview'">
<InstanceAiFormPreview
v-for="(preview, i) in getFormPreviews(block.toolCall)"
:key="`${block.key}-${i}`"
v-bind="preview"
:class="$style.timelineItem"
/>
</template>
<!-- Child agent — flat section -->
<template v-else-if="block.type === 'child'">
<AgentSection :agent-node="block.child" />

View File

@ -13,6 +13,7 @@ import DomainAccessApproval from './DomainAccessApproval.vue';
import GatewayResourceDecision from './GatewayResourceDecision.vue';
import InstanceAiChannelSetup from './InstanceAiChannelSetup.vue';
import InstanceAiCredentialSetup from './InstanceAiCredentialSetup.vue';
import InstanceAiFormAppearance from './InstanceAiFormAppearance.vue';
import type { QuestionAnswer } from './InstanceAiQuestions.vue';
import InstanceAiQuestions from './InstanceAiQuestions.vue';
import InstanceAiWorkflowSetup from '../workflowSetup/InstanceAiWorkflowSetup.vue';
@ -572,6 +573,14 @@ function handlePlanDeny(conf: InstanceAiConfirmation, numTasks: number) {
:agent-id="chunk.item.toolCall.confirmation.channelConfig.agentId"
:project-id="chunk.item.toolCall.confirmation.projectId ?? ''"
/>
<!-- Form appearance apply-theme (forms tool) — presence-based -->
<InstanceAiFormAppearance
v-else-if="chunk.item.toolCall.confirmation.formAppearance"
:key="'form-appearance-' + chunk.item.toolCall.confirmation.requestId"
:request-id="chunk.item.toolCall.confirmation.requestId"
:form-appearance="chunk.item.toolCall.confirmation.formAppearance"
/>
</template>
<!-- ============ Floating approval ============ -->

View File

@ -0,0 +1,110 @@
<script lang="ts" setup>
/**
* HITL confirmation card for the Instance AI `forms` apply-theme action. Shows a
* read-only rendered preview of the proposed appearance plus Approve/Deny,
* translating the choice into instance AI's confirm transport
* (`thread.confirmAction` + `thread.resolveConfirmation`).
*/
import { computed, ref } from 'vue';
import { N8nButton, N8nCard, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { InstanceAiFormAppearance } from '@n8n/api-types';
import { useThread } from '../instanceAi.store';
import InstanceAiFormPreview from './InstanceAiFormPreview.vue';
const props = defineProps<{
requestId: string;
formAppearance: InstanceAiFormAppearance;
}>();
const i18n = useI18n();
const thread = useThread();
const MAX_CONFIRM_ATTEMPTS = 2;
const submitted = ref(false);
const isResolvedOrSubmitted = computed(
() => submitted.value || thread.resolvedConfirmationIds.has(props.requestId),
);
const scopeLabel = computed(() =>
props.formAppearance.scope === 'workflow'
? i18n.baseText('instanceAi.formAppearance.scope.workflow')
: i18n.baseText('instanceAi.formAppearance.scope.node', {
interpolate: { nodeName: props.formAppearance.nodeName ?? '' },
}),
);
async function resolve(approved: boolean) {
if (isResolvedOrSubmitted.value) return;
submitted.value = true;
const resolution = approved ? 'approved' : 'denied';
for (let attempt = 0; attempt < MAX_CONFIRM_ATTEMPTS; attempt++) {
if (await thread.confirmAction(props.requestId, { kind: 'approval', approved })) break;
}
thread.resolveConfirmation(props.requestId, resolution);
}
</script>
<template>
<N8nCard v-if="!submitted" :class="$style.card" data-test-id="instance-ai-form-appearance">
<div :class="$style.header">
<N8nText bold>{{ i18n.baseText('instanceAi.formAppearance.title') }}</N8nText>
<N8nText size="small" color="text-light">
{{
formAppearance.themeLabel
? i18n.baseText('instanceAi.formAppearance.summary', {
interpolate: { theme: formAppearance.themeLabel, scope: scopeLabel },
})
: scopeLabel
}}
</N8nText>
</div>
<InstanceAiFormPreview :preview-html="formAppearance.previewHtml" />
<div :class="$style.actions">
<N8nButton
variant="outline"
size="small"
:disabled="isResolvedOrSubmitted"
data-test-id="instance-ai-form-appearance-deny"
@click="resolve(false)"
>
{{ i18n.baseText('instanceAi.confirmation.deny') }}
</N8nButton>
<N8nButton
variant="solid"
size="small"
:disabled="isResolvedOrSubmitted"
data-test-id="instance-ai-form-appearance-approve"
@click="resolve(true)"
>
{{ i18n.baseText('instanceAi.confirmation.approve') }}
</N8nButton>
</div>
</N8nCard>
</template>
<style module>
.card {
border: 2px solid var(--color--primary);
background-color: var(--background--surface);
}
.header {
display: flex;
flex-direction: column;
gap: var(--spacing--5xs);
margin-bottom: var(--spacing--2xs);
}
.actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing--2xs);
margin-top: var(--spacing--sm);
}
</style>

View File

@ -0,0 +1,90 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { N8nText } from '@n8n/design-system';
defineProps<{
previewHtml: string;
themeLabel?: string;
nodeName?: string;
}>();
const iframeEl = ref<HTMLIFrameElement | null>(null);
// Auto-size the iframe to its content (copied from the editor's form appearance
// preview) so the rendered form isn't clipped or scrollable in the chat.
function onIframeLoad() {
const iframe = iframeEl.value;
if (!iframe?.contentDocument) return;
const doc = iframe.contentDocument;
doc.documentElement.style.height = 'auto';
if (doc.body) doc.body.style.height = 'auto';
function updateHeight() {
if (!iframe?.contentDocument) return;
const contentH = Math.max(
iframe.contentDocument.documentElement.scrollHeight,
iframe.contentDocument.body?.scrollHeight ?? 0,
);
iframe.style.height = `${contentH}px`;
}
updateHeight();
doc.querySelectorAll('img').forEach((img) => {
if (!img.complete) {
img.addEventListener('load', updateHeight, { once: true });
img.addEventListener('error', updateHeight, { once: true });
}
});
}
</script>
<template>
<div :class="$style.wrapper">
<div v-if="themeLabel || nodeName" :class="$style.header">
<N8nText v-if="nodeName" size="medium" bold>{{ nodeName }}</N8nText>
<N8nText v-if="themeLabel" size="small" color="text-light">{{ themeLabel }}</N8nText>
</div>
<div :class="$style.card">
<iframe
ref="iframeEl"
:srcdoc="previewHtml"
sandbox="allow-same-origin"
scrolling="no"
:class="$style.iframe"
data-test-id="instance-ai-form-preview-iframe"
@load="onIframeLoad"
/>
</div>
</div>
</template>
<style module>
.wrapper {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
}
.header {
display: flex;
align-items: baseline;
gap: var(--spacing--2xs);
}
.card {
box-sizing: border-box;
width: 100%;
border: 1.5px solid var(--color--foreground);
border-radius: var(--radius--lg);
overflow: hidden;
}
.iframe {
display: block;
width: 100%;
border: none;
pointer-events: none;
}
</style>

View File

@ -10,6 +10,7 @@ import ToolResultCode from './ToolResultCode.vue';
import ToolResultImage from './ToolResultImage.vue';
import ToolResultFile from './ToolResultFile.vue';
import ToolResultText from './ToolResultText.vue';
import InstanceAiFormPreview from './InstanceAiFormPreview.vue';
const props = defineProps<{
result: unknown;
@ -17,7 +18,13 @@ const props = defineProps<{
toolArgs?: Record<string, unknown>;
}>();
type ResultType = 'content' | 'code' | 'table' | 'json';
type ResultType = 'content' | 'code' | 'table' | 'json' | 'form-preview';
interface FormPreviewItem {
previewHtml: string;
themeLabel?: string;
nodeName?: string;
}
type McpContentItem = McpToolCallResult['content'][number];
function isAction(family: string, action: string): boolean {
@ -111,7 +118,24 @@ function extractMcpContent(result: unknown): McpToolCallResult['content'] | null
return null;
}
function extractFormPreview(result: unknown): FormPreviewItem[] | null {
if (!isAction('forms', 'preview') || !isRecord(result)) return null;
if (!Array.isArray(result.previews)) return null;
const items = result.previews.flatMap((p): FormPreviewItem[] => {
if (!isRecord(p) || typeof p.previewHtml !== 'string' || p.previewHtml.length === 0) return [];
return [
{
previewHtml: p.previewHtml,
...(typeof p.themeLabel === 'string' ? { themeLabel: p.themeLabel } : {}),
...(typeof p.nodeName === 'string' ? { nodeName: p.nodeName } : {}),
},
];
});
return items.length > 0 ? items : null;
}
function detectType(result: unknown): ResultType {
if (extractFormPreview(result) !== null) return 'form-preview';
if (extractMcpContent(result) !== null) return 'content';
if (isCodeTool()) return 'code';
if (isTableTool() && result && typeof result === 'object') return 'table';
@ -159,6 +183,7 @@ const resultType = computed(() => detectType(props.result));
const contentItems = computed(() => extractMcpContent(props.result));
const codeContent = computed(() => extractCode(props.result));
const tableRows = computed(() => extractTableRows(props.result));
const formPreview = computed(() => extractFormPreview(props.result));
</script>
<template>
@ -185,6 +210,15 @@ const tableRows = computed(() => extractTableRows(props.result));
<N8nAiActivityStepResultSection v-else-if="resultType === 'table' && tableRows">
<ToolResultTable :rows="tableRows" />
</N8nAiActivityStepResultSection>
<N8nAiActivityStepResultSection v-else-if="resultType === 'form-preview' && formPreview">
<InstanceAiFormPreview
v-for="(preview, i) in formPreview"
:key="i"
:preview-html="preview.previewHtml"
:theme-label="preview.themeLabel"
:node-name="preview.nodeName"
/>
</N8nAiActivityStepResultSection>
<ToolResultJson v-else :value="props.result" />
</template>

View File

@ -20,6 +20,7 @@ export function isPendingItemFloating(item: PendingConfirmationItem): boolean {
if (conf.credentialRequests?.length) return false;
if (conf.credentialFlow) return false;
if (conf.channelConfig) return false;
if (conf.formAppearance) return false;
switch (conf.inputType) {
case 'questions':

View File

@ -123,6 +123,7 @@ const ARTIFACT_TOOLS = new Set([
'submit-workflow',
'apply-workflow-credentials',
'workflows',
'forms',
'credentials',
'data-tables',
'insert-data-table-rows',

View File

@ -1,6 +1,12 @@
import { ref, computed, watch } from 'vue';
import { useDebounceFn } from '@vueuse/core';
import { NodeHelpers } from 'n8n-workflow';
import {
NodeHelpers,
parseCssVariables,
assembleFormCss,
applyFormThemePreset,
resolveFormTheme,
} from 'n8n-workflow';
import type { INodeParameters, INodeProperties } from 'n8n-workflow';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
@ -11,26 +17,6 @@ import { DEBOUNCE_TIME } from '@/app/constants/durations';
import { getDebounceTime } from '@n8n/composables/useDebounce';
import type { INodeUi } from '@/Interface';
import { fetchFormPreview } from '../api';
import { FORM_THEMES } from '../constants/themes';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function parseCssVariables(css: string): Record<string, string> {
const result: Record<string, string> = {};
for (const match of css.matchAll(/--([a-z0-9-]+)\s*:\s*([^;]+);/g)) {
result[`--${match[1]}`] = match[2].trim();
}
return result;
}
function assembleCss(overrides: Record<string, string>): string {
const entries = Object.entries(overrides);
if (entries.length === 0) return '';
const lines = entries.map(([k, v]) => `\t${k}: ${v};`).join('\n');
return `:root {\n${lines}\n}`;
}
// ---------------------------------------------------------------------------
// Global scope state — shared across all form node modals
@ -130,7 +116,7 @@ export function useFormAppearance(nodeId: string) {
initFromNode();
const assembledCss = computed(() => assembleCss(localOverrides.value));
const assembledCss = computed(() => assembleFormCss(localOverrides.value));
const savedCss = computed(() => {
const options = node.value?.parameters?.options as INodeParameters | undefined;
@ -329,20 +315,11 @@ export function useFormAppearance(nodeId: string) {
}
}
const activeTheme = computed((): string => {
const overrides = localOverrides.value;
const overrideKeys = Object.keys(overrides);
for (const theme of FORM_THEMES) {
const themeKeys = Object.keys(theme.overrides);
if (themeKeys.length !== overrideKeys.length) continue;
if (themeKeys.every((k) => theme.overrides[k] === overrides[k])) return theme.id;
}
return 'custom';
});
const activeTheme = computed((): string => resolveFormTheme(localOverrides.value));
function applyTheme(themeId: string) {
const theme = FORM_THEMES.find((t) => t.id === themeId);
if (theme) localOverrides.value = { ...theme.overrides };
const overrides = applyFormThemePreset(themeId);
if (overrides) localOverrides.value = overrides;
}
return {

View File

@ -1,7 +1,18 @@
import type { BaseTextKey } from '@n8n/i18n';
import {
FORM_CSS_VARIABLE_CONTROLS,
FORM_CSS_VARIABLE_DEFAULTS,
FORM_CSS_VARIABLE_GROUPS,
type FormCssVarType,
type FormCssVarGroup,
} from 'n8n-workflow';
export type CssVarType = 'color' | 'px' | 'text' | 'opacity';
export type CssVarGroup = 'page' | 'form' | 'input' | 'button';
// The editable CSS-variable catalog lives in `n8n-workflow` (`form-theme.ts`) so
// it can be shared with the backend / Instance AI. The frontend only adds the
// i18n label keys on top, keeping the shared catalog dependency-free.
export type CssVarType = FormCssVarType;
export type CssVarGroup = FormCssVarGroup;
export interface CssVarControl {
variable: string;
@ -11,267 +22,60 @@ export interface CssVarControl {
default: string;
}
export const CSS_VARIABLE_GROUPS: Array<{ key: CssVarGroup; labelKey: BaseTextKey }> = [
{ key: 'page', labelKey: 'formStep.appearance.group.page' },
{ key: 'form', labelKey: 'formStep.appearance.group.form' },
{ key: 'input', labelKey: 'formStep.appearance.group.input' },
{ key: 'button', labelKey: 'formStep.appearance.group.button' },
];
/** Maps each shared CSS variable to its i18n label key. */
const CONTROL_LABEL_KEYS: Record<string, BaseTextKey> = {
'--font-family': 'formStep.appearance.control.fontFamily',
'--color-background': 'formStep.appearance.control.colorBackground',
'--container-width': 'formStep.appearance.control.containerWidth',
'--padding-container-top': 'formStep.appearance.control.paddingContainerTop',
'--color-card-bg': 'formStep.appearance.control.colorCardBg',
'--color-card-border': 'formStep.appearance.control.colorCardBorder',
'--color-header': 'formStep.appearance.control.colorHeader',
'--color-header-subtext': 'formStep.appearance.control.colorHeaderSubtext',
'--font-size-header': 'formStep.appearance.control.fontSizeHeader',
'--font-size-subheader': 'formStep.appearance.control.fontSizeSubheader',
'--border-radius-card': 'formStep.appearance.control.borderRadiusCard',
'--padding-card': 'formStep.appearance.control.paddingCard',
'--margin-bottom-card': 'formStep.appearance.control.marginBottomCard',
'--color-input-bg': 'formStep.appearance.control.colorInputBg',
'--color-label': 'formStep.appearance.control.colorLabel',
'--color-input-border': 'formStep.appearance.control.colorInputBorder',
'--color-input-text': 'formStep.appearance.control.colorInputText',
'--color-focus-border': 'formStep.appearance.control.colorFocusBorder',
'--color-error': 'formStep.appearance.control.colorError',
'--color-required': 'formStep.appearance.control.colorRequired',
'--font-size-label': 'formStep.appearance.control.fontSizeLabel',
'--font-size-input': 'formStep.appearance.control.fontSizeInput',
'--border-radius-input': 'formStep.appearance.control.borderRadiusInput',
'--padding-form-input': 'formStep.appearance.control.paddingFormInput',
'--font-size-body': 'formStep.appearance.control.fontSizeBody',
'--font-size-paragraph': 'formStep.appearance.control.fontSizeParagraph',
'--font-size-error': 'formStep.appearance.control.fontSizeError',
'--opacity-placeholder': 'formStep.appearance.control.opacityPlaceholder',
'--color-submit-btn-bg': 'formStep.appearance.control.colorSubmitBtnBg',
'--color-submit-btn-text': 'formStep.appearance.control.colorSubmitBtnText',
'--color-link': 'formStep.appearance.control.colorLink',
'--submit-btn-height': 'formStep.appearance.control.submitBtnHeight',
'--font-size-submit-btn': 'formStep.appearance.control.fontSizeSubmitBtn',
'--font-size-link': 'formStep.appearance.control.fontSizeLink',
};
export const CSS_VARIABLE_CONTROLS: CssVarControl[] = [
// ── Page ─────────────────────────────────────────────────────────────────
{
variable: '--font-family',
labelKey: 'formStep.appearance.control.fontFamily',
type: 'text',
group: 'page',
default: "'Open Sans', sans-serif",
},
{
variable: '--color-background',
labelKey: 'formStep.appearance.control.colorBackground',
type: 'color',
group: 'page',
default: '#fbfcfe',
},
{
variable: '--container-width',
labelKey: 'formStep.appearance.control.containerWidth',
type: 'px',
group: 'page',
default: '448px',
},
{
variable: '--padding-container-top',
labelKey: 'formStep.appearance.control.paddingContainerTop',
type: 'px',
group: 'page',
default: '24px',
},
const GROUP_LABEL_KEYS: Record<CssVarGroup, BaseTextKey> = {
page: 'formStep.appearance.group.page',
form: 'formStep.appearance.group.form',
input: 'formStep.appearance.group.input',
button: 'formStep.appearance.group.button',
};
// ── Form card ─────────────────────────────────────────────────────────────
// Colors
{
variable: '--color-card-bg',
labelKey: 'formStep.appearance.control.colorCardBg',
type: 'color',
group: 'form',
default: '#ffffff',
},
{
variable: '--color-card-border',
labelKey: 'formStep.appearance.control.colorCardBorder',
type: 'color',
group: 'form',
default: '#dbdfe7',
},
{
variable: '--color-header',
labelKey: 'formStep.appearance.control.colorHeader',
type: 'color',
group: 'form',
default: '#525356',
},
{
variable: '--color-header-subtext',
labelKey: 'formStep.appearance.control.colorHeaderSubtext',
type: 'color',
group: 'form',
default: '#7e8186',
},
// Sizes / structure
{
variable: '--font-size-header',
labelKey: 'formStep.appearance.control.fontSizeHeader',
type: 'px',
group: 'form',
default: '20px',
},
{
variable: '--font-size-subheader',
labelKey: 'formStep.appearance.control.fontSizeSubheader',
type: 'px',
group: 'form',
default: '14px',
},
{
variable: '--border-radius-card',
labelKey: 'formStep.appearance.control.borderRadiusCard',
type: 'px',
group: 'form',
default: '8px',
},
{
variable: '--padding-card',
labelKey: 'formStep.appearance.control.paddingCard',
type: 'px',
group: 'form',
default: '24px',
},
{
variable: '--margin-bottom-card',
labelKey: 'formStep.appearance.control.marginBottomCard',
type: 'px',
group: 'form',
default: '16px',
},
export const CSS_VARIABLE_GROUPS: Array<{ key: CssVarGroup; labelKey: BaseTextKey }> =
FORM_CSS_VARIABLE_GROUPS.map((g) => ({ key: g.key, labelKey: GROUP_LABEL_KEYS[g.key] }));
// ── Input fields ──────────────────────────────────────────────────────────
// Colors
{
variable: '--color-input-bg',
labelKey: 'formStep.appearance.control.colorInputBg',
type: 'color',
group: 'input',
default: '#ffffff',
},
{
variable: '--color-label',
labelKey: 'formStep.appearance.control.colorLabel',
type: 'color',
group: 'input',
default: '#555555',
},
{
variable: '--color-input-border',
labelKey: 'formStep.appearance.control.colorInputBorder',
type: 'color',
group: 'input',
default: '#dbdfe7',
},
{
variable: '--color-input-text',
labelKey: 'formStep.appearance.control.colorInputText',
type: 'color',
group: 'input',
default: '#71747a',
},
{
variable: '--color-focus-border',
labelKey: 'formStep.appearance.control.colorFocusBorder',
type: 'color',
group: 'input',
default: '#5a4cc2',
},
{
variable: '--color-error',
labelKey: 'formStep.appearance.control.colorError',
type: 'color',
group: 'input',
default: '#ea1f30',
},
{
variable: '--color-required',
labelKey: 'formStep.appearance.control.colorRequired',
type: 'color',
group: 'input',
default: '#ff6d5a',
},
// Sizes / values
{
variable: '--font-size-label',
labelKey: 'formStep.appearance.control.fontSizeLabel',
type: 'px',
group: 'input',
default: '14px',
},
{
variable: '--font-size-input',
labelKey: 'formStep.appearance.control.fontSizeInput',
type: 'px',
group: 'input',
default: '14px',
},
{
variable: '--border-radius-input',
labelKey: 'formStep.appearance.control.borderRadiusInput',
type: 'px',
group: 'input',
default: '6px',
},
{
variable: '--padding-form-input',
labelKey: 'formStep.appearance.control.paddingFormInput',
type: 'px',
group: 'input',
default: '12px',
},
{
variable: '--font-size-body',
labelKey: 'formStep.appearance.control.fontSizeBody',
type: 'px',
group: 'input',
default: '12px',
},
{
variable: '--font-size-paragraph',
labelKey: 'formStep.appearance.control.fontSizeParagraph',
type: 'px',
group: 'input',
default: '14px',
},
{
variable: '--font-size-error',
labelKey: 'formStep.appearance.control.fontSizeError',
type: 'px',
group: 'input',
default: '12px',
},
{
variable: '--opacity-placeholder',
labelKey: 'formStep.appearance.control.opacityPlaceholder',
type: 'opacity',
group: 'input',
default: '0.5',
},
export const CSS_VARIABLE_CONTROLS: CssVarControl[] = FORM_CSS_VARIABLE_CONTROLS.map((c) => ({
variable: c.variable,
labelKey: CONTROL_LABEL_KEYS[c.variable],
type: c.type,
group: c.group,
default: c.default,
}));
// ── Button & links ────────────────────────────────────────────────────────
// Colors
{
variable: '--color-submit-btn-bg',
labelKey: 'formStep.appearance.control.colorSubmitBtnBg',
type: 'color',
group: 'button',
default: '#ff6d5a',
},
{
variable: '--color-submit-btn-text',
labelKey: 'formStep.appearance.control.colorSubmitBtnText',
type: 'color',
group: 'button',
default: '#ffffff',
},
{
variable: '--color-link',
labelKey: 'formStep.appearance.control.colorLink',
type: 'color',
group: 'button',
default: '#7e8186',
},
// Sizes
{
variable: '--submit-btn-height',
labelKey: 'formStep.appearance.control.submitBtnHeight',
type: 'px',
group: 'button',
default: '48px',
},
{
variable: '--font-size-submit-btn',
labelKey: 'formStep.appearance.control.fontSizeSubmitBtn',
type: 'px',
group: 'button',
default: '14px',
},
{
variable: '--font-size-link',
labelKey: 'formStep.appearance.control.fontSizeLink',
type: 'px',
group: 'button',
default: '12px',
},
];
export const CSS_VARIABLE_DEFAULTS: Record<string, string> = Object.fromEntries(
CSS_VARIABLE_CONTROLS.map((c) => [c.variable, c.default]),
);
export const CSS_VARIABLE_DEFAULTS: Record<string, string> = FORM_CSS_VARIABLE_DEFAULTS;

View File

@ -1,129 +1,4 @@
export interface FormTheme {
id: string;
overrides: Record<string, string>;
}
const DARK_OVERRIDES: Record<string, string> = {
'--color-background': '#1a1b1e',
'--color-card-bg': '#2c2d31',
'--color-card-border': '#3e4045',
'--color-header': '#e4e5e7',
'--color-header-subtext': '#9b9da3',
'--color-label': '#c8c9cb',
'--color-input-bg': '#232428',
'--color-input-border': '#4a4b50',
'--color-input-text': '#b0b2b7',
'--color-focus-border': '#7c6ce0',
'--color-link': '#9b9da3',
};
const COMPACT_OVERRIDES: Record<string, string> = {
'--container-width': '360px',
'--padding-container-top': '16px',
'--padding-card': '16px',
'--padding-form-input': '8px',
'--margin-bottom-card': '8px',
'--submit-btn-height': '40px',
'--font-size-header': '17px',
'--font-size-label': '12px',
'--font-size-input': '12px',
'--border-radius-card': '6px',
'--border-radius-input': '4px',
};
export const FORM_THEMES: FormTheme[] = [
// ── Light family ──────────────────────────────────────────────────────────
{ id: 'light', overrides: {} },
{ id: 'dark', overrides: DARK_OVERRIDES },
// ── Density variants ──────────────────────────────────────────────────────
{
id: 'dense',
overrides: {
'--padding-container-top': '12px',
'--padding-card': '16px',
'--margin-bottom-card': '8px',
'--padding-form-input': '8px',
'--submit-btn-height': '36px',
'--font-size-header': '17px',
'--font-size-subheader': '12px',
},
},
{ id: 'compact', overrides: COMPACT_OVERRIDES },
{ id: 'compactDark', overrides: { ...COMPACT_OVERRIDES, ...DARK_OVERRIDES } },
// ── Enterprise family ─────────────────────────────────────────────────────
{
id: 'enterprise',
overrides: {
'--font-family': "'Helvetica Neue', Helvetica, Arial, sans-serif",
'--color-background': '#f5f6f8',
'--color-card-border': '#cfd5dc',
'--color-header': '#1c2b3a',
'--color-header-subtext': '#4a5a6a',
'--color-submit-btn-bg': '#0055b3',
'--color-focus-border': '#0055b3',
'--color-required': '#cc0000',
'--border-radius-card': '2px',
'--border-radius-input': '2px',
},
},
{
id: 'enterpriseDark',
overrides: {
'--font-family': "'Helvetica Neue', Helvetica, Arial, sans-serif",
'--color-background': '#12192b',
'--color-card-bg': '#1c2540',
'--color-card-border': '#2a3560',
'--color-header': '#dde3f0',
'--color-header-subtext': '#7a88a8',
'--color-label': '#8898b8',
'--color-input-bg': '#161e36',
'--color-input-border': '#2a3560',
'--color-input-text': '#a8b8d8',
'--color-focus-border': '#0077cc',
'--color-link': '#4488cc',
'--color-submit-btn-bg': '#0055b3',
'--color-required': '#cc2222',
'--border-radius-card': '2px',
'--border-radius-input': '2px',
},
},
// ── Fun family ────────────────────────────────────────────────────────────
{
id: 'fun',
overrides: {
'--font-family': "'Georgia', serif",
'--color-background': '#fef0fb',
'--color-card-bg': '#fff8fe',
'--color-card-border': '#f0a8e8',
'--color-header': '#9b1fa8',
'--color-header-subtext': '#c060c8',
'--color-label': '#6a2070',
'--color-submit-btn-bg': '#e040d0',
'--color-focus-border': '#e040d0',
'--color-required': '#e040d0',
'--border-radius-card': '20px',
'--border-radius-input': '16px',
},
},
{
id: 'funColorful',
overrides: {
'--font-family': "'Georgia', serif",
'--color-background': '#e8fff0',
'--color-card-bg': '#f8fffc',
'--color-card-border': '#40d890',
'--color-header': '#ff3838',
'--color-header-subtext': '#ff8c20',
'--color-label': '#1a88d0',
'--color-input-bg': '#ffffff',
'--color-input-border': '#40d890',
'--color-focus-border': '#30c8a8',
'--color-submit-btn-bg': '#ff5520',
'--color-required': '#ff2060',
'--border-radius-card': '24px',
'--border-radius-input': '16px',
},
},
];
// Theme presets live in `n8n-workflow` (`form-theme.ts`) so they can be shared
// with the backend / Instance AI. Re-exported here for the forms feature.
export type { FormTheme } from 'n8n-workflow';
export { FORM_THEMES } from 'n8n-workflow';

View File

@ -0,0 +1,553 @@
import { sanitizeCustomCss } from './form-utils';
/**
* Shared form appearance/theming model.
*
* This is the single source of truth for the form-trigger CSS-variable surface:
* the editable variable catalog, the built-in theme presets, and the pure
* helpers that turn CSS-variable overrides into a `customCss` string and back.
*
* It lives in `n8n-workflow` (next to `form-utils.ts`) so every surface can
* consume it without duplicating the catalog: the editor-ui appearance editor,
* the Instance AI forms tool, and — later — the MCP server. Keep this module
* dependency-free (no i18n, no Vue); UI concerns like translation keys stay in
* the frontend and map onto `variable`.
*/
export type FormCssVarType = 'color' | 'px' | 'text' | 'opacity';
export type FormCssVarGroup = 'page' | 'form' | 'input' | 'button';
export interface FormCssVarControl {
/** The CSS custom property name, e.g. `--color-background`. */
variable: string;
/** Value kind — drives editor widgets and value validation. */
type: FormCssVarType;
/** Which section of the form the variable affects. */
group: FormCssVarGroup;
/** Default value used when the variable is not overridden. */
default: string;
/** Plain-English description — context for the UI and for LLM theme generation. */
description: string;
}
export const FORM_CSS_VARIABLE_GROUPS: Array<{ key: FormCssVarGroup; description: string }> = [
{ key: 'page', description: 'The page around the form (background, width, font)' },
{ key: 'form', description: 'The form card (header, borders, spacing)' },
{ key: 'input', description: 'Input fields and labels' },
{ key: 'button', description: 'Submit button and links' },
];
export const FORM_CSS_VARIABLE_CONTROLS: FormCssVarControl[] = [
// ── Page ─────────────────────────────────────────────────────────────────
{
variable: '--font-family',
type: 'text',
group: 'page',
default: "'Open Sans', sans-serif",
description: 'Font family for the whole form',
},
{
variable: '--color-background',
type: 'color',
group: 'page',
default: '#fbfcfe',
description: 'Page background color behind the form card',
},
{
variable: '--container-width',
type: 'px',
group: 'page',
default: '448px',
description: 'Maximum width of the form container',
},
{
variable: '--padding-container-top',
type: 'px',
group: 'page',
default: '24px',
description: 'Space above the form card',
},
// ── Form card ─────────────────────────────────────────────────────────────
{
variable: '--color-card-bg',
type: 'color',
group: 'form',
default: '#ffffff',
description: 'Form card background color',
},
{
variable: '--color-card-border',
type: 'color',
group: 'form',
default: '#dbdfe7',
description: 'Form card border color',
},
{
variable: '--color-header',
type: 'color',
group: 'form',
default: '#525356',
description: 'Form title (header) text color',
},
{
variable: '--color-header-subtext',
type: 'color',
group: 'form',
default: '#7e8186',
description: 'Form description (subheader) text color',
},
{
variable: '--font-size-header',
type: 'px',
group: 'form',
default: '20px',
description: 'Form title font size',
},
{
variable: '--font-size-subheader',
type: 'px',
group: 'form',
default: '14px',
description: 'Form description font size',
},
{
variable: '--border-radius-card',
type: 'px',
group: 'form',
default: '8px',
description: 'Corner rounding of the form card',
},
{
variable: '--padding-card',
type: 'px',
group: 'form',
default: '24px',
description: 'Inner padding of the form card',
},
{
variable: '--margin-bottom-card',
type: 'px',
group: 'form',
default: '16px',
description: 'Space below the form card',
},
// ── Input fields ──────────────────────────────────────────────────────────
{
variable: '--color-input-bg',
type: 'color',
group: 'input',
default: '#ffffff',
description: 'Input field background color',
},
{
variable: '--color-label',
type: 'color',
group: 'input',
default: '#555555',
description: 'Field label text color',
},
{
variable: '--color-input-border',
type: 'color',
group: 'input',
default: '#dbdfe7',
description: 'Input field border color',
},
{
variable: '--color-input-text',
type: 'color',
group: 'input',
default: '#71747a',
description: 'Text color inside input fields',
},
{
variable: '--color-focus-border',
type: 'color',
group: 'input',
default: '#5a4cc2',
description: 'Input border color when focused (accent)',
},
{
variable: '--color-error',
type: 'color',
group: 'input',
default: '#ea1f30',
description: 'Validation error color',
},
{
variable: '--color-required',
type: 'color',
group: 'input',
default: '#ff6d5a',
description: 'Required-field asterisk color',
},
{
variable: '--font-size-label',
type: 'px',
group: 'input',
default: '14px',
description: 'Field label font size',
},
{
variable: '--font-size-input',
type: 'px',
group: 'input',
default: '14px',
description: 'Input text font size',
},
{
variable: '--border-radius-input',
type: 'px',
group: 'input',
default: '6px',
description: 'Corner rounding of input fields',
},
{
variable: '--padding-form-input',
type: 'px',
group: 'input',
default: '12px',
description: 'Inner padding of input fields',
},
{
variable: '--font-size-body',
type: 'px',
group: 'input',
default: '12px',
description: 'Body text font size',
},
{
variable: '--font-size-paragraph',
type: 'px',
group: 'input',
default: '14px',
description: 'Paragraph text font size',
},
{
variable: '--font-size-error',
type: 'px',
group: 'input',
default: '12px',
description: 'Validation error font size',
},
{
variable: '--opacity-placeholder',
type: 'opacity',
group: 'input',
default: '0.5',
description: 'Placeholder text opacity (0-1)',
},
// ── Button & links ────────────────────────────────────────────────────────
{
variable: '--color-submit-btn-bg',
type: 'color',
group: 'button',
default: '#ff6d5a',
description: 'Submit button background color (primary accent)',
},
{
variable: '--color-submit-btn-text',
type: 'color',
group: 'button',
default: '#ffffff',
description: 'Submit button text color',
},
{
variable: '--color-link',
type: 'color',
group: 'button',
default: '#7e8186',
description: 'Link text color',
},
{
variable: '--submit-btn-height',
type: 'px',
group: 'button',
default: '48px',
description: 'Submit button height',
},
{
variable: '--font-size-submit-btn',
type: 'px',
group: 'button',
default: '14px',
description: 'Submit button font size',
},
{
variable: '--font-size-link',
type: 'px',
group: 'button',
default: '12px',
description: 'Link font size',
},
];
export const FORM_CSS_VARIABLE_DEFAULTS: Record<string, string> = Object.fromEntries(
FORM_CSS_VARIABLE_CONTROLS.map((c) => [c.variable, c.default]),
);
const CONTROLS_BY_VARIABLE: Record<string, FormCssVarControl> = Object.fromEntries(
FORM_CSS_VARIABLE_CONTROLS.map((c) => [c.variable, c]),
);
// ---------------------------------------------------------------------------
// Theme presets
// ---------------------------------------------------------------------------
export interface FormTheme {
id: string;
/** Partial map of CSS variable → value; unset variables fall back to defaults. */
overrides: Record<string, string>;
}
const DARK_OVERRIDES: Record<string, string> = {
'--color-background': '#1a1b1e',
'--color-card-bg': '#2c2d31',
'--color-card-border': '#3e4045',
'--color-header': '#e4e5e7',
'--color-header-subtext': '#9b9da3',
'--color-label': '#c8c9cb',
'--color-input-bg': '#232428',
'--color-input-border': '#4a4b50',
'--color-input-text': '#b0b2b7',
'--color-focus-border': '#7c6ce0',
'--color-link': '#9b9da3',
};
const COMPACT_OVERRIDES: Record<string, string> = {
'--container-width': '360px',
'--padding-container-top': '16px',
'--padding-card': '16px',
'--padding-form-input': '8px',
'--margin-bottom-card': '8px',
'--submit-btn-height': '40px',
'--font-size-header': '17px',
'--font-size-label': '12px',
'--font-size-input': '12px',
'--border-radius-card': '6px',
'--border-radius-input': '4px',
};
export const FORM_THEMES: FormTheme[] = [
// ── Light family ──────────────────────────────────────────────────────────
{ id: 'light', overrides: {} },
{ id: 'dark', overrides: DARK_OVERRIDES },
// ── Density variants ──────────────────────────────────────────────────────
{
id: 'dense',
overrides: {
'--padding-container-top': '12px',
'--padding-card': '16px',
'--margin-bottom-card': '8px',
'--padding-form-input': '8px',
'--submit-btn-height': '36px',
'--font-size-header': '17px',
'--font-size-subheader': '12px',
},
},
{ id: 'compact', overrides: COMPACT_OVERRIDES },
{ id: 'compactDark', overrides: { ...COMPACT_OVERRIDES, ...DARK_OVERRIDES } },
// ── Enterprise family ─────────────────────────────────────────────────────
{
id: 'enterprise',
overrides: {
'--font-family': "'Helvetica Neue', Helvetica, Arial, sans-serif",
'--color-background': '#f5f6f8',
'--color-card-border': '#cfd5dc',
'--color-header': '#1c2b3a',
'--color-header-subtext': '#4a5a6a',
'--color-submit-btn-bg': '#0055b3',
'--color-focus-border': '#0055b3',
'--color-required': '#cc0000',
'--border-radius-card': '2px',
'--border-radius-input': '2px',
},
},
{
id: 'enterpriseDark',
overrides: {
'--font-family': "'Helvetica Neue', Helvetica, Arial, sans-serif",
'--color-background': '#12192b',
'--color-card-bg': '#1c2540',
'--color-card-border': '#2a3560',
'--color-header': '#dde3f0',
'--color-header-subtext': '#7a88a8',
'--color-label': '#8898b8',
'--color-input-bg': '#161e36',
'--color-input-border': '#2a3560',
'--color-input-text': '#a8b8d8',
'--color-focus-border': '#0077cc',
'--color-link': '#4488cc',
'--color-submit-btn-bg': '#0055b3',
'--color-required': '#cc2222',
'--border-radius-card': '2px',
'--border-radius-input': '2px',
},
},
// ── Fun family ────────────────────────────────────────────────────────────
{
id: 'fun',
overrides: {
'--font-family': "'Georgia', serif",
'--color-background': '#fef0fb',
'--color-card-bg': '#fff8fe',
'--color-card-border': '#f0a8e8',
'--color-header': '#9b1fa8',
'--color-header-subtext': '#c060c8',
'--color-label': '#6a2070',
'--color-submit-btn-bg': '#e040d0',
'--color-focus-border': '#e040d0',
'--color-required': '#e040d0',
'--border-radius-card': '20px',
'--border-radius-input': '16px',
},
},
{
id: 'funColorful',
overrides: {
'--font-family': "'Georgia', serif",
'--color-background': '#e8fff0',
'--color-card-bg': '#f8fffc',
'--color-card-border': '#40d890',
'--color-header': '#ff3838',
'--color-header-subtext': '#ff8c20',
'--color-label': '#1a88d0',
'--color-input-bg': '#ffffff',
'--color-input-border': '#40d890',
'--color-focus-border': '#30c8a8',
'--color-submit-btn-bg': '#ff5520',
'--color-required': '#ff2060',
'--border-radius-card': '24px',
'--border-radius-input': '16px',
},
},
];
const THEMES_BY_ID: Record<string, FormTheme> = Object.fromEntries(
FORM_THEMES.map((t) => [t.id, t]),
);
// ---------------------------------------------------------------------------
// Pure helpers
// ---------------------------------------------------------------------------
/** Parse a `:root { --x: y; }` CSS string into a `{ '--x': 'y' }` overrides map. */
export function parseCssVariables(css: string): Record<string, string> {
const result: Record<string, string> = {};
for (const match of css.matchAll(/--([a-z0-9-]+)\s*:\s*([^;]+);/g)) {
result[`--${match[1]}`] = match[2].trim();
}
return result;
}
/** Assemble a CSS-variable overrides map into a `:root { … }` string (empty if none). */
export function assembleFormCss(overrides: Record<string, string>): string {
const entries = Object.entries(overrides);
if (entries.length === 0) return '';
const lines = entries.map(([k, v]) => `\t${k}: ${v};`).join('\n');
return `:root {\n${lines}\n}`;
}
/** Return a copy of a preset's overrides, or `undefined` if the id is unknown. */
export function applyFormThemePreset(themeId: string): Record<string, string> | undefined {
const theme = THEMES_BY_ID[themeId];
return theme ? { ...theme.overrides } : undefined;
}
/**
* Identify which preset an overrides map corresponds to, or `'custom'` when it
* matches none exactly. Mirrors the editor's `activeTheme` computation.
*/
export function resolveFormTheme(overrides: Record<string, string>): string {
const overrideKeys = Object.keys(overrides);
for (const theme of FORM_THEMES) {
const themeKeys = Object.keys(theme.overrides);
if (themeKeys.length !== overrideKeys.length) continue;
if (themeKeys.every((k) => theme.overrides[k] === overrides[k])) return theme.id;
}
return 'custom';
}
// ---------------------------------------------------------------------------
// Validation (used before applying LLM-generated / user-supplied themes)
// ---------------------------------------------------------------------------
export interface ThemeOverrideError {
variable: string;
value: string;
reason: string;
}
export interface ValidateThemeResult {
valid: boolean;
/** Overrides that passed validation, ready to assemble/save. */
overrides: Record<string, string>;
/** Rejected entries with a human-readable reason (surfaced back to the model). */
errors: ThemeOverrideError[];
}
// Values are single CSS declaration values, so no structural CSS or injection
// vectors are ever allowed regardless of the declared type.
const FORBIDDEN_VALUE = /[;{}<>]|url\(|expression\(|@import|javascript:/i;
const COLOR_RE =
/^(#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})|rgba?\([^)]*\)|hsla?\([^)]*\)|[a-z]+)$/i;
const LENGTH_RE = /^-?\d+(?:\.\d+)?(?:px|em|rem|%|vh|vw|vmin|vmax)?$/;
function isValidValue(type: FormCssVarType, value: string): boolean {
if (FORBIDDEN_VALUE.test(value)) return false;
switch (type) {
case 'color':
return COLOR_RE.test(value);
case 'px':
return LENGTH_RE.test(value);
case 'opacity': {
const n = Number(value);
return Number.isFinite(n) && n >= 0 && n <= 1;
}
case 'text':
return value.length > 0 && value.length <= 200;
}
}
/**
* Validate a set of theme overrides against the known variable catalog. Unknown
* variables and type-invalid values are rejected (dropped from `overrides` and
* reported in `errors`) so a caller — e.g. the LLM composing a theme — can
* self-correct instead of writing junk to the node. Surviving values are run
* through `sanitizeCustomCss` as a final guard.
*/
export function validateThemeOverrides(overrides: Record<string, string>): ValidateThemeResult {
const clean: Record<string, string> = {};
const errors: ThemeOverrideError[] = [];
for (const [variable, rawValue] of Object.entries(overrides)) {
const value = typeof rawValue === 'string' ? rawValue.trim() : String(rawValue);
const control = CONTROLS_BY_VARIABLE[variable];
if (!control) {
errors.push({ variable, value, reason: 'Unknown CSS variable' });
continue;
}
if (!isValidValue(control.type, value)) {
errors.push({
variable,
value,
reason: `Invalid value for ${control.type} variable`,
});
continue;
}
clean[variable] = value;
}
// Final structural guard: sanitize the assembled CSS and re-parse. If nothing
// survived, `sanitizeCustomCss` returns undefined → treat as empty.
const sanitized = sanitizeCustomCss(assembleFormCss(clean));
const finalOverrides = sanitized ? parseCssVariables(sanitized) : {};
return { valid: errors.length === 0, overrides: finalOverrides, errors };
}

View File

@ -6,6 +6,7 @@ import * as TelemetryHelpers from './telemetry-helpers';
export * from './errors';
export * from './constants';
export * from './form-utils';
export * from './form-theme';
export * from './common';
export * from './cron';
export * from './data-table.types';

View File

@ -0,0 +1,141 @@
import {
FORM_THEMES,
FORM_CSS_VARIABLE_CONTROLS,
FORM_CSS_VARIABLE_DEFAULTS,
parseCssVariables,
assembleFormCss,
applyFormThemePreset,
resolveFormTheme,
validateThemeOverrides,
} from '../src/form-theme';
describe('form-theme', () => {
describe('parseCssVariables / assembleFormCss', () => {
it('round-trips an overrides map through a :root block', () => {
const overrides = {
'--color-background': '#1a1b1e',
'--container-width': '360px',
};
const css = assembleFormCss(overrides);
expect(css).toContain(':root {');
expect(css).toContain('--color-background: #1a1b1e;');
expect(parseCssVariables(css)).toEqual(overrides);
});
it('assembles an empty string for no overrides', () => {
expect(assembleFormCss({})).toBe('');
});
it('parses values with whitespace and multiple entries', () => {
const parsed = parseCssVariables(':root {\n --a: #fff ;\n --b: 12px;\n}');
expect(parsed).toEqual({ '--a': '#fff', '--b': '12px' });
});
});
describe('applyFormThemePreset', () => {
it('returns a copy of a known preset (not the original reference)', () => {
const dark = FORM_THEMES.find((t) => t.id === 'dark')!;
const applied = applyFormThemePreset('dark');
expect(applied).toEqual(dark.overrides);
expect(applied).not.toBe(dark.overrides);
});
it('returns undefined for an unknown preset', () => {
expect(applyFormThemePreset('nope')).toBeUndefined();
});
});
describe('resolveFormTheme', () => {
it('identifies the matching preset', () => {
const dark = FORM_THEMES.find((t) => t.id === 'dark')!;
expect(resolveFormTheme({ ...dark.overrides })).toBe('dark');
});
it('returns "light" for empty overrides', () => {
expect(resolveFormTheme({})).toBe('light');
});
it('returns "custom" when nothing matches exactly', () => {
expect(resolveFormTheme({ '--color-background': '#123456' })).toBe('custom');
});
});
describe('validateThemeOverrides', () => {
it('accepts valid values by type', () => {
const result = validateThemeOverrides({
'--color-background': '#123456',
'--container-width': '400px',
'--opacity-placeholder': '0.4',
'--font-family': "'Georgia', serif",
});
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.overrides['--color-background']).toBe('#123456');
expect(result.overrides['--container-width']).toBe('400px');
});
it('rejects unknown variables', () => {
const result = validateThemeOverrides({ '--not-a-real-var': '#fff' });
expect(result.valid).toBe(false);
expect(result.errors[0]).toMatchObject({
variable: '--not-a-real-var',
reason: 'Unknown CSS variable',
});
expect(result.overrides).toEqual({});
});
it('rejects type-mismatched values', () => {
const result = validateThemeOverrides({
'--color-background': 'not-a-color!!',
'--container-width': 'wide',
'--opacity-placeholder': '5',
});
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(3);
expect(result.overrides).toEqual({});
});
it('rejects injection attempts in values', () => {
const result = validateThemeOverrides({
'--color-background': 'red; } body { display:none',
'--font-family': 'url(javascript:alert(1))',
});
expect(result.valid).toBe(false);
expect(result.overrides).toEqual({});
});
it('keeps valid entries while dropping invalid ones', () => {
const result = validateThemeOverrides({
'--color-background': '#000000',
'--bogus': 'x',
});
expect(result.valid).toBe(false);
expect(result.overrides).toEqual({ '--color-background': '#000000' });
expect(result.errors).toHaveLength(1);
});
});
describe('catalog integrity', () => {
it('every preset override targets a known variable', () => {
const known = new Set(FORM_CSS_VARIABLE_CONTROLS.map((c) => c.variable));
for (const theme of FORM_THEMES) {
for (const variable of Object.keys(theme.overrides)) {
expect(known.has(variable)).toBe(true);
}
}
});
it('exposes a default for every control', () => {
for (const control of FORM_CSS_VARIABLE_CONTROLS) {
expect(FORM_CSS_VARIABLE_DEFAULTS[control.variable]).toBe(control.default);
}
});
it('every preset override value validates against its control type', () => {
for (const theme of FORM_THEMES) {
const result = validateThemeOverrides({ ...theme.overrides });
expect(result.errors).toEqual([]);
}
});
});
});