diff --git a/e2e/testcafe-devextreme/helpers/themeUtils.ts b/e2e/testcafe-devextreme/helpers/themeUtils.ts index 9e8e738e159b..f6d01ef92b7e 100644 --- a/e2e/testcafe-devextreme/helpers/themeUtils.ts +++ b/e2e/testcafe-devextreme/helpers/themeUtils.ts @@ -20,6 +20,8 @@ export const isMaterial = (): boolean => (process.env.theme ?? defaultThemeName) export const isFluent = (): boolean => (process.env.theme ?? defaultThemeName).startsWith('fluent'); +export const isFluentNext = (): boolean => (process.env.theme ?? defaultThemeName).startsWith('fluent-next'); + export const isMaterialBased = (): boolean => isMaterial() || isFluent(); export const getFullThemeName = (): string => process.env.theme ?? defaultThemeName; diff --git a/e2e/testcafe-devextreme/tests/common/accentColor.ts b/e2e/testcafe-devextreme/tests/common/accentColor.ts new file mode 100644 index 000000000000..317fcc477510 --- /dev/null +++ b/e2e/testcafe-devextreme/tests/common/accentColor.ts @@ -0,0 +1,217 @@ +/* eslint-disable spellcheck/spell-checker */ +import { createScreenshotsComparer } from 'devextreme-screenshot-comparer'; +import { ClientFunction } from 'testcafe'; +import { createWidget } from '../../helpers/createWidget'; +import { appendElementTo } from '../../helpers/domUtils'; +import url from '../../helpers/getPageUrl'; +import { getData } from '../dataGrid/helpers/generateDataSourceData'; +import { isFluentNext, testScreenshot } from '../../helpers/themeUtils'; + +const STEPS = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180]; +const ARBITRARY_ACCENT = '#a703ff'; + +const DESIGNED_PALETTE_TOLERANCE = 4; +const HUE_TOLERANCE = 0.01; + +interface Oklch { + l: number; + c: number; + h: number; +} + +interface MeasuredStep { + step: number; + resolved: string; + oklch: Oklch | null; +} + +const measurePalette = ClientFunction((accent: string | null, steps: number[]) => { + const root = document.documentElement; + + if (accent) { + root.style.setProperty('--dx-accent-color', accent); + } else { + root.style.removeProperty('--dx-accent-color'); + } + + const probe = document.createElement('div'); + document.body.appendChild(probe); + + const asOklch = (color: string): Oklch | null => { + probe.style.backgroundColor = `oklch(from ${color} l c h)`; + const resolved = getComputedStyle(probe).backgroundColor; + const parts = /^oklch\(([-\d.]+) ([-\d.]+) ([-\d.]+)/.exec(resolved); + + return parts ? { l: +parts[1], c: +parts[2], h: +parts[3] } : null; + }; + + const read = (step: number): MeasuredStep => { + probe.style.backgroundColor = `var(--dxds-primary-${step})`; + const resolved = getComputedStyle(probe).backgroundColor; + + return { step, resolved, oklch: asOklch(resolved) }; + }; + + const setting = (name: string): number => +getComputedStyle(root).getPropertyValue(name).trim(); + const measured = steps.map(read); + const source = accent ? asOklch(accent) : null; + + probe.remove(); + + return { + measured, + source, + settings: { + lightnessMax: setting('--dx-accent-lightness-max'), + lightnessMin: setting('--dx-accent-lightness-min'), + chromaMin: setting('--dx-accent-chroma-min'), + }, + }; +}); + +const PALETTE_STRIP = 'accent-palette'; +const ACCENT_GRID = 'accent-grid'; +const GRID_DATA = getData(5, 2); +const SHIPPED_ACCENTS = [ + { palette: 'blue', color: '#0f6cbd' }, + { palette: 'rust', color: '#da3b01' }, + { palette: 'mint', color: '#018574' }, +]; + +const drawPaletteStrip = ClientFunction((accent: string, steps: number[]) => { + document.documentElement.style.setProperty('--dx-accent-color', accent); + const strip = document.getElementById(PALETTE_STRIP)!; + strip.textContent = ''; + + const swatches = steps.map((step) => { + const column = document.createElement('div'); + column.style.cssText = 'width: 60px; font: 10px/14px monospace; color: #000; text-align: center'; + + const swatch = document.createElement('div'); + swatch.style.cssText = 'height: 60px; border: 1px solid #000'; + swatch.style.backgroundColor = `var(--dxds-primary-${step})`; + + const caption = document.createElement('div'); + caption.textContent = `${step}`; + + column.appendChild(swatch); + column.appendChild(caption); + strip.appendChild(column); + + return { step, swatch, caption }; + }); + + const asHex = (color: string): string => { + const probe = document.createElement('div'); + probe.style.backgroundColor = `rgb(from ${color} r g b)`; + strip.appendChild(probe); + const resolved = getComputedStyle(probe).backgroundColor; + probe.remove(); + const channels = (resolved.match(/[-\d.]+/g) ?? []).slice(0, 3); + const scale = resolved.startsWith('color(') ? 255 : 1; + + return channels.length === 3 + ? `#${channels + .map((raw) => Math.round(Math.min(255, Math.max(0, +raw * scale))).toString(16).padStart(2, '0')) + .join('')}` + : resolved; + }; + + swatches.forEach(({ step, swatch, caption }) => { + caption.textContent = `${step}\n${asHex(getComputedStyle(swatch).backgroundColor)}`; + caption.style.whiteSpace = 'pre'; + }); +}, { dependencies: { PALETTE_STRIP } }); +const oklchDistance = (first: Oklch, second: Oklch): number => { + const radians = Math.PI / 180; + const firstA = first.c * Math.cos(first.h * radians); + const firstB = first.c * Math.sin(first.h * radians); + const secondA = second.c * Math.cos(second.h * radians); + const secondB = second.c * Math.sin(second.h * radians); + const squared = (first.l - second.l) ** 2 + (firstA - secondA) ** 2 + (firstB - secondB) ** 2; + + return Math.sqrt(squared) * 100; +}; + +const rounded = (value: number): number => Math.round(value * 1000) / 1000; +const stepOf = (measured: MeasuredStep[], step: number): Oklch => measured + .find((entry) => entry.step === step)!.oklch!; + +fixture`Custom accent color` + .page(url(__dirname, '../container.html')) + .afterEach(async () => { + await measurePalette(null, []); + }); + +(isFluentNext() ? test : test.skip)('the designed seed gives the palette back', async (t) => { + const designed = await measurePalette(null, STEPS); + const designedSteps = designed.measured; + const seed = stepOf(designedSteps, 100); + const derived = await measurePalette(`oklch(${seed.l} ${seed.c} ${seed.h})`, STEPS); + + await t + .expect(designedSteps.every((entry) => entry.resolved.startsWith('rgb'))) + .ok('an unset accent must leave the designed values in place'); + + await t + .expect(derived.measured + .map((entry) => ({ + step: entry.step, + distance: rounded(oklchDistance(stepOf(designedSteps, entry.step), entry.oklch!)), + })) + .filter(({ distance }) => distance > DESIGNED_PALETTE_TOLERANCE)) + .eql([]); +}); + +(isFluentNext() ? test : test.skip)('an arbitrary accent keeps hue, order and clamps', async (t) => { + const { measured, source, settings } = await measurePalette(ARBITRARY_ACCENT, STEPS); + const steps = measured; + const lightest = stepOf(steps, 10); + const darkest = stepOf(steps, 180); + const accentItself = stepOf(steps, 100); + + await t.expect({ + lightest: { l: rounded(lightest.l), c: rounded(lightest.c) }, + darkest: { l: rounded(darkest.l), c: rounded(darkest.c) }, + accentItself: { l: rounded(accentItself.l), c: rounded(accentItself.c) }, + lighteningSteps: steps + .filter((entry, index) => index > 0 && entry.oklch!.l >= steps[index - 1].oklch!.l) + .map((entry) => entry.step), + stepsOffHue: steps + .filter((entry) => Math.abs(entry.oklch!.h - source!.h) > HUE_TOLERANCE) + .map((entry) => entry.step), + }).eql({ + lightest: { l: settings.lightnessMax, c: settings.chromaMin }, + darkest: { l: settings.lightnessMin, c: settings.chromaMin }, + accentItself: { l: rounded(source!.l), c: rounded(source!.c) }, + lighteningSteps: [], + stepsOffHue: [], + }); +}); + +(isFluentNext() ? test : test.skip)('the derived palette is drawn as designed', async (t) => { + const { takeScreenshot, compareResults } = createScreenshotsComparer(t); + + for (const { palette, color } of SHIPPED_ACCENTS) { + await drawPaletteStrip(color, STEPS); + await testScreenshot(t, takeScreenshot, `Accent palette ${palette}.png`, { + element: '#container', + }); + } + + await t + .expect(compareResults.isValid()) + .ok(compareResults.errorMessages()); +}).before(async () => { + await appendElementTo('#container', 'div', PALETTE_STRIP, { display: 'flex' }); + await appendElementTo('#container', 'div', ACCENT_GRID, { marginTop: '8px', width: '1080px' }); + await createWidget('dxDataGrid', { + dataSource: GRID_DATA, + keyExpr: 'field_0', + selection: { mode: 'multiple' }, + selectedRowKeys: [GRID_DATA[0].field_0, GRID_DATA[1].field_0], + focusedRowEnabled: true, + focusedRowKey: GRID_DATA[2].field_0, + showBorders: true, + }, `#${ACCENT_GRID}`); +}); diff --git a/e2e/testcafe-devextreme/tests/common/etalons/Accent palette blue (fluent-next.blue.light).png b/e2e/testcafe-devextreme/tests/common/etalons/Accent palette blue (fluent-next.blue.light).png new file mode 100644 index 000000000000..d5221ab41657 Binary files /dev/null and b/e2e/testcafe-devextreme/tests/common/etalons/Accent palette blue (fluent-next.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/common/etalons/Accent palette mint (fluent-next.blue.light).png b/e2e/testcafe-devextreme/tests/common/etalons/Accent palette mint (fluent-next.blue.light).png new file mode 100644 index 000000000000..32c317810f1a Binary files /dev/null and b/e2e/testcafe-devextreme/tests/common/etalons/Accent palette mint (fluent-next.blue.light).png differ diff --git a/e2e/testcafe-devextreme/tests/common/etalons/Accent palette rust (fluent-next.blue.light).png b/e2e/testcafe-devextreme/tests/common/etalons/Accent palette rust (fluent-next.blue.light).png new file mode 100644 index 000000000000..0175fb28eee5 Binary files /dev/null and b/e2e/testcafe-devextreme/tests/common/etalons/Accent palette rust (fluent-next.blue.light).png differ diff --git a/packages/devextreme-scss/build/tokens/build-tokens.mjs b/packages/devextreme-scss/build/tokens/build-tokens.mjs index cab61187cd6d..36d38f456085 100644 --- a/packages/devextreme-scss/build/tokens/build-tokens.mjs +++ b/packages/devextreme-scss/build/tokens/build-tokens.mjs @@ -280,6 +280,35 @@ StyleDictionary.registerFormat({ .join('\n'), }); +const ACCENT_PROPERTY = '--dx-accent-color'; +const PRIMARY_STEP_DECLARATION = /^(\s*)--dxds-primary-(\d+):\s*([^;]+);$/gm; +const PRIMARY_STEP_TOKEN = /^dxds-primary-\d+$/; + +StyleDictionary.registerFormat({ + name: 'dx/accent-palette', + format: async (args) => { + const palette = await StyleDictionary.hooks.formats['css/variables'](args); + const stepsInDictionary = args.dictionary.allTokens + .filter(({ name }) => PRIMARY_STEP_TOKEN.test(name)).length; + let wrapped = 0; + const withAccentFallback = palette.replace( + PRIMARY_STEP_DECLARATION, + (line, indent, step, value) => { + wrapped += 1; + + return `${indent}--dxds-primary-${step}: var(${ACCENT_PROPERTY}-${step}, ${value});`; + }, + ); + + if (!stepsInDictionary || wrapped !== stepsInDictionary) { + throw new Error(`An accent palette with ${stepsInDictionary} --dxds-primary-* steps, ` + + `${wrapped} of them wrapped into ${ACCENT_PROPERTY}-*`); + } + + return withAccentFallback; + }, +}); + const FILE_OPTIONS = { outputReferences: true, themeable: true, @@ -330,7 +359,7 @@ const createConfig = (name, files, platformFiles) => ({ const createPaletteConfig = (palette) => createConfig(palette, [`base/colors/palettes/${THEME_NAME}/${palette}`], [ { destination: `${THEME_NAME}/accents/${palette}.scss`, - format: 'css/variables', + format: 'dx/accent-palette', filter: (token) => normalizeFilePath(token).includes(`${THEME_NAME}/${palette}.json`), options: FILE_OPTIONS, }, diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/_accent-color.scss b/packages/devextreme-scss/scss/widgets/fluent-next/_accent-color.scss new file mode 100644 index 000000000000..9c12317a80a8 --- /dev/null +++ b/packages/devextreme-scss/scss/widgets/fluent-next/_accent-color.scss @@ -0,0 +1,26 @@ +@supports (color: oklch(from red l c h)) { + :root { + --dx-accent-color-source: var(--dx-accent-color); + --dx-accent-lightness-max: 0.975; + --dx-accent-lightness-min: 0.15; + --dx-accent-chroma-min: 0.02; + --dx-accent-color-10: oklch(from var(--dx-accent-color-source) max(l, var(--dx-accent-lightness-max)) min(c, var(--dx-accent-chroma-min)) h); + --dx-accent-color-20: oklch(from var(--dx-accent-color-source) calc(l + 8 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 8 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dx-accent-color-30: oklch(from var(--dx-accent-color-source) calc(l + 7 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 7 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dx-accent-color-40: oklch(from var(--dx-accent-color-source) calc(l + 6 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 6 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dx-accent-color-50: oklch(from var(--dx-accent-color-source) calc(l + 5 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 5 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dx-accent-color-60: oklch(from var(--dx-accent-color-source) calc(l + 4 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 4 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dx-accent-color-70: oklch(from var(--dx-accent-color-source) calc(l + 3 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 3 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dx-accent-color-80: oklch(from var(--dx-accent-color-source) calc(l + 2 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 2 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dx-accent-color-90: oklch(from var(--dx-accent-color-source) calc(l + 1 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 1 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dx-accent-color-100: oklch(from var(--dx-accent-color-source) l c h); + --dx-accent-color-110: oklch(from var(--dx-accent-color-source) calc(l - 1 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 1 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dx-accent-color-120: oklch(from var(--dx-accent-color-source) calc(l - 2 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 2 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dx-accent-color-130: oklch(from var(--dx-accent-color-source) calc(l - 3 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 3 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dx-accent-color-140: oklch(from var(--dx-accent-color-source) calc(l - 4 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 4 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dx-accent-color-150: oklch(from var(--dx-accent-color-source) calc(l - 5 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 5 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dx-accent-color-160: oklch(from var(--dx-accent-color-source) calc(l - 6 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 6 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dx-accent-color-170: oklch(from var(--dx-accent-color-source) calc(l - 7 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 7 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dx-accent-color-180: oklch(from var(--dx-accent-color-source) min(l, var(--dx-accent-lightness-min)) min(c, var(--dx-accent-chroma-min)) h); + } +} diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss b/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss index 4f1f3ae5a2bd..ea0ddb0dfaff 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss @@ -21,6 +21,7 @@ $accent: colors.$color !default; @include meta.load-css("../../_design-system/base"); @include meta.load-css("../../_design-system/fluent/base"); @include meta.load-css("../../_design-system/fluent/accents/#{$accent}"); +@include meta.load-css("accent-color"); @include meta.load-css("../../_design-system/fluent/semantic/typography"); @include meta.load-css("../../_design-system/fluent/mode-shared"); diff --git a/packages/devextreme-scss/tests/accent-palette.test.ts b/packages/devextreme-scss/tests/accent-palette.test.ts new file mode 100644 index 000000000000..03309097eb4c --- /dev/null +++ b/packages/devextreme-scss/tests/accent-palette.test.ts @@ -0,0 +1,128 @@ +import { existsSync, readFileSync, readdirSync } from 'fs'; +import { dirname, join } from 'path'; + +const contract = require('../tools/naming/accent-contract.json') as { + declaredIn: string; + input: { name: string }; + source: { name: string }; + settings: { name: string }[]; + steps: { prefix: string; values: number[] }; +}; + +const packageRoot = process.cwd(); +const accentStylesheet = join(packageRoot, 'scss', 'widgets', ...contract.declaredIn.split('/')); +const generatedPalettes = join(packageRoot, 'scss', '_design-system', 'fluent', 'accents'); +const artifactsCss = join(packageRoot, '..', 'devextreme', 'artifacts', 'css'); +const tokensDir = dirname(require.resolve('@devexpress/design-tokens-internal/package.json')); +const designedPalettes = join(tokensDir, 'tokens', 'base', 'colors', 'palettes', 'fluent'); + +const contractSteps = contract.steps.values.map(String); +const stepName = (step: string | number): string => `${contract.steps.prefix}${step}`; + +const paletteNames = readdirSync(designedPalettes) + .filter((name) => name.endsWith('.json')) + .map((name) => name.replace('.json', '')) + .sort(); + +const designedSteps = (palette: string): [string, string][] => Object.entries( + JSON.parse(readFileSync(join(designedPalettes, `${palette}.json`), 'utf8')).primary as + Record, +).map(([step, token]) => [step, token.$value.toLowerCase()]); + +const generatedPalette = (palette: string): string => readFileSync( + join(generatedPalettes, `${palette}.scss`), + 'utf8', +); + +const stylesheet = (): string => readFileSync(accentStylesheet, 'utf8'); + +const bundleNames = existsSync(artifactsCss) + ? readdirSync(artifactsCss).filter((name) => /^dx\.fluent-next\.[a-z0-9.]+\.css$/.test(name)).sort() + : []; + +test('the stylesheet declares exactly what the contract names', () => { + const declared = [...stylesheet().matchAll(/(--dx-accent[a-z0-9-]*):/g)].map((match) => match[1]); + + expect(declared).toEqual([ + contract.source.name, + ...contract.settings.map(({ name }) => name), + ...contractSteps.map(stepName), + ]); + expect(stylesheet()).toContain(`var(${contract.input.name})`); +}); + +test('the generator is wired to every designed palette', () => { + expect(readdirSync(generatedPalettes).filter((name) => name.endsWith('.scss')) + .map((name) => name.replace('.scss', '')).sort()).toEqual(paletteNames); +}); + +test('every generated palette hands each designed step to the accent, keeping the designed value', () => { + const offenders = paletteNames.flatMap((palette) => { + const generated = generatedPalette(palette); + return designedSteps(palette) + .filter(([step, value]) => !generated.includes( + `--dxds-primary-${step}: var(${stepName(step)}, ${value});`, + )) + .map(([step]) => `${palette}: step ${step} is not wrapped into ${stepName(step)} with its ` + + `designed value — ${/--dxds-primary-\d+: .*/ + .exec(generated.slice(generated.indexOf(`--dxds-primary-${step}:`)))?.[0] + ?? 'the step is missing'}`); + }); + + expect(offenders).toEqual([]); +}); + +test('the palettes read back exactly the steps the contract fixes', () => { + const readBack = paletteNames.map((palette) => [ + palette, + [...generatedPalette(palette).matchAll(new RegExp(`var\\(${contract.steps.prefix}(\\d+),`, 'g'))] + .map((match) => match[1]), + ]); + + expect(readBack).toEqual(paletteNames.map((palette) => [palette, contractSteps])); +}); + +test('no step multiplies and divides by the same number — Chrome 145 folds that term to zero', () => { + const degenerateFactors = (expression: string): string[] => { + const offenders: string[] = []; + const multiplier = /(\d+) \* \(/g; + let match = multiplier.exec(expression); + while (match) { + let depth = 1; + let index = match.index + match[0].length; + while (depth > 0 && index < expression.length) { + if (expression[index] === '(') depth += 1; + if (expression[index] === ')') depth -= 1; + index += 1; + } + const divisor = /^ \/ (\d+)/.exec(expression.slice(index)); + if (divisor?.[1] === match[1]) offenders.push(`${match[1]} * (…) / ${divisor[1]}`); + match = multiplier.exec(expression); + } + return offenders; + }; + + const offenders = [...stylesheet().matchAll(/--dx-accent-color-(\d+): (.+);/g)] + .flatMap(([, step, value]) => degenerateFactors(value).map((shape) => `step ${step}: ${shape}`)); + + expect(offenders).toEqual([]); +}); + +test('the built bundles carry both halves of the accent', () => { + expect(bundleNames.length).toBeGreaterThan(0); + + const offenders = bundleNames.flatMap((name) => { + const css = readFileSync(join(artifactsCss, name), 'utf8'); + const computed = [...css.matchAll(/--dx-accent-color-(\d+):/g)].map((match) => match[1]); + const unwrapped = [...css.matchAll(/--dxds-primary-(\d+): ?(?!var\()/g)].map((match) => match[1]); + return [ + ...(/@supports \(color: ?oklch\(from red l c h\)\)/.test(css) + ? [] : [`${name}: the computed steps are not gated by @supports`]), + ...(computed.join() === contractSteps.join() + ? [] : [`${name}: computes steps [${computed.join(', ')}]`]), + ...unwrapped.map((step) => `${name}: primary step ${step} bypasses ${stepName(step)}`), + ]; + }); + + expect(offenders).toEqual([]); +}); diff --git a/packages/devextreme-scss/tests/calc-budget.json b/packages/devextreme-scss/tests/calc-budget.json index 903e8e4de13a..a69de74fcb7f 100644 --- a/packages/devextreme-scss/tests/calc-budget.json +++ b/packages/devextreme-scss/tests/calc-budget.json @@ -1,18 +1,18 @@ { "dx.fluent-next.blue.dark.compact.css": { - "calcOccurrences": 359, + "calcOccurrences": 389, "declarationsWithDeepCalc": 5 }, "dx.fluent-next.blue.dark.css": { - "calcOccurrences": 359, + "calcOccurrences": 389, "declarationsWithDeepCalc": 5 }, "dx.fluent-next.blue.light.compact.css": { - "calcOccurrences": 359, + "calcOccurrences": 389, "declarationsWithDeepCalc": 5 }, "dx.fluent-next.blue.light.css": { - "calcOccurrences": 359, + "calcOccurrences": 389, "declarationsWithDeepCalc": 5 } } diff --git a/packages/devextreme-scss/tests/fluent-next-naming.test.ts b/packages/devextreme-scss/tests/fluent-next-naming.test.ts index 76fa000e89f5..3943aeb32a0b 100644 --- a/packages/devextreme-scss/tests/fluent-next-naming.test.ts +++ b/packages/devextreme-scss/tests/fluent-next-naming.test.ts @@ -175,6 +175,30 @@ const RUNTIME_CONTRACT = new Set( .map(({ name }) => name), ); +/* + * The application -> CSS contract of the custom accent, the second category outside the component + * tier: one colour written by the application and the scale fluent-next derives from it. The names, + * the reasons and the step set live in tools/naming/accent-contract.json, next to the runtime + * contract; tests/accent-palette.test.ts holds the stylesheet and the generated palettes to it. + */ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const accentContract = require('../tools/naming/accent-contract.json') as { + declaredIn: string; + input: { name: string }; + source: { name: string }; + settings: { name: string }[]; + steps: { prefix: string; values: number[] }; +}; +const isAccentContractFile = (file: string): boolean => file.endsWith( + join(...accentContract.declaredIn.split('/')), +); +const ACCENT_CONTRACT = new Set([ + accentContract.input.name, + accentContract.source.name, + ...accentContract.settings.map(({ name }) => name), + ...accentContract.steps.values.map((step) => `${accentContract.steps.prefix}${step}`), +]); + /** Every `--dx-*` read anywhere outside the theme sources, or null when the monorepo is unavailable. */ const publicNameConsumers = (): Set | null => { const roots = [ @@ -436,7 +460,7 @@ const findings = { publicSurfaceUnused: (() => { const declared = new Set(); THEMES.forEach((theme) => walk(join(packageRoot, 'scss', 'widgets', theme), '.scss') - .filter((file) => !isPublicTierFile(file)) + .filter((file) => !isPublicTierFile(file) && !isAccentContractFile(file)) .forEach((file) => [...stripScssComments(readFileSync(file, 'utf8'), sourceLabel(file)) .matchAll(/(--dx-[a-z0-9-]+)\s*:/g)] .forEach((match) => declared.add(match[1])))); @@ -448,14 +472,15 @@ const findings = { publicSurfaceUndeclared: (() => { const declared = new Set(); THEMES.forEach((theme) => walk(join(packageRoot, 'scss', 'widgets', theme), '.scss') - .filter((file) => !isPublicTierFile(file)) + .filter((file) => !isPublicTierFile(file) && !isAccentContractFile(file)) .forEach((file) => [...stripScssComments(readFileSync(file, 'utf8'), sourceLabel(file)) .matchAll(/(--dx-[a-z0-9-]+)\s*:/g)] .forEach((match) => declared.add(match[1])))); const consumers = publicNameConsumers(); if (consumers === null) return []; return [...consumers] - .filter((name) => !declared.has(name) && !RUNTIME_CONTRACT.has(name)) + .filter((name) => !declared.has(name) && !RUNTIME_CONTRACT.has(name) + && !ACCENT_CONTRACT.has(name)) .sort(); })(), @@ -463,7 +488,7 @@ const findings = { const perTheme = THEMES.map((theme) => { const names = new Set(); walk(join(packageRoot, 'scss', 'widgets', theme), '.scss') - .filter((file) => !isPublicTierFile(file)) + .filter((file) => !isPublicTierFile(file) && !isAccentContractFile(file)) .forEach((file) => { [...stripScssComments(readFileSync(file, 'utf8'), sourceLabel(file)).matchAll(/(--dx-[a-z0-9-]+)\s*:/g)] .forEach((match) => names.add(match[1])); @@ -484,7 +509,7 @@ const findings = { * bits). Exact list by design: a new manual emission is a conscious baseline edit. */ publicTierManualDeclarations: walk(themeRoot, '.scss') - .filter((file) => !isPublicTierFile(file)) + .filter((file) => !isPublicTierFile(file) && !isAccentContractFile(file)) .flatMap((file) => [...declarationBody(readFileSync(file, 'utf8'), sourceLabel(file)) .matchAll(/(--dx-[a-z0-9-]+)\s*:/g)] .map((match) => `${sourceLabel(file)}: ${match[1]}`)) @@ -962,6 +987,7 @@ test('component tier: every --dx-… read in the theme resolves to a declared na const declared = new Set([ ...[...tierDeclared.keys()].map((variable) => `--dx-${variable.slice(1)}`), ...RUNTIME_CONTRACT, + ...ACCENT_CONTRACT, ...findings.publicTierManualDeclarations.map((entry) => entry.slice(entry.indexOf(': ') + 2)), ]); const READS = [ diff --git a/packages/devextreme-scss/tools/naming/accent-contract.json b/packages/devextreme-scss/tools/naming/accent-contract.json new file mode 100644 index 000000000000..3a282a85da51 --- /dev/null +++ b/packages/devextreme-scss/tools/naming/accent-contract.json @@ -0,0 +1,33 @@ +{ + "comment": "The application -> CSS contract of the custom accent, the mirror image of runtime-contract.json's JS -> CSS one: an application writes ONE colour into --dx-accent-color and scss/widgets/fluent-next/_accent-color.scss derives the whole primary scale from it with relative colour syntax. These names are not part of the component tier - `accent` is neither a component nor a registered system concern - and fluent-next is the only theme that derives a palette, so tests/fluent-next-naming.test.ts excludes them from the public-surface checks. tests/accent-palette.test.ts holds the stylesheet, the generated palettes and the built bundles to this file.", + "declaredIn": "fluent-next/_accent-color.scss", + "input": { + "name": "--dx-accent-color", + "setBy": "the application, on the root element", + "whenUnset": "every derived step is invalid at computed-value time, so each generated palette keeps the designed colour through var(--dx-accent-color-, )" + }, + "source": { + "name": "--dx-accent-color-source", + "purpose": "the single read of the application's name, so the 18 step expressions do not each carry it" + }, + "settings": [ + { + "name": "--dx-accent-lightness-max", + "purpose": "the lightness the light end of the scale arrives at" + }, + { + "name": "--dx-accent-lightness-min", + "purpose": "the lightness the dark end of the scale arrives at" + }, + { + "name": "--dx-accent-chroma-min", + "purpose": "the chroma both ends fade to" + } + ], + "steps": { + "prefix": "--dx-accent-color-", + "values": [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180], + "readIn": "_design-system/fluent/accents/*.scss", + "fallback": "the designed value of the step, so an unset accent leaves the theme exactly as drawn" + } +}