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
17 changes: 15 additions & 2 deletions packages/blade-core/src/styles/Button/button.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
115 changes: 115 additions & 0 deletions packages/blade-core/src/styles/Button/buttonOverrides.ts
Original file line number Diff line number Diff line change
@@ -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<cssVarName, value>`. 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<string, string> {
if (!overrides) return {};
const vars: Record<string, string> = {};

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, string | number>): string {
return Object.entries(vars)
.map(([key, value]) => `${key}:${value}`)
.join(';');
}
6 changes: 6 additions & 0 deletions packages/blade-core/src/styles/Button/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
29 changes: 26 additions & 3 deletions packages/blade-core/src/styles/Card/card.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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, <default>)`, 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,
* <default token>)` 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);
}
Expand Down
74 changes: 74 additions & 0 deletions packages/blade-core/src/styles/Card/cardOverrides.ts
Original file line number Diff line number Diff line change
@@ -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<cssVarName, value>` 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, <default>)` 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<string, string> {
if (!overrides) return {};
const vars: Record<string, string> = {};

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;
}
2 changes: 2 additions & 0 deletions packages/blade-core/src/styles/Card/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ export type {
CardHeaderVariants,
CardFooterVariants,
} from './card';
export { resolveCardOverrides, CARD_OVERRIDE_VARS } from './cardOverrides';
export type { CardStyleOverrides, CardSurfaceOverrides } from './cardOverrides';
14 changes: 13 additions & 1 deletion packages/blade-core/src/styles/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/blade-core/src/styles/slotTheme/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { resolveSlotTheme } from './slotTheme';
export type { SlotThemeMap, SlotThemeComponent } from './slotTheme';
52 changes: 52 additions & 0 deletions packages/blade-core/src/styles/slotTheme/slotTheme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { resolveButtonOverrides, type ButtonStyleOverrides } from '../Button/buttonOverrides';

Check failure on line 1 in packages/blade-core/src/styles/slotTheme/slotTheme.ts

View workflow job for this annotation

GitHub Actions / Validate Source Code

Parsing error: ',' expected
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<string, ButtonStyleOverrides>;
card?: Record<string, CardStyleOverrides>;
};

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<string, string> {
if (!themeMap || !slotKey) return {};

if (component === 'button') {
return resolveButtonOverrides(themeMap.button?.[slotKey]);
}
if (component === 'card') {
return resolveCardOverrides(themeMap.card?.[slotKey]);
}

return {};
}
2 changes: 2 additions & 0 deletions packages/blade-core/src/styles/themeScope/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { flattenThemeOverridesToVars } from './themeScope';
export type { ThemeOverrideTree } from './themeScope';
Loading
Loading