diff --git a/services/content/FlatRender.ContentSvc/Application/Services/SceneColorService.cs b/services/content/FlatRender.ContentSvc/Application/Services/SceneColorService.cs
new file mode 100644
index 0000000..f063bf0
--- /dev/null
+++ b/services/content/FlatRender.ContentSvc/Application/Services/SceneColorService.cs
@@ -0,0 +1,190 @@
+using FlatRender.ContentSvc.Domain.Entities;
+using FlatRender.ContentSvc.Domain.Enums;
+using FlatRender.ContentSvc.Infrastructure.Data;
+using FlatRender.ContentSvc.Models;
+using Microsoft.EntityFrameworkCore;
+
+namespace FlatRender.ContentSvc.Application.Services;
+
+/// CRUD for project-scoped scenes, shared colors and colour presets.
+public class SceneColorService(ContentDbContext db)
+{
+ // ── Scenes ──────────────────────────────────────────────────────────────
+
+ public async Task> GetScenesAsync(Guid projectId) =>
+ await db.Scenes.Where(s => s.ProjectId == projectId && s.DeletedAt == null)
+ .OrderBy(s => s.Sort)
+ .Select(s => ToSceneResponse(s)).ToListAsync();
+
+ public async Task CreateSceneAsync(CreateSceneRequest req)
+ {
+ var scene = new Scene
+ {
+ ProjectId = req.ProjectId,
+ Key = req.Key,
+ Title = req.Title,
+ LocalizedTitle = req.LocalizedTitle,
+ SceneType = ParseSceneKind(req.SceneType),
+ Image = req.Image,
+ Demo = req.Demo,
+ SceneColorSvg = req.SceneColorSvg,
+ SnapshotUrl = req.SnapshotUrl,
+ GenerateKf = req.GenerateKf,
+ DefaultDurationSec = req.DefaultDurationSec,
+ MinDurationSec = req.MinDurationSec,
+ MaxDurationSec = req.MaxDurationSec,
+ OverlapAtEndSec = req.OverlapAtEndSec,
+ CanHandleDuration = req.CanHandleDuration,
+ ManualColorSelection = req.ManualColorSelection,
+ Sort = req.Sort,
+ IsActive = req.IsActive,
+ };
+ db.Scenes.Add(scene);
+ await db.SaveChangesAsync();
+ return ToSceneResponse(scene);
+ }
+
+ public async Task UpdateSceneAsync(Guid id, UpdateSceneRequest req)
+ {
+ var scene = await db.Scenes.FindAsync(id) ?? throw new KeyNotFoundException($"Scene {id} not found");
+ scene.Key = req.Key;
+ scene.Title = req.Title;
+ scene.LocalizedTitle = req.LocalizedTitle;
+ scene.SceneType = ParseSceneKind(req.SceneType);
+ scene.Image = req.Image;
+ scene.Demo = req.Demo;
+ scene.SceneColorSvg = req.SceneColorSvg;
+ scene.SnapshotUrl = req.SnapshotUrl;
+ scene.GenerateKf = req.GenerateKf;
+ scene.DefaultDurationSec = req.DefaultDurationSec;
+ scene.MinDurationSec = req.MinDurationSec;
+ scene.MaxDurationSec = req.MaxDurationSec;
+ scene.OverlapAtEndSec = req.OverlapAtEndSec;
+ scene.CanHandleDuration = req.CanHandleDuration;
+ scene.ManualColorSelection = req.ManualColorSelection;
+ scene.Sort = req.Sort;
+ scene.IsActive = req.IsActive;
+ scene.UpdatedAt = DateTime.UtcNow;
+ await db.SaveChangesAsync();
+ return ToSceneResponse(scene);
+ }
+
+ public async Task DeleteSceneAsync(Guid id)
+ {
+ var scene = await db.Scenes.FindAsync(id) ?? throw new KeyNotFoundException($"Scene {id} not found");
+ scene.DeletedAt = DateTime.UtcNow;
+ await db.SaveChangesAsync();
+ }
+
+ // ── Shared colours ──────────────────────────────────────────────────────
+
+ public async Task> GetSharedColorsAsync(Guid projectId) =>
+ await db.SharedColors.Where(c => c.ProjectId == projectId).OrderBy(c => c.Sort)
+ .Select(c => new SharedColorResponse(c.Id, c.ProjectId, c.ElementKey, c.Title, c.Icon,
+ c.AttrValue.ToString(), c.DefaultColor, c.Sort)).ToListAsync();
+
+ public async Task CreateSharedColorAsync(SaveSharedColorRequest req)
+ {
+ var c = new SharedColor
+ {
+ ProjectId = req.ProjectId,
+ ElementKey = req.ElementKey,
+ Title = req.Title,
+ Icon = req.Icon,
+ AttrValue = ParseAttrValue(req.AttrValue),
+ DefaultColor = req.DefaultColor,
+ Sort = req.Sort,
+ };
+ db.SharedColors.Add(c);
+ await db.SaveChangesAsync();
+ return new SharedColorResponse(c.Id, c.ProjectId, c.ElementKey, c.Title, c.Icon, c.AttrValue.ToString(), c.DefaultColor, c.Sort);
+ }
+
+ public async Task UpdateSharedColorAsync(Guid id, SaveSharedColorRequest req)
+ {
+ var c = await db.SharedColors.FindAsync(id) ?? throw new KeyNotFoundException($"Color {id} not found");
+ c.ElementKey = req.ElementKey;
+ c.Title = req.Title;
+ c.Icon = req.Icon;
+ c.AttrValue = ParseAttrValue(req.AttrValue);
+ c.DefaultColor = req.DefaultColor;
+ c.Sort = req.Sort;
+ c.UpdatedAt = DateTime.UtcNow;
+ await db.SaveChangesAsync();
+ return new SharedColorResponse(c.Id, c.ProjectId, c.ElementKey, c.Title, c.Icon, c.AttrValue.ToString(), c.DefaultColor, c.Sort);
+ }
+
+ public async Task DeleteSharedColorAsync(Guid id)
+ {
+ var c = await db.SharedColors.FindAsync(id) ?? throw new KeyNotFoundException($"Color {id} not found");
+ db.SharedColors.Remove(c);
+ await db.SaveChangesAsync();
+ }
+
+ // ── Colour presets (palettes) ───────────────────────────────────────────
+
+ public async Task> GetColorPresetsAsync(Guid projectId) =>
+ await db.SharedColorPresets.Where(p => p.ProjectId == projectId).OrderBy(p => p.Sort)
+ .Select(p => new ColorPresetResponse(p.Id, p.ProjectId, p.Name, p.Sort,
+ p.Items.OrderBy(i => i.Sort).Select(i => new ColorPresetItemResponse(i.Id, i.ElementKey, i.Value, i.Sort)).ToList()))
+ .ToListAsync();
+
+ public async Task CreateColorPresetAsync(SaveColorPresetRequest req)
+ {
+ var preset = new SharedColorPreset
+ {
+ ProjectId = req.ProjectId,
+ Name = req.Name,
+ Sort = req.Sort,
+ Items = (req.Items ?? []).Select(i => new SharedColorPresetItem
+ {
+ ElementKey = i.ElementKey, Value = i.Value, Sort = i.Sort,
+ }).ToList(),
+ };
+ db.SharedColorPresets.Add(preset);
+ await db.SaveChangesAsync();
+ return await GetPresetAsync(preset.Id);
+ }
+
+ public async Task UpdateColorPresetAsync(Guid id, SaveColorPresetRequest req)
+ {
+ var preset = await db.SharedColorPresets.Include(p => p.Items).FirstOrDefaultAsync(p => p.Id == id)
+ ?? throw new KeyNotFoundException($"Preset {id} not found");
+ preset.Name = req.Name;
+ preset.Sort = req.Sort;
+ db.SharedColorPresetItems.RemoveRange(preset.Items);
+ preset.Items = (req.Items ?? []).Select(i => new SharedColorPresetItem
+ {
+ PresetId = preset.Id, ElementKey = i.ElementKey, Value = i.Value, Sort = i.Sort,
+ }).ToList();
+ await db.SaveChangesAsync();
+ return await GetPresetAsync(preset.Id);
+ }
+
+ public async Task DeleteColorPresetAsync(Guid id)
+ {
+ var preset = await db.SharedColorPresets.FindAsync(id) ?? throw new KeyNotFoundException($"Preset {id} not found");
+ db.SharedColorPresets.Remove(preset);
+ await db.SaveChangesAsync();
+ }
+
+ private async Task GetPresetAsync(Guid id) =>
+ await db.SharedColorPresets.Where(p => p.Id == id)
+ .Select(p => new ColorPresetResponse(p.Id, p.ProjectId, p.Name, p.Sort,
+ p.Items.OrderBy(i => i.Sort).Select(i => new ColorPresetItemResponse(i.Id, i.ElementKey, i.Value, i.Sort)).ToList()))
+ .FirstAsync();
+
+ // ── helpers ─────────────────────────────────────────────────────────────
+
+ private static SceneResponse ToSceneResponse(Scene s) => new(
+ s.Id, s.ProjectId, s.Key, s.Title, s.LocalizedTitle, s.SceneType.ToString(),
+ s.Image, s.Demo, s.SceneColorSvg, s.SnapshotUrl, s.GenerateKf,
+ s.DefaultDurationSec, s.MinDurationSec, s.MaxDurationSec, s.OverlapAtEndSec,
+ s.CanHandleDuration, s.ManualColorSelection, s.Sort, s.IsActive);
+
+ private static SceneKind ParseSceneKind(string? v) =>
+ Enum.TryParse(v, true, out var k) ? k : SceneKind.Normal;
+
+ private static AttrValueKind ParseAttrValue(string? v) =>
+ Enum.TryParse(v, true, out var a) ? a : AttrValueKind.fill;
+}
diff --git a/services/content/FlatRender.ContentSvc/Controllers/SceneColorController.cs b/services/content/FlatRender.ContentSvc/Controllers/SceneColorController.cs
new file mode 100644
index 0000000..c2bd2df
--- /dev/null
+++ b/services/content/FlatRender.ContentSvc/Controllers/SceneColorController.cs
@@ -0,0 +1,87 @@
+using FlatRender.ContentSvc.Application.Services;
+using FlatRender.ContentSvc.Models;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace FlatRender.ContentSvc.Controllers;
+
+[ApiController]
+[Route("v1/scenes")]
+public class ScenesController(SceneColorService svc) : ControllerBase
+{
+ [HttpGet]
+ public async Task List([FromQuery(Name = "project_id")] Guid projectId) =>
+ Ok(await svc.GetScenesAsync(projectId));
+
+ [Authorize(Roles = "Admin")]
+ [HttpPost]
+ public async Task Create([FromBody] CreateSceneRequest req) =>
+ Ok(await svc.CreateSceneAsync(req));
+
+ [Authorize(Roles = "Admin")]
+ [HttpPut("{id:guid}")]
+ public async Task Update(Guid id, [FromBody] UpdateSceneRequest req) =>
+ Ok(await svc.UpdateSceneAsync(id, req));
+
+ [Authorize(Roles = "Admin")]
+ [HttpDelete("{id:guid}")]
+ public async Task Delete(Guid id)
+ {
+ await svc.DeleteSceneAsync(id);
+ return NoContent();
+ }
+}
+
+[ApiController]
+[Route("v1/shared-colors")]
+public class SharedColorsController(SceneColorService svc) : ControllerBase
+{
+ [HttpGet]
+ public async Task List([FromQuery(Name = "project_id")] Guid projectId) =>
+ Ok(await svc.GetSharedColorsAsync(projectId));
+
+ [Authorize(Roles = "Admin")]
+ [HttpPost]
+ public async Task Create([FromBody] SaveSharedColorRequest req) =>
+ Ok(await svc.CreateSharedColorAsync(req));
+
+ [Authorize(Roles = "Admin")]
+ [HttpPut("{id:guid}")]
+ public async Task Update(Guid id, [FromBody] SaveSharedColorRequest req) =>
+ Ok(await svc.UpdateSharedColorAsync(id, req));
+
+ [Authorize(Roles = "Admin")]
+ [HttpDelete("{id:guid}")]
+ public async Task Delete(Guid id)
+ {
+ await svc.DeleteSharedColorAsync(id);
+ return NoContent();
+ }
+}
+
+[ApiController]
+[Route("v1/color-presets")]
+public class ColorPresetsController(SceneColorService svc) : ControllerBase
+{
+ [HttpGet]
+ public async Task List([FromQuery(Name = "project_id")] Guid projectId) =>
+ Ok(await svc.GetColorPresetsAsync(projectId));
+
+ [Authorize(Roles = "Admin")]
+ [HttpPost]
+ public async Task Create([FromBody] SaveColorPresetRequest req) =>
+ Ok(await svc.CreateColorPresetAsync(req));
+
+ [Authorize(Roles = "Admin")]
+ [HttpPut("{id:guid}")]
+ public async Task Update(Guid id, [FromBody] SaveColorPresetRequest req) =>
+ Ok(await svc.UpdateColorPresetAsync(id, req));
+
+ [Authorize(Roles = "Admin")]
+ [HttpDelete("{id:guid}")]
+ public async Task Delete(Guid id)
+ {
+ await svc.DeleteColorPresetAsync(id);
+ return NoContent();
+ }
+}
diff --git a/services/content/FlatRender.ContentSvc/Models/SceneColor.cs b/services/content/FlatRender.ContentSvc/Models/SceneColor.cs
new file mode 100644
index 0000000..3599e65
--- /dev/null
+++ b/services/content/FlatRender.ContentSvc/Models/SceneColor.cs
@@ -0,0 +1,50 @@
+namespace FlatRender.ContentSvc.Models;
+
+// ── Scenes (project-scoped scene templates) ──────────────────────────────────
+
+public record SceneResponse(
+ Guid Id, Guid ProjectId, string Key, string Title, string? LocalizedTitle,
+ string SceneType, string? Image, string? Demo, string? SceneColorSvg, string? SnapshotUrl,
+ bool GenerateKf, decimal? DefaultDurationSec, decimal? MinDurationSec, decimal? MaxDurationSec,
+ decimal OverlapAtEndSec, bool CanHandleDuration, bool ManualColorSelection, int Sort, bool IsActive
+);
+
+public record CreateSceneRequest(
+ Guid ProjectId, string Key, string Title, string? LocalizedTitle, string? SceneType,
+ string? Image, string? Demo, string? SceneColorSvg, string? SnapshotUrl, bool GenerateKf,
+ decimal? DefaultDurationSec, decimal? MinDurationSec, decimal? MaxDurationSec, decimal OverlapAtEndSec,
+ bool CanHandleDuration, bool ManualColorSelection, int Sort, bool IsActive
+);
+
+public record UpdateSceneRequest(
+ string Key, string Title, string? LocalizedTitle, string? SceneType,
+ string? Image, string? Demo, string? SceneColorSvg, string? SnapshotUrl, bool GenerateKf,
+ decimal? DefaultDurationSec, decimal? MinDurationSec, decimal? MaxDurationSec, decimal OverlapAtEndSec,
+ bool CanHandleDuration, bool ManualColorSelection, int Sort, bool IsActive
+);
+
+// ── Shared colors (project-scoped named colors) ──────────────────────────────
+
+public record SharedColorResponse(
+ Guid Id, Guid ProjectId, string ElementKey, string Title, string? Icon,
+ string AttrValue, string DefaultColor, int Sort
+);
+
+public record SaveSharedColorRequest(
+ Guid ProjectId, string ElementKey, string Title, string? Icon,
+ string? AttrValue, string DefaultColor, int Sort
+);
+
+// ── Color presets (named palettes of colors) ─────────────────────────────────
+
+public record ColorPresetItemResponse(Guid Id, string ElementKey, string Value, int Sort);
+
+public record ColorPresetResponse(
+ Guid Id, Guid ProjectId, string? Name, int Sort, List Items
+);
+
+public record ColorPresetItemInput(string ElementKey, string Value, int Sort);
+
+public record SaveColorPresetRequest(
+ Guid ProjectId, string? Name, int Sort, List Items
+);
diff --git a/services/content/FlatRender.ContentSvc/Program.cs b/services/content/FlatRender.ContentSvc/Program.cs
index 40b00bc..0f7c034 100644
--- a/services/content/FlatRender.ContentSvc/Program.cs
+++ b/services/content/FlatRender.ContentSvc/Program.cs
@@ -64,6 +64,7 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
// HTTP client for the OpenAI-compatible AI provider (base URL is per-tenant config).
builder.Services.AddHttpClient("openai");
diff --git a/services/gateway/cmd/server/main.go b/services/gateway/cmd/server/main.go
index ebb3377..c547f51 100644
--- a/services/gateway/cmd/server/main.go
+++ b/services/gateway/cmd/server/main.go
@@ -116,6 +116,9 @@ func main() {
v1.Any("/comments/*path", apiRL, auth, content.Handler())
v1.Any("/favorites/*path", apiRL, auth, content.Handler())
v1.Any("/ai/*path", apiRL, auth, content.Handler())
+ v1.Any("/scenes/*path", apiRL, optionalAuth, content.Handler())
+ v1.Any("/shared-colors/*path", apiRL, optionalAuth, content.Handler())
+ v1.Any("/color-presets/*path", apiRL, optionalAuth, content.Handler())
// ── File Service ─────────────────────────────────────────────────────────
v1.Any("/files/*path", apiRL, auth, file.Handler())