Skip to content

Commit 2be073c

Browse files
authored
Merge pull request #13 from Artsen/fix/semantic-token-collisions
Prevent semantic token collisions and stabilize duplicate contrast rows
2 parents ab96278 + c533c34 commit 2be073c

9 files changed

Lines changed: 227 additions & 20 deletions

File tree

docs/architecture.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,16 @@ The frontend generates every export without an API request:
114114
- SVG swatch sheet
115115

116116
CSS emits base `--color-*` values and assigned `--role-*` aliases. Tailwind
117-
emits base keys and assigned `role-*` semantic keys. SVG annotates each row with
118-
its assigned roles. Comments replace line breaks and the `*/` sequence in the
119-
palette name. SVG output escapes `&`, `<`, `>`, `"`, and `'`. Each swatch label
120-
uses black or white according to the higher measured contrast ratio. Download
121-
uses an object URL and revokes the URL after the browser starts the download.
117+
emits base keys and assigned `role-*` semantic keys. The Tailwind `role-*`
118+
namespace is reserved even when roles are unassigned. The shared base-token
119+
allocator adds deterministic numeric suffixes when a normalized color name
120+
would collide with a reserved or previously allocated key. CSS keeps base and
121+
semantic values in separate `--color-*` and `--role-*` namespaces. SVG
122+
annotates each row with its assigned roles. Comments replace line breaks and
123+
the `*/` sequence in the palette name. SVG output escapes `&`, `<`, `>`, `"`,
124+
and `'`. Each swatch label uses black or white according to the higher measured
125+
contrast ratio. Download uses an object URL and revokes the URL after the
126+
browser starts the download.
122127

123128
Export does not create or update a saved palette record.
124129

docs/user-guide.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,9 @@ stated in percentage points.
202202

203203
Named colors produce safe ASCII keys in CSS and Tailwind output and visible
204204
labels in SVG. Unnamed colors keep the `palette-1`, `palette-2` numeric pattern.
205+
Tailwind reserves the generated `role-*` namespace for semantic role keys. A
206+
color name that normalizes to a reserved key receives a deterministic numeric
207+
suffix, such as `role-primary-action-2`.
205208
ColorCraft JSON schema version 3 preserves Unicode names, exact order,
206209
extraction metadata, and unambiguous role ownership. Each color receives a
207210
document-local key such as `color-1`; the file does not contain internal
@@ -213,8 +216,9 @@ exported file through the browser. Export does not create or update a saved
213216
palette record.
214217

215218
CSS output adds assigned `--role-*` aliases that reference base `--color-*`
216-
tokens. Tailwind output adds assigned `role-*` keys, and SVG rows identify their
217-
assigned roles. Imported version-1 and version-2 files still use HEX role
219+
tokens. These separate CSS namespaces prevent collisions. Tailwind output adds
220+
assigned `role-*` keys, and SVG rows identify their assigned roles. Imported
221+
version-1 and version-2 files still use HEX role
218222
references. When duplicate HEX values make those older files ambiguous,
219223
ColorCraft maps the role to the first matching color in palette order.
220224

docs/writing-style.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ contents, clipboard contents, or unrelated palette data in user-facing errors.
206206
| exported file | A file that the browser creates from the current palette. | Do not imply that ColorCraft stores the file. |
207207
| ColorCraft JSON | The portable, single-palette JSON format that ColorCraft exports and imports in the browser. | Distinguish its schema version from the IndexedDB saved-palette schema and backend API schemas. |
208208
| portable color key | A deterministic document-local color reference such as `color-1` in ColorCraft JSON version 3. | Do not call it an internal workspace ID. |
209-
| semantic role token | An assigned `--role-*` CSS alias or `role-*` Tailwind key generated from a color role. | Keep it separate from the base palette token. |
209+
| semantic role token | An assigned `--role-*` CSS alias or `role-*` Tailwind key generated from a color role. | The Tailwind `role-*` namespace is reserved. Keep CSS base `--color-*` and semantic `--role-*` tokens separate. |
210210
| imported palette | A validated ColorCraft JSON palette activated in the current session. | State that it remains unsaved until **Save palette** is selected. |
211211
| copy | Place generated export text on the system clipboard. | Do not use as a synonym for export or download. |
212212
| download | Save generated export data as a local file through the browser. | Do not use as a synonym for copy. |

frontend/src/components/AnalysisResults.test.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { render, screen } from '@testing-library/react'
2-
import { describe, expect, it } from 'vitest'
2+
import { describe, expect, it, vi } from 'vitest'
33
import AnalysisResults from './AnalysisResults'
44
import { analysis, blue, red } from '../test/fixtures'
55

@@ -26,4 +26,39 @@ describe('AnalysisResults', () => {
2626
screen.getByText('#ff0000 + #0000ff • Ratio: 2.15'),
2727
).toBeInTheDocument()
2828
})
29+
30+
it('renders duplicate pairs and issues without duplicate React keys', () => {
31+
const pair = { ...analysis.accessibility.pairs[0] }
32+
const issue = { ...analysis.accessibility.issues[0] }
33+
const duplicateAnalysis = {
34+
...analysis,
35+
accessibility: {
36+
...analysis.accessibility,
37+
pairs: [{ ...pair }, { ...pair }],
38+
issues: [{ ...issue }, { ...issue }],
39+
},
40+
}
41+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
42+
try {
43+
const { container } = render(
44+
<AnalysisResults
45+
analysis={duplicateAnalysis}
46+
colors={[red, { ...red, id: 'duplicate-red' }, blue]}
47+
/>,
48+
)
49+
expect(
50+
screen.getAllByText(
51+
'Low contrast detected between #ff0000 and #0000ff.',
52+
),
53+
).toHaveLength(2)
54+
expect(
55+
container.querySelectorAll('.analysis-section .contrast-row'),
56+
).toHaveLength(2)
57+
expect(consoleError.mock.calls.flat().join(' ')).not.toMatch(
58+
/same key|unique "key"/i,
59+
)
60+
} finally {
61+
consoleError.mockRestore()
62+
}
63+
})
2964
})

frontend/src/components/AnalysisResults.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,9 @@ export default function AnalysisResults({ analysis }: AnalysisResultsProps) {
154154
<section className="analysis-section">
155155
<h3>Issues found</h3>
156156
<div className="issue-list">
157-
{accessibility.issues.map((issue) => (
157+
{accessibility.issues.map((issue, index) => (
158158
<Notice
159-
key={`${issue.color1}-${issue.color2}`}
159+
key={`${issue.color1}-${issue.color2}-${index}`}
160160
variant="warning"
161161
>
162162
<p>{issue.message}</p>
@@ -175,9 +175,9 @@ export default function AnalysisResults({ analysis }: AnalysisResultsProps) {
175175
<section className="analysis-section">
176176
<h3>Color pair contrast</h3>
177177
<div className="contrast-list">
178-
{accessibility.pairs.map((pair) => (
178+
{accessibility.pairs.map((pair, index) => (
179179
<div
180-
key={`${pair.color1}-${pair.color2}`}
180+
key={`${pair.color1}-${pair.color2}-${index}`}
181181
className="contrast-row"
182182
>
183183
<div className="contrast-row-header">

frontend/src/components/ReviewWorkspace.test.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,48 @@ describe('ReviewWorkspace outcomes', () => {
248248
expect(within(result).getByText(/1.00 to 1/)).toBeInTheDocument()
249249
})
250250

251+
it('renders duplicate all-pairs rows without duplicate React keys', () => {
252+
const duplicatePair = {
253+
color1: '#FF0000',
254+
color2: '#0000FF',
255+
ratio: 2.15,
256+
aaNormal: false,
257+
aaLarge: false,
258+
aaaNormal: false,
259+
aaaLarge: false,
260+
}
261+
const duplicateAnalysis = measuredAnalysis()
262+
duplicateAnalysis.accessibility.pairs = [
263+
{ ...duplicatePair },
264+
{ ...duplicatePair },
265+
]
266+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
267+
try {
268+
const { container } = render(
269+
<ReviewWorkspace
270+
colors={[red, blue]}
271+
analysis={duplicateAnalysis}
272+
analysisStale={false}
273+
analyzing={false}
274+
selectedTab="contrast"
275+
roles={{}}
276+
onSelectTab={vi.fn()}
277+
onAnalyze={vi.fn()}
278+
onAssignRole={vi.fn()}
279+
onAddColor={vi.fn()}
280+
/>,
281+
)
282+
expect(
283+
container.querySelectorAll('.all-pairs .contrast-row'),
284+
).toHaveLength(2)
285+
expect(consoleError.mock.calls.flat().join(' ')).not.toMatch(
286+
/same key|unique "key"/i,
287+
)
288+
} finally {
289+
consoleError.mockRestore()
290+
}
291+
})
292+
251293
it('clearly identifies stale analysis without rendering prior results', () => {
252294
render(
253295
<ReviewWorkspace

frontend/src/components/ReviewWorkspace.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -532,10 +532,10 @@ function Contrast({
532532
components or focus indicators.
533533
</p>
534534
<div className="contrast-list">
535-
{analysis.accessibility.pairs.map((pair) => (
535+
{analysis.accessibility.pairs.map((pair, index) => (
536536
<div
537537
className="contrast-row"
538-
key={`${pair.color1}-${pair.color2}`}
538+
key={`${pair.color1}-${pair.color2}-${index}`}
539539
>
540540
<div className="contrast-row-header">
541541
<span>

frontend/src/exporters.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
generateJson,
77
generateSvg,
88
generateTailwind,
9+
reservedSemanticTokens,
910
sanitizeFilename,
1011
} from './exporters'
1112

@@ -105,6 +106,107 @@ describe('browser palette exporters', () => {
105106
)
106107
})
107108

109+
it('reserves every semantic role token even when roles are unassigned', () => {
110+
const colors = reservedSemanticTokens.map((token, index) => ({
111+
...(index % 2 === 0 ? red : blue),
112+
id: `reserved-${index}`,
113+
name: token
114+
.split('-')
115+
.map((part) => `${part[0].toUpperCase()}${part.slice(1)}`)
116+
.join(' '),
117+
}))
118+
expect(exportTokens(colors)).toEqual(
119+
reservedSemanticTokens.map((token) => `${token}-2`),
120+
)
121+
const withoutRoles = generateTailwind({
122+
...palette,
123+
colors,
124+
roles: {},
125+
})
126+
const withRoles = generateTailwind({
127+
...palette,
128+
colors,
129+
roles: { primaryAction: colors[0].id },
130+
})
131+
const keys = (output: string) =>
132+
[...output.matchAll(/^\s+'([^']+)':/gm)].map((match) => match[1])
133+
expect(keys(withRoles).slice(0, colors.length)).toEqual(
134+
keys(withoutRoles).slice(0, colors.length),
135+
)
136+
})
137+
138+
it('allocates deterministic suffixes across reserved and existing suffixes', () => {
139+
const colors = [
140+
{ ...red, id: 'one', name: 'Role primary action' },
141+
{ ...blue, id: 'two', name: 'Role primary action 2' },
142+
{ ...red, id: 'three', name: 'Role primary action' },
143+
{ ...blue, id: 'four', name: 'Ordinary' },
144+
{ ...red, id: 'five', name: 'Ordinary' },
145+
{ ...blue, id: 'six', name: 'Ordinary 2' },
146+
]
147+
expect(exportTokens(colors)).toEqual([
148+
'role-primary-action-2',
149+
'role-primary-action-2-2',
150+
'role-primary-action-3',
151+
'ordinary',
152+
'ordinary-2',
153+
'ordinary-2-2',
154+
])
155+
})
156+
157+
it.each([
158+
'Role primary action',
159+
'ROLE PRIMARY ACTION',
160+
'role---primary___action!!!',
161+
])('normalizes the reserved-name variant %s safely', (name) => {
162+
expect(exportTokens([{ ...red, name }])).toEqual(['role-primary-action-2'])
163+
})
164+
165+
it('keeps unnamed fallbacks and ordinary names unchanged', () => {
166+
expect(exportTokens([red, blue])).toEqual(['palette-1', 'palette-2'])
167+
expect(
168+
exportTokens([
169+
{ ...red, name: 'Primary action' },
170+
{ ...blue, name: 'Surface neutral' },
171+
]),
172+
).toEqual(['primary-action', 'surface-neutral'])
173+
})
174+
175+
it('uses collision-safe base tokens consistently in CSS and Tailwind', () => {
176+
const collisionPalette = {
177+
...palette,
178+
colors: [
179+
{ ...red, name: 'Role primary action' },
180+
{ ...blue, name: 'Role primary action' },
181+
],
182+
roles: {
183+
primaryAction: red.id,
184+
pageBackground: blue.id,
185+
},
186+
}
187+
const css = generateCss(collisionPalette)
188+
expect(css).toContain('--color-role-primary-action-2: #ff0000;')
189+
expect(css).toContain('--color-role-primary-action-3: #0000ff;')
190+
expect(css).toContain(
191+
'--role-primary-action: var(--color-role-primary-action-2);',
192+
)
193+
expect(css).toContain(
194+
'--role-page-background: var(--color-role-primary-action-3);',
195+
)
196+
197+
const tailwind = generateTailwind(collisionPalette)
198+
const keys = [...tailwind.matchAll(/^\s+'([^']+)':/gm)].map(
199+
(match) => match[1],
200+
)
201+
expect(new Set(keys).size).toBe(keys.length)
202+
expect(keys).toEqual([
203+
'role-primary-action-2',
204+
'role-primary-action-3',
205+
'role-page-background',
206+
'role-primary-action',
207+
])
208+
})
209+
108210
it('keeps duplicate-color semantic ownership distinct', () => {
109211
const duplicate = { ...red, id: 'second-red', name: 'Second red' }
110212
const duplicatePalette = {

frontend/src/exporters.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { PaletteColor } from './workspace'
22
import { serializePortablePalette } from './portablePalette'
33
import {
44
contrastRatio,
5+
paletteRoles,
56
roleAssignmentEntries,
67
roleLabels,
78
roleTokenNames,
@@ -163,13 +164,31 @@ export function exportToken(value: string, fallback: string): string {
163164
)
164165
}
165166

167+
export const reservedSemanticTokens = paletteRoles.map(
168+
(role) => `role-${roleTokenNames[role]}`,
169+
)
170+
171+
function allocateToken(
172+
requested: string,
173+
allocated: Set<string>,
174+
reserved: ReadonlySet<string>,
175+
): string {
176+
let token = requested
177+
let suffix = 2
178+
while (allocated.has(token) || reserved.has(token)) {
179+
token = `${requested}-${suffix}`
180+
suffix += 1
181+
}
182+
allocated.add(token)
183+
return token
184+
}
185+
166186
export function exportTokens(colors: PaletteColor[]): string[] {
167-
const counts = new Map<string, number>()
187+
const allocated = new Set<string>()
188+
const reserved = new Set(reservedSemanticTokens)
168189
return colors.map((color, index) => {
169-
const base = exportToken(color.name ?? '', `palette-${index + 1}`)
170-
const count = (counts.get(base) ?? 0) + 1
171-
counts.set(base, count)
172-
return count === 1 ? base : `${base}-${count}`
190+
const requested = exportToken(color.name ?? '', `palette-${index + 1}`)
191+
return allocateToken(requested, allocated, reserved)
173192
})
174193
}
175194

0 commit comments

Comments
 (0)