@cawalch/porchlight 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +28 -0
  2. package/dist/porchlight.css +3765 -0
  3. package/dist/porchlight.min.css +1 -0
  4. package/package.json +59 -0
  5. package/porchlight.css +62 -0
  6. package/src/00-layer-order.css +22 -0
  7. package/src/01-reset.css +53 -0
  8. package/src/02-tokens.css +254 -0
  9. package/src/03-themes.css +79 -0
  10. package/src/04-base.css +78 -0
  11. package/src/05-layout.css +209 -0
  12. package/src/06-components/accordion.css +161 -0
  13. package/src/06-components/alert.css +102 -0
  14. package/src/06-components/avatar.css +112 -0
  15. package/src/06-components/badge.css +73 -0
  16. package/src/06-components/breadcrumb.css +111 -0
  17. package/src/06-components/button.css +180 -0
  18. package/src/06-components/card.css +186 -0
  19. package/src/06-components/chip.css +146 -0
  20. package/src/06-components/command-palette.css +201 -0
  21. package/src/06-components/data-table.css +380 -0
  22. package/src/06-components/dialog.css +148 -0
  23. package/src/06-components/drawer.css +137 -0
  24. package/src/06-components/dropdown.css +180 -0
  25. package/src/06-components/empty-state.css +85 -0
  26. package/src/06-components/field.css +125 -0
  27. package/src/06-components/file-upload.css +104 -0
  28. package/src/06-components/nav.css +185 -0
  29. package/src/06-components/pagination.css +106 -0
  30. package/src/06-components/popover-menu.css +146 -0
  31. package/src/06-components/progress.css +77 -0
  32. package/src/06-components/reveal.css +73 -0
  33. package/src/06-components/scroll-progress.css +73 -0
  34. package/src/06-components/segmented.css +113 -0
  35. package/src/06-components/skeleton.css +73 -0
  36. package/src/06-components/stat.css +107 -0
  37. package/src/06-components/stepper.css +172 -0
  38. package/src/06-components/switch.css +138 -0
  39. package/src/06-components/tabs.css +164 -0
  40. package/src/06-components/tag-input.css +77 -0
  41. package/src/06-components/textarea-auto.css +77 -0
  42. package/src/06-components/timeline.css +129 -0
  43. package/src/06-components/toast.css +175 -0
  44. package/src/06-components/toolbar.css +87 -0
  45. package/src/06-components/tooltip.css +104 -0
  46. package/src/07-utilities.css +77 -0
  47. package/src/08-enhancements.css +129 -0
@@ -0,0 +1,112 @@
1
+ /*
2
+ * Porchlight - avatar component
3
+ * ===========================================================================
4
+ * A user identity chip - initials in a colored circle, or an image. Used in
5
+ * headers, lists, comments, and mentions. Three sizes via [data-size]:
6
+ * "sm" (1.5rem), default (2.5rem), "lg" (3rem).
7
+ *
8
+ * When no image is provided, initials render on a deterministic accent
9
+ * background derived from the name. The app sets the initials; the framework
10
+ * styles the circle. For an image, use <img class="c-avatar__img"> inside.
11
+ *
12
+ * Group stacking: .c-avatar-group overlaps multiple avatars with a negative
13
+ * margin and a ring for separation.
14
+ */
15
+ @layer porchlight.components {
16
+ @scope (.c-avatar) {
17
+ :scope {
18
+ --c-avatar-size: 2.5rem;
19
+
20
+ display: inline-flex;
21
+ align-items: center;
22
+ justify-content: center;
23
+ flex-shrink: 0;
24
+ inline-size: var(--c-avatar-size);
25
+ block-size: var(--c-avatar-size);
26
+ border-radius: var(--pl-radius-pill);
27
+ background: var(--pl-color-accent);
28
+ color: var(--pl-color-accent-text);
29
+ font-size: calc(var(--c-avatar-size) * 0.4);
30
+ font-weight: var(--pl-font-weight-semibold);
31
+ line-height: 1;
32
+ overflow: hidden;
33
+ user-select: none;
34
+ }
35
+
36
+ :scope[data-size="sm"] {
37
+ --c-avatar-size: 1.5rem;
38
+
39
+ font-size: var(--pl-text-xs);
40
+ }
41
+
42
+ :scope[data-size="lg"] {
43
+ --c-avatar-size: 3rem;
44
+
45
+ font-size: var(--pl-text-lg);
46
+ }
47
+
48
+ .c-avatar__img {
49
+ inline-size: 100%;
50
+ block-size: 100%;
51
+ object-fit: cover;
52
+ }
53
+ }
54
+
55
+ /* Avatar group - overlapping stack. */
56
+ :where(.c-avatar-group) {
57
+ --c-avatar-group-size: 2.5rem;
58
+
59
+ display: inline-flex;
60
+ align-items: center;
61
+ }
62
+
63
+ :where(.c-avatar-group[data-size="sm"]) {
64
+ --c-avatar-group-size: 1.5rem;
65
+ }
66
+
67
+ :where(.c-avatar-group[data-size="lg"]) {
68
+ --c-avatar-group-size: 3rem;
69
+ }
70
+
71
+ :where(.c-avatar-group) .c-avatar {
72
+ --c-avatar-size: var(--c-avatar-group-size);
73
+
74
+ border: 2px solid var(--pl-color-surface);
75
+ margin-inline-start: calc(var(--c-avatar-group-size) * -0.35);
76
+ }
77
+
78
+ :where(.c-avatar-group) .c-avatar:first-child {
79
+ margin-inline-start: 0;
80
+ }
81
+
82
+ /* A count overflow chip at the end of a group. */
83
+ :where(.c-avatar-group__more) {
84
+ display: inline-flex;
85
+ align-items: center;
86
+ justify-content: center;
87
+ inline-size: var(--c-avatar-group-size, 2.5rem);
88
+ block-size: var(--c-avatar-group-size, 2.5rem);
89
+ border-radius: var(--pl-radius-pill);
90
+ border: 2px solid var(--pl-color-surface);
91
+ background: var(--pl-color-surface-2);
92
+ color: var(--pl-color-text-muted);
93
+ font-size: calc(var(--c-avatar-group-size, 2.5rem) * 0.4);
94
+ font-weight: var(--pl-font-weight-semibold);
95
+ margin-inline-start: calc(var(--c-avatar-group-size, 2.5rem) * -0.35);
96
+ }
97
+
98
+ :where(.c-avatar-group[data-size="sm"]) .c-avatar-group__more {
99
+ font-size: var(--pl-text-xs);
100
+ }
101
+
102
+ :where(.c-avatar-group[data-size="lg"]) .c-avatar-group__more {
103
+ font-size: var(--pl-text-lg);
104
+ }
105
+
106
+ @media (forced-colors: active) {
107
+ :where(.c-avatar) {
108
+ background: Highlight;
109
+ color: HighlightText;
110
+ }
111
+ }
112
+ }
@@ -0,0 +1,73 @@
1
+ /*
2
+ * Porchlight - badge component
3
+ * ===========================================================================
4
+ * A compact label for statuses, counts, and tags. Inline-flex pill; tones via
5
+ * [data-tone]. Tones consume the semantic -bg/-text pairs (success/warning/
6
+ * danger) that were already made WCAG-AA, plus an accent and a neutral default.
7
+ *
8
+ * Density is fixed (badges shouldn't scale with [data-density] - they label,
9
+ * not actuate). Contrast is guaranteed by the token pairs, so the affordance
10
+ * check is covered by contrast.spec.ts; this adds a badge-specific check that
11
+ * each tone's label clears AA against its own bg.
12
+ */
13
+ @layer porchlight.components {
14
+ @scope (.c-badge) {
15
+ :scope {
16
+ display: inline-flex;
17
+ align-items: center;
18
+ gap: var(--pl-space-1);
19
+ min-block-size: var(--pl-space-6);
20
+ padding-inline: var(--pl-space-2);
21
+ border-radius: var(--pl-radius-pill);
22
+
23
+ /* Pill radius with a dot often looks better than 999rem for small
24
+ labels; pill is the framework's "fully rounded" token. */
25
+ font-size: var(--pl-text-xs);
26
+ font-weight: var(--pl-font-weight-semibold);
27
+ line-height: 1;
28
+
29
+ /* Optical alignment so the label sits centered without inline overshoot. */
30
+ text-wrap: nowrap;
31
+ background: var(--pl-color-surface-2);
32
+ color: var(--pl-color-text);
33
+ }
34
+
35
+ /* Accent - for primary/brand counts (unread, new). */
36
+ :scope[data-tone="accent"] {
37
+ background: var(--pl-color-accent);
38
+ color: var(--pl-color-accent-text);
39
+ }
40
+
41
+ /* Status tones - consume the WCAG-AA -bg/-text pairs. */
42
+ :scope[data-tone="success"] {
43
+ background: var(--pl-color-success-bg);
44
+ color: var(--pl-color-success-text);
45
+ }
46
+
47
+ :scope[data-tone="warning"] {
48
+ background: var(--pl-color-warning-bg);
49
+ color: var(--pl-color-warning-text);
50
+ }
51
+
52
+ :scope[data-tone="danger"] {
53
+ background: var(--pl-color-danger-bg);
54
+ color: var(--pl-color-danger-text);
55
+ }
56
+
57
+ /* Optional leading dot - a status indicator pip. */
58
+ .c-badge__dot {
59
+ inline-size: 0.375rem;
60
+ block-size: 0.375rem;
61
+ border-radius: var(--pl-radius-pill);
62
+ background: currentColor;
63
+ opacity: 0.9;
64
+ }
65
+
66
+ /* Forced colors: fall back to the system highlight so the tone still reads. */
67
+ @media (forced-colors: active) {
68
+ :scope {
69
+ border: 1px solid ButtonBorder;
70
+ }
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,111 @@
1
+ /*
2
+ * Porchlight - breadcrumb component
3
+ * ===========================================================================
4
+ * A trail of navigation links showing the user's location in the site/app
5
+ * hierarchy. The last item has aria-current="page" and is not a link.
6
+ *
7
+ * Separators are drawn via CSS (no extra DOM elements). Uses gap decorations
8
+ * where supported (@supports-gated), falls back to ::after on each item.
9
+ *
10
+ * Structure:
11
+ * <nav class="c-breadcrumb" aria-label="Breadcrumb">
12
+ * <ol class="c-breadcrumb__list">
13
+ * <li class="c-breadcrumb__item">
14
+ * <a class="c-breadcrumb__link" href="/">Home</a>
15
+ * </li>
16
+ * <li class="c-breadcrumb__item">
17
+ * <a class="c-breadcrumb__link" href="/projects">Projects</a>
18
+ * </li>
19
+ * <li class="c-breadcrumb__item">
20
+ * <span class="c-breadcrumb__current" aria-current="page">API Redesign</span>
21
+ * </li>
22
+ * </ol>
23
+ * </nav>
24
+ */
25
+ @layer porchlight.components {
26
+ @scope (.c-breadcrumb) {
27
+ .c-breadcrumb__list {
28
+ display: flex;
29
+ flex-wrap: wrap;
30
+ align-items: center;
31
+ gap: var(--pl-space-1);
32
+ margin: 0;
33
+ padding: 0;
34
+ list-style: none;
35
+ }
36
+
37
+ .c-breadcrumb__item {
38
+ display: inline-flex;
39
+ align-items: center;
40
+ gap: var(--pl-space-1);
41
+ }
42
+
43
+ /* Fallback separator: ::after on each item except the last.
44
+ Hidden where gap decorations are supported (replaced by rule). */
45
+ .c-breadcrumb__item:not(:last-child)::after {
46
+ content: "/";
47
+ color: var(--pl-color-text-muted);
48
+ font-size: var(--pl-text-sm);
49
+ opacity: 0.6;
50
+ margin-inline-start: var(--pl-space-1);
51
+ }
52
+
53
+ .c-breadcrumb__link {
54
+ color: var(--pl-color-text-muted);
55
+ font-size: var(--pl-text-sm);
56
+ text-decoration: none;
57
+ transition: color var(--pl-duration-1) var(--pl-ease-standard);
58
+ }
59
+
60
+ .c-breadcrumb__link:hover {
61
+ color: var(--pl-color-accent);
62
+ text-decoration: underline;
63
+ }
64
+
65
+ .c-breadcrumb__link:focus-visible {
66
+ outline: var(--pl-focus-size) solid var(--pl-focus-color);
67
+ outline-offset: var(--pl-focus-offset);
68
+ border-radius: var(--pl-radius-sm);
69
+ }
70
+
71
+ .c-breadcrumb__current {
72
+ color: var(--pl-color-text);
73
+ font-size: var(--pl-text-sm);
74
+ font-weight: var(--pl-font-weight-medium);
75
+ }
76
+
77
+ /* Long trail truncation: collapse middle items with ellipsis. */
78
+ .c-breadcrumb__item[data-truncate]
79
+ :is(.c-breadcrumb__link, .c-breadcrumb__current) {
80
+ display: inline-block;
81
+ max-inline-size: 8rem;
82
+ overflow: hidden;
83
+ text-overflow: ellipsis;
84
+ white-space: nowrap;
85
+ vertical-align: bottom;
86
+ }
87
+ }
88
+
89
+ /* Gap decorations separator (progressive enhancement). */
90
+ @supports (rule: 1px solid CanvasText) {
91
+ :where(.c-breadcrumb__list) {
92
+ rule: 0 auto var(--pl-color-border);
93
+ rule-visibility-items: between;
94
+ }
95
+
96
+ /* Hide the fallback ::after separator where gap decorations work. */
97
+ :where(.c-breadcrumb__item:not(:last-child))::after {
98
+ content: none;
99
+ }
100
+ }
101
+
102
+ @media (forced-colors: active) {
103
+ :where(.c-breadcrumb__link) {
104
+ color: LinkText;
105
+ }
106
+
107
+ :where(.c-breadcrumb__current) {
108
+ color: CanvasText;
109
+ }
110
+ }
111
+ }
@@ -0,0 +1,180 @@
1
+ /*
2
+ * Porchlight - button component
3
+ * ===========================================================================
4
+ * The canonical action control. Native <button> (or <a> when it is genuinely
5
+ * navigation). Token-driven via component-local --c-button-* aliases that
6
+ * default to framework semantic tokens, so an app can theme one button or all
7
+ * of them without leaving the component.
8
+ *
9
+ * Variants: [data-variant="primary" | "secondary" | "ghost"] (default = none,
10
+ * white surface with border). States: hover, active, aria-pressed, disabled / aria-disabled,
11
+ * focus-visible (inherited outline from base, never removed). Density comes
12
+ * for free from --pl-control-* (set [data-density] on an ancestor).
13
+ *
14
+ * Modern polish: color-mix() hover derives a theme-aware shade from the
15
+ * button's own bg + the text color; a subtle inset top-highlight on filled
16
+ * variants gives depth without a heavy shadow; transform active gives tactile
17
+ * feedback. All motion respects --pl-motion-scale (zeroed under reduced
18
+ * motion). text-box optical alignment is gated behind @supports.
19
+ */
20
+ @layer porchlight.components {
21
+ @scope (.c-button) {
22
+ :scope {
23
+ --c-button-bg: var(--pl-color-surface);
24
+ --c-button-fg: var(--pl-color-text);
25
+ --c-button-border: var(--pl-color-border);
26
+ --c-button-highlight: transparent;
27
+
28
+ display: inline-flex;
29
+ align-items: center;
30
+ justify-content: center;
31
+ gap: var(--pl-control-gap);
32
+ min-block-size: var(--pl-control-block-size);
33
+ min-inline-size: var(--pl-control-block-size);
34
+ padding-inline: var(--pl-control-padding-inline);
35
+ border: var(--pl-control-border-width) solid var(--c-button-border);
36
+ border-radius: var(--pl-control-radius);
37
+ background: var(--c-button-bg);
38
+ color: var(--c-button-fg);
39
+ font-family: inherit;
40
+ font-weight: var(--pl-font-weight-semibold);
41
+ line-height: 1;
42
+ text-align: center;
43
+ text-decoration: none;
44
+ white-space: nowrap;
45
+ cursor: default;
46
+ user-select: none;
47
+
48
+ /* Inset top catch gives filled controls a subtle lift; transparent by
49
+ default so outlined/ghost variants stay flat. */
50
+ box-shadow: inset 0 1px 0 var(--c-button-highlight);
51
+ transition:
52
+ background-color var(--pl-duration-1) var(--pl-ease-standard),
53
+ border-color var(--pl-duration-1) var(--pl-ease-standard),
54
+ color var(--pl-duration-1) var(--pl-ease-standard),
55
+ transform var(--pl-duration-1) var(--pl-ease-spring),
56
+ box-shadow var(--pl-duration-1) var(--pl-ease-standard);
57
+ }
58
+
59
+ /* Primary - the accent fill. Carries accent-text for WCAG-AA contrast. */
60
+ :scope[data-variant="primary"] {
61
+ --c-button-bg: var(--pl-color-accent);
62
+ --c-button-fg: var(--pl-color-accent-text);
63
+ --c-button-border: var(--pl-color-accent);
64
+ --c-button-highlight: oklch(100% 0 0deg / 14%);
65
+ }
66
+
67
+ /* Secondary - a raised surface (surface-2) with a border. */
68
+ :scope[data-variant="secondary"] {
69
+ --c-button-bg: var(--pl-color-surface-2);
70
+ }
71
+
72
+ /* Ghost - transparent; for low-emphasis actions in dense toolbars. */
73
+ :scope[data-variant="ghost"] {
74
+ --c-button-bg: transparent;
75
+ --c-button-border: transparent;
76
+ }
77
+
78
+ /* Default (outlined) variant - lighter border so it doesn't read as
79
+ heavy as a card edge at the same visual layer. */
80
+ :scope:not([data-variant]) {
81
+ --c-button-border: color-mix(
82
+ in oklab,
83
+ var(--pl-color-border),
84
+ transparent 20%
85
+ );
86
+ }
87
+
88
+ /* Hover: mix a little of the text color into the current fill. Done via
89
+ the background-color LONGHAND, NOT by reassigning --c-button-bg -
90
+ reassigning a custom property in terms of itself is a cycle, which CSS
91
+ resolves to guaranteed-invalid and collapses the fill to transparent.
92
+ Theme-aware by construction (darkens in light, lightens in dark). */
93
+ :scope:is(:hover, [data-hover]):not(:disabled, [aria-disabled="true"]) {
94
+ background-color: color-mix(
95
+ in oklab,
96
+ var(--c-button-bg),
97
+ var(--pl-color-text) 12%
98
+ );
99
+ cursor: pointer;
100
+ }
101
+
102
+ /* Ghost has no fill to mix into (transparent) - mixing text into
103
+ transparent yields a ~7%-alpha veil that reads as "barely there".
104
+ Give it a real, visible accent wash instead. */
105
+ :scope[data-variant="ghost"]:is(:hover, [data-hover]):not(
106
+ :disabled,
107
+ [aria-disabled="true"]
108
+ ) {
109
+ background-color: color-mix(
110
+ in oklab,
111
+ var(--pl-color-accent),
112
+ transparent 85%
113
+ );
114
+ }
115
+
116
+ /* Active + pressed: scale dip gives tactile feedback without the
117
+ skeuomorphic down-push of translateY. Spring easing makes the
118
+ snap-back feel lively. */
119
+ :scope:is(:active, [aria-pressed="true"]):not(
120
+ :disabled,
121
+ [aria-disabled="true"]
122
+ ) {
123
+ transform: scale(0.97);
124
+ }
125
+
126
+ /* Primary focus: accent glow instead of the generic outline. */
127
+ :scope[data-variant="primary"]:focus-visible {
128
+ outline: none;
129
+ box-shadow:
130
+ var(--pl-shadow-glow),
131
+ inset 0 1px 0 var(--c-button-highlight);
132
+ }
133
+
134
+ /* A pressed ghost/secondary also gets an accent tint so the selected/
135
+ pressed state reads clearly (a ~12%-alpha veil is too faint - see the
136
+ affordance test). */
137
+ :scope:is(
138
+ [data-variant="ghost"],
139
+ [data-variant="secondary"]
140
+ )[aria-pressed="true"] {
141
+ --c-button-bg: color-mix(
142
+ in oklab,
143
+ var(--pl-color-accent),
144
+ transparent 82%
145
+ );
146
+
147
+ /* Default text (not accent) on the accent tint: accent-text on an
148
+ accent-hue tint can't clear AA (same hue family, low ΔL). The TINT
149
+ is the selection signal; the default text guarantees contrast. */
150
+ --c-button-fg: var(--pl-color-text);
151
+ }
152
+
153
+ :scope:is(:disabled, [aria-disabled="true"]) {
154
+ opacity: var(--pl-opacity-disabled);
155
+ cursor: not-allowed;
156
+ box-shadow: none;
157
+ }
158
+
159
+ /* Optical alignment: trim the cap-height half-leading so the label sits
160
+ visually centered without the inline-box overshoot. Progressive only. */
161
+ @supports (text-box: trim-both cap alphabetic) {
162
+ :scope {
163
+ text-box: trim-both cap alphabetic;
164
+ }
165
+ }
166
+
167
+ /* Forced colors (Windows High Contrast): fall back to system colors. */
168
+ @media (forced-colors: active) {
169
+ :scope {
170
+ border-color: ButtonBorder;
171
+ }
172
+
173
+ :scope[data-variant="primary"] {
174
+ background: Highlight;
175
+ color: HighlightText;
176
+ border-color: Highlight;
177
+ }
178
+ }
179
+ }
180
+ }
@@ -0,0 +1,186 @@
1
+ /*
2
+ * Porchlight - card component
3
+ * ===========================================================================
4
+ * A raised surface grouping related content - the workhorse of SaaS dashboards
5
+ * (KPI tiles, list items, settings sections, detail panels). Header + body
6
+ * slots; the header adapts to the card's OWN width via a container query (not
7
+ * the viewport), so a card in a narrow split pane stacks its header even on a
8
+ * wide screen.
9
+ *
10
+ * Elevation comes from shadow, not bg contrast - surface is intentionally close
11
+ * to bg (a ~2% step); the border + a soft shadow define the card. This is the
12
+ * modern approach (subtle depth over heavy fill differences).
13
+ *
14
+ * Interactive variant: add [data-interactive] (or use an <a.c-card>) to get a
15
+ * hover lift + focus ring - for clickable cards (recent items, navigation
16
+ * tiles). Motion respects --pl-motion-scale.
17
+ *
18
+ * Token-driven via --c-card-* aliases. Density comes from --pl-space-*.
19
+ */
20
+ @layer porchlight.components {
21
+ @scope (.c-card) {
22
+ :scope {
23
+ --c-card-padding: var(--pl-space-5);
24
+ --c-card-bg: var(--pl-color-surface);
25
+ --c-card-border: var(--pl-color-border);
26
+ --c-card-shadow: var(--pl-shadow-1);
27
+ --c-card-radius: var(--pl-radius-2xl);
28
+
29
+ container: c-card / inline-size;
30
+ display: grid;
31
+ gap: var(--pl-space-4);
32
+
33
+ /* When cards sit in an .l-grid (equal-height rows via align-items:
34
+ stretch), a shorter card gets stretched to match its tallest sibling.
35
+ Without this, the card's grid rows absorb the extra height -
36
+ inflating the header row and pushing the title down inconsistently. */
37
+ align-content: start;
38
+ padding: var(--c-card-padding);
39
+ border: 1px solid var(--c-card-border);
40
+ border-radius: var(--c-card-radius);
41
+ background: var(--c-card-bg);
42
+ box-shadow: var(--c-card-shadow);
43
+ }
44
+
45
+ /* Elevated variant - no border; shadow-2 + top-highlight do the lifting.
46
+ Use when the card floats over a busy background or in a hero context. */
47
+ :scope[data-elevated] {
48
+ --c-card-border: transparent;
49
+ --c-card-shadow: var(--pl-shadow-2);
50
+ }
51
+
52
+ /* Accent variant - left-edge brand bar + barely-there tinted surface.
53
+ Use for featured KPI tiles, selected states, upgrade CTAs. */
54
+ :scope[data-variant="accent"] {
55
+ --c-card-border: color-mix(
56
+ in oklab,
57
+ var(--pl-color-accent),
58
+ transparent 70%
59
+ );
60
+
61
+ background: color-mix(
62
+ in oklab,
63
+ var(--pl-color-accent),
64
+ var(--pl-color-surface) 94%
65
+ );
66
+ box-shadow:
67
+ inset var(--pl-accent-bar-width) 0 0 var(--pl-color-accent),
68
+ var(--pl-shadow-1);
69
+ }
70
+
71
+ /* :has() semantic rings - automatic state rings driven by child content.
72
+ A card containing a danger badge or [data-status="error"] element gets a
73
+ danger-hued ring; no extra wrapper attribute needed. */
74
+ :scope:has(.c-badge[data-variant="danger"]),
75
+ :scope:has([data-status="error"]) {
76
+ --c-card-border: color-mix(
77
+ in oklab,
78
+ var(--pl-color-danger),
79
+ transparent 55%
80
+ );
81
+
82
+ box-shadow:
83
+ 0 0 0 1px color-mix(in oklab, var(--pl-color-danger), transparent 72%),
84
+ var(--pl-shadow-1);
85
+ }
86
+
87
+ :scope:has(.c-badge[data-variant="success"]),
88
+ :scope:has([data-status="success"]) {
89
+ --c-card-border: color-mix(
90
+ in oklab,
91
+ var(--pl-color-success),
92
+ transparent 55%
93
+ );
94
+
95
+ box-shadow:
96
+ 0 0 0 1px color-mix(in oklab, var(--pl-color-success), transparent 72%),
97
+ var(--pl-shadow-1);
98
+ }
99
+
100
+ .c-card__header {
101
+ display: flex;
102
+ align-items: start;
103
+ justify-content: space-between;
104
+ gap: var(--pl-space-3);
105
+ }
106
+
107
+ .c-card__title {
108
+ margin: 0;
109
+ font-size: var(--pl-text-lg);
110
+ font-weight: var(--pl-font-weight-bold);
111
+ line-height: var(--pl-leading-tight);
112
+ text-wrap: balance;
113
+ }
114
+
115
+ .c-card__body {
116
+ color: var(--pl-color-text-muted);
117
+ }
118
+
119
+ .c-card__footer {
120
+ display: flex;
121
+ align-items: center;
122
+ justify-content: flex-end;
123
+ gap: var(--pl-space-2);
124
+ padding-block-start: var(--pl-space-3);
125
+
126
+ /* Hairline divider - whitespace does most of the work; the border is
127
+ a hint, not a structural element. */
128
+ border-block-start: 1px solid color-mix(
129
+ in oklab,
130
+ var(--c-card-border),
131
+ transparent 45%
132
+ );
133
+ }
134
+
135
+ /* Header collapses to a stack when the CARD is narrow (container query). */
136
+ @container c-card (inline-size < 28rem) {
137
+ .c-card__header {
138
+ align-items: stretch;
139
+ flex-direction: column;
140
+ }
141
+ }
142
+
143
+ /* Interactive cards - asymmetric hover: the default (resting/exit) state
144
+ uses the fast accelerate curve so moving away feels snappy; the :hover
145
+ state overrides the transition with the slower decelerate curve so
146
+ entering feels deliberate. This is the correct split-transition trick
147
+ for asymmetric CSS hover without JS. */
148
+ :scope[data-interactive],
149
+ :scope[href] {
150
+ cursor: pointer;
151
+ text-decoration: none;
152
+ color: inherit;
153
+
154
+ /* Exit transition: fast and clean. */
155
+ transition:
156
+ transform var(--pl-duration-exit) var(--pl-ease-accelerate),
157
+ box-shadow var(--pl-duration-exit) var(--pl-ease-accelerate);
158
+ }
159
+
160
+ :scope[data-interactive]:is(:hover, [data-hover]),
161
+ :scope[href]:is(:hover, [data-hover]) {
162
+ /* Enter transition: overrides the exit on hover. */
163
+ transition:
164
+ transform var(--pl-duration-enter) var(--pl-ease-decelerate),
165
+ box-shadow var(--pl-duration-enter) var(--pl-ease-decelerate);
166
+ transform: translateY(-3px);
167
+ box-shadow:
168
+ var(--pl-shadow-2),
169
+ 0 0 0 1px color-mix(in oklab, var(--pl-color-accent), transparent 88%);
170
+ }
171
+
172
+ :scope[data-interactive]:focus-visible,
173
+ :scope[href]:focus-visible {
174
+ outline: none;
175
+ box-shadow:
176
+ 0 0 0 var(--pl-focus-size) var(--pl-focus-color),
177
+ var(--pl-shadow-2);
178
+ }
179
+ }
180
+
181
+ @media (forced-colors: active) {
182
+ :where(.c-card) {
183
+ border-color: ButtonBorder;
184
+ }
185
+ }
186
+ }