Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/__tests__/siteImport/cssToStyleRules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
21 changes: 20 additions & 1 deletion src/core/css-substitution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
}
Expand Down