You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
355 lines
15 KiB
355 lines
15 KiB
/*
|
|
* Card — display container with optional structural slots.
|
|
*
|
|
* Five-axis variant cube:
|
|
* - data-variant: soft | solid | outline | ghost
|
|
* - data-color: primary | secondary | neutral | affirm | fulfill | risk | threat | loss
|
|
* - data-size: xs | sm | md | lg | xl
|
|
* - data-rounded: sm | md | lg
|
|
* - data-interactive (boolean) — promotes to <button> + press/focus chrome
|
|
* - data-lift (boolean) — hover elevation; implied by data-interactive
|
|
*
|
|
* Plus three state attrs:
|
|
* - data-selected — pinned by consumer; recipe paints accent ring
|
|
* - data-disabled — recipe dims + locks pointer
|
|
* - data-state="idle|selected" — emitted by morfo runtime
|
|
*
|
|
* Internal slots (rendered conditionally by the Svelte file):
|
|
* - [data-card-header] — top slot (Title + Description usually)
|
|
* - [data-card-title] — display heading
|
|
* - [data-card-description] — secondary prose
|
|
* - [data-card-body] — payload
|
|
* - [data-card-footer] — actions / metadata row
|
|
*
|
|
* Color: when data-color-custom is present, the consumer provided a
|
|
* CSS color string (hex / rgb / oklch). The recipe derives track,
|
|
* border and text via color-mix() instead of the canonical palette
|
|
* cascade.
|
|
*
|
|
* Tokens cascade through `--_card-*` private vars so the rules below
|
|
* stay flat. The recipe surface (`--card-*`) feeds the canonical
|
|
* accent slots.
|
|
*/
|
|
|
|
[data-card] {
|
|
--_card-padding-inline: var(--card-padding-inline-md);
|
|
--_card-padding-block: var(--card-padding-block-md);
|
|
--_card-gap: var(--card-gap-md);
|
|
--_card-title-font-size: var(--card-title-font-size-md);
|
|
--_card-description-font-size: var(--card-description-font-size-md);
|
|
--_card-body-font-size: var(--card-body-font-size-md);
|
|
--_card-radius: var(--card-radius-md);
|
|
|
|
/* `--_card-palette-{track,border,solid,text,contrast}` are TSC-emitted from
|
|
* `lib/recipes/base.ts > card._palette-*` with default = neutral + 7 per-color
|
|
* overrides; the THM-2 shared-layer forward routes them to `color="teal"` +
|
|
* the 33 scales. */
|
|
|
|
/* Default variant = soft; overridden by data-variant below. */
|
|
/* private: CONMUTADOR by variant, 4 sources — three are the THM-2 palette
|
|
bridge and `ghost` is the identity of the variant. A public on top would
|
|
let a theme PIN it and kill every instance's `color=` in silence
|
|
(card.md §5, signed 2026-08-23; §3.pre of the handoff). */
|
|
--_card-bg: var(--_card-palette-track);
|
|
/* private: same CONMUTADOR, and here BOTH sources are the THM-2 bridge —
|
|
read at the root, the title and the body (card.md §5, signed 2026-08-23). */
|
|
--_card-fg: var(--_card-palette-text);
|
|
--_card-border-color: transparent;
|
|
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--_card-gap);
|
|
padding-inline: var(--_card-padding-inline);
|
|
padding-block: var(--_card-padding-block);
|
|
border: var(--card-border-width) solid var(--_card-border-color);
|
|
border-radius: var(--_card-radius);
|
|
background: var(--_card-bg);
|
|
color: var(--_card-fg);
|
|
font-family: var(--card-font-family);
|
|
line-height: var(--card-line-height);
|
|
text-align: start;
|
|
transition:
|
|
transform var(--duration-fast) var(--ease-default),
|
|
box-shadow var(--duration-fast) var(--ease-default),
|
|
background var(--duration-fast) var(--ease-default),
|
|
border-color var(--duration-fast) var(--ease-default);
|
|
}
|
|
|
|
/* Mount animation — fade-in + slide-up, consuming the CHANNEL's registered
|
|
* keyframes (`slide-from-bottom` + `fade-in`, themeable via EidosConfig.motion)
|
|
* with the recipe's tuned tokens. Disabled by data-no-emerge (long lists) and
|
|
* by prefers-reduced-motion.
|
|
*
|
|
* Why not `motionAttrs`/a preset stamp: the card carries its own SEMANTIC
|
|
* `data-state` (selected machine) — the constant `data-state='open'` that
|
|
* `motionAttrs` spreads would clobber it. So the trigger stays a recipe rule;
|
|
* only the MATERIALS come from the channel (no local @keyframes).
|
|
*
|
|
* `:not([data-animation-style])` — when the card opts into the motion system
|
|
* (the `motion` prop sets data-animation-style), THAT system owns the element's
|
|
* single `animation` property. The mount emerge yields: otherwise toggling
|
|
* `data-motion-state` would flip animation-name and re-trigger this mount fade
|
|
* on every deselect. */
|
|
[data-card]:not([data-no-emerge]):not([data-animation-style]) {
|
|
--motion-distance-md: var(--card-emerge-distance);
|
|
animation:
|
|
slide-from-bottom var(--card-emerge-duration) var(--card-emerge-ease) both,
|
|
fade-in var(--card-emerge-duration) var(--card-emerge-ease) both;
|
|
}
|
|
|
|
/* ── Size scale ──────────────────────────────────────────────────────── */
|
|
|
|
[data-card][data-size='xs'] {
|
|
--_card-padding-inline: var(--card-padding-inline-xs);
|
|
--_card-padding-block: var(--card-padding-block-xs);
|
|
--_card-gap: var(--card-gap-xs);
|
|
--_card-title-font-size: var(--card-title-font-size-xs);
|
|
--_card-description-font-size: var(--card-description-font-size-xs);
|
|
--_card-body-font-size: var(--card-body-font-size-xs);
|
|
}
|
|
|
|
[data-card][data-size='sm'] {
|
|
--_card-padding-inline: var(--card-padding-inline-sm);
|
|
--_card-padding-block: var(--card-padding-block-sm);
|
|
--_card-gap: var(--card-gap-sm);
|
|
--_card-title-font-size: var(--card-title-font-size-sm);
|
|
--_card-description-font-size: var(--card-description-font-size-sm);
|
|
--_card-body-font-size: var(--card-body-font-size-sm);
|
|
}
|
|
|
|
[data-card][data-size='lg'] {
|
|
--_card-padding-inline: var(--card-padding-inline-lg);
|
|
--_card-padding-block: var(--card-padding-block-lg);
|
|
--_card-gap: var(--card-gap-lg);
|
|
--_card-title-font-size: var(--card-title-font-size-lg);
|
|
--_card-description-font-size: var(--card-description-font-size-lg);
|
|
--_card-body-font-size: var(--card-body-font-size-lg);
|
|
}
|
|
|
|
[data-card][data-size='xl'] {
|
|
--_card-padding-inline: var(--card-padding-inline-xl);
|
|
--_card-padding-block: var(--card-padding-block-xl);
|
|
--_card-gap: var(--card-gap-xl);
|
|
--_card-title-font-size: var(--card-title-font-size-xl);
|
|
--_card-description-font-size: var(--card-description-font-size-xl);
|
|
--_card-body-font-size: var(--card-body-font-size-xl);
|
|
}
|
|
|
|
/* ── Rounded ─────────────────────────────────────────────────────────── */
|
|
|
|
[data-card][data-rounded='sm'] {
|
|
--_card-radius: var(--card-radius-sm);
|
|
}
|
|
[data-card][data-rounded='lg'] {
|
|
--_card-radius: var(--card-radius-lg);
|
|
}
|
|
|
|
/* Custom CSS colour: the wrapper stamps `data-color-custom` + the shared
|
|
* `--color-custom` seed, and the recipe's palette forward derives every
|
|
* `--_card-palette-*` slot from it (the same generic path every component
|
|
* uses). No bespoke `[data-card][data-color-custom]` block — that one was
|
|
* shadowed by the forward and silently dropped raw colours. Trade-off
|
|
* unchanged: no light/dark adaptation, no guaranteed contrast. */
|
|
|
|
/* ── Variant slices ──────────────────────────────────────────────────── */
|
|
|
|
[data-card][data-variant='soft'] {
|
|
--_card-bg: var(--_card-palette-track);
|
|
--_card-fg: var(--_card-palette-text);
|
|
--_card-border-color: transparent;
|
|
}
|
|
|
|
[data-card][data-variant='solid'] {
|
|
--_card-bg: var(--_card-palette-solid);
|
|
--_card-fg: var(--_card-palette-contrast);
|
|
--_card-border-color: transparent;
|
|
}
|
|
|
|
[data-card][data-variant='outline'] {
|
|
--_card-bg: var(--card-outline-bg);
|
|
--_card-fg: var(--_card-palette-text);
|
|
--_card-border-color: var(--_card-palette-border);
|
|
}
|
|
|
|
[data-card][data-variant='ghost'] {
|
|
--_card-bg: transparent;
|
|
--_card-fg: var(--_card-palette-text);
|
|
--_card-border-color: transparent;
|
|
}
|
|
|
|
/* ── Interactive treatment ───────────────────────────────────────────── */
|
|
|
|
[data-card][data-interactive] {
|
|
cursor: pointer;
|
|
user-select: none;
|
|
/* Reset native <button> chrome so the recipe owns the visual. */
|
|
font: inherit;
|
|
text-align: inherit;
|
|
appearance: none;
|
|
-webkit-tap-highlight-color: transparent;
|
|
}
|
|
|
|
/* The LIFT is its own axis. `interactive` stamps `data-lift` too, so a clickable
|
|
* card is unchanged; but a card that already holds its own call to action (a
|
|
* pricing plan, an article teaser) can take the elevation WITHOUT becoming a
|
|
* `<button>`: nesting one inside another is invalid markup and makes two
|
|
* activations compete for one gesture. Cursor, press-scale and focus ring stay
|
|
* under `interactive` — they are what a CONTROL owes the visitor. */
|
|
[data-card][data-lift]:hover:not([data-disabled]) {
|
|
transform: translateY(var(--card-hover-lift));
|
|
box-shadow: var(--card-hover-shadow);
|
|
}
|
|
|
|
[data-card][data-lift][data-variant='ghost']:hover:not([data-disabled]) {
|
|
background: var(--_card-palette-track);
|
|
}
|
|
|
|
[data-card][data-lift][data-variant='outline']:hover:not([data-disabled]) {
|
|
background: var(--_card-palette-track);
|
|
}
|
|
|
|
/* `:not(:has(…:active))` — when an INTERACTIVE descendant is being pressed (a
|
|
* Footer button, a link, a form control), the card is ALSO `:active` because the
|
|
* activation chain cascades to ancestors; without this guard pressing a control
|
|
* would press-scale the whole card. Pressing a control inside the card must not
|
|
* move the card. (Plain content presses still scale: only interactive descendants
|
|
* suppress it, so `:active` on the card's own surface is unaffected.) */
|
|
[data-card][data-interactive]:active:not([data-disabled]):not(
|
|
:has(
|
|
button:active,
|
|
a:active,
|
|
input:active,
|
|
select:active,
|
|
textarea:active,
|
|
[role='button']:active
|
|
)
|
|
) {
|
|
transform: scale(var(--card-press-scale));
|
|
transition-duration: var(--card-press-duration);
|
|
}
|
|
|
|
[data-card][data-interactive]:focus-visible {
|
|
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
|
outline-offset: var(--focus-ring-offset);
|
|
}
|
|
|
|
/* ── Selected state ──────────────────────────────────────────────────── */
|
|
|
|
[data-card][data-selected] {
|
|
box-shadow: 0 0 0 var(--card-selected-ring-width) var(--_card-palette-solid);
|
|
}
|
|
|
|
[data-card][data-selected][data-variant='solid'] {
|
|
/* On solid bg the ring would clash with the fill — use the contrast
|
|
* tone for a visible outline. */
|
|
box-shadow: 0 0 0 var(--card-selected-ring-width) var(--_card-palette-contrast);
|
|
}
|
|
|
|
/* Selected + hovered: `box-shadow` is a single property, so the interactive
|
|
* hover shadow would otherwise REPLACE the selection ring (the ring disappears
|
|
* on hover). Compose both — ring first, hover shadow second. */
|
|
[data-card][data-selected][data-lift]:hover:not([data-disabled]) {
|
|
box-shadow:
|
|
0 0 0 var(--card-selected-ring-width) var(--_card-palette-solid),
|
|
var(--card-hover-shadow);
|
|
}
|
|
[data-card][data-selected][data-variant='solid'][data-lift]:hover:not([data-disabled]) {
|
|
box-shadow:
|
|
0 0 0 var(--card-selected-ring-width) var(--_card-palette-contrast),
|
|
var(--card-hover-shadow);
|
|
}
|
|
|
|
/* ── Disabled state ──────────────────────────────────────────────────── */
|
|
|
|
/* Attenuated through `filter`, NOT `opacity` — and that is not a style
|
|
* preference. The card's own mount animation (`card-emerge`: `slide-from-bottom,
|
|
* fade-in`, both `fill-mode: both`) ends on `opacity: 1`, and an animation
|
|
* outranks a normal declaration in the cascade, so a plain `opacity` here was
|
|
* simply swallowed: the token resolved to 0.4, the `cursor` of this same rule
|
|
* applied, and the card still painted at full strength. Measured, and isolated by
|
|
* putting `data-no-emerge` on the same node, where it dimmed correctly. Two
|
|
* owners fighting over one property on one node is the KNOWN-FRAGILE pattern the
|
|
* motion doctrine names; the repair is to stop sharing the property. */
|
|
[data-card][data-disabled] {
|
|
filter: opacity(var(--card-disabled-opacity));
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
[data-card][data-interactive][data-disabled] {
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* ── Header slot ─────────────────────────────────────────────────────── */
|
|
|
|
[data-card-header] {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--card-header-gap);
|
|
}
|
|
|
|
/* ── Title slot ──────────────────────────────────────────────────────── */
|
|
|
|
[data-card-title] {
|
|
margin: 0;
|
|
font-family: var(--card-title-font-family);
|
|
font-size: var(--_card-title-font-size);
|
|
font-weight: var(--card-title-font-weight);
|
|
line-height: var(--card-title-line-height);
|
|
letter-spacing: var(--card-title-letter-spacing);
|
|
color: var(--_card-fg);
|
|
}
|
|
|
|
/* ── Description slot ────────────────────────────────────────────────── */
|
|
|
|
[data-card-description] {
|
|
margin: 0;
|
|
font-family: var(--card-description-font-family);
|
|
font-size: var(--_card-description-font-size);
|
|
line-height: var(--card-description-line-height);
|
|
color: var(--card-description-fg);
|
|
}
|
|
|
|
/* Solid variant pulls the description toward the contrast tone with a
|
|
* slight transparency so it reads as secondary on the saturated bg. */
|
|
[data-card][data-variant='solid'] [data-card-description] {
|
|
color: color-mix(in srgb, var(--_card-palette-contrast) 80%, transparent);
|
|
}
|
|
|
|
/* ── Body slot ───────────────────────────────────────────────────────── */
|
|
|
|
[data-card-body] {
|
|
font-size: var(--_card-body-font-size);
|
|
line-height: var(--card-body-line-height);
|
|
color: var(--_card-fg);
|
|
}
|
|
|
|
/* ── Footer slot ─────────────────────────────────────────────────────── */
|
|
|
|
[data-card-footer] {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--card-footer-gap);
|
|
padding-block-start: var(--card-footer-padding-start);
|
|
border-block-start: var(--card-border-width) solid var(--card-footer-border-fg);
|
|
margin-block-start: auto;
|
|
}
|
|
|
|
/* Footer border for solid variant — use a translucent contrast line. */
|
|
[data-card][data-variant='solid'] [data-card-footer] {
|
|
border-block-start-color: color-mix(in srgb, var(--_card-palette-contrast) 18%, transparent);
|
|
}
|
|
|
|
/* ── Reduced motion ──────────────────────────────────────────────────── */
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
[data-card]:not([data-no-emerge]) {
|
|
animation: none;
|
|
}
|
|
[data-card][data-lift]:hover:not([data-disabled]) {
|
|
transform: none;
|
|
}
|
|
[data-card][data-interactive]:active:not([data-disabled]) {
|
|
transform: none;
|
|
}
|
|
}
|