diff --git a/src/__tests__/siteImport/cssToStyleRules.test.ts b/src/__tests__/siteImport/cssToStyleRules.test.ts index e40c54e30..218534731 100644 --- a/src/__tests__/siteImport/cssToStyleRules.test.ts +++ b/src/__tests__/siteImport/cssToStyleRules.test.ts @@ -760,3 +760,45 @@ describe('cssToStyleRules — custom @media as conditional layers (no warnings)' expect(a.contextStyles[cid]).toMatchObject({ color: 'red', fontSize: '14px' }) }) }) + +// --------------------------------------------------------------------------- +// Shorthand expansion: `none` must never reach a colour longhand. +// +// `background: none` and `border: none` are common resets. Expanding them can +// put `none` in colour longhands. Browsers reject those declarations, causing +// an authored reset to stop resetting and expose an underlying theme style. +// --------------------------------------------------------------------------- + +describe('cssToStyleRules — shorthand expansion into colour longhands', () => { + it('background: none does not publish background-color: none', () => { + const { rules } = cssToStyleRules('.close { background: none }') + expect(rules[0].styles.backgroundImage).toBe('none') + expect(rules[0].styles.backgroundColor).toBe('initial') + }) + + it('border: none does not publish border-*-color: none', () => { + const { rules } = cssToStyleRules('.close { border: none }') + expect(rules[0].styles.borderTopStyle).toBe('none') + for (const side of ['borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor']) { + expect(rules[0].styles[side]).toBe('initial') + } + }) + + it('a real colour is left exactly as authored', () => { + const { rules } = cssToStyleRules('.close { background: #e6e6e6; border: 1px solid #ccc }') + expect(rules[0].styles.backgroundColor).toBe('#e6e6e6') + expect(rules[0].styles.borderTopColor).toBe('#ccc') + }) + + it('none is still honoured on the properties that accept it', () => { + const { rules } = cssToStyleRules('.close { background-image: none; border-style: none }') + expect(rules[0].styles.backgroundImage).toBe('none') + expect(rules[0].styles.borderTopStyle).toBe('none') + }) + + it('none survives verbatim in camelCase and kebab-case custom properties', () => { + const { rules } = cssToStyleRules('.card { --myColor: none; --my-color: none }') + expect(rules[0].styles['--myColor']).toBe('none') + expect(rules[0].styles['--my-color']).toBe('none') + }) +}) diff --git a/src/core/css-substitution/index.ts b/src/core/css-substitution/index.ts index 5f6e10410..e9bfd0953 100644 --- a/src/core/css-substitution/index.ts +++ b/src/core/css-substitution/index.ts @@ -230,6 +230,25 @@ export interface CssDeclarationBlock { priorities: CSSDeclarationPriorityBag } +/** + * Repair a longhand expanded from a shorthand. + * + * `background: none` and `border: none` are everyday CSS, but expansion can + * put `none` into colour longhands such as `background-color` and + * `border-top-color`. No colour property accepts `none`, so the browser drops + * that declaration and the reset stops resetting. + * + * `initial` supplies the intended initial value in both cases: `transparent` + * for a background and `currentcolor` for a border. + * This intentionally also repairs an author-typed invalid colour longhand; + * otherwise the browser would drop it and could preserve an earlier value. + */ +function repairExpandedShorthandValue(camel: string, value: string): string { + if (camel.startsWith('--')) return value + if (!camel.endsWith('Color')) return value + return value.trim().toLowerCase() === 'none' ? 'initial' : value +} + /** * Walk a parsed `CSSStyleDeclaration` into a camelCase property bag: * decodes substitution markers back to their real property @@ -262,7 +281,7 @@ export function readCssDeclarationBlock( onBlockedProperty?.(camel, kebab) continue } - styles[camel] = value + styles[camel] = repairExpandedShorthandValue(camel, value) if (style.getPropertyPriority(rawKebab).toLowerCase() === 'important') { priorities[camel] = 'important' }