diff --git a/services/studio/FlatRender.StudioSvc/Application/Services/StudioService.cs b/services/studio/FlatRender.StudioSvc/Application/Services/StudioService.cs
index 0efd64c..50f53a1 100644
--- a/services/studio/FlatRender.StudioSvc/Application/Services/StudioService.cs
+++ b/services/studio/FlatRender.StudioSvc/Application/Services/StudioService.cs
@@ -321,6 +321,40 @@ public class StudioService(StudioDbContext db)
return updated;
}
+ ///
+ /// Update the project's theme colours by element key (accentColor/secondaryColor/
+ /// backgroundColor/textColor). Upserts saved_shared_colors so the FlexStory render
+ /// binder (GetFlexStoryProps) reads the studio theme picker's colours.
+ ///
+ public async Task UpdateSharedColorsAsync(Guid projectId, Guid userId, List items)
+ {
+ var owns = await db.SavedProjects.AnyAsync(x => x.Id == projectId && x.UserId == userId);
+ if (!owns) throw new KeyNotFoundException($"Project {projectId} not found");
+
+ var updated = 0;
+ foreach (var item in items)
+ {
+ if (string.IsNullOrWhiteSpace(item.Key) || string.IsNullOrWhiteSpace(item.Value)) continue;
+ // Upsert by (project, element_key): the row usually exists (copied from the
+ // template's shared colours); insert if a FlexStory key is missing.
+ var n = await db.Database.ExecuteSqlInterpolatedAsync($@"
+ UPDATE studio.saved_shared_colors
+ SET value = {item.Value}
+ WHERE saved_project_id = {projectId} AND element_key = {item.Key}");
+ if (n == 0)
+ {
+ n = await db.Database.ExecuteSqlInterpolatedAsync($@"
+ INSERT INTO studio.saved_shared_colors
+ (saved_project_id, element_key, attr_value, value, is_selected, sort)
+ VALUES ({projectId}, {item.Key}, 'fill', {item.Value}, true, 0)");
+ }
+ updated += n;
+ }
+ await db.Database.ExecuteSqlInterpolatedAsync(
+ $"UPDATE studio.saved_projects SET last_edit_date = now(), updated_at = now() WHERE id = {projectId}");
+ return updated;
+ }
+
public async Task DeleteProjectAsync(Guid id, Guid userId)
{
var project = await db.SavedProjects
diff --git a/services/studio/FlatRender.StudioSvc/Controllers/StudioController.cs b/services/studio/FlatRender.StudioSvc/Controllers/StudioController.cs
index 84bfd68..a5aa2c5 100644
--- a/services/studio/FlatRender.StudioSvc/Controllers/StudioController.cs
+++ b/services/studio/FlatRender.StudioSvc/Controllers/StudioController.cs
@@ -60,6 +60,12 @@ public class StudioController(StudioService svc) : ControllerBase
public async Task UpdateContents(Guid id, [FromBody] UpdateContentsRequest req) =>
Ok(new { updated = await svc.UpdateSceneContentsAsync(id, UserId, req.Items ?? new List()) });
+ /// Update the project's theme colours (accentColor/secondaryColor/
+ /// backgroundColor/textColor) so the FlexStory render reads the theme picker.
+ [HttpPatch("{id:guid}/shared-colors")]
+ public async Task UpdateColors(Guid id, [FromBody] UpdateColorsRequest req) =>
+ Ok(new { updated = await svc.UpdateSharedColorsAsync(id, UserId, req.Items ?? new List()) });
+
/// Internal endpoint: get project for render service (no user-ownership check).
[HttpGet("{id:guid}/render-payload")]
[Authorize(Roles = "Service")]
diff --git a/services/studio/FlatRender.StudioSvc/Models/Requests/Requests.cs b/services/studio/FlatRender.StudioSvc/Models/Requests/Requests.cs
index bcd86eb..19762a3 100644
--- a/services/studio/FlatRender.StudioSvc/Models/Requests/Requests.cs
+++ b/services/studio/FlatRender.StudioSvc/Models/Requests/Requests.cs
@@ -128,3 +128,9 @@ public record SavedProjectListRequest(
/// writes the user's edits here (by content key) so the render binder picks them up.
public record UpdateContentsRequest(List Items);
public record UpdateContentItem(string Key, string? Value, Guid? ValueFileId);
+
+/// Update the project-wide theme colours (the studio theme picker) by
+/// element key (accentColor/secondaryColor/backgroundColor/textColor) so the
+/// FlexStory render binder reads them from saved_shared_colors.
+public record UpdateColorsRequest(List Items);
+public record UpdateColorItem(string Key, string Value);
diff --git a/src/app/api/projects/[projectId]/colors/route.ts b/src/app/api/projects/[projectId]/colors/route.ts
new file mode 100644
index 0000000..f55a898
--- /dev/null
+++ b/src/app/api/projects/[projectId]/colors/route.ts
@@ -0,0 +1,46 @@
+import { NextResponse } from "next/server";
+
+import { gatewayFetch } from "@/lib/api/gateway";
+import { getAccessToken } from "@/lib/auth/session";
+
+export const dynamic = "force-dynamic";
+
+/**
+ * Persist the studio theme picker's brand colours to the saved project's shared
+ * colours (studio-svc PATCH /v1/saved-projects/{id}/shared-colors). The FlexStory
+ * render binder (GetFlexStoryProps) reads saved_shared_colors by element_key, so this
+ * is what makes the theme picker reach the MP4.
+ * Body: { items: [{ key, value }] } — key ∈ accentColor/secondaryColor/backgroundColor/textColor
+ */
+export async function PATCH(
+ req: Request,
+ ctx: { params: Promise<{ projectId: string }> }
+) {
+ const { projectId } = await ctx.params;
+ const token = await getAccessToken();
+ if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+
+ const body = (await req.json().catch(() => null)) as {
+ items?: Array<{ key?: string; value?: string }>;
+ } | null;
+ const items = Array.isArray(body?.items)
+ ? body!.items.filter(
+ (i) => typeof i?.key === "string" && i.key.length > 0 && typeof i?.value === "string"
+ )
+ : [];
+ if (items.length === 0) return NextResponse.json({ updated: 0 });
+
+ const res = await gatewayFetch(`/v1/saved-projects/${projectId}/shared-colors`, {
+ method: "PATCH",
+ headers: { Authorization: `Bearer ${token}` },
+ body: JSON.stringify({ items }),
+ });
+ if (!res.ok) {
+ const d = await res.json().catch(() => null);
+ return NextResponse.json(
+ { error: d?.message ?? "Could not save colours" },
+ { status: res.status }
+ );
+ }
+ return NextResponse.json(await res.json().catch(() => ({ updated: items.length })));
+}
diff --git a/src/hooks/useStudioProjectPersistence.ts b/src/hooks/useStudioProjectPersistence.ts
index 6260ea4..ad223e1 100644
--- a/src/hooks/useStudioProjectPersistence.ts
+++ b/src/hooks/useStudioProjectPersistence.ts
@@ -12,8 +12,10 @@ import {
contentValuesFromSceneData,
fetchProject,
isProjectNotFoundError,
+ patchProjectColors,
patchProjectContents,
patchProjectSceneData,
+ themeColorsFromSceneData,
} from "@/lib/project-api";
import { isDevProjectId } from "@/lib/project-ids";
import {
@@ -159,6 +161,12 @@ export function useStudioProjectPersistence(
projectId,
contentValuesFromSceneData(scene_data)
).catch(() => {});
+ // Persist the theme colours to saved_shared_colors so the FlexStory render
+ // reads the theme picker (best-effort).
+ void patchProjectColors(
+ projectId,
+ themeColorsFromSceneData(scene_data)
+ ).catch(() => {});
setUsingLocalStorage(false);
markSaved(payloadJson, false);
} catch (error) {
diff --git a/src/lib/project-api.ts b/src/lib/project-api.ts
index 662eb2a..759a909 100644
--- a/src/lib/project-api.ts
+++ b/src/lib/project-api.ts
@@ -98,6 +98,31 @@ export async function patchProjectContents(
}
}
+export interface ColorValueUpdate {
+ key: string;
+ value: string;
+}
+
+/**
+ * Persist the project's theme colours (accentColor/secondaryColor/backgroundColor/
+ * textColor) to saved_shared_colors so the FlexStory render binder reads them.
+ * Best-effort (callers may ignore failures).
+ */
+export async function patchProjectColors(
+ projectId: string,
+ items: ColorValueUpdate[]
+): Promise {
+ if (items.length === 0) return;
+ const response = await fetch(`/api/projects/${projectId}/colors`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ items }),
+ });
+ if (!response.ok) {
+ throw new ProjectApiError("Failed to save colours", response.status);
+ }
+}
+
/** Extract editable input values from studio scene_data (bridged `c-` layers). */
export function contentValuesFromSceneData(
sceneData: Record
@@ -126,3 +151,21 @@ export function contentValuesFromSceneData(
}
return items;
}
+
+/** Map studio scene_data theme colours → the FlexStory colorSchema shared-colour keys. */
+export function themeColorsFromSceneData(
+ sceneData: Record
+): ColorValueUpdate[] {
+ const map: Array<[string, string]> = [
+ ["accentColor", "sceneAccentColor"],
+ ["secondaryColor", "sceneSecondaryColor"],
+ ["backgroundColor", "sceneBackgroundColor"],
+ ["textColor", "sceneTextColor"],
+ ];
+ const items: ColorValueUpdate[] = [];
+ for (const [key, field] of map) {
+ const v = sceneData[field];
+ if (typeof v === "string" && v) items.push({ key, value: v });
+ }
+ return items;
+}