Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions src/__tests__/collab/paletteBurstWrite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/**
* Installing a palette must be ONE mutation, and a refused write must be
* reported as a failure.
*
* `site_set_color_tokens` used to loop one `createFrameworkColorToken` per
* token. Each of those is its own site mutation, and any mutation that ensures
* a not-yet-bound collab doc registers a write gate at `synced: false` — so
* every remaining write in the same synchronous tick was refused with
* `syncing`. The action still returned a token object regardless, so the tool
* answered "14 tokens created" for a palette of which only a prefix reached
* the relay and the database. Colours the CSS then referenced simply did not
* exist, and nothing in the authoring flow said so.
*/
import { afterEach, describe, expect, it } from 'bun:test'
import * as Y from 'yjs'
import * as awarenessProtocol from 'y-protocols/awareness'
import {
connectCollabProvider,
disconnectCollabProvider,
} from '@site/store/slices/site/collabBinding'
import type {
BoundCollabDoc,
CollabProvider,
CollabResetListener,
} from '@site/collab/collabProvider'
import { clearCollabBlockNotice } from '@site/store/slices/site/collabNotices'
import { useEditorStore } from '@site/store/store'
import { runSetColorTokens } from '@site/agent/tokenRunners'
import '@modules/base/index'

/** Provider whose docs stay unsynced until the test releases them. */
function deferredProvider(): CollabProvider & { releaseAll: () => void } {
const presenceDoc = new Y.Doc()
const awareness = new awarenessProtocol.Awareness(presenceDoc)
const bound = new Map<string, BoundCollabDoc>()
const releases: Array<() => void> = []
const resetListeners = new Set<CollabResetListener>()
return {
bind: (docId) => {
let entry = bound.get(docId)
if (!entry) {
let release = (): void => {}
const whenSynced = new Promise<void>((resolve) => {
release = () => resolve()
})
entry = { doc: new Y.Doc(), synced: false, whenSynced }
releases.push(() => {
entry!.synced = true
release()
})
bound.set(docId, entry)
}
return entry
},
unbind: (docId) => {
bound.get(docId)?.doc.destroy()
bound.delete(docId)
},
awareness,
status: () => 'connected',
canSend: () => true,
reconnectNow: () => {},
onStatus: () => () => {},
onReset: (listener) => {
resetListeners.add(listener)
return () => resetListeners.delete(listener)
},
releaseAll: () => {
for (const release of releases) release()
},
destroy: () => {
awareness.destroy()
presenceDoc.destroy()
},
}
}

const PALETTE = Array.from({ length: 8 }, (_, i) => ({
slug: `hue-${i + 1}`,
lightValue: `hsla(${i * 40}, 60%, 50%, 1)`,
}))

function storedSlugs(): string[] {
return (useEditorStore.getState().site?.settings.framework?.colors.tokens ?? []).map(
(token) => token.slug,
)
}

afterEach(() => {
disconnectCollabProvider()
// These tests load a site into the shared editor store; leaving it behind
// leaks into every later suite that reads the same singleton.
useEditorStore.getState().clearSite()
// A refused write latches the "one toast per outage" flag in module scope.
// Left set, the next suite's first outage is silently swallowed.
clearCollabBlockNotice()
})

describe('installing a colour palette in one call', () => {
it('writes every token in a single mutation once docs are synced', async () => {
useEditorStore.getState().createSite('Palette Site')
const provider = deferredProvider()
connectCollabProvider(provider)
provider.releaseAll()
// Let the whenSynced promises settle so the gates are open.
await new Promise((resolve) => setTimeout(resolve, 10))

const result = useEditorStore.getState().upsertFrameworkColorTokens(PALETTE)

expect(result.accepted).toBe(true)
expect(result.tokens).toHaveLength(8)
expect(storedSlugs()).toEqual(PALETTE.map((t) => t.slug))
})

it('keeps slugs unique against tokens created earlier in the same batch', async () => {
useEditorStore.getState().createSite('Dedup Site')
const provider = deferredProvider()
connectCollabProvider(provider)
provider.releaseAll()
await new Promise((resolve) => setTimeout(resolve, 10))

// Two DIFFERENT entries normalizing to the same slug must not collapse:
// the second is a distinct token and gets a suffixed slug, exactly as a
// sequence of single-token calls would have produced.
useEditorStore.getState().upsertFrameworkColorTokens([
{ slug: 'brand', lightValue: 'hsla(0, 0%, 0%, 1)' },
{ slug: 'Brand Two', lightValue: 'hsla(0, 0%, 50%, 1)' },
])

expect(storedSlugs()).toEqual(['brand', 'brand-two'])
})

it('re-running the same palette updates in place instead of suffixing', async () => {
useEditorStore.getState().createSite('Rerun Site')
const provider = deferredProvider()
connectCollabProvider(provider)
provider.releaseAll()
await new Promise((resolve) => setTimeout(resolve, 10))

useEditorStore.getState().upsertFrameworkColorTokens(PALETTE)
const second = useEditorStore.getState().upsertFrameworkColorTokens(PALETTE)

expect(second.tokens.every((t) => t.action === 'updated')).toBe(true)
expect(storedSlugs()).toEqual(PALETTE.map((t) => t.slug))
})

it('reports a refused batch as an error instead of a list of created tokens', () => {
useEditorStore.getState().createSite('Blocked Site')
// Never released: every doc stays unsynced, so the write path refuses.
connectCollabProvider(deferredProvider())

const output = runSetColorTokens({ tokens: PALETTE })

expect(output.ok).toBe(false)
expect(String(output.error)).toContain('not saved')
expect(storedSlugs()).toEqual([])
})
})
57 changes: 39 additions & 18 deletions src/admin/pages/site/agent/tokenRunners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import {
} from '@core/ai'
import { apiRequest } from '@core/http'
import { getErrorMessage } from '@core/utils/errorMessage'
import { normalizeFrameworkColorSlug } from '@core/framework'
import { FontEntrySchema, normalizeFontTokenVariable } from '@core/fonts'
import type { EditorStore } from '@site/store/types'
import { getAgentStoreApi } from './storeRef'
Expand All @@ -46,6 +45,9 @@ const FontInstallResponseSchema = Type.Object({ font: FontEntrySchema })
// Runners
// ---------------------------------------------------------------------------

const SCALE_NOT_SAVED =
'The scale was not saved: the editor could not reach the collaboration server. Try again in a moment.'

/** Build the `--<prefix>-<step>` variable list a scale group generates. */
function generatedScaleVars(namingConvention: string, steps: string): string[] {
return steps
Expand All @@ -59,29 +61,29 @@ export function runSetColorTokens(rawInput: unknown): AiToolOutput {
const input = parseValue(SetColorTokensInputSchema, rawInput)
const store = getStoreState()
if (!store.site) return aiToolError('No active site.')
const results: Array<{ slug: string; ref: string; action: 'created' | 'updated' }> = []

for (const t of input.tokens) {
// Create-or-update by normalized slug so re-runs patch the existing token
// instead of minting `primary-2`.
const norm = normalizeFrameworkColorSlug(t.slug)
const existing = getStoreState().site?.settings.framework?.colors.tokens ?? []
const match = existing.find((e) => normalizeFrameworkColorSlug(e.slug) === norm)
const patch = {
// One mutation for the whole palette, not one per token. Create-or-update is
// keyed by normalized slug inside the batch, so re-runs patch the existing
// token instead of minting `primary-2`. Batching is a correctness
// requirement, not an optimization: a per-token loop lets the collab write
// gate close midway and silently drop every remaining token.
const { tokens, accepted } = store.upsertFrameworkColorTokens(
input.tokens.map((t) => ({
slug: t.slug,
lightValue: t.lightValue,
...(t.category !== undefined ? { category: t.category } : {}),
...(t.darkValue !== undefined ? { darkValue: t.darkValue } : {}),
...(t.darkModeEnabled !== undefined ? { darkModeEnabled: t.darkModeEnabled } : {}),
}
if (match) {
store.updateFrameworkColorToken(match.id, patch)
results.push({ slug: match.slug, ref: `var(--${match.slug})`, action: 'updated' })
} else {
const created = store.createFrameworkColorToken({ slug: t.slug, ...patch })
results.push({ slug: created.slug, ref: `var(--${created.slug})`, action: 'created' })
}
})),
)
if (!accepted) {
return aiToolError(
'Color tokens were not saved: the editor could not reach the collaboration server. Try again in a moment.',
)
}
return aiToolOk({ tokens: results })
return aiToolOk({
tokens: tokens.map((t) => ({ ...t, ref: `var(--${t.slug})` })),
})
}

export async function runSetFontTokens(rawInput: unknown): Promise<AiToolOutput> {
Expand Down Expand Up @@ -158,6 +160,19 @@ export async function runSetFontTokens(rawInput: unknown): Promise<AiToolOutput>
})
}
}

// Read back rather than trusting the writes: a refused mutation leaves the
// token absent while the loop above still recorded it as created, which is
// how a site ends up referencing `var(--font-x)` that was never installed.
const saved = new Set(
(getStoreState().site?.settings.fonts?.tokens ?? []).map((token) => token.variable),
)
const lost = results.filter((r) => !saved.has(r.variable)).map((r) => r.variable)
if (lost.length > 0) {
return aiToolError(
`Font tokens were not saved (${lost.join(', ')}): the editor could not reach the collaboration server. Try again in a moment.`,
)
}
return aiToolOk({ tokens: results })
}

Expand Down Expand Up @@ -197,6 +212,10 @@ export function runSetTypeScale(rawInput: unknown): AiToolOutput {
const group = getStoreState().site?.settings.framework?.typography?.groups.find(
(g) => g.id === groupId,
)
// Read back rather than trusting the write: a mutation the collab path
// refused leaves no group behind, and answering with the requested steps
// would report a scale the site does not have.
if (!group) return aiToolError(SCALE_NOT_SAVED)
const namingConvention = group?.namingConvention ?? input.namingConvention ?? 'text'
const steps = group?.steps ?? input.steps ?? ''
return aiToolOk({
Expand Down Expand Up @@ -243,6 +262,8 @@ export function runSetSpacingScale(rawInput: unknown): AiToolOutput {
const group = getStoreState().site?.settings.framework?.spacing?.groups.find(
(g) => g.id === groupId,
)
// See the matching read-back in `runSetTypeScale`.
if (!group) return aiToolError(SCALE_NOT_SAVED)
const namingConvention = group?.namingConvention ?? input.namingConvention ?? 'space'
const steps = group?.steps ?? input.steps ?? ''
return aiToolOk({
Expand Down
52 changes: 52 additions & 0 deletions src/admin/pages/site/store/slices/site/framework/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ function reorderFrameworkColorTokenInGroup(
type FrameworkColorActions = Pick<
SiteSlice,
| 'createFrameworkColorToken'
| 'upsertFrameworkColorTokens'
| 'updateFrameworkColorToken'
| 'duplicateFrameworkColorToken'
| 'reorderFrameworkColorToken'
Expand Down Expand Up @@ -275,6 +276,57 @@ export function createFrameworkColorActions({
return token
},

/**
* Create-or-update a whole token list in ONE site mutation.
*
* A loop of single-token actions is not equivalent: each one is its own
* mutation, and a mutation that ensures a not-yet-bound collab doc
* registers an unsynced write gate, so every LATER write in the same tick
* is refused with `syncing`. Callers that install a palette in one go
* (`site_set_color_tokens`, theme presets) then keep only the prefix that
* ran before the gate closed — with each individual action still returning
* a token, so the loss is silent. One mutation means one gate check, and
* the boolean below reports the outcome honestly.
*/
upsertFrameworkColorTokens: (inputs) => {
const { site } = get()
if (!site) throw new Error('[siteSlice] Site document is not initialized')
const results: Array<{ slug: string; action: 'created' | 'updated' }> = []
// Plan against a growing copy so slug uniqueness and category
// canonicalization see the tokens earlier entries in THIS batch added —
// the same view a sequence of single-token calls would have had.
const planned = [...(site.settings.framework?.colors?.tokens ?? [])]
const creations: Array<{ token: FrameworkColorToken }> = []
const updates: Array<{ id: string; patch: UpdateFrameworkColorTokenPatch }> = []

for (const input of inputs) {
const norm = normalizeFrameworkColorSlug(input.slug)
const match = planned.find((t) => normalizeFrameworkColorSlug(t.slug) === norm)
if (match) {
updates.push({ id: match.id, patch: input })
results.push({ slug: match.slug, action: 'updated' })
continue
}
const token = createFrameworkColorTokenFromInput(input, { tokens: planned })
planned.push(token)
creations.push({ token })
results.push({ slug: token.slug, action: 'created' })
}

const accepted = mutateSite((draftSite) => {
const draftColors = ensureFrameworkColors(draftSite)
for (const { id, patch } of updates) {
const token = draftColors.tokens.find((candidate) => candidate.id === id)
if (token) applyFrameworkColorTokenPatch(token, patch, draftColors)
}
for (const { token } of creations) draftColors.tokens.push(token)
reconcileFrameworkClasses(draftSite)
return true
})

return { tokens: results, accepted }
},

updateFrameworkColorToken: (tokenId, patch) => {
mutateSite((site) => {
const colors = site.settings.framework?.colors
Expand Down
9 changes: 9 additions & 0 deletions src/admin/pages/site/store/slices/site/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,15 @@ export interface SiteSlice {

// Framework color mutations
createFrameworkColorToken: (input: CreateFrameworkColorTokenInput) => FrameworkColorToken
/**
* Create-or-update many tokens in ONE mutation. `accepted` is false when the
* collab write path refused the whole batch (offline / still syncing) — the
* caller must not report those tokens as installed.
*/
upsertFrameworkColorTokens: (inputs: readonly CreateFrameworkColorTokenInput[]) => {
tokens: Array<{ slug: string; action: 'created' | 'updated' }>
accepted: boolean
}
updateFrameworkColorToken: (tokenId: string, patch: UpdateFrameworkColorTokenPatch) => void
duplicateFrameworkColorToken: (tokenId: string) => FrameworkColorToken | null
reorderFrameworkColorToken: (tokenId: string, direction: 'up' | 'down') => void
Expand Down
Loading