diff --git a/.changelog/next/added-issue-4188.md b/.changelog/next/added-issue-4188.md
index a94a436c57..a803b4eed6 100644
--- a/.changelog/next/added-issue-4188.md
+++ b/.changelog/next/added-issue-4188.md
@@ -1,3 +1,4 @@
- Universes can now link a mood board directly on the record: pick or create a board from the Universe Bible tab and the link survives reload, stays per-universe, and syncs to your other machines (previously the reference strip only remembered one board per browser).
- Mood boards can now pull items straight from your galleries: pick images from the image gallery, pick or upload videos, and video items play right on the board with a poster thumbnail. Uploaded videos land in the shared video gallery, so board items sync to your other machines like any other media.
- Mood board items can now be analyzed with AI: an "Analyze with AI" action on gallery-backed items runs the same Prompt-from-media flow as the video page (your choice of vision provider) and saves the resulting prompt, negative prompt, and rationale onto the item — shown with a highlighted badge, viewable/copyable/removable from the item, and synced to your other machines with the board.
+- A universe's linked mood board can now be distilled into its style guide: "Synthesize style" (next to the mood-board picker in the Universe Builder) runs the board's notes, captions, and item analyses through your chosen AI provider, previews the proposed style prompt / negative prompt / style notes as a before-and-after diff, and "Adopt" applies it to the universe — respecting any fields you've locked.
diff --git a/client/src/components/universeBuilder/MoodBoardStyleSynthesis.jsx b/client/src/components/universeBuilder/MoodBoardStyleSynthesis.jsx
new file mode 100644
index 0000000000..fc25f10120
--- /dev/null
+++ b/client/src/components/universeBuilder/MoodBoardStyleSynthesis.jsx
@@ -0,0 +1,219 @@
+/**
+ * Board → universe style synthesis (#4188 Phase 4).
+ *
+ * "Synthesize style" on the universe's mood-board tool: runs the linked
+ * board's collected content (notes, captions, per-item analyses) through a
+ * user-picked API LLM, previews the proposed style guide as the shared
+ * StyleDiffPreview against the CURRENT draft values, and "Adopt" hands the
+ * proposal to the caller (`onAdopt` — the draft hook's queued-write adopt,
+ * which also advances the saved-style bookkeeping). Never a client wholesale
+ * `influences` PATCH. Locks are honored at proposal time and re-checked
+ * server-side on adopt.
+ */
+
+import { useEffect, useState } from 'react';
+import { Loader2, Sparkles, Wand2, X } from 'lucide-react';
+import { synthesizeMoodBoardStyle } from '../../services/api';
+import useMounted from '../../hooks/useMounted';
+import useProviderModels from '../../hooks/useProviderModels';
+import ProviderModelSelector from '../ProviderModelSelector';
+import Modal from '../ui/Modal';
+import toast from '../ui/Toast';
+import StyleDiffPreview from './StyleDiffPreview';
+
+const apiProviderFilter = (p) => p.enabled && p.type === 'api';
+
+// Inner body so the provider fetch (useProviderModels mounts it) is deferred
+// until the modal actually opens. The parent keys this by universe+board, so
+// switching targets remounts it and a stale proposal can never be adopted
+// into a different universe.
+function SynthesisBody({ boardId, styleNotes, influences, locked, onAdopt, onBusyChange, onClose }) {
+ const [result, setResult] = useState(null);
+ const [running, setRunning] = useState(false);
+ const [adopting, setAdopting] = useState(false);
+ const mountedRef = useMounted();
+ const {
+ providers,
+ selectedProviderId,
+ selectedModel,
+ availableModels,
+ setSelectedProviderId,
+ setSelectedModel,
+ loading: providersLoading,
+ } = useProviderModels({ filter: apiProviderFilter, silent: true });
+
+ const busy = running || adopting;
+
+ // The parent gates modal dismissal (backdrop/Escape/close button) on this,
+ // so a run can't be silently orphaned mid-flight. Cleared on unmount.
+ useEffect(() => {
+ onBusyChange?.(busy);
+ return () => onBusyChange?.(false);
+ }, [busy, onBusyChange]);
+
+ const synthesize = async () => {
+ if (!selectedProviderId || busy) return;
+ setRunning(true);
+ const data = await synthesizeMoodBoardStyle(boardId, {
+ styleNotes: styleNotes || '',
+ influences: influences || {},
+ locked: locked || {},
+ providerId: selectedProviderId,
+ model: selectedModel || undefined,
+ }, { silent: true }).catch((error) => {
+ if (mountedRef.current) toast.error(`Style synthesis failed: ${error.message}`);
+ return null;
+ });
+ if (!mountedRef.current) return;
+ setRunning(false);
+ if (data) setResult(data);
+ };
+
+ const adopt = async () => {
+ if (!result?.proposed || busy) return;
+ setAdopting(true);
+ const ok = await onAdopt?.({
+ styleNotes: result.proposed.styleNotes || '',
+ influences: result.proposed.influences || {},
+ });
+ if (!mountedRef.current) return;
+ setAdopting(false);
+ // Force-close: the parent's busy gate reads its own state, which hasn't
+ // re-rendered from the setAdopting(false) above yet — a plain close()
+ // would still see busy and refuse.
+ if (ok) onClose(true);
+ };
+
+ return (
+
+
+
+
Synthesize style from mood board
+
+ Distills the board's notes, captions, and item analyses into a proposed style guide. Review the diff, then adopt.
+
+
+
+
+
+
+
+
+ {providers.length === 0 && !providersLoading ? (
+
No API providers are enabled. Add one in Settings → Providers.
+ ) : null}
+
+ {result ? (
+
+ ) : null}
+ {result?.context?.droppedItems ? (
+
+ {result.context.droppedItems} item{result.context.droppedItems === 1 ? '' : 's'} beyond the context limit were not considered.
+
+ ) : null}
+
+
+
+ Cancel
+
+
+ {running ? : }
+ {running ? 'Synthesizing…' : result ? 'Synthesize again' : 'Synthesize'}
+
+ {result ? (
+
+ {adopting ? : }
+ Adopt style
+
+ ) : null}
+
+
+ );
+}
+
+export default function MoodBoardStyleSynthesis({
+ boardId,
+ universeId,
+ styleNotes,
+ influences,
+ locked,
+ saved = false,
+ onAdopt,
+}) {
+ const [open, setOpen] = useState(false);
+ const [bodyBusy, setBodyBusy] = useState(false);
+ if (!boardId) return null;
+ // Guarded close: backdrop, Escape (Modal routes both here), and the body's
+ // Cancel/X are ignored mid-run so a request can't be silently orphaned.
+ // The body passes `force === true` after a successful adopt, where its own
+ // busy flag has just cleared but this component hasn't re-rendered yet.
+ const close = (force) => {
+ if (bodyBusy && force !== true) return;
+ setOpen(false);
+ };
+ return (
+
+ setOpen(true)}
+ disabled={!saved}
+ title={saved
+ ? 'Distill the linked mood board into the universe style guide'
+ : 'Save the universe before synthesizing its style'}
+ className="inline-flex min-h-[38px] items-center gap-1.5 rounded border border-port-accent/40 px-2.5 py-1.5 text-xs text-port-accent hover:bg-port-accent/10 disabled:opacity-50"
+ >
+
+ Synthesize style
+
+ Board notes + analyses → style prompt, negative prompt, style notes.
+
+ {open ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/client/src/components/universeBuilder/MoodBoardStyleSynthesis.test.jsx b/client/src/components/universeBuilder/MoodBoardStyleSynthesis.test.jsx
new file mode 100644
index 0000000000..eb3508fc9a
--- /dev/null
+++ b/client/src/components/universeBuilder/MoodBoardStyleSynthesis.test.jsx
@@ -0,0 +1,123 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import MoodBoardStyleSynthesis from './MoodBoardStyleSynthesis';
+
+const apiMocks = vi.hoisted(() => ({
+ synthesizeMoodBoardStyle: vi.fn(),
+}));
+vi.mock('../../services/api', () => ({ ...apiMocks }));
+vi.mock('../../hooks/useProviderModels', () => ({
+ default: vi.fn(() => ({
+ providers: [{ id: 'ollama', name: 'Ollama', type: 'api', enabled: true }],
+ selectedProviderId: 'ollama',
+ selectedModel: 'qwen',
+ availableModels: ['qwen'],
+ setSelectedProviderId: vi.fn(),
+ setSelectedModel: vi.fn(),
+ loading: false,
+ })),
+}));
+vi.mock('../ui/Toast', () => ({ default: { error: vi.fn(), success: vi.fn() } }));
+
+const synthesis = {
+ proposed: {
+ styleNotes: 'Tactile ink-wash science fiction.',
+ influences: { embrace: ['ink wash'], avoid: ['gloss'] },
+ },
+ diff: {
+ hasChanges: true,
+ styleNotes: { before: 'Clean vector art', after: 'Tactile ink-wash science fiction.', changed: true },
+ influences: {
+ embrace: { changed: true, added: ['ink wash'], removed: ['clean vectors'] },
+ avoid: { changed: true, added: ['gloss'], removed: ['grain'] },
+ },
+ },
+ rationale: 'The board trades polish for tactile marks.',
+ context: { items: 3, droppedItems: 0 },
+ llm: { provider: 'ollama', model: 'qwen' },
+};
+
+const baseProps = {
+ boardId: 'mb-1',
+ universeId: 'u1',
+ styleNotes: 'Clean vector art',
+ influences: { embrace: ['clean vectors'], avoid: ['grain'] },
+ locked: { influencesAvoid: true },
+ saved: true,
+};
+
+const renderPanel = (props = {}) => render(
+ ,
+);
+
+const runSynthesis = async () => {
+ fireEvent.click(screen.getByRole('button', { name: /synthesize style/i }));
+ fireEvent.click(screen.getByRole('button', { name: /^synthesize$/i }));
+ await waitFor(() => expect(screen.getByText('Style guide preview')).toBeInTheDocument());
+};
+
+describe('MoodBoardStyleSynthesis (#4188 Phase 4)', () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it('renders nothing without a linked board and disables the trigger until saved', () => {
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+
+ renderPanel({ saved: false });
+ expect(screen.getByRole('button', { name: /synthesize style/i })).toBeDisabled();
+ });
+
+ it('synthesizes with the draft style context, previews the diff, and adopts through the caller', async () => {
+ apiMocks.synthesizeMoodBoardStyle.mockResolvedValue(synthesis);
+ const onAdopt = vi.fn().mockResolvedValue(true);
+
+ renderPanel({ onAdopt });
+ await runSynthesis();
+
+ expect(apiMocks.synthesizeMoodBoardStyle).toHaveBeenCalledWith('mb-1', {
+ styleNotes: 'Clean vector art',
+ influences: { embrace: ['clean vectors'], avoid: ['grain'] },
+ locked: { influencesAvoid: true },
+ providerId: 'ollama',
+ model: 'qwen',
+ }, { silent: true });
+ expect(screen.getByText('+ ink wash')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: /adopt style/i }));
+ await waitFor(() => {
+ expect(onAdopt).toHaveBeenCalledWith({
+ styleNotes: 'Tactile ink-wash science fiction.',
+ influences: { embrace: ['ink wash'], avoid: ['gloss'] },
+ });
+ });
+ // A successful adopt closes the modal (force path — the parent's busy
+ // gate hasn't re-rendered yet when the body requests the close).
+ await waitFor(() => {
+ expect(screen.queryByText('Style guide preview')).toBeNull();
+ });
+ });
+
+ it('disables Adopt when the proposal matches the current guidance', async () => {
+ apiMocks.synthesizeMoodBoardStyle.mockResolvedValue({
+ ...synthesis,
+ diff: { ...synthesis.diff, hasChanges: false },
+ });
+ renderPanel();
+ await runSynthesis();
+ expect(screen.getByRole('button', { name: /adopt style/i })).toBeDisabled();
+ });
+
+ it('discards a stale proposal when the target universe changes (remount by key)', async () => {
+ apiMocks.synthesizeMoodBoardStyle.mockResolvedValue(synthesis);
+ const { rerender } = renderPanel();
+ await runSynthesis();
+ expect(screen.getByRole('button', { name: /adopt style/i })).toBeInTheDocument();
+
+ // Navigating to a different universe while the modal is open must wipe
+ // the proposal — adopting universe A's synthesis into B would silently
+ // copy one universe's style into another.
+ rerender( );
+ expect(screen.queryByRole('button', { name: /adopt style/i })).toBeNull();
+ expect(screen.queryByText('Style guide preview')).toBeNull();
+ });
+});
diff --git a/client/src/components/universeBuilder/StyleDiffPreview.jsx b/client/src/components/universeBuilder/StyleDiffPreview.jsx
new file mode 100644
index 0000000000..5929c88503
--- /dev/null
+++ b/client/src/components/universeBuilder/StyleDiffPreview.jsx
@@ -0,0 +1,64 @@
+/**
+ * Style-guide diff preview — renders the `{ diff, rationale }` shape the
+ * server's `buildStyleReferenceDiff` produces (style-reference analysis and
+ * mood-board style synthesis both return it), so every "review before adopt"
+ * flow shows the same before/after. Extracted from UniverseStyleReferences
+ * (#4188 Phase 4).
+ */
+
+function TokenDiff({ label, diff, tone }) {
+ if (!diff?.changed) return null;
+ const addedClass = tone === 'positive' ? 'text-port-success' : 'text-port-error';
+ return (
+
+
{label}
+
+ {diff.removed.map((token) => (
+
+ {token}
+
+ ))}
+ {diff.added.map((token) => (
+
+ + {token}
+
+ ))}
+
+
+ );
+}
+
+export default function StyleDiffPreview({ analysis, description = 'Review this diff before deciding whether the reference should update the universe.' }) {
+ const diff = analysis?.diff;
+ if (!diff) return null;
+ return (
+
+
+
Style guide preview
+
{description}
+
+ {!diff.hasChanges ? (
+ The current guidance already matches this reference.
+ ) : null}
+ {diff.styleNotes?.changed ? (
+
+
+
Current style notes
+
+ {diff.styleNotes.before || 'None'}
+
+
+
+
Proposed style notes
+
+ {diff.styleNotes.after || 'Clear style notes'}
+
+
+
+ ) : null}
+
+
+ {analysis.rationale ? {analysis.rationale}
: null}
+
+ );
+}
diff --git a/client/src/components/universeBuilder/UniverseBibleTab.jsx b/client/src/components/universeBuilder/UniverseBibleTab.jsx
index ba62948516..17a8f7c13c 100644
--- a/client/src/components/universeBuilder/UniverseBibleTab.jsx
+++ b/client/src/components/universeBuilder/UniverseBibleTab.jsx
@@ -14,6 +14,7 @@ import MoodBoardReferenceStrip from '../moodBoard/MoodBoardReferenceStrip';
import StyleProbeImage from '../universe/StyleProbeImage';
import VisionProviderPicker from '../universe/VisionProviderPicker';
import InfluenceChipsInput from './InfluenceChipsInput';
+import MoodBoardStyleSynthesis from './MoodBoardStyleSynthesis';
import UniverseStyleReferences from './UniverseStyleReferences';
function LockButton({ field, locked, onToggle, label }) {
@@ -111,6 +112,7 @@ export default function BibleTab({
saved = false,
onPersistStyleReference,
onRemoveStyleReference,
+ onAdoptStyleGuide,
}) {
const { providers, providerModels, providerLabel, activeProviderId } = llm;
const {
@@ -176,6 +178,21 @@ export default function BibleTab({
newBoardName={draft.name?.trim() || ''}
/>
+ {/* Board → style synthesis (#4188 Phase 4): distill the linked board
+ into the style guide. Adoption goes through the draft hook
+ (adoptStyleGuideFromBoard), which runs the server-side queued
+ write AND the same saved-snapshot/watermark bookkeeping as an
+ art-reference adopt — so styleProbeDirty clears correctly. */}
+
+
)}
diff --git a/client/src/components/universeBuilder/UniverseStyleReferences.jsx b/client/src/components/universeBuilder/UniverseStyleReferences.jsx
index 3f6f2033a2..175c1d99f5 100644
--- a/client/src/components/universeBuilder/UniverseStyleReferences.jsx
+++ b/client/src/components/universeBuilder/UniverseStyleReferences.jsx
@@ -8,69 +8,11 @@ import GalleryImagePicker from '../imageGen/GalleryImagePicker';
import Modal from '../ui/Modal';
import toast from '../ui/Toast';
import VisionProviderPicker from '../universe/VisionProviderPicker';
+import StyleDiffPreview from './StyleDiffPreview';
const TITLE_MAX = 120;
const PROMPT_MAX = 4000;
-function TokenDiff({ label, diff, tone }) {
- if (!diff?.changed) return null;
- const addedClass = tone === 'positive' ? 'text-port-success' : 'text-port-error';
- return (
-
-
{label}
-
- {diff.removed.map((token) => (
-
- {token}
-
- ))}
- {diff.added.map((token) => (
-
- + {token}
-
- ))}
-
-
- );
-}
-
-function StyleDiff({ analysis }) {
- const diff = analysis?.diff;
- if (!diff) return null;
- return (
-
-
-
Style guide preview
-
- Review this diff before deciding whether the reference should update the universe.
-
-
- {!diff.hasChanges ? (
- The current guidance already matches this reference.
- ) : null}
- {diff.styleNotes?.changed ? (
-
-
-
Current style notes
-
- {diff.styleNotes.before || 'None'}
-
-
-
-
Proposed style notes
-
- {diff.styleNotes.after || 'Clear style notes'}
-
-
-
- ) : null}
-
-
- {analysis.rationale ? {analysis.rationale}
: null}
-
- );
-}
-
export default function UniverseStyleReferences({
universe,
saved,
@@ -244,7 +186,7 @@ export default function UniverseStyleReferences({
-
+
Cancel
diff --git a/client/src/hooks/useUniverseDraft.js b/client/src/hooks/useUniverseDraft.js
index b3afde840c..724e1699bd 100644
--- a/client/src/hooks/useUniverseDraft.js
+++ b/client/src/hooks/useUniverseDraft.js
@@ -3,6 +3,7 @@ import useMounted from './useMounted';
import toast from '../components/ui/Toast';
import {
addUniverseStyleReference,
+ adoptUniverseStyleGuide,
createUniverse,
deleteUniverse,
getProviders,
@@ -496,6 +497,32 @@ export default function useUniverseDraft({ selectedId, goToWorld }) {
return true;
}, [applyStyleReferenceResult, draft, selectedId]);
+ // Adopt a board-synthesized style guide (#4188 Phase 4) — the reference-less
+ // sibling of the persistStyleReference adopt path. Routes through the same
+ // applyStyleReferenceResult bookkeeping so the saved snapshot + update
+ // watermark advance and styleProbeDirty clears, exactly as an art-reference
+ // adopt does; the server re-checks field locks in its queued write.
+ const adoptStyleGuideFromBoard = useCallback(async (proposed) => {
+ if (!selectedId) return false;
+ const targetId = selectedId;
+ const current = draftRef.current || draft;
+ const capturedStyle = {
+ styleNotes: current.styleNotes || '',
+ influences: ensureInfluences(current.influences),
+ };
+ const updated = await adoptUniverseStyleGuide(targetId, {
+ styleNotes: proposed?.styleNotes || '',
+ influences: ensureInfluences(proposed?.influences),
+ }, { silent: true }).catch((error) => {
+ toast.error(`Adopting the style guide failed: ${error.message}`);
+ return null;
+ });
+ if (!updated) return false;
+ applyStyleReferenceResult(targetId, updated, capturedStyle);
+ toast.success('Style guide adopted from mood board');
+ return true;
+ }, [applyStyleReferenceResult, draft, selectedId]);
+
// Remove one art reference by id. Also a delta, so back-to-back removals need
// no client-side queue — the server's record write queue serializes them.
const removeStyleReference = useCallback(async (referenceId) => {
@@ -719,6 +746,7 @@ export default function useUniverseDraft({ selectedId, goToWorld }) {
// Exposed so a consumer can retry the catalog/settings load after a failed
// refresh instead of forcing a full page reload.
refresh,
+ adoptStyleGuideFromBoard,
persistStyleReference,
removeCategory,
removeStyleReference,
diff --git a/client/src/services/apiMoodBoard.js b/client/src/services/apiMoodBoard.js
index afcc3943d6..e86828dc7b 100644
--- a/client/src/services/apiMoodBoard.js
+++ b/client/src/services/apiMoodBoard.js
@@ -46,6 +46,19 @@ export const removeMoodBoardItem = (id, itemId, options) =>
...options,
});
+// Board → universe style synthesis (#4188 Phase 4). Stateless review step:
+// sends the universe's CURRENT style context (styleNotes/influences/locked)
+// plus the chosen LLM; resolves to `{ proposed, diff, rationale, llm }`.
+// Adoption goes through adoptUniverseStyleGuide (apiUniverseBuilder.js).
+export const synthesizeMoodBoardStyle = (id, {
+ styleNotes, influences, locked, providerId, model,
+} = {}, options = {}) =>
+ request(`/mood-boards/${encodeURIComponent(id)}/synthesize-style`, {
+ method: 'POST',
+ body: JSON.stringify({ styleNotes, influences, locked, providerId, model }),
+ ...options,
+ });
+
// Pinterest importer: link a board to a Pinterest board URL, unlink, and run a
// manual "Sync now" that pulls new pins (download + dedupe) server-side.
export const linkMoodBoardPinterest = (id, url, options) =>
diff --git a/client/src/services/apiUniverseBuilder.js b/client/src/services/apiUniverseBuilder.js
index 560a019eba..68eb21da1c 100644
--- a/client/src/services/apiUniverseBuilder.js
+++ b/client/src/services/apiUniverseBuilder.js
@@ -61,6 +61,15 @@ export const removeUniverseStyleReference = (id, referenceId, options = {}) => r
{ method: 'DELETE', ...options },
);
+// Adopt a proposed style guide with no reference record attached (#4188
+// Phase 4 — mood-board synthesis). Server-side queued write; locks are
+// re-checked against the freshest persisted record. Resolves with the full
+// updated universe.
+export const adoptUniverseStyleGuide = (id, { styleNotes, influences } = {}, options = {}) => request(
+ `/universe-builder/${encodeURIComponent(id)}/adopt-style`,
+ { method: 'POST', body: JSON.stringify({ styleNotes, influences }), ...options },
+);
+
export const expandUniverse = ({
starterPrompt, influences,
preservedVariations, preservedCompositeSheets,
diff --git a/server/routes/moodBoard.js b/server/routes/moodBoard.js
index 8615a67acb..33948ace2d 100644
--- a/server/routes/moodBoard.js
+++ b/server/routes/moodBoard.js
@@ -8,6 +8,7 @@
*/
import { Router } from 'express';
+import { z } from 'zod';
import { asyncHandler, ServerError } from '../lib/errorHandler.js';
import {
validateRequest,
@@ -19,6 +20,9 @@ import {
isPaginationRequested,
paginateArray,
} from '../lib/validation.js';
+import { influencesSchema, lockedSchema } from './universeBuilder/shared.js';
+import { synthesizeBoardStyle } from '../services/moodBoardStyleSynthesis.js';
+import { STYLE_NOTES_MAX } from '../services/universeBuilder.js';
import {
listBoards,
getBoard,
@@ -87,6 +91,25 @@ router.delete('/:id/items/:itemId', asyncHandler(async (req, res) => {
res.json(board);
}));
+// Board → universe style synthesis (#4188 Phase 4). Stateless like
+// /analyze-style-reference: the client sends the universe's CURRENT style
+// context (draft values — possibly unsaved), the server reads the board and
+// returns a proposal + diff. Persistence happens only through the universe's
+// queued-write adopt endpoint after the user reviews the diff.
+const synthesizeStyleSchema = z.object({
+ styleNotes: z.string().trim().max(STYLE_NOTES_MAX).optional().default(''),
+ influences: influencesSchema.optional().default({ embrace: [], avoid: [] }),
+ locked: lockedSchema.optional().default({}),
+ providerId: z.string().trim().max(80).optional(),
+ model: z.string().trim().max(200).optional(),
+}).strict();
+router.post('/:id/synthesize-style', asyncHandler(async (req, res) => {
+ const body = validateRequest(synthesizeStyleSchema, req.body ?? {});
+ const board = await getBoard(req.params.id);
+ if (!board) throw new ServerError('Mood board not found', { status: 404, code: 'NOT_FOUND' });
+ res.json(await synthesizeBoardStyle({ board, ...body }));
+}));
+
// Link the board to a public Pinterest board's RSS feed.
router.put('/:id/pinterest', asyncHandler(async (req, res) => {
const data = validateRequest(moodBoardPinterestLinkSchema, req.body);
diff --git a/server/routes/moodBoard.test.js b/server/routes/moodBoard.test.js
index 362d4980ee..39e5ccc323 100644
--- a/server/routes/moodBoard.test.js
+++ b/server/routes/moodBoard.test.js
@@ -19,7 +19,14 @@ vi.mock('../services/moodBoard/index.js', () => ({
syncPinterestBoard: vi.fn(),
}));
+// The synthesis service pulls the aiProvider/promptRunner stack — stub it so
+// this stays a routing test (service behavior is covered in its own suite).
+vi.mock('../services/moodBoardStyleSynthesis.js', () => ({
+ synthesizeBoardStyle: vi.fn(),
+}));
+
import * as svc from '../services/moodBoard/index.js';
+import { synthesizeBoardStyle } from '../services/moodBoardStyleSynthesis.js';
import moodBoardRoutes from './moodBoard.js';
const makeApp = () => {
@@ -70,4 +77,43 @@ describe('mood-board routes', () => {
expect(res.body.id).toBe('mb-1');
});
});
+
+ describe('POST /:id/synthesize-style (#4188 Phase 4)', () => {
+ it('404s when the board is missing', async () => {
+ svc.getBoard.mockResolvedValueOnce(null);
+ const res = await request(makeApp()).post('/api/mood-boards/nope/synthesize-style').send({});
+ expect(res.status).toBe(404);
+ expect(synthesizeBoardStyle).not.toHaveBeenCalled();
+ });
+
+ it('passes the board and the validated style context to the service', async () => {
+ const board = { id: 'mb-1', name: 'A', items: [] };
+ svc.getBoard.mockResolvedValueOnce(board);
+ synthesizeBoardStyle.mockResolvedValueOnce({ proposed: {}, diff: { hasChanges: false } });
+ const res = await request(makeApp()).post('/api/mood-boards/mb-1/synthesize-style').send({
+ styleNotes: 'current',
+ influences: { embrace: ['a'], avoid: [] },
+ locked: { influencesAvoid: true },
+ providerId: 'ollama',
+ model: 'qwen',
+ });
+ expect(res.status).toBe(200);
+ expect(synthesizeBoardStyle).toHaveBeenCalledWith({
+ board,
+ styleNotes: 'current',
+ influences: { embrace: ['a'], avoid: [] },
+ locked: { influencesAvoid: true },
+ providerId: 'ollama',
+ model: 'qwen',
+ });
+ });
+
+ it('400s on an unknown body key (strict schema)', async () => {
+ const res = await request(makeApp()).post('/api/mood-boards/mb-1/synthesize-style').send({
+ universeId: 'w-1',
+ });
+ expect(res.status).toBe(400);
+ expect(synthesizeBoardStyle).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/server/routes/universeBuilder/styleReferences.js b/server/routes/universeBuilder/styleReferences.js
index 1c4efcdb3e..d147beb828 100644
--- a/server/routes/universeBuilder/styleReferences.js
+++ b/server/routes/universeBuilder/styleReferences.js
@@ -75,6 +75,22 @@ router.post('/:id/style-references', asyncHandler(async (req, res) => {
res.json(w);
}));
+// Adopt a style guide with NO reference record attached (#4188 Phase 4: the
+// mood-board synthesis flow proposes styleNotes + influences from board
+// content, not from a single image). Same queued-write semantics as the
+// `adopt` half of the add-reference endpoint; locks are re-checked server-side
+// against the freshest persisted record.
+const adoptStyleSchema = z.object({
+ styleNotes: z.string().trim().max(svc.STYLE_NOTES_MAX).optional().default(''),
+ influences: influencesSchema.optional().default({ embrace: [], avoid: [] }),
+}).strict();
+router.post('/:id/adopt-style', asyncHandler(async (req, res) => {
+ const body = validateRequest(adoptStyleSchema, req.body ?? {});
+ const w = await svc.adoptStyleGuide(req.params.id, body)
+ .catch((err) => { throw mapServiceError(err); });
+ res.json(w);
+}));
+
// The id is only ever compared against stored reference ids (never used as a
// path/SQL operand), but validate it anyway so an absurdly long param is a 400
// here rather than a silent no-op deeper in.
diff --git a/server/services/moodBoardStyleSynthesis.js b/server/services/moodBoardStyleSynthesis.js
new file mode 100644
index 0000000000..2b060ee046
--- /dev/null
+++ b/server/services/moodBoardStyleSynthesis.js
@@ -0,0 +1,193 @@
+/**
+ * Mood board → universe style synthesis (#4188 Phase 4).
+ *
+ * Stateless, mirroring `analyzeUniverseStyleReference`: one text LLM run over
+ * the board's collected content (description, text notes, captions, and the
+ * per-item prompt-from-media analyses Phase 3 persists), proposing
+ * `{ styleNotes, influences: { embrace, avoid } }` shaped for the universe
+ * style guide, plus the same diff the style-reference review step renders.
+ * Nothing is persisted here — the client previews the diff and adoption goes
+ * through the universe's queued-write adopt endpoint.
+ */
+
+import { parseLLMJSON, resolveAPIProvider } from '../lib/aiProvider.js';
+import { ServerError } from '../lib/errorHandler.js';
+import { assertProvider, runPromptThroughProvider } from '../lib/promptRunner.js';
+import { trimTo } from '../lib/storyBible.js';
+import {
+ sanitizeInfluences,
+ sanitizeLocked,
+ STYLE_NOTES_MAX,
+} from './universeBuilder.js';
+import { buildStyleReferenceDiff } from './universeStyleReference.js';
+
+// Context bounds: a board caps at 500 items, but the synthesis context must
+// stay well inside a chat-completion window. Items are taken in board order
+// (the user's curation order); fragments beyond either cap are dropped and
+// the count is reported in the result so the UI can say so. The aggregate
+// character budget is the load-bearing one — 60 items can each carry four
+// 600-char fields, far past what a small local model's window fits — and it
+// is a FIXED conservative budget (≈6k tokens) rather than model-aware:
+// provider window metadata isn't reliably known here, and a proposal
+// synthesized from the first N curated items beats a request the model
+// truncates or rejects.
+const CONTEXT_ITEMS_MAX = 60;
+const CONTEXT_FIELD_MAX = 600;
+const CONTEXT_TOTAL_CHARS_MAX = 24000;
+const RATIONALE_MAX = 1000;
+
+/**
+ * Reduce a board to the style-relevant text fragments the LLM sees. An item
+ * contributes only what it actually carries: a text note, a caption, and/or a
+ * persisted analysis (prompt + negative + rationale). Media items without any
+ * of those contribute nothing — synthesis reads text, not pixels (analyzing
+ * an item is Phase 3's explicit per-item vision step).
+ */
+export function collectBoardStyleContext(board) {
+ const items = Array.isArray(board?.items) ? board.items : [];
+ const fragments = [];
+ let dropped = 0;
+ let totalChars = 0;
+ for (const it of items) {
+ if (!it || typeof it !== 'object') continue;
+ const entry = {};
+ if (it.type === 'text' && typeof it.text === 'string' && it.text.trim()) {
+ entry.note = trimTo(it.text, CONTEXT_FIELD_MAX);
+ }
+ if (typeof it.caption === 'string' && it.caption.trim()) {
+ entry.caption = trimTo(it.caption, CONTEXT_FIELD_MAX);
+ }
+ const analysis = it.analysis;
+ if (analysis && typeof analysis === 'object' && typeof analysis.prompt === 'string' && analysis.prompt.trim()) {
+ entry.analyzedPrompt = trimTo(analysis.prompt, CONTEXT_FIELD_MAX);
+ if (typeof analysis.negativePrompt === 'string' && analysis.negativePrompt.trim()) {
+ entry.analyzedNegative = trimTo(analysis.negativePrompt, CONTEXT_FIELD_MAX);
+ }
+ if (typeof analysis.rationale === 'string' && analysis.rationale.trim()) {
+ entry.analysisRationale = trimTo(analysis.rationale, CONTEXT_FIELD_MAX);
+ }
+ }
+ if (!Object.keys(entry).length) continue;
+ const entrySize = Object.values(entry).reduce((sum, v) => sum + v.length, 0);
+ if (fragments.length >= CONTEXT_ITEMS_MAX || totalChars + entrySize > CONTEXT_TOTAL_CHARS_MAX) {
+ dropped += 1;
+ continue;
+ }
+ totalChars += entrySize;
+ fragments.push({ kind: it.type, ...entry });
+ }
+ return {
+ name: trimTo(board?.name, 200) || null,
+ description: trimTo(board?.description, 2000) || null,
+ items: fragments,
+ droppedItems: dropped,
+ };
+}
+
+export function buildBoardStyleSynthesisPrompt({ context, styleNotes, influences, locked }) {
+ const payload = JSON.stringify({
+ board: context,
+ currentStyleNotes: trimTo(styleNotes, STYLE_NOTES_MAX),
+ currentGuidance: sanitizeInfluences(influences),
+ locked: sanitizeLocked(locked),
+ });
+ return `Synthesize a UNIVERSE VISUAL STYLE GUIDE from the mood board below. The board collects a user's curated inspiration: text notes, image/video captions, and per-item AI analyses (render prompts reverse-engineered from the pinned media).
+
+Distill the board into renderable visual style guidance: medium, line or brush treatment, shapes, texture, palette, lighting, composition, era, mood, and finish. Find the through-line across the items — what this board consistently embraces and what it consistently avoids — rather than describing any single item. Do not invent story facts, named characters, locations, or copyrighted-artist attribution.
+
+Mood board and current universe context:
+${payload}
+
+Return JSON only:
+{
+ "styleNotes": "complete proposed replacement for currentStyleNotes, prose",
+ "influences": {
+ "embrace": ["complete ordered positive style-token list"],
+ "avoid": ["complete ordered negative style-token list"]
+ },
+ "rationale": "one concise explanation of the synthesized direction"
+}
+
+Honor every locked field: styleNotes, influencesEmbrace, or influencesAvoid must remain equivalent to the corresponding current value when locked. Preserve useful current guidance that does not conflict with the board. An empty array is a valid intentional recommendation.`;
+}
+
+export async function synthesizeBoardStyle({
+ board,
+ styleNotes,
+ influences,
+ locked,
+ providerId,
+ model,
+} = {}) {
+ const context = collectBoardStyleContext(board);
+ if (!context.description && !context.items.length) {
+ throw new ServerError(
+ 'This board has nothing to synthesize from yet — analyze some items, add captions or notes, or give the board a description first.',
+ { status: 400, code: 'NOTHING_TO_SYNTHESIZE' },
+ );
+ }
+
+ const provider = await resolveAPIProvider(providerId);
+ assertProvider(provider, {
+ message: 'Synthesizing a style guide needs an API-based provider. Configure one under Settings → Providers.',
+ code: 'NO_API_PROVIDER',
+ status: 503,
+ });
+
+ const result = await runPromptThroughProvider({
+ provider,
+ prompt: buildBoardStyleSynthesisPrompt({ context, styleNotes, influences, locked }),
+ source: 'mood-board-style-synthesis',
+ model: model || undefined,
+ });
+ let parsed;
+ try {
+ parsed = parseLLMJSON(result.text || '');
+ } catch (error) {
+ throw new ServerError(`The model returned invalid style synthesis: ${error.message}`, {
+ status: 502,
+ code: 'SYNTHESIS_BAD_JSON',
+ });
+ }
+
+ const currentInfluences = sanitizeInfluences(influences);
+ const safeLocked = sanitizeLocked(locked);
+ const parsedInfluences = parsed?.influences && typeof parsed.influences === 'object'
+ ? parsed.influences
+ : {};
+ // Locked fields keep their current value verbatim regardless of what the
+ // model proposed — the same belt the style-reference analyzer wears; the
+ // adopt write re-checks locks against the freshest persisted record.
+ const proposed = {
+ styleNotes: safeLocked.styleNotes
+ ? trimTo(styleNotes, STYLE_NOTES_MAX)
+ : (typeof parsed?.styleNotes === 'string'
+ ? trimTo(parsed.styleNotes, STYLE_NOTES_MAX)
+ : trimTo(styleNotes, STYLE_NOTES_MAX)),
+ influences: {
+ embrace: safeLocked.influencesEmbrace
+ ? currentInfluences.embrace
+ : (Array.isArray(parsedInfluences.embrace)
+ ? sanitizeInfluences({ embrace: parsedInfluences.embrace }).embrace
+ : currentInfluences.embrace),
+ avoid: safeLocked.influencesAvoid
+ ? currentInfluences.avoid
+ : (Array.isArray(parsedInfluences.avoid)
+ ? sanitizeInfluences({ avoid: parsedInfluences.avoid }).avoid
+ : currentInfluences.avoid),
+ },
+ };
+
+ return {
+ proposed,
+ diff: buildStyleReferenceDiff({ styleNotes, influences: currentInfluences }, proposed),
+ rationale: trimTo(parsed?.rationale, RATIONALE_MAX),
+ context: { items: context.items.length, droppedItems: context.droppedItems },
+ llm: {
+ provider: result.provider?.id || provider.id,
+ model: result.model || null,
+ },
+ };
+}
+
+export const __testing = { collectBoardStyleContext, buildBoardStyleSynthesisPrompt };
diff --git a/server/services/moodBoardStyleSynthesis.test.js b/server/services/moodBoardStyleSynthesis.test.js
new file mode 100644
index 0000000000..fa66e6b55b
--- /dev/null
+++ b/server/services/moodBoardStyleSynthesis.test.js
@@ -0,0 +1,169 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('../lib/aiProvider.js', async (importActual) => {
+ const actual = await importActual();
+ return { ...actual, resolveAPIProvider: vi.fn() };
+});
+vi.mock('../lib/promptRunner.js', async (importActual) => {
+ const actual = await importActual();
+ return { ...actual, runPromptThroughProvider: vi.fn() };
+});
+
+const aiProvider = await import('../lib/aiProvider.js');
+const promptRunner = await import('../lib/promptRunner.js');
+const {
+ collectBoardStyleContext,
+ buildBoardStyleSynthesisPrompt,
+ synthesizeBoardStyle,
+} = await import('./moodBoardStyleSynthesis.js');
+
+const apiProvider = { id: 'ollama', type: 'api', defaultModel: 'qwen' };
+const synthesisText = JSON.stringify({
+ styleNotes: 'Tactile ink-wash science fiction with restrained, dusty light.',
+ influences: {
+ embrace: ['granular ink wash', 'muted ochre'],
+ avoid: ['glossy 3D', 'neon'],
+ },
+ rationale: 'The board consistently trades polish for tactile marks.',
+});
+
+const boardWith = (items) => ({
+ id: 'mb-1',
+ name: 'Universe refs',
+ description: 'Dusty painted sci-fi.',
+ items,
+});
+
+const analyzedItem = {
+ id: 'i1',
+ type: 'image',
+ mediaKey: 'image:ref.png',
+ caption: 'palette anchor',
+ analysis: {
+ prompt: 'a weathered foundry in granular ink wash',
+ negativePrompt: 'gloss, neon',
+ rationale: 'muted, tactile look',
+ analyzedAt: '2026-08-14T00:00:00.000Z',
+ },
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ aiProvider.resolveAPIProvider.mockResolvedValue(apiProvider);
+ promptRunner.runPromptThroughProvider.mockResolvedValue({ text: synthesisText, model: 'qwen' });
+});
+
+describe('collectBoardStyleContext', () => {
+ it('gathers notes, captions, and persisted analyses; skips media items with neither', () => {
+ const ctx = collectBoardStyleContext(boardWith([
+ analyzedItem,
+ { id: 'i2', type: 'text', text: 'lean grim and spiritual', caption: null },
+ { id: 'i3', type: 'video', mediaKey: 'video:clip.mp4', caption: null, analysis: null },
+ ]));
+ expect(ctx.description).toBe('Dusty painted sci-fi.');
+ expect(ctx.items).toHaveLength(2);
+ expect(ctx.items[0]).toMatchObject({
+ kind: 'image',
+ caption: 'palette anchor',
+ analyzedPrompt: expect.stringContaining('granular ink wash'),
+ analyzedNegative: 'gloss, neon',
+ });
+ expect(ctx.items[1]).toMatchObject({ kind: 'text', note: 'lean grim and spiritual' });
+ expect(ctx.droppedItems).toBe(0);
+ });
+
+ it('caps the fragment list and reports the overflow', () => {
+ const many = Array.from({ length: 70 }, (_, i) => ({ id: `t${i}`, type: 'text', text: `note ${i}` }));
+ const ctx = collectBoardStyleContext(boardWith(many));
+ expect(ctx.items).toHaveLength(60);
+ expect(ctx.droppedItems).toBe(10);
+ });
+
+ it('bounds the AGGREGATE character budget, not just the fragment count', () => {
+ // 50 items × ~600 chars each ≈ 30k chars — under the 60-item cap but past
+ // the 24k aggregate budget, so the tail must be dropped.
+ const big = Array.from({ length: 50 }, (_, i) => ({ id: `t${i}`, type: 'text', text: 'x'.repeat(600) }));
+ const ctx = collectBoardStyleContext(boardWith(big));
+ expect(ctx.items.length).toBeLessThan(50);
+ expect(ctx.items.length).toBeGreaterThan(0);
+ expect(ctx.droppedItems).toBe(50 - ctx.items.length);
+ const total = ctx.items.reduce((sum, it) => sum + it.note.length, 0);
+ expect(total).toBeLessThanOrEqual(24000);
+ });
+});
+
+describe('synthesizeBoardStyle', () => {
+ it('400s a board with no synthesizable content', async () => {
+ await expect(synthesizeBoardStyle({
+ board: { id: 'mb-1', name: 'Empty', description: '', items: [{ id: 'i1', type: 'image', mediaKey: 'image:a.png' }] },
+ providerId: 'ollama',
+ })).rejects.toMatchObject({ code: 'NOTHING_TO_SYNTHESIZE', status: 400 });
+ expect(promptRunner.runPromptThroughProvider).not.toHaveBeenCalled();
+ });
+
+ it('synthesizes a proposal with a reviewable diff and feeds the board content to the prompt', async () => {
+ const result = await synthesizeBoardStyle({
+ board: boardWith([analyzedItem]),
+ styleNotes: 'Clean vector art',
+ influences: { embrace: ['clean vectors'], avoid: ['grain'] },
+ providerId: 'ollama',
+ model: 'qwen',
+ });
+
+ const sentPrompt = promptRunner.runPromptThroughProvider.mock.calls[0][0].prompt;
+ expect(sentPrompt).toContain('granular ink wash');
+ expect(sentPrompt).toContain('Dusty painted sci-fi.');
+
+ expect(result.proposed.styleNotes).toContain('Tactile ink-wash');
+ expect(result.proposed.influences.embrace).toEqual(['granular ink wash', 'muted ochre']);
+ expect(result.diff.hasChanges).toBe(true);
+ expect(result.diff.influences.embrace.added).toContain('granular ink wash');
+ expect(result.diff.influences.embrace.removed).toContain('clean vectors');
+ expect(result.rationale).toContain('tactile marks');
+ expect(result.llm).toMatchObject({ provider: 'ollama' });
+ expect(result.context).toEqual({ items: 1, droppedItems: 0 });
+ });
+
+ it('keeps locked fields at their current values regardless of the model output', async () => {
+ const result = await synthesizeBoardStyle({
+ board: boardWith([analyzedItem]),
+ styleNotes: 'Locked prose',
+ influences: { embrace: ['keep me'], avoid: [] },
+ locked: { styleNotes: true, influencesEmbrace: true },
+ providerId: 'ollama',
+ });
+ expect(result.proposed.styleNotes).toBe('Locked prose');
+ expect(result.proposed.influences.embrace).toEqual(['keep me']);
+ // The avoid list is unlocked, so the model's proposal applies.
+ expect(result.proposed.influences.avoid).toEqual(['glossy 3D', 'neon']);
+ });
+
+ it('502s when the model returns unparseable JSON', async () => {
+ promptRunner.runPromptThroughProvider.mockResolvedValue({ text: 'not json at all' });
+ await expect(synthesizeBoardStyle({
+ board: boardWith([analyzedItem]),
+ providerId: 'ollama',
+ })).rejects.toMatchObject({ code: 'SYNTHESIS_BAD_JSON', status: 502 });
+ });
+
+ it('503s when no API provider is configured', async () => {
+ aiProvider.resolveAPIProvider.mockResolvedValue(null);
+ await expect(synthesizeBoardStyle({
+ board: boardWith([analyzedItem]),
+ })).rejects.toMatchObject({ code: 'NO_API_PROVIDER', status: 503 });
+ });
+});
+
+describe('buildBoardStyleSynthesisPrompt', () => {
+ it('embeds current guidance and lock state so the model can honor them', () => {
+ const prompt = buildBoardStyleSynthesisPrompt({
+ context: { name: 'B', description: 'd', items: [], droppedItems: 0 },
+ styleNotes: 'current notes',
+ influences: { embrace: ['a'], avoid: ['b'] },
+ locked: { influencesAvoid: true },
+ });
+ expect(prompt).toContain('current notes');
+ expect(prompt).toContain('influencesAvoid');
+ expect(prompt).toContain('Return JSON only');
+ });
+});
diff --git a/server/services/universeBuilder.test.js b/server/services/universeBuilder.test.js
index 02c3468645..fa24f2e454 100644
--- a/server/services/universeBuilder.test.js
+++ b/server/services/universeBuilder.test.js
@@ -448,6 +448,36 @@ describe("universeBuilder service", () => {
expect(fresh.styleReferences).toHaveLength(1);
});
+ it("adoptStyleGuide writes the proposed guide with no reference attached (#4188 Phase 4)", async () => {
+ const w = await svc.createUniverse({ name: "Adopt Guide", influences: { embrace: ["old"], avoid: [] } });
+ const updated = await svc.adoptStyleGuide(w.id, {
+ styleNotes: "Board-synthesized prose",
+ influences: { embrace: ["ink wash"], avoid: ["gloss"] },
+ });
+ expect(updated.styleNotes).toBe("Board-synthesized prose");
+ expect(updated.influences).toEqual({ embrace: ["ink wash"], avoid: ["gloss"] });
+ const fresh = await svc.getUniverse(w.id);
+ expect(fresh.styleNotes).toBe("Board-synthesized prose");
+ expect(fresh.styleReferences ?? []).toHaveLength(0);
+ });
+
+ it("adoptStyleGuide re-checks locks against the freshest persisted record", async () => {
+ const w = await svc.createUniverse({
+ name: "Adopt Locked",
+ styleNotes: "Locked prose",
+ influences: { embrace: ["keep me"], avoid: ["old bad"] },
+ locked: { styleNotes: true, influencesEmbrace: true },
+ });
+ const updated = await svc.adoptStyleGuide(w.id, {
+ styleNotes: "Should not land",
+ influences: { embrace: ["should not land"], avoid: ["new bad"] },
+ });
+ // Locked fields keep the persisted values; the unlocked avoid list adopts.
+ expect(updated.styleNotes).toBe("Locked prose");
+ expect(updated.influences.embrace).toEqual(["keep me"]);
+ expect(updated.influences.avoid).toEqual(["new bad"]);
+ });
+
it("addStyleReference is idempotent on a re-sent id and rejects an invalid reference", async () => {
const reference = {
id: "style-ref-once",
diff --git a/server/services/universeBuilder/crud.js b/server/services/universeBuilder/crud.js
index 36b77798c7..e4e8dc934c 100644
--- a/server/services/universeBuilder/crud.js
+++ b/server/services/universeBuilder/crud.js
@@ -18,7 +18,7 @@ import {
import { store } from './storeFacade.js';
import {
sanitizeTemplate, sanitizeRun, sanitizeImageRefFilename, resolveInfluences,
- sanitizeStyleReference,
+ sanitizeStyleReference, sanitizeInfluences, sanitizeLocked, mergeInfluencesWithLocks,
makeErr, UNIVERSE_ID_RE,
ERR_NOT_FOUND, ERR_VALIDATION, ERR_DUPLICATE, ERR_HAS_LIVE_SERIES,
NAME_MAX_LENGTH, CURRENT_SCHEMA_VERSION, ENTRY_REF_KIND, IMAGE_REFS_PER_ENTRY_MAX,
@@ -751,6 +751,31 @@ export async function addStyleReference(id, reference, { adopt = null } = {}) {
}, { touchesCanon: false });
}
+/**
+ * Adopt a proposed style guide (styleNotes + influences) as one queued write —
+ * the standalone counterpart to `addStyleReference`'s `adopt` half, for flows
+ * with no reference record to add (mood-board style synthesis, #4188 Phase 4).
+ * Locks are enforced HERE against the freshest persisted record, not just at
+ * proposal time: a field the user locked after the proposal was generated
+ * keeps its current value (`mergeInfluencesWithLocks` for the lists; a locked
+ * styleNotes keeps the persisted prose).
+ */
+export async function adoptStyleGuide(id, { styleNotes, influences } = {}) {
+ return updateUniverse(id, (cur) => {
+ const locked = sanitizeLocked(cur.locked);
+ return {
+ styleNotes: locked.styleNotes
+ ? trimTo(cur.styleNotes, STYLE_NOTES_MAX)
+ : trimTo(styleNotes, STYLE_NOTES_MAX),
+ influences: mergeInfluencesWithLocks(
+ locked,
+ sanitizeInfluences(influences),
+ resolveInfluences(cur),
+ ),
+ };
+ }, { touchesCanon: false });
+}
+
/**
* Remove one art reference by id — the delta counterpart to
* `addStyleReference`, for the same reason (see its doc comment). Resolves with