import { describe, expect, test } from 'vitest'
import {
buildPreviewHTML,
buildProbeGroups,
deriveAppearance,
findColorMixCalls,
formatSRGBColor,
mapThemeVariables,
replaceColorMix,
resolveLightDark,
selectVariableNames
} from './palette'
describe('buildPreviewHTML', () => {
const baseUrl = 'file:///themes/acme/'
const baseStyleSheetURLs = [
'file:///pkgs/@inkdropapp/css/reset.css',
'file:///pkgs/@inkdropapp/base-ui-theme/styles/theme.css'
]
test('emits the base href so relative theme links resolve', () => {
const html = buildPreviewHTML({ name: 'acme' }, baseUrl, [])
expect(html).toContain(``)
})
test('links each resolved Inkdrop base stylesheet as an absolute URL', () => {
const html = buildPreviewHTML({ name: 'acme' }, baseUrl, baseStyleSheetURLs)
for (const href of baseStyleSheetURLs) {
expect(html).toContain(``)
}
})
test('links each declared theme stylesheet under styles/', () => {
const html = buildPreviewHTML(
{ name: 'acme', styleSheets: ['base.css', 'syntax.css'] },
baseUrl,
[]
)
expect(html).toContain('')
expect(html).toContain('')
})
test('omits theme stylesheet links when none are declared', () => {
const html = buildPreviewHTML({ name: 'acme' }, baseUrl, [])
expect(html).not.toContain('href="styles/')
})
test('sets the body class to the theme name', () => {
const html = buildPreviewHTML({ name: 'acme-dark' }, baseUrl, [])
expect(html).toContain('
')
})
test('appends an appearance-mode class when an appearance is forced', () => {
const html = buildPreviewHTML({ name: 'acme' }, baseUrl, [], 'dark')
expect(html).toContain('')
})
test('adds no appearance class when appearance is omitted', () => {
const html = buildPreviewHTML({ name: 'acme' }, baseUrl, [])
expect(html).not.toContain('-mode')
})
test('renders .cm-editor and .mde-preview elements so scoped tokens can be probed off them', () => {
const html = buildPreviewHTML({ name: 'acme' }, baseUrl, [])
expect(html).toContain('
')
expect(html).toContain('
')
})
})
describe('deriveAppearance', () => {
test('is dark when the name contains "dark"', () => {
expect(deriveAppearance({ name: 'solarized-dark-ui' })).toBe('dark')
})
test('is dark when themeAppearance is "dark"', () => {
expect(deriveAppearance({ name: 'acme-ui', themeAppearance: 'dark' })).toBe('dark')
})
test('is light otherwise', () => {
expect(deriveAppearance({ name: 'acme-light-ui' })).toBe('light')
expect(deriveAppearance({ name: 'acme-ui', themeAppearance: 'light' })).toBe('light')
expect(deriveAppearance({})).toBe('light')
})
})
describe('resolveLightDark', () => {
test('keeps the first branch for light, the second for dark', () => {
expect(resolveLightDark('light-dark(red, blue)', 'light')).toBe('red')
expect(resolveLightDark('light-dark(red, blue)', 'dark')).toBe('blue')
})
test('leaves values without light-dark untouched', () => {
expect(resolveLightDark('hsl(192deg 100% 5%)', 'dark')).toBe('hsl(192deg 100% 5%)')
})
test('ignores commas nested inside the branches', () => {
const value = 'light-dark(hsl(215deg 14% 34%), hsl(218deg 11% 65% / 40%))'
expect(resolveLightDark(value, 'dark')).toBe('hsl(218deg 11% 65% / 40%)')
expect(resolveLightDark(value, 'light')).toBe('hsl(215deg 14% 34%)')
})
test('resolves nested light-dark within the chosen branch', () => {
const value = 'light-dark(light-dark(a, b), c)'
expect(resolveLightDark(value, 'light')).toBe('a')
expect(resolveLightDark(value, 'dark')).toBe('c')
})
test('resolves light-dark embedded in a larger value', () => {
const value = '0px 1px 2px 0 light-dark(rgba(0, 0, 0, 0.1), hsl(0deg 0% 0% / 20%)) inset'
expect(resolveLightDark(value, 'dark')).toBe('0px 1px 2px 0 hsl(0deg 0% 0% / 20%) inset')
})
test('resolves multiple light-dark occurrences in one value', () => {
const value = 'light-dark(a, b), light-dark(c, d)'
expect(resolveLightDark(value, 'light')).toBe('a, c')
expect(resolveLightDark(value, 'dark')).toBe('b, d')
})
})
describe('findColorMixCalls', () => {
test('returns nothing for values without color-mix', () => {
expect(findColorMixCalls('hsl(192deg 100% 5%)')).toEqual([])
})
test('finds a call embedded in a larger value', () => {
const value = '0 1px 2px color-mix(in srgb, hsl(200deg 50% 50%) 20%, white) inset'
expect(findColorMixCalls(value)).toEqual(['color-mix(in srgb, hsl(200deg 50% 50%) 20%, white)'])
})
test('lists only the outermost call when calls are nested', () => {
const value = 'color-mix(in srgb, color-mix(in srgb, red, blue), white)'
expect(findColorMixCalls(value)).toEqual([value])
})
test('finds multiple sibling calls in order', () => {
const value = 'color-mix(in srgb, red, white), color-mix(in oklab, blue, black)'
expect(findColorMixCalls(value)).toEqual([
'color-mix(in srgb, red, white)',
'color-mix(in oklab, blue, black)'
])
})
test('skips an unbalanced call', () => {
expect(findColorMixCalls('color-mix(in srgb, red, white')).toEqual([])
})
})
describe('replaceColorMix', () => {
test('splices resolved colours in place of their calls', () => {
const value = '1px solid color-mix(in srgb, red, white)'
expect(replaceColorMix(value, { 'color-mix(in srgb, red, white)': '#ff8080' })).toBe(
'1px solid #ff8080'
)
})
test('leaves unresolved calls untouched', () => {
const value = 'color-mix(in srgb, bogus, white)'
expect(replaceColorMix(value, {})).toBe(value)
})
})
describe('formatSRGBColor', () => {
test('formats an opaque colour as hex', () => {
expect(formatSRGBColor('color(srgb 0.3 0 0.7)')).toBe('#4d00b3')
})
test('formats a translucent colour as rgba', () => {
expect(formatSRGBColor('color(srgb 1 1 1 / 0.2)')).toBe('rgba(255, 255, 255, 0.2)')
})
test('clamps out-of-gamut channels', () => {
expect(formatSRGBColor('color(srgb -0.3 1.2 0.5)')).toBe('#00ff80')
})
test('treats none as zero', () => {
expect(formatSRGBColor('color(srgb none 1 none)')).toBe('#00ff00')
})
test('rejects values that are not sRGB color()', () => {
expect(formatSRGBColor('rgb(0, 0, 0)')).toBeUndefined()
expect(formatSRGBColor('color(display-p3 1 0 0)')).toBeUndefined()
})
})
describe('selectVariableNames', () => {
const manifest = {
ui: ['--font-name', '--primary-color'],
status: ['--note-status-active'],
tags: ['--tag-red-text-color'],
'task-progress': ['--task-progress-view-border-color'],
markdown: ['--gfm-alert-note'],
mermaid: ['--mermaid-text-color'],
syntax: ['--syntax-comment-color']
}
test('ui covers ui, status, tags, and task-progress in declaration order', () => {
expect(selectVariableNames(manifest, 'ui')).toEqual([
'--font-name',
'--primary-color',
'--note-status-active',
'--tag-red-text-color',
'--task-progress-view-border-color'
])
})
test('syntax covers only the syntax category', () => {
expect(selectVariableNames(manifest, 'syntax')).toEqual(['--syntax-comment-color'])
})
test('preview covers the markdown and mermaid categories in declaration order', () => {
expect(selectVariableNames(manifest, 'preview')).toEqual([
'--gfm-alert-note',
'--mermaid-text-color'
])
})
test('skips categories absent from the manifest', () => {
expect(selectVariableNames({ ui: ['--a'] }, 'ui')).toEqual(['--a'])
})
})
describe('buildProbeGroups', () => {
const manifest = {
ui: ['--primary-color'],
status: ['--note-status-active'],
tags: ['--tag-red-text-color'],
'task-progress': ['--task-progress-view-border-color'],
markdown: ['--gfm-alert-note'],
mermaid: ['--mermaid-text-color'],
syntax: ['--syntax-comment-color']
}
test('produces one group per surface in ui, syntax, preview merge order', () => {
expect(buildProbeGroups(manifest).map((group) => group.type)).toEqual([
'ui',
'syntax',
'preview'
])
})
test('pairs each surface with the element its tokens are scoped under', () => {
const probesByType = Object.fromEntries(
buildProbeGroups(manifest).map((group) => [group.type, group.probeSelector])
)
expect(probesByType).toEqual({ ui: 'body', syntax: '.cm-editor', preview: '.mde-preview' })
})
test("collects each surface's variable names from its categories", () => {
const namesByType = Object.fromEntries(
buildProbeGroups(manifest).map((group) => [group.type, group.variableNames])
)
expect(namesByType.ui).toEqual([
'--primary-color',
'--note-status-active',
'--tag-red-text-color',
'--task-progress-view-border-color'
])
expect(namesByType.syntax).toEqual(['--syntax-comment-color'])
expect(namesByType.preview).toEqual(['--gfm-alert-note', '--mermaid-text-color'])
})
})
describe('mapThemeVariables', () => {
test('maps each declared name to its computed value, ignoring extras', () => {
const result = mapThemeVariables(['--accent', '--bg'], {
'--accent': '#f00',
'--bg': '#fff',
'--extra': '#000'
})
expect(result).toEqual({ '--accent': '#f00', '--bg': '#fff' })
})
test('preserves the declaration order of the variable names', () => {
const result = mapThemeVariables(['--b', '--a'], { '--a': '1', '--b': '2' })
expect(Object.keys(result)).toEqual(['--b', '--a'])
})
test('leaves variables absent from the computed set undefined', () => {
const result = mapThemeVariables(['--missing'], { '--accent': '#f00' })
expect(result['--missing']).toBeUndefined()
})
test('returns an empty object when no names are declared', () => {
expect(mapThemeVariables([], { '--accent': '#f00' })).toEqual({})
})
})