diff --git a/studio/frontend/src/components/markdown/markdown-preview.tsx b/studio/frontend/src/components/markdown/markdown-preview.tsx
index cc6ddede65..b3a00a01eb 100644
--- a/studio/frontend/src/components/markdown/markdown-preview.tsx
+++ b/studio/frontend/src/components/markdown/markdown-preview.tsx
@@ -23,7 +23,7 @@ function MarkdownPreviewImpl({
= 100 %}VIP{% elif order_total >= 50 %}Standard{% else %}Starter{% endif %}`\n- Simple math:\n - `{{ quantity * unit_price }}`\n - `{{ (subtotal - discount) | round(2) }}`\n\n### Good rule:\n- If the value can be computed from existing data, use Expression first.\n- Use LLM only when you need true language generation.",
+ "note_color": "#fffbeb",
+ "note_opacity": "35"
+ },
+ {
+ "id": "note_2",
+ "x": 1944,
+ "y": 760.9999999999999,
+ "width": 400,
+ "node_type": "markdown_note",
+ "name": "note_2",
+ "markdown": "### LLM Structured block\nGenerates JSON that matches your Output Format schema.\nThink of Output Format as a contract for what the model must return.\n\n#### Prompt tips:\n\n- Reference existing columns with Jinja: `{{ column_name }}`\n- You can reference nested values too: `{{ customer.first_name }}`\n- Be explicit about what each field should contain.\n\n#### Example prompt pattern:\n\n```text\nCreate a support ticket summary.\nCustomer: {{ customer_name }}\nIssue text: {{ issue_text }}\n\nReturn data for:\n- priority\n- short_title\n- resolution_steps\n```",
+ "note_color": "#f4efdc",
+ "note_opacity": "35"
+ },
+ {
+ "id": "note_3",
+ "x": 2381.178207301403,
+ "y": 790.2196835690842,
+ "width": 638,
+ "node_type": "markdown_note",
+ "name": "note_3",
+ "markdown": "## Example output format shape (concept):\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"priority\": { \"type\": \"string\" },\n \"short_title\": { \"type\": \"string\" },\n \"resolution_steps\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } }\n },\n \"required\": [\"priority\", \"short_title\", \"resolution_steps\"]\n}\n```",
+ "note_color": "#fbf6e5",
+ "note_opacity": "35"
+ },
+ {
+ "id": "note_4",
+ "x": 2405.796928768747,
+ "y": -362.26583299682716,
+ "width": 399,
+ "node_type": "markdown_note",
+ "name": "note_4",
+ "markdown": "### Model provider & Config\nEvery LLM block needs a model alias.\nThat alias comes from a Model Config.\nModel Config points to a Model Provider.\n\n#### Minimum setup:\n\n1. Create **Model Provider**\n - Set endpoint/provider type\n - Prefer env var auth (`api_key_env`) over hardcoded keys\n\n2. Create **Model Config**\n - Set alias (example: `model_1`)\n - Set model id\n - Link to provider\n - Tune params (temperature, max_tokens, etc.)\n\n3. In each LLM block\n - Set `model_alias` to that alias\n\nIf alias/provider link is missing, validation/run will fail.",
+ "note_color": "#f6f4ef",
+ "note_opacity": "35"
+ },
{
"id": "provider_column",
"x": 1947.2039072039072,
diff --git a/studio/frontend/src/features/data-recipes/learning-recipes/text-to-python.json b/studio/frontend/src/features/data-recipes/learning-recipes/text-to-python.json
index 7b65b9b1bc..83b36fa396 100644
--- a/studio/frontend/src/features/data-recipes/learning-recipes/text-to-python.json
+++ b/studio/frontend/src/features/data-recipes/learning-recipes/text-to-python.json
@@ -150,6 +150,28 @@
"x": 2040.9251520522098,
"y": -13.362336454344792,
"width": 400
+ },
+ {
+ "id": "note_1",
+ "x": 1482.1328175027095,
+ "y": 242.4370179053253,
+ "width": 568,
+ "node_type": "markdown_note",
+ "name": "note_1",
+ "markdown": "The **LLM Code** block is where Python code is generated from your instruction/prompt.\n\n##### How it works in this recipe:\n\n- You provide a clear prompt (often using Jinja references from earlier columns)\n- The model returns a response\n- The block extracts code content directly for the output column\n\n##### Current status:\n\n- We are **not** running Python lint/syntax validation in this recipe yet (Soon)\n- Validation support is planned and will be added\n\n##### What this means:\n\n- You may get mostly correct code, but some rows can still have syntax/style issues\n- Keep prompts specific and constrained to reduce bad outputs\n\n##### Tip:\n\n- Ask for one self-contained function/script\n- Ask for required imports\n- Ask for no markdown fences if you want cleaner extraction\n",
+ "note_color": "#FDE68A",
+ "note_opacity": "35"
+ },
+ {
+ "id": "note_2",
+ "x": 2513.2527820497985,
+ "y": -235.2544980991115,
+ "width": 471,
+ "node_type": "markdown_note",
+ "name": "note_2",
+ "markdown": "The **LLM Judge** block evaluates generated outputs with rubric-style scores.\n\n##### Important:\n\n- A judge can have **one or many scores**\n- Each score has:\n - a name (for example: `Correctness`)\n - a description\n - options (value + meaning)\n\n##### Example multi-score setup:\n\n- Correctness\n- Readability\n- Efficiency\n\n##### Why use multiple scores:\n\n- You get richer quality signals than a single pass/fail\n- Easier filtering and weighting later in training data prep\n\n##### Practical pattern:\n\n1. Generate code with LLM Code\n2. Judge with 2-4 focused scores\n3. Keep high-quality rows based on score thresholds\n",
+ "note_color": "#FDE68A",
+ "note_opacity": "35"
}
],
"edges": [
diff --git a/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx b/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx
index e58510aa2d..e86c9adc03 100644
--- a/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx
+++ b/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx
@@ -54,6 +54,27 @@ import { LabeledHandle } from "./rf-ui/labeled-handle";
type IconType = typeof CodeIcon;
+function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
+ const normalized = hex.trim().replace("#", "");
+ if (!/^[0-9a-fA-F]{6}$/.test(normalized)) {
+ return null;
+ }
+ const int = Number.parseInt(normalized, 16);
+ return {
+ r: (int >> 16) & 255,
+ g: (int >> 8) & 255,
+ b: int & 255,
+ };
+}
+
+function parseNoteOpacity(value: string | undefined): number {
+ const parsed = Number.parseInt(value ?? "", 10);
+ if (!Number.isFinite(parsed)) {
+ return 0.35;
+ }
+ return Math.max(0.05, Math.min(1, parsed / 100));
+}
+
const NODE_META = {
sampler: {
tone: "bg-emerald-50 text-emerald-600 border-emerald-100",
@@ -353,6 +374,40 @@ function RecipeGraphNodeBase({
updateNodeInternals(id);
}, [id, layoutDirection, config, updateNodeInternals]);
+ if (config?.kind === "markdown_note") {
+ const rgb = hexToRgb(config.note_color ?? "#FDE68A");
+ const alpha = parseNoteOpacity(config.note_opacity);
+ const noteStyle = rgb
+ ? {
+ backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`,
+ borderColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${Math.min(1, Math.max(alpha + 0.15, 0.3))})`,
+ }
+ : undefined;
+
+ return (
+
+
+
+
+
+
+ );
+ }
+
const showDataHandles =
data.kind === "llm" ||
data.kind === "expression" ||
diff --git a/studio/frontend/src/features/recipe-studio/dialogs/markdown-note/markdown-note-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/markdown-note/markdown-note-dialog.tsx
index 4724ff9efe..5cb04b5169 100644
--- a/studio/frontend/src/features/recipe-studio/dialogs/markdown-note/markdown-note-dialog.tsx
+++ b/studio/frontend/src/features/recipe-studio/dialogs/markdown-note/markdown-note-dialog.tsx
@@ -1,3 +1,4 @@
+import { Slider } from "@/components/ui/slider";
import { Textarea } from "@/components/ui/textarea";
import type { ReactElement } from "react";
import type { MarkdownNoteConfig } from "../../types";
@@ -14,10 +15,46 @@ export function MarkdownNoteDialog({
onUpdate,
}: MarkdownNoteDialogProps): ReactElement {
const markdownId = `${config.id}-markdown`;
+ const colorId = `${config.id}-note-color`;
+ const opacity =
+ Number.parseInt(config.note_opacity ?? "35", 10) > 0
+ ? Math.max(0, Math.min(100, Number.parseInt(config.note_opacity ?? "35", 10)))
+ : 35;
return (
onUpdate({ name: value })} />
+
+
+
+
onUpdate({ note_color: event.target.value })}
+ />
+
+
+ Opacity
+ {opacity}%
+
+
+ onUpdate({ note_opacity: String(Math.round(value)) })
+ }
+ />
+
+
+
) => {
+ if (node.type !== "builder") {
+ return;
+ }
+ const nodeConfig = configs[node.id];
+ if (nodeConfig?.kind === "markdown_note") {
+ openConfig(node.id);
+ }
+ },
+ [configs, openConfig],
+ );
+
const handleNodesChange = useCallback(
(changes: NodeChange>[]) => {
applyAuxNodeChanges(changes, { setAuxNodePosition, setAuxNodeSize });
@@ -408,6 +423,7 @@ export function RecipeStudioPage({
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
onNodeClick={handleNodeClick}
+ onNodeDoubleClick={handleNodeDoubleClick}
isValidConnection={isValidConnection}
nodesDraggable={interactive}
nodesConnectable={interactive}
diff --git a/studio/frontend/src/features/recipe-studio/types/index.ts b/studio/frontend/src/features/recipe-studio/types/index.ts
index f2febc4bc0..ec61d3aaa5 100644
--- a/studio/frontend/src/features/recipe-studio/types/index.ts
+++ b/studio/frontend/src/features/recipe-studio/types/index.ts
@@ -222,6 +222,10 @@ export type MarkdownNoteConfig = {
kind: "markdown_note";
name: string;
markdown: string;
+ // ui-only
+ note_color?: string;
+ // ui-only (0-100 as string for slider/input ergonomics)
+ note_opacity?: string;
};
export type SeedConfig = {
diff --git a/studio/frontend/src/features/recipe-studio/utils/config-factories.ts b/studio/frontend/src/features/recipe-studio/utils/config-factories.ts
index dee84dacec..f92589b213 100644
--- a/studio/frontend/src/features/recipe-studio/utils/config-factories.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/config-factories.ts
@@ -287,6 +287,8 @@ export function makeMarkdownNoteConfig(
kind: "markdown_note",
name: nextName(existing, "note"),
markdown: "## Note\n\nAdd markdown here.",
+ note_color: "#FDE68A",
+ note_opacity: "35",
};
}
diff --git a/studio/frontend/src/features/recipe-studio/utils/import/importer.ts b/studio/frontend/src/features/recipe-studio/utils/import/importer.ts
index 60f44c3825..e2ea6d754a 100644
--- a/studio/frontend/src/features/recipe-studio/utils/import/importer.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/import/importer.ts
@@ -44,6 +44,8 @@ type UiInput = {
type UiMarkdownNoteNode = {
name: string;
markdown: string;
+ note_color?: string;
+ note_opacity?: string;
};
function readStringNumber(value: unknown): string | undefined {
@@ -247,6 +249,8 @@ function parseUiMarkdownNoteNodes(input: unknown): UiMarkdownNoteNode[] {
noteNodes.push({
name: name.trim(),
markdown: readString(node.markdown) ?? "",
+ note_color: readString(node.note_color) ?? undefined,
+ note_opacity: readStringNumber(node.note_opacity) ?? undefined,
});
}
return noteNodes;
@@ -342,6 +346,8 @@ export function importRecipePayload(input: string): ImportResult {
kind: "markdown_note",
name: note.name,
markdown: note.markdown,
+ note_color: note.note_color ?? "#FDE68A",
+ note_opacity: note.note_opacity ?? "35",
};
if (nameToId.has(config.name)) {
errors.push(`Duplicate column name: ${config.name}.`);
diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts b/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts
index ee49fec0aa..606f6a3cd4 100644
--- a/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts
@@ -202,6 +202,8 @@ export function buildRecipePayload(
node_type: "markdown_note" as const,
name: config.name,
markdown: config.markdown,
+ note_color: config.note_color,
+ note_opacity: config.note_opacity,
},
];
}
diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/types.ts b/studio/frontend/src/features/recipe-studio/utils/payload/types.ts
index 12e3fce9ff..e97f8db970 100644
--- a/studio/frontend/src/features/recipe-studio/utils/payload/types.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/payload/types.ts
@@ -38,6 +38,8 @@ export type RecipePayload = {
node_type?: "markdown_note";
name?: string;
markdown?: string;
+ note_color?: string;
+ note_opacity?: string;
}>;
edges: {
from: string;