- {analysis.accessibility.pairs.map((pair) => (
+ {analysis.accessibility.pairs.map((pair, index) => (
diff --git a/frontend/src/exporters.test.ts b/frontend/src/exporters.test.ts
index 12f937b..0fa033a 100644
--- a/frontend/src/exporters.test.ts
+++ b/frontend/src/exporters.test.ts
@@ -6,6 +6,7 @@ import {
generateJson,
generateSvg,
generateTailwind,
+ reservedSemanticTokens,
sanitizeFilename,
} from './exporters'
@@ -105,6 +106,107 @@ describe('browser palette exporters', () => {
)
})
+ it('reserves every semantic role token even when roles are unassigned', () => {
+ const colors = reservedSemanticTokens.map((token, index) => ({
+ ...(index % 2 === 0 ? red : blue),
+ id: `reserved-${index}`,
+ name: token
+ .split('-')
+ .map((part) => `${part[0].toUpperCase()}${part.slice(1)}`)
+ .join(' '),
+ }))
+ expect(exportTokens(colors)).toEqual(
+ reservedSemanticTokens.map((token) => `${token}-2`),
+ )
+ const withoutRoles = generateTailwind({
+ ...palette,
+ colors,
+ roles: {},
+ })
+ const withRoles = generateTailwind({
+ ...palette,
+ colors,
+ roles: { primaryAction: colors[0].id },
+ })
+ const keys = (output: string) =>
+ [...output.matchAll(/^\s+'([^']+)':/gm)].map((match) => match[1])
+ expect(keys(withRoles).slice(0, colors.length)).toEqual(
+ keys(withoutRoles).slice(0, colors.length),
+ )
+ })
+
+ it('allocates deterministic suffixes across reserved and existing suffixes', () => {
+ const colors = [
+ { ...red, id: 'one', name: 'Role primary action' },
+ { ...blue, id: 'two', name: 'Role primary action 2' },
+ { ...red, id: 'three', name: 'Role primary action' },
+ { ...blue, id: 'four', name: 'Ordinary' },
+ { ...red, id: 'five', name: 'Ordinary' },
+ { ...blue, id: 'six', name: 'Ordinary 2' },
+ ]
+ expect(exportTokens(colors)).toEqual([
+ 'role-primary-action-2',
+ 'role-primary-action-2-2',
+ 'role-primary-action-3',
+ 'ordinary',
+ 'ordinary-2',
+ 'ordinary-2-2',
+ ])
+ })
+
+ it.each([
+ 'Role primary action',
+ 'ROLE PRIMARY ACTION',
+ 'role---primary___action!!!',
+ ])('normalizes the reserved-name variant %s safely', (name) => {
+ expect(exportTokens([{ ...red, name }])).toEqual(['role-primary-action-2'])
+ })
+
+ it('keeps unnamed fallbacks and ordinary names unchanged', () => {
+ expect(exportTokens([red, blue])).toEqual(['palette-1', 'palette-2'])
+ expect(
+ exportTokens([
+ { ...red, name: 'Primary action' },
+ { ...blue, name: 'Surface neutral' },
+ ]),
+ ).toEqual(['primary-action', 'surface-neutral'])
+ })
+
+ it('uses collision-safe base tokens consistently in CSS and Tailwind', () => {
+ const collisionPalette = {
+ ...palette,
+ colors: [
+ { ...red, name: 'Role primary action' },
+ { ...blue, name: 'Role primary action' },
+ ],
+ roles: {
+ primaryAction: red.id,
+ pageBackground: blue.id,
+ },
+ }
+ const css = generateCss(collisionPalette)
+ expect(css).toContain('--color-role-primary-action-2: #ff0000;')
+ expect(css).toContain('--color-role-primary-action-3: #0000ff;')
+ expect(css).toContain(
+ '--role-primary-action: var(--color-role-primary-action-2);',
+ )
+ expect(css).toContain(
+ '--role-page-background: var(--color-role-primary-action-3);',
+ )
+
+ const tailwind = generateTailwind(collisionPalette)
+ const keys = [...tailwind.matchAll(/^\s+'([^']+)':/gm)].map(
+ (match) => match[1],
+ )
+ expect(new Set(keys).size).toBe(keys.length)
+ expect(keys).toEqual([
+ 'role-primary-action-2',
+ 'role-primary-action-3',
+ 'role-page-background',
+ 'role-primary-action',
+ ])
+ })
+
it('keeps duplicate-color semantic ownership distinct', () => {
const duplicate = { ...red, id: 'second-red', name: 'Second red' }
const duplicatePalette = {
diff --git a/frontend/src/exporters.ts b/frontend/src/exporters.ts
index be1fb02..a8f915c 100644
--- a/frontend/src/exporters.ts
+++ b/frontend/src/exporters.ts
@@ -2,6 +2,7 @@ import type { PaletteColor } from './workspace'
import { serializePortablePalette } from './portablePalette'
import {
contrastRatio,
+ paletteRoles,
roleAssignmentEntries,
roleLabels,
roleTokenNames,
@@ -163,13 +164,31 @@ export function exportToken(value: string, fallback: string): string {
)
}
+export const reservedSemanticTokens = paletteRoles.map(
+ (role) => `role-${roleTokenNames[role]}`,
+)
+
+function allocateToken(
+ requested: string,
+ allocated: Set,
+ reserved: ReadonlySet,
+): string {
+ let token = requested
+ let suffix = 2
+ while (allocated.has(token) || reserved.has(token)) {
+ token = `${requested}-${suffix}`
+ suffix += 1
+ }
+ allocated.add(token)
+ return token
+}
+
export function exportTokens(colors: PaletteColor[]): string[] {
- const counts = new Map()
+ const allocated = new Set()
+ const reserved = new Set(reservedSemanticTokens)
return colors.map((color, index) => {
- const base = exportToken(color.name ?? '', `palette-${index + 1}`)
- const count = (counts.get(base) ?? 0) + 1
- counts.set(base, count)
- return count === 1 ? base : `${base}-${count}`
+ const requested = exportToken(color.name ?? '', `palette-${index + 1}`)
+ return allocateToken(requested, allocated, reserved)
})
}