From 13b66cf08aa9d22dc210d28ab380a8adcd451d4b Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Wed, 19 Aug 2026 15:42:19 +0200 Subject: [PATCH 1/2] fix fetch of css custom properties values --- src/common/utils/CssCustomProperties.ts | 190 ++++++++++++---------- src/common/utils/colorHash.ts | 25 +-- src/common/utils/getColorConfiguration.ts | 67 +++----- 3 files changed, 145 insertions(+), 137 deletions(-) diff --git a/src/common/utils/CssCustomProperties.ts b/src/common/utils/CssCustomProperties.ts index 4d0b3c28..7bb2fe70 100644 --- a/src/common/utils/CssCustomProperties.ts +++ b/src/common/utils/CssCustomProperties.ts @@ -1,26 +1,43 @@ /** - * Based on CSS Tricks tutorial. - * @see https://css-tricks.com/how-to-get-all-custom-properties-on-a-page-in-javascript/ + * Read CSS custom properties from the DOM. + * + * We do not collect them from the CSSOM (`document.styleSheets`) on purpose: + * declarations can be nested inside grouping rules (`@layer`, `@media`, `@supports`, `@container`), + * they can sit in cross origin stylesheets that must not be read at all, and their document order + * does not represent the cascade anymore (unlayered declarations win over layered ones). + * Reading the computed style of an element instead always returns the values the browser really + * applies, including already resolved `var()` references. */ -type AllowedCSSRule = CSSStyleRule | CSSPageRule; // they have necessary `selectorText` and `style` properties +type CustomPropertyEntry = [string, string]; -interface getLocalCssStyleRulesProps { - cssRuleType?: "CSSStyleRule"; +/** Element that supports the CSS typed object model, we only need to iterate over the property names. */ +type TypedOMElement = Element & { + computedStyleMap?: () => { forEach: (callback: (value: unknown, propertyName: string) => void) => void }; +}; + +const rootSelectors = [":root", "html", ":root:root"]; +const classSelectorPattern = /^(?:\.-?[_a-zA-Z][\w-]*)+$/; + +interface getCustomPropertiesProps { + /** + * Selector of the element the custom properties are read from. + * `:root` and `html` are mapped to the root element of the document. + * For any other selector the first matching element is used, if nothing matches and the + * selector consists of class names only, then a temporary hidden element is created. + */ selectorText?: string; -} -interface getLocalCssStyleRulePropertiesProps extends getLocalCssStyleRulesProps { - propertyType?: "all" | "normal" | "custom"; -} -interface getCustomPropertiesProps extends getLocalCssStyleRulesProps { + /** Only return custom properties whose name (including the `--` prefix) passes this test. */ filterName?: (name: string) => boolean; + /** Remove the leading `--` from the returned property names. */ removeDashPrefix?: boolean; + /** Return an object instead of a list of name and value pairs. */ returnObject?: boolean; } export default class CssCustomProperties { getterDefaultProps = {} as getCustomPropertiesProps; - customprops = {}; + customprops = {} as CustomPropertyEntry[] | Record; constructor(props: getCustomPropertiesProps = {}) { this.getterDefaultProps = props; @@ -28,13 +45,13 @@ export default class CssCustomProperties { // Methods - customProperties = (props: getCustomPropertiesProps = {}): [string, string][] | Record => { + customProperties = (props: getCustomPropertiesProps = {}): CustomPropertyEntry[] | Record => { // FIXME: - // in case of performance issues results should get saved at least into intern variables - // other cache strategies could be also tested - if (Object.keys(this.customprops).length > 1) { + // in case of performance issues other cache strategies could be also tested + if (Object.keys(this.customprops).length > 0) { return this.customprops; } + // an empty result is not cached, the stylesheets may be loaded later on const customprops = CssCustomProperties.listCustomProperties({ ...this.getterDefaultProps, ...props, @@ -43,88 +60,93 @@ export default class CssCustomProperties { return customprops; }; - static listLocalStylesheets = (): CSSStyleSheet[] => { - if (document && document.styleSheets) { - return (Array.from(document.styleSheets) as CSSStyleSheet[]).filter((stylesheet) => { - // is inline stylesheet or from same domain - if (!stylesheet.href) { - return true; - } - return stylesheet.href.indexOf(window.location.origin) === 0; - }); + /** + * Return the element the custom properties of a selector can be read from. + * The second item of the returned tuple removes a temporarily created element again. + */ + static targetElement = (selectorText: string = ":root"): [Element | undefined, (() => void) | undefined] => { + if (typeof document === "undefined") { + return [undefined, undefined]; } - return [] as CSSStyleSheet[]; - }; + if (rootSelectors.includes(selectorText.trim().toLowerCase())) { + return [document.documentElement, undefined]; + } - static listLocalCssRules = (): CSSRule[] => { - return CssCustomProperties.listLocalStylesheets() - .map((stylesheet) => { - return Array.from(stylesheet.cssRules); - }) - .flat(); + try { + const existingElement = document.querySelector(selectorText); + if (existingElement) { + return [existingElement, undefined]; + } + } catch { + // selector cannot be used by the DOM API, we try to create a placeholder below + } + + if (!classSelectorPattern.test(selectorText)) { + return [undefined, undefined]; + } + + // we need an element inside the DOM, otherwise the browser does not calculate the values for us + const placeholder = document.createElement("div"); + placeholder.classList.add(...selectorText.split(".").filter(Boolean)); + placeholder.setAttribute("style", "display: none"); + (document.body ?? document.documentElement).appendChild(placeholder); + + return [placeholder, () => placeholder.remove()]; }; - static listLocalCssStyleRules = (filter: getLocalCssStyleRulesProps = {}): CSSStyleRule[] => { - const { cssRuleType = "CSSStyleRule", selectorText } = filter; - const cssStyleRules = CssCustomProperties.listLocalCssRules().filter((rule) => { - const cssrule = rule as AllowedCSSRule; - if (cssrule.style) { - if (cssrule.constructor.name !== cssRuleType) { - return false; - } - if (!!selectorText && cssrule.selectorText !== selectorText) { - return false; - } - return true; - } else { - return false; + static listElementCustomProperties = (element: Element): CustomPropertyEntry[] => { + const documentView = element.ownerDocument?.defaultView; + if (!documentView) { + return []; + } + + const computedStyle = documentView.getComputedStyle(element); + const propertyNames = new Set(); + + for (let i = 0; i < computedStyle.length; i++) { + const propertyName = computedStyle.item(i); + if (propertyName.startsWith("--")) { + propertyNames.add(propertyName); } - }); - return cssStyleRules as CSSStyleRule[]; - }; + } - static listLocalCssStyleRuleProperties = (filter: getLocalCssStyleRulePropertiesProps = {}): string[][] => { - const { propertyType = "all", ...otherFilters } = filter; - return CssCustomProperties.listLocalCssStyleRules(otherFilters) - .map((cssrule) => { - return [...(cssrule as CSSStyleRule).style].map((propertyname) => { - return [propertyname.trim(), (cssrule as CSSStyleRule).style.getPropertyValue(propertyname).trim()]; - }); - }) - .flat() - .filter((declaration) => { - switch (propertyType) { - case "normal": - return declaration[0].indexOf("--") !== 0; - case "custom": - return declaration[0].indexOf("--") === 0; + const typedOMElement = element as TypedOMElement; + if (propertyNames.size === 0 && typeof typedOMElement.computedStyleMap === "function") { + // Chromium before v141 does not enumerate custom properties in `getComputedStyle()`, + // but they are available via the typed object model + typedOMElement.computedStyleMap().forEach((_value, propertyName) => { + if (propertyName.startsWith("--")) { + propertyNames.add(propertyName); } - return true; // case "all" }); + } + + return [...propertyNames].map((propertyName) => [ + propertyName, + computedStyle.getPropertyValue(propertyName).trim(), + ]); }; static listCustomProperties = ( props: getCustomPropertiesProps = {}, - ): [string, string][] | Record => { - const { removeDashPrefix = true, returnObject = true, filterName = () => true, ...filterProps } = props; - - const customProperties = CssCustomProperties.listLocalCssStyleRuleProperties({ - ...filterProps, - propertyType: "custom", - }) - .filter((declaration) => { - return filterName(declaration[0]); - }) - .map((declaration) => { - if (removeDashPrefix) { - return [declaration[0].substr(2), declaration[1]]; - } - return declaration; - }); + ): CustomPropertyEntry[] | Record => { + const { selectorText = ":root", removeDashPrefix = true, returnObject = true, filterName = () => true } = props; + + const [element, removePlaceholder] = CssCustomProperties.targetElement(selectorText); - return returnObject - ? (Object.fromEntries(customProperties) as Record) - : (customProperties as [string, string][]); + try { + const customProperties = (element ? CssCustomProperties.listElementCustomProperties(element) : []) + .filter(([propertyName]) => filterName(propertyName)) + .map(([propertyName, value]): CustomPropertyEntry => { + return [removeDashPrefix ? propertyName.slice(2) : propertyName, value]; + }); + + return returnObject + ? (Object.fromEntries(customProperties) as Record) + : (customProperties as CustomPropertyEntry[]); + } finally { + removePlaceholder?.(); + } }; } diff --git a/src/common/utils/colorHash.ts b/src/common/utils/colorHash.ts index 300ec562..b7af083f 100644 --- a/src/common/utils/colorHash.ts +++ b/src/common/utils/colorHash.ts @@ -27,22 +27,23 @@ export function getEnabledColorsFromPalette(props: getEnabledColorsProps): Color const configId = JSON.stringify({ includePaletteGroup: props.includePaletteGroup, includeColorWeight: props.includeColorWeight, + minimalColorDistance: props.minimalColorDistance, }); if (getEnabledColorsFromPaletteCache.has(configId)) { return getEnabledColorsFromPaletteCache.get(configId)!; } - const colorPropertiesFromPalette = Object.values(getEnabledColorPropertiesFromPalette(props)); + const colorsFromPalette = getEnabledColorPropertiesFromPalette(props).map((color) => { + return Color(color[1]); + }); - getEnabledColorsFromPaletteCache.set( - configId, - colorPropertiesFromPalette.map((color) => { - return Color(color[1]); - }), - ); + if (colorsFromPalette.length > 0) { + // an empty result is not cached, the stylesheets may be loaded later on + getEnabledColorsFromPaletteCache.set(configId, colorsFromPalette); + } - return getEnabledColorsFromPaletteCache.get(configId)!; + return colorsFromPalette; } export function getEnabledColorPropertiesFromPalette({ @@ -54,6 +55,7 @@ export function getEnabledColorPropertiesFromPalette({ const configId = JSON.stringify({ includePaletteGroup, includeColorWeight, + minimalColorDistance, }); if (getEnabledColorPropertiesFromPaletteCache.has(configId)) { @@ -93,9 +95,12 @@ export function getEnabledColorPropertiesFromPalette({ }, colorsFromPaletteValues) : colorsFromPaletteValues; - getEnabledColorPropertiesFromPaletteCache.set(configId, colorsFromPaletteWithEnoughDistance); + if (colorsFromPaletteWithEnoughDistance.length > 0) { + // an empty result is not cached, the stylesheets may be loaded later on + getEnabledColorPropertiesFromPaletteCache.set(configId, colorsFromPaletteWithEnoughDistance); + } - return getEnabledColorPropertiesFromPaletteCache.get(configId)!; + return colorsFromPaletteWithEnoughDistance; } function getColorcode(text: string): ColorOrFalse { diff --git a/src/common/utils/getColorConfiguration.ts b/src/common/utils/getColorConfiguration.ts index 11ac0371..eb562649 100644 --- a/src/common/utils/getColorConfiguration.ts +++ b/src/common/utils/getColorConfiguration.ts @@ -17,49 +17,30 @@ const colorConfigurationMemo = new Map>(); const getColorConfiguration = (configId: colorconfigs): Record => { if (!colorConfigurationMemo.has(configId)) { const selectorClass = `${eccgui}-configuration--colors__${configId}`; - colorConfigurationMemo.set( - configId, - Object.fromEntries( - ( - new CssCustomProperties({ - selectorText: `.${selectorClass}`, - removeDashPrefix: true, - returnObject: false, - }).customProperties() as string[][] - ).map((setting) => { - // check if the value could be a color - - let testColorValue = setting[1]; - // check if value itself is a reference to another css custom property - if (testColorValue.slice(0, 3) === "var") { - // we currently only extract the first part and ignore any fallbacks - const customPropertyName = /var\(\s*(--[a-zA-Z0-9_-]+)/g.exec(testColorValue); - if (customPropertyName && customPropertyName[1]) { - let selectorElement = document.getElementsByClassName(selectorClass)[0]; - if (!selectorElement) { - // we need to add an empty element that the JS API can read the value of the custom prop - selectorElement = document.createElement("div"); - selectorElement.classList.add(selectorClass); - selectorElement.setAttribute("style", "display: none"); - document.body.appendChild(selectorElement); - } - // only check 1 time, not recursive - testColorValue = getComputedStyle(selectorElement).getPropertyValue(customPropertyName[1]); - } - } - - try { - if (Color(testColorValue)) { - return [setting[0], testColorValue]; - } else { - return [setting[0], undefined]; - } - } catch { - return [setting[0], undefined]; - } - }), - ) as Record, - ); + const colorConfiguration = Object.fromEntries( + ( + new CssCustomProperties({ + selectorText: `.${selectorClass}`, + removeDashPrefix: true, + returnObject: false, + }).customProperties() as string[][] + ).map((setting) => { + // check if the value could be a color, `var()` references are already resolved + try { + Color(setting[1]); + return [setting[0], setting[1]]; + } catch { + return [setting[0], undefined]; + } + }), + ) as Record; + + if (Object.keys(colorConfiguration).length === 0) { + // an empty result is not cached, the stylesheets may be loaded later on + return colorConfiguration; + } + + colorConfigurationMemo.set(configId, colorConfiguration); } return colorConfigurationMemo.get(configId)!; }; From 7b7746e56d7a45f8f1d2fdb45e65ea27bff2afbd Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Wed, 19 Aug 2026 15:42:35 +0200 Subject: [PATCH 2/2] update changelog --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ceda7e5..10b90756 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - the two-column display is only used if the container is wide enough, this way property name columns do not get too small - in narrower containers, property name and value are displayed as stacked rows - make the breakpoint configurable via SCSS (`$eccgui-propertyvalue-size-column-breakpoint-small`) +- `utils` + - CSS custom properties are read from the computed style of an element now instead of collecting them from the CSSOM, so returned values are always resolved values, e.g. `var()` references are already replaced + - `CssCustomProperties` no longer provides the CSSOM based helpers `listLocalStylesheets()`, `listLocalCssRules()`, `listLocalCssStyleRules()` and `listLocalCssStyleRuleProperties()` ### Fixed @@ -25,6 +28,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `` - default handle class names were removed as soon as an `intent` was given - fix runtime error if the element holding the handle tools is not available +- `utils` + - CSS custom properties are also found if the `:root` rule defining them is nested inside a cascade layer (`@layer`) or any other grouping rule; this affects `textToColorHash()`, `getEnabledColorsFromPalette()`, `getEnabledColorPropertiesFromPalette()` and `getColorConfiguration()` + - empty results are not cached anymore, this way they are read again if the stylesheets are loaded later on + - `minimalColorDistance` is part of the cache key of `getEnabledColorsFromPalette()` and `getEnabledColorPropertiesFromPalette()` now ## [26.0.0] - 2026-07-08