diff --git a/.ai/rules/code-conformance.md b/.ai/rules/code-conformance.md index 555d57e117f..511f538b86d 100644 --- a/.ai/rules/code-conformance.md +++ b/.ai/rules/code-conformance.md @@ -64,6 +64,7 @@ Reference: [Linting tools](../../CONTRIBUTOR-DOCS/02_style-guide/03_linting-tool **What to check:** +- Every item in the Component CSS PR checklist passes — work through it explicitly, do not skim - CSS property ordering matches the documented order - Custom property naming follows the convention - No patterns from the anti-patterns guide are present diff --git a/.ai/rules/migration-phase-awareness.md b/.ai/rules/migration-phase-awareness.md index a3d06e2525c..018b7ed697d 100644 --- a/.ai/rules/migration-phase-awareness.md +++ b/.ai/rules/migration-phase-awareness.md @@ -17,12 +17,13 @@ Apply any time: ## Before declaring a phase complete -Before ending a response that declares a migration phase complete, verify against two sources and perform one update: +Before ending a response that declares a migration phase complete, verify against two sources and perform two updates: 1. **Phase skill quality gate** — every checklist item in the active skill's quality gate section 2. **Migration plan** — read `CONTRIBUTOR-DOCS/03_project-planning/03_components/[component]/migration-plan.md` and verify that the work done in this phase matches what the plan specifies for it. If the plan is missing, note that as a risk. If the implementation drifted from the plan, call it out explicitly — do not silently accept drift. -3. **Consistency pass** — the checks described in `.ai/rules/consistency-pass.md` have been run on files changed in this phase, or are explicitly deferred and noted in the checkpoint -4. **Status table** — update `CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/01_status.md` to reflect the completed phase for this component +3. **Migration plan checklist** — update the plan's checklist for the current phase: mark every completed item `[x]`, fill in any "to be determined" values that were resolved, and leave incomplete items unchecked with a note. The checklist is the per-component record that this phase was done and what was produced — keep it current. +4. **Consistency pass** — the checks described in `.ai/rules/consistency-pass.md` have been run on files changed in this phase, or are explicitly deferred and noted in the checkpoint +5. **Status table** — update `CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/01_status.md` to reflect the completed phase for this component Do not silently skip incomplete items. If something cannot be completed, say so and record it in the checkpoint. @@ -35,6 +36,7 @@ When you complete a migration phase, end your response with a Migration Checkpoi **Migration Checkpoint: [Component Name]** Phase completed: [N] — [Phase Name] Next phase: [N+1] — [Phase Name] (invoke `migration-[skill]`) +Plan checklist: [updated | not updated — reason] Consistency pass: [done | not yet run | deferred — reason] Phase blockers: [items from this phase that are incomplete or at risk; "none" if clean — do not include future-phase concerns] --- diff --git a/.ai/skills/consumer-migration-guide/SKILL.md b/.ai/skills/consumer-migration-guide/SKILL.md index 99e3bcc6340..1d9665d73e6 100644 --- a/.ai/skills/consumer-migration-guide/SKILL.md +++ b/.ai/skills/consumer-migration-guide/SKILL.md @@ -48,6 +48,8 @@ Create per-component migration guidance for application developers upgrading app Before writing anything, read `CONTRIBUTOR-DOCS/03_project-planning/03_components/[component]/migration-plan.md`. Locate every item in the documentation checklist that is flagged as "deferred to consumer migration guide" — these are the breaking changes Phase 7 has already identified as needing coverage here. They are the primary input for this guide's `## What changed` and `## Update your code` sections. If the migration plan is absent, derive the breaking changes from the 1st-gen and 2nd-gen source comparison and note the risk. +**Scope the guide to what actually shipped.** The migration plan categories (Must-ship, Additive, Deferred) reflect planning intent, not final state — by the time the guide is written, some Additive items may have been implemented and some Must-ship items may have slipped. The 2nd-gen source is the only authority. For every feature you consider including: verify it exists in the shipped source. If it is there, include it regardless of how the plan categorized it. If it is not in the source, exclude it regardless of how the plan categorized it. + ### Consistent import and tag patterns All guides follow the same import and tag name conventions. Do not grep for these — derive them from the component name: diff --git a/.ai/skills/consumer-migration-guide/references/consumer-migration-guide-prompt.md b/.ai/skills/consumer-migration-guide/references/consumer-migration-guide-prompt.md index f0d8ee06436..23c521bd622 100644 --- a/.ai/skills/consumer-migration-guide/references/consumer-migration-guide-prompt.md +++ b/.ai/skills/consumer-migration-guide/references/consumer-migration-guide-prompt.md @@ -31,6 +31,7 @@ Keep the guide **short, direct, and scannable**. A consumer should be able to co - `::part()` shadow parts unless a part is explicitly public API - Maintainer-facing migration rationale or sequencing - **Links to `CONTRIBUTOR-DOCS/` project-planning docs.** Those are maintainer-facing. Do not include them in the guide. +- **Unshipped features** — do not include any feature that is not present in the 2nd-gen source, regardless of how it is categorized in the migration plan. Migration plan categories (Must-ship, Additive, Deferred) reflect planning intent; the source is the final authority on what shipped. ### Structure steps logically @@ -110,7 +111,7 @@ Replace `` with `` and update the import. The public API is Use up to three `###` sub-section tables — **only include a sub-section if it has entries**. Each sub-section is a table focused on one kind of change: - **`### Renamed`** — tag, import path, property prefixes, or other 1:1 renames. Columns: `Area | Spectrum 1 | Spectrum 2`. -- **`### Added in Spectrum 2`** — new attributes, variants, slots, or custom properties the consumer may adopt. Columns: `Addition | Notes`. +- **`### Added in Spectrum 2`** — new attributes, variants, slots, or custom properties confirmed present in the shipped 2nd-gen source that the consumer may adopt. Columns: `Addition | Notes`. Verify each entry against the source — migration plan categories (Additive, Deferred) reflect planning intent and may not match final state. - **`### Removed in Spectrum 2`** — removed public API with replacement guidance. Columns: `Removed | Replacement`. Do **not** include an `### Unchanged` sub-section. Unchanged API requires no consumer action and adds noise. diff --git a/.ai/skills/migration-documentation/SKILL.md b/.ai/skills/migration-documentation/SKILL.md index 7fe41e16d44..05be76571f1 100644 --- a/.ai/skills/migration-documentation/SKILL.md +++ b/.ai/skills/migration-documentation/SKILL.md @@ -95,6 +95,14 @@ Return to the list from Step 0. For each unchecked documentation item in the mig - If it belongs in the consumer migration guide (breaking changes, migration paths from 1st-gen), note it as deferred to the `consumer-migration-guide` skill — do **not** add it to the stories file. - If it is genuinely missing from both stories and the consumer guide, flag it to the user. +**Verify `@cssprop` completeness and accuracy.** Read the component's CSS file (`2nd-gen/packages/swc/components/[component]/[component].css`) and list every exposed `--swc-*` property. Then read the SWC class (`2nd-gen/packages/swc/components/[component]/[Component].ts`) and confirm: + +- Every exposed property has a `@cssprop` tag on the primary class export. +- Each description is accurate: it names what the property controls and its default token, with no stale or invented values. +- No `--_swc-*` private properties are tagged. + +Fix any missing or inaccurate `@cssprop` tags before marking Phase 7 complete. If Phase 5 was skipped or the tags were never added, add them now. + --- ## What NOT to include in stories JSDoc diff --git a/.ai/skills/migration-styling/SKILL.md b/.ai/skills/migration-styling/SKILL.md index 8d2f1ea906d..c9c08c2817d 100644 --- a/.ai/skills/migration-styling/SKILL.md +++ b/.ai/skills/migration-styling/SKILL.md @@ -67,3 +67,15 @@ Common case: confirming that subcomponent class names follow the single-hyphen s If a rename is needed, make the template change first, confirm the component still renders correctly in Storybook, then write the CSS. **Step 4 — Execute the phase.** Follow **[Phase 5: Styling](../../../CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/02_step-by-step/01_washing-machine-workflow.md#phase-5-styling)** in the washing machine workflow doc — it covers what to do, what to check, common problems, and the quality gate for this phase. + +**Step 5 — Document exposed custom properties.** After writing the CSS, add a `@cssprop` JSDoc tag to the SWC component class (`2nd-gen/packages/swc/components/[component]/[Component].ts`) for every exposed `--swc-*` property. Place all `@cssprop` tags on the primary SWC class export (not the core base class). Each tag should name the property and give a one-line description of what it controls, including its default token where relevant. + +```ts +/** + * @cssprop --swc-badge-height - Block size of the badge. Defaults to the medium component height token. + * @cssprop --swc-badge-background-color - Background fill. Defaults to the neutral subdued background token. + */ +export class Badge extends BadgeBase { … } +``` + +Storybook picks these up automatically and surfaces them in the API docs panel. Do not add `@cssprop` tags for private `--_swc-*` properties — those are internal only. diff --git a/.ai/skills/migration-styling/references/tldr-component-css-guidelines.md b/.ai/skills/migration-styling/references/tldr-component-css-guidelines.md index 2d3f2a33365..ee1bf66e1e2 100644 --- a/.ai/skills/migration-styling/references/tldr-component-css-guidelines.md +++ b/.ai/skills/migration-styling/references/tldr-component-css-guidelines.md @@ -72,8 +72,11 @@ ### 1. `:host` vs Component Class -Put only layout-participation styles on `:host`. Put actual visuals on `.swcComponentName` or internal parts. -→ See [01_component-css](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/01_component-css.md) +Put only layout-participation styles on `:host`. Put actual visuals on `.swc-ComponentName` or internal parts. + +**Exception**: three categories of styles may legitimately live on `:host` — each for a distinct reason: UA resets when the browser applies default styles directly to the host element (for example, the native popover stylesheet); transition properties (`opacity`, `transition-*`, `transition-behavior: allow-discrete`) when the host is itself the transition target (for example, when `@starting-style` applies to the host); and positioning surface (`position: absolute`, `inset: auto`, dimension constraints) when an external controller such as a placement controller writes coordinates directly to the host element. + +→ See [01_component-css#when-to-use-host](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/01_component-css.md#when-to-use-host) ### 2. Stylesheet Order @@ -84,6 +87,15 @@ Follow the prescribed stylesheet order to manage specificity and reduce selector Use `token()` for design token values. Use `--swc-*` only for intentionally exposed override points, and `--_swc-*` for internal/private properties. Do not keep old `--mod-*` chains. +Key rules: + +- **`--_swc-*` on `:host`/`:host()` is not private.** Properties on the host element are part of the external style surface — consumers can set them regardless of the prefix. Declare truly private properties on the internal wrapper (`.swc-ComponentName`), not on `:host`. +- **Expose only when the component itself overrides the property** based on its own variant, state, or size needs. Do not expose for consumer convenience. Exceptions: nested component relationships and shared utility properties. +- **No size-specific custom properties.** When a property changes per size, expose a single property (e.g. `--swc-button-padding-vertical`) and override it per size selector (`:host([size="s"])`). Do not create size-specific properties — they become publicly addressable API. +- **Prefer variant overrides over per-variant redefinition.** For component API attributes (size, variant, fill-style), set a CSS property once on the base using a custom property and override it per `:host([variant])`. Never create variant-specific custom property names. +- **Native browser states on internal elements need state-specific custom properties.** For properties that change across `:hover`, `:focus-visible`, `:active` on an internal wrapper (e.g. `.swc-Button`), expose one property per state (`--swc-button-background-color-default`, `-hover`, `-focus`, `-down`). Override the full set from `:host([variant])` and `:host([static-color])`. This is the mechanism that makes static-color compound overrides possible. Exception: properties that never vary by variant (e.g. `outline` on `:focus-visible`) — define those directly on the state selector. +- **Use full property names** in custom property names: `padding` not `pad`, `background` not `bg`, `color` not `clr`. + Every exposed `--swc-*` property must be documented with a `@cssprop` JSDoc tag on the primary component class export (the SWC layer class, not the core base). Storybook picks these up automatically and surfaces them in the API docs panel. ```ts @@ -125,3 +137,36 @@ Keep selector specificity at or below `(0,1,0)`. If you need a compounded select Only add `@media (forced-colors: active)` if browser defaults are not conveying correct semantic intent, and always put it at the end of the component stylesheet. → See [01_component-css#forced-colors-requirements](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/01_component-css.md#forced-colors-requirements) + +### 8. Browser API selectors + +Prefer native CSS pseudo-classes over attribute selectors when one exists for the same state (`:host(:popover-open)` not `:host([open])`; `:host(:disabled)` not `:host([disabled])`). Use attribute selectors for custom attributes and ARIA states with no native pseudo-class. +→ See [01_component-css#state-implementation-patterns](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/01_component-css.md#state-implementation-patterns) + +### 9. Sub-element inheritance + +When a sub-element must always match a variant-driven property on the parent, use `inherit` rather than repeating the `var()` reference in each variant rule. +→ See [01_component-css#variant-implementation-patterns](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/01_component-css.md#variant-implementation-patterns) + +### 10. Compound pseudo-classes on `:host()` via CSS nesting + +CSS nesting inside a `:host([...])` rule — e.g. `&:dir(rtl)` — expands to `:host([...]):dir(rtl)`, which chains the pseudo-class **outside** the `:host()` argument. Browsers do not support this; the rule silently fails with no parse error. + +```css +/* ❌ Silent failure: :dir(rtl) is outside :host() */ +:host([placement='start']:popover-open) { + &:dir(rtl) { + transform: translateX(1rem); + } +} + +/* ✅ All conditions inside the :host() argument */ +:host(:dir(rtl)[placement='start']:popover-open) { + transform: translateX(1rem); +} +``` + +**Exception**: when the parent selector targets a descendant (e.g. `:host([...]) .swc-Child`), nesting `&:dir(rtl)` correctly applies `:dir()` to the inner element — that is valid and fine. + +**Migration note**: `:dir()` is a common place this surfaces. Whenever you add `:dir()` to a `:host`-level rule, write a separate `:host(:dir(rtl)[...])` rule instead of nesting. +→ See [05_anti-patterns#9](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/05_anti-patterns.md#9-nesting-compound-pseudo-classes-on-host-via-css-nesting) diff --git a/CONTRIBUTOR-DOCS/02_style-guide/01_css/01_component-css.md b/CONTRIBUTOR-DOCS/02_style-guide/01_css/01_component-css.md index 02e071e8cff..100fc4e49fd 100644 --- a/CONTRIBUTOR-DOCS/02_style-guide/01_css/01_component-css.md +++ b/CONTRIBUTOR-DOCS/02_style-guide/01_css/01_component-css.md @@ -172,15 +172,15 @@ Use comments to explain non-obvious choices. Keep them short and use sentence ca **When to comment**: -- Section headers for long stylesheets (e.g. `/* Size variants */`) +- Section banners for long stylesheets (see format below) - Non-obvious design decisions (e.g. why a token was chosen) -- Notes about spec or migration (e.g. `/* NOTE: accent is the default color */`) +- Notes about spec or behavior (e.g. `/* NOTE: accent is the default color */`) **Style**: -- Use sentence case: `/* Adjust padding when icon is present */` not `/* Adjust Padding When Icon Is Present */` +- Use sentence case for inline and `/* NOTE: */` comments - Use `/* NOTE: */` for important caveats -- Avoid comments that repeat what the code does +- Do not comment what the selector or property value already communicates **Example from [Badge](../../../2nd-gen/packages/swc/components/badge/badge.css)**: @@ -192,6 +192,28 @@ Use comments to explain non-obvious choices. Keep them short and use sentence ca } ``` +**Section banner format**: + +Use banner comments to label distinct sections in longer stylesheets. The label is ALL CAPS. Add a rationale line only when the section's purpose or constraints are non-obvious — omit it when the label is self-explanatory. + +```css +/* ───────────────────────────────────────────────────────────────────────────── + SECTION LABEL + Optional rationale for non-obvious constraints or decisions in this section. + ───────────────────────────────────────────────────────────────────────────── */ +``` + +```css +/* ───────────────────────────────────────────────────────────────────────────── + VARIANTS + ───────────────────────────────────────────────────────────────────────────── */ + +/* ───────────────────────────────────────────────────────────────────────────── + BASE TOOLTIP + Defaulted to "top" placement (tooltip appears above trigger, tip points down ▽). + ───────────────────────────────────────────────────────────────────────────── */ +``` + ## Selector patterns Choose the right selector based on whether you need custom property exposure and how the component expresses state. @@ -210,6 +232,40 @@ Use `:host` only for layout participation. Do not put visual styles here. **Why**: `:host` is part of the public styling API. Visual styles here are harder to override. See [anti-pattern #1](05_anti-patterns.md#1-leaving-visual-styles-on-host). +#### Exception: styles that must target the host element directly + +Three categories of styles may legitimately live on `:host`, each for a distinct reason: + +1. **UA style resets** — the browser applies default styles directly to the host element (for example, the native popover stylesheet sets `padding`, `margin`, `background`, `border`, and `color` on any `[popover]` element). Those defaults cannot be overridden from an inner class and must be reset on `:host`. +2. **Entry/exit transitions** — `opacity`, `transition-*`, and `transition-behavior: allow-discrete` must be on `:host` when the host element is itself the transition target — for instance, when `@starting-style` or `overlay` applies to the host rather than a descendant. +3. **Positioning surface** — `position: absolute`, `inset: auto`, and dimension constraints belong on `:host` when an external controller (such as a placement controller) writes coordinates directly to the host element. + +```css +:host { + /* UA reset */ + padding: 0; + margin: 0; + color: unset; + background: transparent; + border: none; + overflow: visible; + + /* Positioning surface for placement controller */ + position: absolute; + inset: auto; + max-inline-size: min(100%, token("component-maximum-width")); + + /* Entry/exit transition — must be on :host for @starting-style */ + opacity: 0; + transition-property: transform, opacity, overlay, display; + transition-timing-function: ease-in-out; + transition-duration: token("animation-duration-100"); + transition-behavior: allow-discrete; +} +``` + +All other visual styles still belong on the inner wrapper class (`.swc-ComponentName`). + ### When to use `:host([attribute])` Use `:host([attribute])` when the variant or state should expose custom properties for consumer overrides. See [variant implementation patterns](#variant-implementation-patterns) and [size variant patterns](#size-variant-patterns) for detailed examples. @@ -262,6 +318,14 @@ Variants change how the component looks. Use the right selector based on customi } ``` +**Sub-elements that track a variant value**: use `inherit` on the sub-element rather than repeating the override in every variant rule. Since the sub-element is a descendant, `inherit` copies the computed value from the parent. + +```css +.swc-Tooltip-tip { + background-color: inherit; /* always matches .swc-Tooltip's variant color */ +} +``` + ## State implementation patterns States reflect user interaction or component condition. Attach them to `:host` when the host element carries the state. @@ -275,6 +339,8 @@ States reflect user interaction or component condition. Attach them to `:host` w **Why**: States on `:host` let consumers style `swc-badge[disabled]` or `swc-badge:focus-visible`. If the state lives on an internal element, target that element directly. +**Prefer native pseudo-classes**: when the browser exposes a pseudo-class that maps to the same state, use it instead of the attribute selector. `:host(:popover-open)` is correct for a component using `popover="auto"`; `:host(:disabled)` is correct where the host element carries the disabled state natively. The pseudo-class reflects actual browser state rather than a synced property. + **Note**: Badge and Status Light are non-interactive, so they do not define focus or disabled states. See interactive components (e.g. Button) for examples. **Derived states are not on `:host`**: If a state is computed from slot content (e.g. icon-only), it is not a consumer-settable attribute and must not appear in the state table above. Express it as a class modifier on the internal element via `classMap`. See [When to use classes vs attributes](#when-to-use-classes-vs-attributes). diff --git a/CONTRIBUTOR-DOCS/02_style-guide/01_css/02_custom-properties.md b/CONTRIBUTOR-DOCS/02_style-guide/01_css/02_custom-properties.md index f3a628fc5c6..113fac95dec 100644 --- a/CONTRIBUTOR-DOCS/02_style-guide/01_css/02_custom-properties.md +++ b/CONTRIBUTOR-DOCS/02_style-guide/01_css/02_custom-properties.md @@ -40,6 +40,8 @@ This guide explains how to manage **private, internal, and exposed custom proper > Private properties are “pseudo-private”: defined on nested shadow elements rather than `:host` to prevent accidental overrides. +**Use full property names** when naming custom properties: write `padding` not `pad`, `background` not `bg`, `color` not `clr`. Abbreviations obscure intent and make property names inconsistent across components. + ## Private Properties - Used for **repeated**, **multi-value**, or **contextually updated** properties (themes, states, passthroughs) @@ -54,6 +56,8 @@ This guide explains how to manage **private, internal, and exposed custom proper CSS custom properties *normally* can't actually be "private". However, due to shadow DOM encapsulation, we can (partially*) enforce them as private by defining them on a nested wrapper within the component instead of on :host. +> **Declaring `--_swc-*` properties on `:host` or `:host()` does not protect them.** Properties set on the host element are part of the component's external style surface — consumers can set them from outside the shadow root. The `--_swc-*` prefix signals internal intent only. For genuine encapsulation, declare private properties on the internal wrapper (`.swc-ComponentName`), not on `:host`. + **Example from [Badge](../../../2nd-gen/packages/swc/components/badge/badge.css)** — private properties for internal calculations, with exposed properties consumed inline via `var()`: ```css @@ -109,7 +113,8 @@ This keeps all overrides and derived calculations linked to the private property **Selector choice encodes API intent**: exposed properties are modified via `:host()`, while internal-only behavior is implemented with internal class selectors. -- Only expose component properties when needed by the component itself or for passthrough (nested) styling +- Expose only when the component itself overrides the property based on its own variant, state, or size needs. Do not expose properties for consumer convenience alone. Exceptions: properties required for nested component relationships (e.g. a Picker passing a property down to its Menu) and shared utility properties. +- When a property changes across size variants, expose a **single** property (e.g. `--swc-button-padding-vertical`) and override it per size selector (`:host([size="s"])`). Do not create size-specific custom properties (e.g. `--_swc-button-padding-vertical-s`) — they become part of the external style surface and cannot be made genuinely private. - Exposed singularly based on CSS *property type*, and no longer based on states or variants - This distinction directly affects which selector type is used (`:host()` vs internal class selectors). See [Variant Selectors and Inheritance](01_component-css.md#shadow-dom-specificity-and-custom-property-inheritance). - May be exposed via inclusion in private property, or inline with CSS property @@ -195,6 +200,14 @@ Use internal selectors (ex. `.swc-Badge--magenta` ) to pass library overrides fo ## Selector Conventions +**Prefer overriding a custom property per variant rather than redefining the CSS property.** When a CSS property changes across component API attributes (size, variant, fill-style, etc.), define it once on the base using a custom property and override that custom property in `:host([variant])` selectors. Redefining the underlying CSS property in each variant rule creates duplication and obscures which values are intentionally exposed. + +**Native browser states on internal elements require state-specific custom properties.** When a property changes across native states (`:hover`, `:focus-visible`, `:active`) on an internal element such as `.swc-ComponentName`, expose a separate custom property for each state — for example, `--swc-button-background-color-default`, `--swc-button-background-color-hover`, `--swc-button-background-color-focus`, `--swc-button-background-color-down`. Apply each on the internal element's state selector, then override the complete set from `:host([variant])` and `:host([static-color])`. This is the only way to support compound overrides from `:host`, since consumers cannot reach `.swc-ComponentName:hover` from outside the shadow root. + +This pattern does not apply to component API attributes — those use a single property overridden per selector, never variant-specific properties. + +Exception: properties that change in a native state but never need per-variant variation (e.g. `outline` and `outline-offset` on `:focus-visible`). Define these directly on the state selector without a custom property. + Exposed properties **require** `:host()` to maintain override capability: ```css @@ -207,8 +220,6 @@ Consumers can then override exposed properties based on attributes and states: ```css swc-button[size="s"] -swc-button[aria-expanded] -swc-button:focus-visible ``` ### Variant Selectors and Inheritance diff --git a/CONTRIBUTOR-DOCS/02_style-guide/01_css/03_component-css-pr-checklist.md b/CONTRIBUTOR-DOCS/02_style-guide/01_css/03_component-css-pr-checklist.md index 63c34fae82d..960095052d6 100644 --- a/CONTRIBUTOR-DOCS/02_style-guide/01_css/03_component-css-pr-checklist.md +++ b/CONTRIBUTOR-DOCS/02_style-guide/01_css/03_component-css-pr-checklist.md @@ -16,6 +16,7 @@ - [Specificity](#specificity) - [Variants & States](#variants--states) - [Cascade Layers (if used)](#cascade-layers-if-used) +- [Custom properties](#custom-properties) - [High-Contrast](#high-contrast) @@ -33,7 +34,7 @@ Use this checklist when opening or reviewing a PR. ## Layout & Tokens -- [ ] Visual values come from design tokens and are applied via `token()` +- [ ] Visual values come from design tokens and are applied via `token()` - [ ] Non-tokenized values are limited to allowed properties (layout, alignment) - [ ] Layout primitives (`gap`, alignment, min/max sizes) are preferred over margins/padding hacks - [ ] Padding values are defensive, not layout-driven, where possible @@ -58,6 +59,15 @@ Use this checklist when opening or reviewing a PR. - [ ] Unused layers are still declared - [ ] Nested layers are rare and justified +## Custom properties + +- [ ] Private `--_swc-*` properties are declared on the internal wrapper (`.swc-ComponentName`), not on `:host` or `:host()` +- [ ] Exposed `--swc-*` properties are only those the component itself overrides per variant, state, or size — not added for consumer convenience (exceptions: nested component relationships and shared utility properties) +- [ ] Size variants use a single exposed property overridden per size selector — no size-specific custom properties +- [ ] Component API attribute rules (size, variant, fill-style) override a custom property rather than redefining the CSS property directly +- [ ] Properties that change across native browser states (`:hover`, `:focus-visible`, `:active`) on internal elements use state-specific custom properties (`-default`, `-hover`, `-focus`, `-down`), overridden as a set from `:host([variant])` and `:host([static-color])` +- [ ] Custom property names use full property words: `padding` not `pad`, `background` not `bg` + ## High-Contrast - [ ] Forced-colors styles are only added when necessary diff --git a/CONTRIBUTOR-DOCS/02_style-guide/01_css/05_anti-patterns.md b/CONTRIBUTOR-DOCS/02_style-guide/01_css/05_anti-patterns.md index a0ca1c44d96..e924dabcabb 100644 --- a/CONTRIBUTOR-DOCS/02_style-guide/01_css/05_anti-patterns.md +++ b/CONTRIBUTOR-DOCS/02_style-guide/01_css/05_anti-patterns.md @@ -16,6 +16,7 @@ - [Why This Happens](#why-this-happens) - [Why This Is a Problem](#why-this-is-a-problem) - [✅ Correct Approach](#-correct-approach) + - [Exception: styles that must target the host element directly](#exception-styles-that-must-target-the-host-element-directly) - [2. Preserving `--mod-*` as an Extra Indirection Layer](#2-preserving---mod--as-an-extra-indirection-layer) - [❌ Anti-Pattern](#-anti-pattern) - [Why This Happens](#why-this-happens) @@ -56,6 +57,16 @@ - [Specificity escalation → `:where()`](#specificity-escalation--where) - [Size classes in render → `:host([size])`](#size-classes-in-render--hostsize) - [`--mod-*` chain → single property](#--mod--chain--single-property) +- [9. Nesting compound pseudo-classes on `:host()` via CSS nesting](#9-nesting-compound-pseudo-classes-on-host-via-css-nesting) + - [❌ Anti-Pattern](#-anti-pattern) + - [Why This Happens](#why-this-happens) + - [Why This Is a Problem](#why-this-is-a-problem) + - [✅ Correct Approach](#-correct-approach) +- [10. Size-Specific Custom Properties](#10-size-specific-custom-properties) + - [❌ Anti-Pattern](#-anti-pattern) + - [Why This Happens](#why-this-happens) + - [Why This Is a Problem](#why-this-is-a-problem) + - [✅ Correct Approach](#-correct-approach) - [Final Reminder](#final-reminder) @@ -110,6 +121,16 @@ See the migrated Badge where `:host` is limited to layout (`display`, `place-sel 📖 See: *Component CSS Style Guide → [Rule order](01_component-css.md#rule-order)* +### Exception: styles that must target the host element directly + +Three categories of styles may legitimately live on `:host`, each for a distinct reason: + +1. **UA style resets** — the browser applies default styles directly to the host element (for example, the native popover stylesheet). Those cannot be overridden from an inner class and must be reset on `:host`. +2. **Entry/exit transitions** — `opacity`, `transition-*`, and `transition-behavior: allow-discrete` must be on `:host` when the host element is itself the transition target (for instance, when `@starting-style` or `overlay` applies to the host rather than a descendant). +3. **Positioning surface** — `position: absolute`, `inset: auto`, and dimension constraints belong on `:host` when an external controller (such as a placement controller) writes coordinates directly to the host element. + +📖 See: *Component CSS Style Guide → [When to use `:host`](01_component-css.md#when-to-use-host)* + ## 2. Preserving `--mod-*` as an Extra Indirection Layer @@ -316,8 +337,9 @@ Badge safely compounds attributes within `:host()` when updating custom properti ### ✅ Correct Approach -- Expose only what the component itself needs +- Expose only what the component itself needs based on its own variant, state, or size requirements - Keep mechanical and derived values private +- Exception: expose properties required for nested component relationships or shared utility styling 🔎 **Badge reference:** Badge exposes a minimal, intentional surface and uses `_swc-*` properties for derived calculations. @@ -419,6 +441,113 @@ After migration, Badge relies solely on `.swc-Badge` and attributes. | ------------------------------------------------------- | -------------------------------------------------------- | | `var(--mod-badge-height, var(--spectrum-badge-height))` | `var(--swc-badge-height, token("component-height-100"))` | +## 9. Nesting compound pseudo-classes on `:host()` via CSS nesting + +### ❌ Anti-Pattern + +```css +/* Intends to target the host in RTL when placement="start" is open */ +:host([placement="start"]:popover-open) { + transform: translateX(calc(-1 * var(--_swc-component-animation-distance))); + + &:dir(rtl) { + transform: translateX(var(--_swc-component-animation-distance)); + } +} +``` + +### Why This Happens + +CSS nesting with `&` replaces `&` with the parent selector. Inside a `:host([...])` rule, `&:dir(rtl)` expands to `:host([...]):dir(rtl)` — a pseudo-class chained after the `:host()` function. This looks syntactically correct, but browsers do not support compound selectors appended outside of the `:host()` argument. + +### Why This Is a Problem + +- The rule silently fails: the `:dir()` override never applies +- No lint or parse error is produced, making it hard to detect +- Properties meant for RTL layout apply in all directions + +### ✅ Correct Approach + +Move all conditions inside the `:host()` argument as a compound selector: + +```css +:host([placement="start"]:popover-open) { + transform: translateX(calc(-1 * var(--_swc-component-animation-distance))); +} + +:host(:dir(rtl)[placement="start"]:popover-open) { + transform: translateX(var(--_swc-component-animation-distance)); +} +``` + +#### Exception: descendants are fine + +This restriction only applies when `:host()` is the outermost element being targeted. When nesting targets a **descendant** of the host, expanding `&:dir(rtl)` applies `:dir()` to the inner element — which is valid: + +```css +/* ✅ Fine: :dir(rtl) targets .swc-Component-tip, not :host() */ +:host([placement="end"]) .swc-Component-tip { + transform: rotate(45deg); + + &:dir(rtl) { + transform: rotate(-135deg); + } +} +``` + +#### Migration note: `:dir()` in RTL-aware components + +`:dir()` is the most common pseudo-class where this issue surfaces during migrations because RTL overrides are nearly always added after a component's base styles are written. When adding `:dir()` to any `:host`-level rule during migration, always write it as a separate `:host(:dir(rtl)[...])` rule rather than a nested `&:dir(rtl)`. + +## 10. Size-Specific Custom Properties + +### ❌ Anti-Pattern + +```css +:host([size="compact"]) { + --_swc-accordion-compact-padding-top: token("spacing-100"); +} +``` + +### Why This Happens + +- Attempting to keep size-specific values "private" while still referencing them in variant rules +- Mapping one custom property per size variant for clarity + +### Why This Is a Problem + +- A custom property defined on `:host([size="compact"])` is part of the component's external style surface — the `--_swc-*` prefix does not make it inaccessible from outside the shadow root +- Every size requires its own named property, bloating the API surface +- Consumers cannot override a single "padding-top" concept; they must know and target every size-specific property name + +### ✅ Correct Approach + +Expose a single property on the base and override it per size selector: + +```css +.swc-Accordion { + padding-block-start: var(--swc-accordion-padding-top, token("spacing-200")); +} + +:host([size="compact"]) { + --swc-accordion-padding-top: token("spacing-100"); +} + +:host([size="spacious"]) { + --swc-accordion-padding-top: token("spacing-300"); +} +``` + +Consumers targeting a specific size can still override via attribute selectors on the host: + +```css +swc-accordion[size="compact"] { + --swc-accordion-padding-top: var(--my-compact-spacing); +} +``` + +📖 See: *Custom Properties Style Guide → [Component custom property exposure](02_custom-properties.md#component-custom-property-exposure)* + ## Final Reminder If you find yourself: diff --git a/CONTRIBUTOR-DOCS/03_project-planning/README.md b/CONTRIBUTOR-DOCS/03_project-planning/README.md index c9d9fc827a7..4bbf025e26c 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/README.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/README.md @@ -77,8 +77,6 @@ - [Milestones](04_milestones/README.md) - Strategies - [Focus Management Strategy: 2nd-Gen Proposal](05_strategies/focus-management-strategy-rfc.md) -- Initiatives - - Decisions diff --git a/CONTRIBUTOR-DOCS/README.md b/CONTRIBUTOR-DOCS/README.md index 11f57d94f86..ab34fd58c88 100644 --- a/CONTRIBUTOR-DOCS/README.md +++ b/CONTRIBUTOR-DOCS/README.md @@ -43,7 +43,6 @@ - [Components](03_project-planning/03_components/README.md) - [Milestones](03_project-planning/04_milestones/README.md) - Strategies - - Initiatives diff --git a/linters/stylelint-property-order.js b/linters/stylelint-property-order.js index 1787ef67e95..bb2c8171dc3 100644 --- a/linters/stylelint-property-order.js +++ b/linters/stylelint-property-order.js @@ -824,6 +824,7 @@ const propertyGroups = [ 'transition-timing-function', 'transition-duration', 'transition-property', + 'transition-behavior', ], }, diff --git a/stylelint.config.js b/stylelint.config.js index fdf672f1243..2d024f7660f 100644 --- a/stylelint.config.js +++ b/stylelint.config.js @@ -83,6 +83,25 @@ export default { 'none', 'transparent', 'unset', + 'AccentColor', + 'AccentColorText', + 'ActiveText', + 'ButtonBorder', + 'ButtonFace', + 'ButtonText', + 'Canvas', + 'CanvasText', + 'Field', + 'FieldText', + 'GrayText', + 'Highlight', + 'HighlightText', + 'LinkText', + 'Mark', + 'MarkText', + 'SelectedItem', + 'SelectedItemText', + 'VisitedText', ], message: "Use a design token (CSS custom property or token() function) instead of hardcoded value for '${property}'",