diff --git a/packages/blade-core/src/styles/Button/button.module.css b/packages/blade-core/src/styles/Button/button.module.css index 260284568ae..adc658ff85d 100644 --- a/packages/blade-core/src/styles/Button/button.module.css +++ b/packages/blade-core/src/styles/Button/button.module.css @@ -8,7 +8,19 @@ outline: none; text-decoration: none; overflow: hidden; - border-radius: var(--border-radius-small); + /* + * Instance-level styling seam (Option B). `--btn-radius` defaults to the + * existing token so unspecified instances render pixel-identical; a per-instance + * styleOverride sets `--btn-radius` inline to override just this button. + */ + border-radius: var(--btn-radius, var(--border-radius-small)); + /* + * Content color seam. When a `textColor` override is present the component sets + * `--btn-content-color` and passes `color="currentColor"` to the text/icon, which + * then inherit this value. Defaults to `inherit` so without an override the + * token-class color on the text/icon is unaffected. + */ + color: var(--btn-content-color, inherit); transition-property: background-color, background-image, box-shadow; transition-timing-function: var(--easing-standard); transition-duration: var(--duration-xquick); @@ -66,7 +78,8 @@ .large { --btn-gradient-size: 72px; min-height: 48px; - border-radius: var(--border-radius-medium); + /* Large's larger default radius, still overridable via `--btn-radius`. */ + border-radius: var(--btn-radius, var(--border-radius-medium)); padding-left: var(--spacing-5); padding-right: var(--spacing-5); padding-top: 0; diff --git a/packages/blade-core/src/styles/Button/buttonOverrides.ts b/packages/blade-core/src/styles/Button/buttonOverrides.ts new file mode 100644 index 00000000000..648add6c38d --- /dev/null +++ b/packages/blade-core/src/styles/Button/buttonOverrides.ts @@ -0,0 +1,115 @@ +import { deriveColorStates, isOverrideColorValue } from '../../utils/colorOverrides'; + +/** + * ## Option B — `styleOverrides` → element-scoped CSS variables (recommended core) + * + * The Button CSS module is already parameterized by element-scoped custom + * properties (`--btn-accent-bg-default/highlighted/disabled`, `--btn-content-color`, + * `--btn-radius`). `resolveButtonOverrides` maps a bounded, typed override object + * onto exactly those variables and *derives* the interaction states the consumer + * never sends (the centerpiece of v1 — see instance-level-styling-proposal §4 + * Option B and §7). + * + * Because the override feeds the *same* variables the `:hover` / `:active` / + * `:focus-visible` / `[disabled]` rules already read, state variants keep working — + * making this a strict improvement over the flat-inline-style Option A. + * + * `blade-core` stays framework-agnostic: this is a pure function returning a + * `Record`. The Svelte (or future React) layer just spreads + * the result onto the element's `style`. + * + * **v1 vocabulary is an opaque hex passthrough.** Values are emitted to CSS as-is; + * no token validation happens here (sanitization stays in the consumer). Token + * support / explicit per-state keys are a later-phase extension of this same + * resolver (proposal §7 "Later phases"). + */ +export type ButtonStyleOverrides = Partial<{ + /** + * Base background color (an opaque CSS color string, e.g. a 6-digit hex from + * checkout's `getUIConfigColor`). Hover/active/focus and disabled backgrounds + * are derived from this automatically. + */ + backgroundColor: string; + /** + * Text + icon color (opaque CSS color string). + */ + textColor: string; + /** + * Base border color. Highlighted border is derived from this. + */ + borderColor: string; + /** + * Border radius (any CSS length, e.g. `24px`). Maps to the `--btn-radius` seam. + */ + borderRadius: string; +}>; + +/** + * Element-scoped CSS variable names the Button module consumes. Centralized here + * so the CSS seam and the resolver never drift. + */ +export const BUTTON_OVERRIDE_VARS = { + bgDefault: '--btn-accent-bg-default', + bgHighlighted: '--btn-accent-bg-highlighted', + bgDisabled: '--btn-accent-bg-disabled', + borderDefault: '--btn-accent-border-default', + borderHighlighted: '--btn-accent-border-highlighted', + contentColor: '--btn-content-color', + radius: '--btn-radius', +} as const; + +/** + * Resolve a Button override object into element-scoped CSS variables, synthesizing + * the hover/active/focus and disabled states from the single base color. + * + * @example + * resolveButtonOverrides({ backgroundColor: '#1a59ff', textColor: '#ffffff' }) + * // { + * // '--btn-accent-bg-default': '#1a59ff', + * // '--btn-accent-bg-highlighted': '#0042e6', // derived (darken) + * // '--btn-accent-bg-disabled': 'rgba(26,89,255,0.5)', // derived (alpha) + * // '--btn-content-color': '#ffffff', + * // } + */ +export function resolveButtonOverrides( + overrides: ButtonStyleOverrides | undefined, +): Record { + if (!overrides) return {}; + const vars: Record = {}; + + if (isOverrideColorValue(overrides.backgroundColor)) { + const states = deriveColorStates(overrides.backgroundColor); + vars[BUTTON_OVERRIDE_VARS.bgDefault] = states.default; + vars[BUTTON_OVERRIDE_VARS.bgHighlighted] = states.highlighted; + vars[BUTTON_OVERRIDE_VARS.bgDisabled] = states.disabled; + } + + if (isOverrideColorValue(overrides.borderColor)) { + const states = deriveColorStates(overrides.borderColor); + vars[BUTTON_OVERRIDE_VARS.borderDefault] = states.default; + vars[BUTTON_OVERRIDE_VARS.borderHighlighted] = states.highlighted; + } + + if (isOverrideColorValue(overrides.textColor)) { + vars[BUTTON_OVERRIDE_VARS.contentColor] = overrides.textColor; + } + + if (isOverrideColorValue(overrides.borderRadius)) { + vars[BUTTON_OVERRIDE_VARS.radius] = overrides.borderRadius; + } + + return vars; +} + +/** + * Serialize a CSS-variable map into an inline `style` string. Shared helper so + * every component/option emits overrides the same way. + * + * @example + * styleObjectToString({ '--btn-content-color': '#fff' }) // '--btn-content-color:#fff' + */ +export function styleObjectToString(vars: Record): string { + return Object.entries(vars) + .map(([key, value]) => `${key}:${value}`) + .join(';'); +} diff --git a/packages/blade-core/src/styles/Button/index.ts b/packages/blade-core/src/styles/Button/index.ts index 762a5db962a..0445a364d72 100644 --- a/packages/blade-core/src/styles/Button/index.ts +++ b/packages/blade-core/src/styles/Button/index.ts @@ -21,3 +21,9 @@ export { getButtonSpinnerSize, } from './button'; export type { ButtonVariants, ButtonColor, ButtonVariant, ActionStatesType } from './button'; +export { + resolveButtonOverrides, + styleObjectToString, + BUTTON_OVERRIDE_VARS, +} from './buttonOverrides'; +export type { ButtonStyleOverrides } from './buttonOverrides'; diff --git a/packages/blade-core/src/styles/Card/card.module.css b/packages/blade-core/src/styles/Card/card.module.css index ab1d9a9b568..4388233a0dc 100644 --- a/packages/blade-core/src/styles/Card/card.module.css +++ b/packages/blade-core/src/styles/Card/card.module.css @@ -82,12 +82,35 @@ box-sizing: border-box; text-align: left; border: none; - background-image: linear-gradient(to bottom, hsla(0, 0%, 100%, 1) 0%, hsla(0, 0%, 100%, 1) 100%), - linear-gradient(to bottom, hsla(0, 0%, 100%, 1) 0%, hsla(0, 0%, 97%, 1) 100%); + /* + * Instance-level styling seam (Option B, composite "parts" — see §4.5 seam audit + * in instance-level-styling-proposal). The surface background was a hardcoded + * gradient of raw hsla() (no CSS variable existed). The gradient *stops* are now + * re-expressed as `var(--card-surface-bg, )`, so a merchant override + * (resolveCardOverrides → `--card-surface-bg`) recolors only this card's surface + * while preserving the subtle bottom-edge treatment. Unset → pixel-identical. + */ + background-image: linear-gradient( + to bottom, + var(--card-surface-bg, hsla(0, 0%, 100%, 1)) 0%, + var(--card-surface-bg, hsla(0, 0%, 100%, 1)) 100% + ), + linear-gradient( + to bottom, + var(--card-surface-bg, hsla(0, 0%, 100%, 1)) 0%, + var(--card-surface-bg, hsla(0, 0%, 97%, 1)) 100% + ); background-position: center top, center calc(100% - 2px); background-size: calc(100% - 2px) 16px, calc(100% - 2px) 16px; background-repeat: no-repeat; - box-shadow: inset 0px 0px 0px 1px var(--interactive-border-gray-disabled), + /* + * Border seam. The border is drawn as the inner-ring layer of this box-shadow + * (not `border-color`). That inner ring's color is now `var(--card-surface-border-color, + * )` so it can be overridden per-instance without touching the + * elevation/lip layers. Unset → identical to before. + */ + box-shadow: inset 0px 0px 0px 1px + var(--card-surface-border-color, var(--interactive-border-gray-disabled)), 0px 6px 32px 4px hsla(205, 8%, 71%, 0.06), inset 0px -1.5px 0px 1px var(--surface-background-gray-intense); } diff --git a/packages/blade-core/src/styles/Card/cardOverrides.ts b/packages/blade-core/src/styles/Card/cardOverrides.ts new file mode 100644 index 00000000000..f60daaed352 --- /dev/null +++ b/packages/blade-core/src/styles/Card/cardOverrides.ts @@ -0,0 +1,74 @@ +import { deriveColorStates, isOverrideColorValue } from '../../utils/colorOverrides'; + +/** + * ## Option B extended to internal parts (axis E) — Card + * + * Card is the *composite* counterpart to Button: its visible surface lives on a + * **child** element (`.cardSurface`), and v1 demonstrates the "seam audit" + * (instance-level-styling-proposal §4.5). Before any part can be overridden, that + * part's target property must be expressed as a CSS variable on the right element. + * `card.module.css` was given additive, backward-compatible seams: + * + * --card-surface-bg (gradient stop → flat fill when set) + * --card-surface-border-color + * + * `resolveCardOverrides` proves the "bounded, per-component **parts** vocabulary" + * that is the slice of Option D we keep — addressing parts via *nested keys* + * (`surface: { ... }`) rather than a centralized cross-repo slot taxonomy. + * + * The carrier is identical to Button's: a pure `Record` that the + * framework layer spreads as inline CSS vars. The only difference is *which element* + * the vars must land on — here, the surface child (handled by the component). + */ + +/** Overrides for the Card's surface part (the `.cardSurface` child element). */ +export type CardSurfaceOverrides = Partial<{ + backgroundColor: string; + borderColor: string; +}>; + +/** + * Card's bounded, typed override object. Keys are *named parts*, each exposing only + * the properties whose CSS seam exists. Growth = add a part key + the matching + * `var(--part-prop, )` seam in the CSS module (proposal §4.5 + * "Extensibility contract"). + */ +export type CardStyleOverrides = Partial<{ + surface: CardSurfaceOverrides; +}>; + +export const CARD_OVERRIDE_VARS = { + surfaceBg: '--card-surface-bg', + surfaceBorderColor: '--card-surface-border-color', +} as const; + +/** + * Resolve the Card override object into CSS variables destined for the surface + * child element. + * + * @example + * resolveCardOverrides({ surface: { backgroundColor: '#ffffff', borderColor: '#1a59ff' } }) + * // { '--card-surface-bg': '#ffffff', '--card-surface-border-color': '#1a59ff' } + */ +export function resolveCardOverrides( + overrides: CardStyleOverrides | undefined, +): Record { + if (!overrides) return {}; + const vars: Record = {}; + + const surface = overrides.surface; + if (surface) { + if (isOverrideColorValue(surface.backgroundColor)) { + vars[CARD_OVERRIDE_VARS.surfaceBg] = surface.backgroundColor; + } + if (isOverrideColorValue(surface.borderColor)) { + // Derive a slightly darker selected/hover border for parity with Button, + // even though Card only consumes `default` today — keeps the resolver shape + // consistent and ready for state-aware parts later. + const states = deriveColorStates(surface.borderColor); + vars[CARD_OVERRIDE_VARS.surfaceBorderColor] = states.default; + } + } + + return vars; +} diff --git a/packages/blade-core/src/styles/Card/index.ts b/packages/blade-core/src/styles/Card/index.ts index ec50312d635..685eba8cb98 100644 --- a/packages/blade-core/src/styles/Card/index.ts +++ b/packages/blade-core/src/styles/Card/index.ts @@ -11,3 +11,5 @@ export type { CardHeaderVariants, CardFooterVariants, } from './card'; +export { resolveCardOverrides, CARD_OVERRIDE_VARS } from './cardOverrides'; +export type { CardStyleOverrides, CardSurfaceOverrides } from './cardOverrides'; diff --git a/packages/blade-core/src/styles/index.ts b/packages/blade-core/src/styles/index.ts index 370b1733dcb..0dd01473d48 100644 --- a/packages/blade-core/src/styles/index.ts +++ b/packages/blade-core/src/styles/index.ts @@ -41,8 +41,17 @@ export { getButtonIconSize, getButtonIconOnlySize, getButtonSpinnerSize, + resolveButtonOverrides, + styleObjectToString, + BUTTON_OVERRIDE_VARS, } from './Button'; -export type { ButtonVariants, ButtonColor, ButtonVariant } from './Button'; +export type { ButtonVariants, ButtonColor, ButtonVariant, ButtonStyleOverrides } from './Button'; +// Instance-level styling — Option D (slot-keyed theme map) and Option C (scoped +// theme provider) framework-agnostic resolvers. See instance-level-styling-proposal. +export { resolveSlotTheme } from './slotTheme'; +export type { SlotThemeMap, SlotThemeComponent } from './slotTheme'; +export { flattenThemeOverridesToVars } from './themeScope'; +export type { ThemeOverrideTree } from './themeScope'; export { utilityClasses, getUtilityClass } from './utilities'; // @ts-expect-error - CSS modules may not have type definitions in build export { default as utilities } from './utilities.module.css'; @@ -127,11 +136,14 @@ export { getCardFooterClasses, getCardTemplateClasses, } from './Card'; +export { resolveCardOverrides, CARD_OVERRIDE_VARS } from './Card'; export type { CardRootVariants, CardSurfaceVariants, CardHeaderVariants, CardFooterVariants, + CardStyleOverrides, + CardSurfaceOverrides, } from './Card'; export { animatedChipCva, diff --git a/packages/blade-core/src/styles/slotTheme/index.ts b/packages/blade-core/src/styles/slotTheme/index.ts new file mode 100644 index 00000000000..0a72d1c79b1 --- /dev/null +++ b/packages/blade-core/src/styles/slotTheme/index.ts @@ -0,0 +1,2 @@ +export { resolveSlotTheme } from './slotTheme'; +export type { SlotThemeMap, SlotThemeComponent } from './slotTheme'; diff --git a/packages/blade-core/src/styles/slotTheme/slotTheme.ts b/packages/blade-core/src/styles/slotTheme/slotTheme.ts new file mode 100644 index 00000000000..a541271dc26 --- /dev/null +++ b/packages/blade-core/src/styles/slotTheme/slotTheme.ts @@ -0,0 +1,52 @@ +import { resolveButtonOverrides, type ButtonStyleOverrides } from '../Button/buttonOverrides'; +import { resolveCardOverrides, type CardStyleOverrides } from '../Card/cardOverrides'; + +/** + * ## Option D — Slot-keyed theme map (component "parts" theming, à la shadcn/Stitches) + * + * A single declarative object keyed by `component → slotKey → overrides`, consumed + * via context. A component opts into a slot with `themeKey="primaryCta"` and the + * map is looked up to produce the same CSS variables Option B emits. + * + * Included for evaluation. The proposal's verdict (instance-level-styling-proposal + * §4 Option D, §6): **reject the centralized cross-repo slot taxonomy** (it invents + * a naming dimension that must be documented and synced across Blade + checkout) but + * **keep the parts-addressing instinct**, folded into Option B as nested keys. + * + * Note this is explicitly a *sugar layer, not a mechanism*: it still needs Option + * B's carrier underneath — `resolveSlotTheme` just routes a slot's overrides into + * the per-component resolver, so the var output is byte-identical to calling + * `resolveButtonOverrides` / `resolveCardOverrides` directly. + */ +export type SlotThemeMap = { + button?: Record; + card?: Record; +}; + +export type SlotThemeComponent = keyof SlotThemeMap; + +/** + * Look up a component+slot in the theme map and resolve it to CSS variables using + * the matching per-component resolver (Option B carrier). + * + * @example + * const map = { button: { primaryCta: { backgroundColor: '#1a59ff' } } }; + * resolveSlotTheme(map, 'button', 'primaryCta'); + * // -> same vars as resolveButtonOverrides({ backgroundColor: '#1a59ff' }) + */ +export function resolveSlotTheme( + themeMap: SlotThemeMap | undefined, + component: SlotThemeComponent, + slotKey: string | undefined, +): Record { + if (!themeMap || !slotKey) return {}; + + if (component === 'button') { + return resolveButtonOverrides(themeMap.button?.[slotKey]); + } + if (component === 'card') { + return resolveCardOverrides(themeMap.card?.[slotKey]); + } + + return {}; +} diff --git a/packages/blade-core/src/styles/themeScope/index.ts b/packages/blade-core/src/styles/themeScope/index.ts new file mode 100644 index 00000000000..8e5edb2ccb8 --- /dev/null +++ b/packages/blade-core/src/styles/themeScope/index.ts @@ -0,0 +1,2 @@ +export { flattenThemeOverridesToVars } from './themeScope'; +export type { ThemeOverrideTree } from './themeScope'; diff --git a/packages/blade-core/src/styles/themeScope/themeScope.ts b/packages/blade-core/src/styles/themeScope/themeScope.ts new file mode 100644 index 00000000000..325075ad4f3 --- /dev/null +++ b/packages/blade-core/src/styles/themeScope/themeScope.ts @@ -0,0 +1,51 @@ +import { tokenToCSSVariable } from '../../utils/tokenToCSSVariable'; + +/** + * ## Option C — Scoped theme via `BladeProvider` + wrapper CSS variables (subtree) + * + * Instead of repainting `:root`, a `BladeProvider` re-declares the affected token + * CSS variables on its **own wrapper element**, establishing a scoped cascade so + * every Blade component inside inherits the override. This finally gives Svelte the + * provider parity React has (instance-level-styling-proposal §4 Option C). + * + * `flattenThemeOverridesToVars` is the framework-agnostic half: it walks a partial, + * nested token override (mirroring the `interactive`/`surface`/… token shape) and + * flattens it into `{ '--interactive-background-primary-default': '#1a59ff', … }` + * using the same `tokenToCSSVariable` mapping the rest of Blade uses. + * + * Verdict (proposal §6): C is **complementary** to B, not a substitute — great for + * region/brand theming ("make this whole drawer festive"), too coarse for isolating + * a single sibling instance. Both ship; pick per use case. + */ +export type ThemeOverrideTree = { + [key: string]: string | number | ThemeOverrideTree; +}; + +/** + * Recursively flatten a nested token override tree into a flat CSS-variable map. + * + * @example + * flattenThemeOverridesToVars({ + * interactive: { background: { primary: { default: '#1a59ff' } } }, + * }); + * // { '--interactive-background-primary-default': '#1a59ff' } + */ +export function flattenThemeOverridesToVars( + overrides: ThemeOverrideTree | undefined, + path: string[] = [], +): Record { + if (!overrides) return {}; + const vars: Record = {}; + + for (const [key, value] of Object.entries(overrides)) { + const nextPath = [...path, key]; + if (value !== null && typeof value === 'object') { + Object.assign(vars, flattenThemeOverridesToVars(value, nextPath)); + } else if (value !== undefined && value !== '') { + const cssVar = tokenToCSSVariable(nextPath.join('.')); + vars[cssVar] = String(value); + } + } + + return vars; +} diff --git a/packages/blade-core/src/utils/colorOverrides/deriveColorStates.ts b/packages/blade-core/src/utils/colorOverrides/deriveColorStates.ts new file mode 100644 index 00000000000..61335407c16 --- /dev/null +++ b/packages/blade-core/src/utils/colorOverrides/deriveColorStates.ts @@ -0,0 +1,100 @@ +import tinycolor from 'tinycolor2'; +import type { ColorInput } from 'tinycolor2'; + +/** + * v1 instance-styling vocabulary is an *opaque color passthrough*: checkout's + * `getUIConfigColor` only ever emits a single 6-digit hex per slot (no per-state + * values). The centerpiece of v1 is therefore *deriving* the interaction states + * the consumer never sends — so an overridden component keeps a working + * hover/active/focus and disabled appearance instead of dead-ending (the bug in + * checkout's current inline-style CTAs — see instance-level-styling-proposal §1.1). + * + * These offsets are intentionally small and are tuned to visually approximate + * Blade's existing `highlighted` / `disabled` token steps. They are scheme-agnostic + * by design (checkout runs a single color scheme per session; dark-mode-aware + * derivation is out of scope for v1 — see proposal §Non-goals). + */ + +/** How much to darken the base color for the hover/active/focus state. */ +export const HIGHLIGHTED_DARKEN_AMOUNT = 8; +/** Alpha applied to the base color for the disabled state. */ +export const DISABLED_ALPHA = 0.5; + +/** + * Returns true when the value is a non-empty string Blade can hand to CSS as-is. + * v1 does no format validation (security/sanitization stays in the consumer — + * checkout already hex-validates). Empty string means "merchant did not set it", + * so we skip it and let the token cascade keep the default. + */ +export const isOverrideColorValue = (value: unknown): value is string => { + return typeof value === 'string' && value.trim().length > 0; +}; + +/** + * Darken a color by a percentage. Falls back to the original string if tinycolor + * cannot parse it (v1 treats values as opaque — never throw on a merchant value). + */ +export const darkenColor = ( + color: ColorInput, + amount: number = HIGHLIGHTED_DARKEN_AMOUNT, +): string => { + const parsed = tinycolor(color); + if (!parsed.isValid()) { + return String(color); + } + return parsed.darken(amount).toHexString(); +}; + +/** + * Apply an alpha channel to a color, returning an `rgba()` string. Falls back to + * the original string if tinycolor cannot parse it. + */ +export const fadeColor = (color: ColorInput, alpha: number = DISABLED_ALPHA): string => { + const parsed = tinycolor(color); + if (!parsed.isValid()) { + return String(color); + } + return parsed.setAlpha(alpha).toRgbString(); +}; + +export type DerivedColorStates = { + /** The base color, unchanged. */ + default: string; + /** Darkened base — used for hover / active / focus. */ + highlighted: string; + /** Faded base — used for the disabled state. */ + disabled: string; +}; + +/** + * Synthesize the default / highlighted / disabled triplet from a single base + * color. This is what makes the per-instance override *state-aware* even though + * the consumer only provides one value. + * + * @example + * deriveColorStates('#1a59ff') + * // { default: '#1a59ff', highlighted: '#0042e6', disabled: 'rgba(26, 89, 255, 0.5)' } + */ +export const deriveColorStates = (baseColor: string): DerivedColorStates => { + return { + default: baseColor, + highlighted: darkenColor(baseColor), + disabled: fadeColor(baseColor), + }; +}; + +/** + * Pick the most readable foreground (black or white) for a given background. + * Useful as an a11y guard when a merchant sets a background but not a text color. + * Returns undefined when the background can't be parsed. + */ +export const getReadableForeground = ( + backgroundColor: string, + candidates: [string, string] = ['#ffffff', '#0c1117'], +): string | undefined => { + const parsed = tinycolor(backgroundColor); + if (!parsed.isValid()) { + return undefined; + } + return tinycolor.mostReadable(backgroundColor, candidates).toHexString(); +}; diff --git a/packages/blade-core/src/utils/colorOverrides/index.ts b/packages/blade-core/src/utils/colorOverrides/index.ts new file mode 100644 index 00000000000..de742e4516c --- /dev/null +++ b/packages/blade-core/src/utils/colorOverrides/index.ts @@ -0,0 +1,2 @@ +export * from './deriveColorStates'; +export * from './visualStyledProps'; diff --git a/packages/blade-core/src/utils/colorOverrides/visualStyledProps.ts b/packages/blade-core/src/utils/colorOverrides/visualStyledProps.ts new file mode 100644 index 00000000000..37ad689d75e --- /dev/null +++ b/packages/blade-core/src/utils/colorOverrides/visualStyledProps.ts @@ -0,0 +1,64 @@ +import { getTokenCSSVariable } from '../tokenToCSSVariable'; + +/** + * ## Option A — Visual styled props (inline-style fallback) + * + * Extends the styled-props idea to *visual* properties (background, color, border, + * radius). Resolves a token string to a `var(--token)` reference, otherwise passes + * the raw value straight through to an inline style declaration — exactly mirroring + * how spacing styled props fall back from `spacing.4` to `12px`. + * + * ⚠️ This option is included for side-by-side evaluation. It is intentionally + * **state-unaware**: writing `background-color` directly *dead-ends* a stateful + * component's hover/active/disabled background (those come from token classes the + * inline value now shadows). This is the shipped bug in checkout's Contact CTA and + * NotificationBanner (proposal §1.1). Prefer Option B (`resolveButtonOverrides`) + * for stateful components. See instance-level-styling-proposal §4 Option A. + */ +export type VisualStyledProps = Partial<{ + backgroundColor: string; + color: string; + borderColor: string; + borderRadius: string; +}>; + +const TOKEN_PREFIXES = ['surface.', 'interactive.', 'feedback.', 'spacing.', 'border.']; + +/** + * If the value looks like a Blade dot-notation token, return a `var(--token)` + * reference; otherwise return the raw value (arbitrary CSS string passthrough). + */ +const resolveVisualValue = (value: string): string => { + const isToken = TOKEN_PREFIXES.some((prefix) => value.startsWith(prefix)); + return isToken ? getTokenCSSVariable(value) : value; +}; + +/** + * Convert visual styled props into a flat inline-style map (camelCase keys, as + * the Svelte/React layer would spread onto a `style` attribute). + * + * @example + * resolveVisualStyledProps({ backgroundColor: '#1a59ff', borderRadius: '24px' }) + * // { backgroundColor: '#1a59ff', borderRadius: '24px' } + */ +export const resolveVisualStyledProps = ( + props: VisualStyledProps | undefined, +): Record => { + if (!props) return {}; + const styles: Record = {}; + + if (props.backgroundColor) { + styles.backgroundColor = resolveVisualValue(props.backgroundColor); + } + if (props.color) { + styles.color = resolveVisualValue(props.color); + } + if (props.borderColor) { + styles.borderColor = resolveVisualValue(props.borderColor); + } + if (props.borderRadius) { + styles.borderRadius = resolveVisualValue(props.borderRadius); + } + + return styles; +}; diff --git a/packages/blade-core/src/utils/index.ts b/packages/blade-core/src/utils/index.ts index f693e8965b1..907958388a4 100644 --- a/packages/blade-core/src/utils/index.ts +++ b/packages/blade-core/src/utils/index.ts @@ -11,6 +11,7 @@ export * from './lodashButBetter/isNumber'; export * from './lodashButBetter/isUndefined'; export * from './lodashButBetter/kebabCase'; export * from './lodashButBetter/throttle'; +export * from './colorOverrides'; export * from './getFloatingPlacementParts'; export * from './hasSameObjectStructure'; export * from './isPartialMatchObjectKeys'; diff --git a/packages/blade-svelte/docs/instance-level-styling-proposal.md b/packages/blade-svelte/docs/instance-level-styling-proposal.md new file mode 100644 index 00000000000..17fb28648f4 --- /dev/null +++ b/packages/blade-svelte/docs/instance-level-styling-proposal.md @@ -0,0 +1,481 @@ +# Instance-Level Styling for Blade Svelte — Design Proposal + +> **Status:** Draft / RFC +> **Author:** Staff Frontend (Blade) +> **Scope:** `@razorpay/blade-svelte` + `@razorpay/blade-core` +> **Purpose:** **Adoption-readiness / API-fit review.** Checkout V2 just shipped a deep merchant-configurability layer (Config V2, PR [checkout#11286](https://github.com/razorpay/checkout/pull/11286)) but **does not consume Blade Svelte yet** — it styles its own components. This doc evaluates the architectural/API changes Blade Svelte needs so checkout can drop those bespoke components and adopt Blade *without losing the merchant-config surface*. +> **Related:** `docs/theming-and-classnames.md`, `checkout/app/v2/docs/CONFIG_V2_SPEC.md`, `checkout/app/v2/utils/config-driver/*` + +> **📦 Reference implementations (all 5 options are now buildable & inspectable).** To make this RFC concrete, every option A–E has a working spike landed in the codebase so the team can compare them side-by-side in Storybook (especially hover/disabled behaviour) rather than from prose alone. **These are evaluation spikes, not a final API commitment** — the recommendation (B core + C complementary) is unchanged. +> +> | Option | `blade-core` (framework-agnostic) | `blade-svelte` wiring | +> |---|---|---| +> | **A** — visual styled props | `utils/colorOverrides/visualStyledProps.ts` (`resolveVisualStyledProps`) | `Button` prop `visualProps` | +> | **B** — `styleOverrides` → CSS vars (**recommended**) | `styles/Button/buttonOverrides.ts` (`resolveButtonOverrides`), `styles/Card/cardOverrides.ts` (`resolveCardOverrides`, parts), `utils/colorOverrides/deriveColorStates.ts` (state synthesis) | `Button`/`Card` prop `styleOverrides`; CSS seams added to `button.module.css` (`--btn-content-color`, `--btn-radius`) and `card.module.css` (`--card-surface-bg`, `--card-surface-border-color`) | +> | **C** — scoped provider | `styles/themeScope/themeScope.ts` (`flattenThemeOverridesToVars`) | `BladeProvider.svelte` (`themeOverrides`) | +> | **D** — slot-keyed map | `styles/slotTheme/slotTheme.ts` (`resolveSlotTheme`) | `BladeProvider` (`slotTheme`) + `Button` prop `themeKey` | +> | **E** — `className` | — | `Button` prop `className` | +> +> **See it:** Storybook → *Patterns / Instance-Level Styling* (`src/components/Button/StyleOverrides.stories.svelte`). The “A vs B · hover & disabled” story is the money shot — A dead-ends states, B derives them. Everything is additive/backward-compatible (`svelte-check` + `blade-core` typecheck green; unspecified instances render pixel-identical via `var(--x, )`). + +--- + +## 1. Problem statement + +Blade theming is **global or color-scheme scoped**. Tokens are flat CSS custom properties on `:root` (and re-declared under `body[data-theme='dark']`). White-labelling (`createTheme`) regenerates that **one** global palette from a single brand color. (Note: blade-svelte has **no `BladeProvider`/ThemeProvider component at all** — theming is the `theme.css` import + `data-theme` attribute; `createTheme` injects vars into `:root` via JS. Verified in `docs/theming-and-classnames.md`.) + +Checkout's Config V2 needs **instance-level** customization. On a single Contact page there can be two `Button`s that must look different *at the same time*: + +| Instance | border radius | background | +|----------|---------------|------------| +| Primary CTA | `24px` | blue | +| Secondary CTA | `8px` | white | + +Traditional DS theming only expresses `theme.button.primary` / `theme.button.secondary` — a **type-level** axis, not an **instance-level** one. + +The merchant config expresses CTA intent today as **surface-scoped** paths (per *screen*, not per sibling instance), and they **inherit from the global theme** when unset: + +```ts +// checkout/app/v2/utils/config-driver/paths.ts +CONTACT_CTA_BG: 'surface.contact.cta.primary.background', +CONTACT_CTA_TEXT_COLOR:'surface.contact.cta.primary.text_color', +``` + +```ts +// checkout/app/v2/utils/config-driver/resolver.ts — applyHierarchyInheritance() +// surface.contact.cta.primary.background ← global.theme.cta.primary.background (when empty) +``` + +So even today the config axis is **surface/type-level**: there is no path by which two sibling CTAs on the *same* screen can differ. That gap — true per-instance precision — is precisely what a Blade API must add. And on the Blade side there is currently **no API to receive even the surface-level value**: `StyledPropsBlade` deliberately excludes color/background/border-radius (verified — it lists only margin/layout/flex/position/grid), components expose no `className`/`style`, and the only override knob (`:root` variables) repaints every instance on the page. + +### Goals +- G1. Per-instance override of a **bounded, growable** set of visual properties (start: bg + text color; later: border color, radius, and **internal parts** of composite components — see §4.5). +- G2. Two instances of the same component, same page, styled differently. +- G3. Preserve interaction states (hover/focus/disabled), WCAG behavior, and the class-first token architecture. +- G4. Clean mapping from Checkout `config-driver` output → Blade overrides, so adoption is a near drop-in swap. +- G5. Zero / opt-in change for existing Blade consumers (`blade-core` must stay framework-agnostic so React can adopt the same resolver later). + +### Non-goals +- Arbitrary free-form CSS injection by merchants (security/brand risk). +- Re-theming via per-instance `createTheme` (palette regeneration is global + expensive). +- Changing the global token names or the `data-theme` mechanism. +- **Dark-mode-aware overrides for the checkout use case.** Checkout runs a single color scheme per session and de-scopes dark mode for merchant overrides; v1 state derivation (Option B, §4) is scheme-agnostic by design. Dark-mode-aware *parts* remain a concern for general Blade adoption (composite components like Card already ship `body[data-theme='dark']` part rules) but are explicitly out of scope for v1. + +--- + +## 1.1 Current state in checkout (the anti-pattern this replaces) + +Checkout already solves per-component merchant styling — with the **flat inline-`style` approach this doc calls Option A** (§4). Two shipped examples: + +**Contact CTA** reads the config color and writes it straight onto `background`/`color`: + +```svelte + +const ctaBg$ = useUIConfigColor(CONFIG_PATHS.CONTACT_CTA_BG); +const ctaTextColor$ = useUIConfigColor(CONFIG_PATHS.CONTACT_CTA_TEXT_COLOR); +... +