@kwtv/components 0.8.0 → 0.9.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.
package/README.md CHANGED
@@ -7,14 +7,15 @@ there is no separate library build or consumer alias configuration.
7
7
 
8
8
  ## What ships
9
9
 
10
- | Path | What it is |
11
- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
12
- | `src/astro/*.astro` | Static components. Scoped styles, no client JS. |
13
- | `src/react/*.tsx` | Interactive islands. Select, Modal, Toast, Drawer, QuantityStepper, NewsCarousel, OrderStatus, OrderTimeline. |
14
- | `src/styles/base.css` | The shared primitives the React islands import: focus ring, touch targets, screen-reader helper. Astro components carry their own scoped styles. |
15
- | `src/styles/controls.css` | The one copy of the button and icon-button styles. `Button` and `IconButton` import it. React islands import it for the `ui-btn` / `ui-iconbtn` classes. |
16
- | `src/styles/navlink.css` | The one copy of the header nav link styles. `NavLink` and `NavDropdown` import it. |
17
- | `src/styles/motion.css` | The `pl-` motion layer: lift, underline, arrow and row nudges, rise, marquee, chip rail and swipe carousel. |
10
+ | Path | What it is |
11
+ | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
12
+ | `src/astro/*.astro` | Static components. Scoped styles, no client JS. |
13
+ | `src/react/*.tsx` | Interactive islands. Select, Modal, Toast, Drawer, QuantityStepper, NewsCarousel, OrderStatus, OrderTimeline. |
14
+ | `src/styles/base.css` | The shared primitives the React islands import: focus ring, touch targets, screen-reader helper. Astro components carry their own scoped styles. |
15
+ | `src/styles/controls.css` | The one copy of the button and icon-button styles. `Button` and `IconButton` import it. React islands import it for the `ui-btn` / `ui-iconbtn` classes. |
16
+ | `src/styles/navlink.css` | The one copy of the header nav link styles. `NavLink` and `NavDropdown` import it. |
17
+ | `src/styles/feedback.css` | The one copy of the alert, empty state and skeleton styles. `Alert`, `EmptyState` and `Skeleton` import it. React islands import it for the `ui-alert`, `ui-empty` and `ui-skel` classes. |
18
+ | `src/styles/motion.css` | The `pl-` motion layer: lift, underline, arrow and row nudges, rise, marquee, chip rail and swipe carousel. |
18
19
 
19
20
  ## Install
20
21
 
@@ -70,6 +71,7 @@ classes (Astro cannot pass its scoped styles into a React island):
70
71
  ```ts
71
72
  import "@kwtv/components/styles/base.css";
72
73
  import "@kwtv/components/styles/controls.css";
74
+ import "@kwtv/components/styles/feedback.css";
73
75
  ```
74
76
 
75
77
  ## Cascade layers
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kwtv/components",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "description": "KWTV UI components for Astro, with React islands. Built on the KWTV design tokens.",
@@ -39,6 +39,6 @@
39
39
  }
40
40
  },
41
41
  "dependencies": {
42
- "@kwtv/tokens": "^0.8.0"
42
+ "@kwtv/tokens": "^0.9.0"
43
43
  }
44
44
  }
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import Icon from "./Icon.astro";
3
+ import "../styles/feedback.css";
3
4
 
4
5
  /**
5
6
  * Alert. Inline message tied to what the reader is looking at. Never used for
@@ -8,6 +9,9 @@ import Icon from "./Icon.astro";
8
9
  * are read in document order.
9
10
  *
10
11
  * Every alert carries both an icon and a worded tone. Body keeps full strength.
12
+ *
13
+ * React and Preact islands write the same markup and import
14
+ * `@kwtv/components/styles/feedback.css`.
11
15
  */
12
16
  interface Props {
13
17
  tone?: "info" | "success" | "warning" | "danger";
@@ -39,60 +43,3 @@ const classes = ["ui-alert", `ui-alert--${tone}`, className]
39
43
  <slot />
40
44
  </span>
41
45
  </div>
42
-
43
- <style>
44
- @layer reset, tokens, base, components, motion, utilities;
45
-
46
- @layer components {
47
- .ui-alert {
48
- display: flex;
49
- align-items: center;
50
- gap: var(--space-2-5, 0.625rem);
51
- padding: var(--space-3) var(--space-4);
52
- border-left: 3px solid currentColor;
53
- border-radius: var(--radius-md);
54
- font-family: var(--font-sans);
55
- font-size: var(--text-sm);
56
- line-height: var(--leading-sm);
57
- }
58
-
59
- .ui-alert__icon {
60
- display: inline-flex;
61
- flex-shrink: 0;
62
- align-self: flex-start;
63
- margin-top: calc((var(--leading-sm, 1.375rem) - 16px) / 2);
64
- }
65
-
66
- /* Body keeps full strength; the left rule carries the tone. */
67
- .ui-alert__body {
68
- color: var(--text);
69
- display: flex;
70
- flex-direction: column;
71
- gap: var(--space-1);
72
- }
73
-
74
- .ui-alert__title {
75
- font-weight: var(--weight-semibold);
76
- }
77
-
78
- .ui-alert--info {
79
- background: var(--surface-accent);
80
- color: var(--surface-accent-fg, var(--primary));
81
- }
82
-
83
- .ui-alert--success {
84
- background: var(--success-bg);
85
- color: var(--success);
86
- }
87
-
88
- .ui-alert--warning {
89
- background: var(--warning-bg);
90
- color: var(--warning);
91
- }
92
-
93
- .ui-alert--danger {
94
- background: var(--danger-bg);
95
- color: var(--danger);
96
- }
97
- }
98
- </style>
@@ -1,8 +1,13 @@
1
1
  ---
2
+ import "../styles/feedback.css";
3
+
2
4
  /**
3
5
  * Empty state. Says what is missing and offers the one action that fixes it.
4
6
  * Never a shrug: an empty cart links back to the category page, an empty filter
5
7
  * result offers to clear filters.
8
+ *
9
+ * React and Preact islands write the same markup and import
10
+ * `@kwtv/components/styles/feedback.css`.
6
11
  */
7
12
  interface Props {
8
13
  title: string;
@@ -24,35 +29,3 @@ const hasAction = Astro.slots.has("action");
24
29
  </div>
25
30
  )}
26
31
  </div>
27
-
28
- <style>
29
- @layer reset, tokens, base, components, motion, utilities;
30
-
31
- @layer components {
32
- .ui-empty {
33
- display: flex;
34
- flex-direction: column;
35
- align-items: flex-start;
36
- gap: var(--space-2);
37
- padding: var(--space-8) var(--space-6);
38
- border: 1px dashed var(--border-strong);
39
- border-radius: var(--radius-lg);
40
- background: var(--surface);
41
- }
42
-
43
- .ui-empty__title {
44
- font-size: var(--text-lg);
45
- font-weight: var(--weight-semibold);
46
- color: var(--text);
47
- }
48
-
49
- .ui-empty__body {
50
- font-size: var(--text-sm);
51
- line-height: var(--leading-sm);
52
- color: var(--text-muted);
53
- }
54
-
55
- /* The flex `gap` above already spaces the action from the body; an added */
56
- /* margin here doubled that gap and threw off the rhythm between the two. */
57
- }
58
- </style>
@@ -8,6 +8,9 @@ import "../styles/controls.css";
8
8
  * The count pill is decorative (aria-hidden); the number must be repeated in
9
9
  * the label by the consumer, so it is announced once, not twice.
10
10
  *
11
+ * Pass `href` to render a link with the same look, for a control that goes to
12
+ * a page, like the cart. The link gets no `type` and no `aria-expanded`.
13
+ *
11
14
  * Pass `icon` to use the shared icon set, or a custom SVG via the default slot.
12
15
  */
13
16
  interface Props {
@@ -15,7 +18,11 @@ interface Props {
15
18
  icon?: string;
16
19
  /** Decorative count pill. Repeat the number in `label`. */
17
20
  count?: number;
21
+ /** Renders an `<a>` to this URL instead of a `<button>`. */
22
+ href?: string;
23
+ /** Button only. Ignored when `href` is set. */
18
24
  type?: "button" | "submit" | "reset";
25
+ /** Button only. Ignored when `href` is set. */
19
26
  expanded?: boolean;
20
27
  class?: string;
21
28
  }
@@ -24,6 +31,7 @@ const {
24
31
  label,
25
32
  icon,
26
33
  count,
34
+ href,
27
35
  type = "button",
28
36
  expanded,
29
37
  class: className,
@@ -33,17 +41,28 @@ const {
33
41
  const classes = ["ui-iconbtn", className].filter(Boolean).join(" ");
34
42
  ---
35
43
 
36
- <button
37
- class={classes}
38
- type={type}
39
- aria-label={label}
40
- aria-expanded={expanded === undefined ? undefined : String(expanded)}
41
- {...rest}
42
- >
43
- {icon ? <Icon name={icon as any} size={20} stroke={1.7} /> : <slot />}
44
- {count !== undefined && (
45
- <span class="ui-iconbtn__count" aria-hidden="true">
46
- {count}
47
- </span>
48
- )}
49
- </button>
44
+ {href ? (
45
+ <a class={classes} href={href} aria-label={label} {...rest}>
46
+ {icon ? <Icon name={icon as any} size={20} stroke={1.7} /> : <slot />}
47
+ {count !== undefined && (
48
+ <span class="ui-iconbtn__count" aria-hidden="true">
49
+ {count}
50
+ </span>
51
+ )}
52
+ </a>
53
+ ) : (
54
+ <button
55
+ class={classes}
56
+ type={type}
57
+ aria-label={label}
58
+ aria-expanded={expanded === undefined ? undefined : String(expanded)}
59
+ {...rest}
60
+ >
61
+ {icon ? <Icon name={icon as any} size={20} stroke={1.7} /> : <slot />}
62
+ {count !== undefined && (
63
+ <span class="ui-iconbtn__count" aria-hidden="true">
64
+ {count}
65
+ </span>
66
+ )}
67
+ </button>
68
+ )}
@@ -1,8 +1,13 @@
1
1
  ---
2
+ import "../styles/feedback.css";
3
+
2
4
  /**
3
5
  * Loading skeleton. Blocks match the real layout so nothing jumps when content
4
6
  * arrives. The shimmer stops for readers who ask for reduced motion, leaving a
5
7
  * flat block. Decorative: aria-hidden by default.
8
+ *
9
+ * React and Preact islands write the same markup and import
10
+ * `@kwtv/components/styles/feedback.css`.
6
11
  */
7
12
  interface Props {
8
13
  variant?: "img" | "title" | "line" | "block";
@@ -40,59 +45,3 @@ const style = [
40
45
  aria-hidden="true"
41
46
  {...rest}
42
47
  ></span>
43
-
44
- <style>
45
- @layer reset, tokens, base, components, motion, utilities;
46
-
47
- @layer components {
48
- .ui-skel {
49
- display: block;
50
- background: var(--surface-hover);
51
- background-image: linear-gradient(
52
- 90deg,
53
- var(--surface-hover) 0%,
54
- var(--border) 50%,
55
- var(--surface-hover) 100%
56
- );
57
- background-size: 200% 100%;
58
- border-radius: var(--radius-md);
59
- animation: ui-skel-shimmer 1400ms ease-in-out infinite;
60
- }
61
-
62
- .ui-skel--img {
63
- width: 100%;
64
- aspect-ratio: 4 / 3;
65
- border-radius: 0;
66
- }
67
-
68
- .ui-skel--title {
69
- height: 0.875rem;
70
- border-radius: var(--radius-sm);
71
- }
72
-
73
- .ui-skel--line {
74
- height: 0.625rem;
75
- border-radius: var(--radius-sm);
76
- }
77
-
78
- .ui-skel--block {
79
- height: 1rem;
80
- }
81
-
82
- @keyframes ui-skel-shimmer {
83
- 0% {
84
- background-position: 200% 0;
85
- }
86
- 100% {
87
- background-position: -200% 0;
88
- }
89
- }
90
-
91
- @media (prefers-reduced-motion: reduce) {
92
- .ui-skel {
93
- animation: none;
94
- background-image: none;
95
- }
96
- }
97
- }
98
- </style>
@@ -1,4 +1,4 @@
1
- /* Mobile menu drawer. All values from the design-system tokens. */
1
+ /* Menu drawer. All values from the design-system tokens. */
2
2
 
3
3
  @layer reset, tokens, base, components, motion, utilities;
4
4
 
@@ -68,4 +68,74 @@
68
68
  .ui-drawer__cta {
69
69
  margin-top: var(--space-2);
70
70
  }
71
+
72
+ .ui-drawer__close {
73
+ align-self: flex-end;
74
+ flex-shrink: 0;
75
+ }
76
+
77
+ /* Placement. The panel is fixed to one edge, over a scrim that stays dark
78
+ in both themes, like the modal scrim. */
79
+ .ui-drawer-scrim {
80
+ position: fixed;
81
+ inset: 0;
82
+ z-index: var(--z-modal);
83
+ display: flex;
84
+ background: color-mix(in srgb, var(--charcoal-xdark) 52%, transparent);
85
+ animation: ui-drawer-fade var(--duration-base) var(--ease-standard);
86
+ }
87
+
88
+ .ui-drawer-scrim--end {
89
+ justify-content: flex-end;
90
+ }
91
+
92
+ .ui-drawer-scrim--bottom {
93
+ align-items: flex-end;
94
+ }
95
+
96
+ .ui-drawer--start,
97
+ .ui-drawer--end,
98
+ .ui-drawer--bottom {
99
+ overflow-y: auto;
100
+ overscroll-behavior: contain;
101
+ box-shadow: var(--shadow-xl);
102
+ animation: ui-drawer-in var(--duration-slow) var(--ease-enter);
103
+ }
104
+
105
+ .ui-drawer--start,
106
+ .ui-drawer--end {
107
+ --_from: translateX(-100%);
108
+ width: min(360px, 100% - var(--space-8));
109
+ height: 100%;
110
+ }
111
+
112
+ .ui-drawer--end {
113
+ --_from: translateX(100%);
114
+ }
115
+
116
+ .ui-drawer--bottom {
117
+ --_from: translateY(100%);
118
+ width: 100%;
119
+ max-height: 85dvh;
120
+ border-radius: var(--radius-xl) var(--radius-xl) 0 0;
121
+ }
122
+
123
+ @keyframes ui-drawer-fade {
124
+ from {
125
+ opacity: 0;
126
+ }
127
+ }
128
+
129
+ @keyframes ui-drawer-in {
130
+ from {
131
+ transform: var(--_from);
132
+ }
133
+ }
134
+
135
+ @media (prefers-reduced-motion: reduce) {
136
+ .ui-drawer-scrim,
137
+ .ui-drawer {
138
+ animation: none;
139
+ }
140
+ }
71
141
  }
@@ -1,15 +1,27 @@
1
1
  import { useEffect, useRef, type ReactNode } from "react";
2
+ import "../styles/controls.css";
2
3
  import "./Drawer.css";
3
4
 
4
5
  /**
5
- * Mobile menu drawer. A full-width panel under the header, opened from the menu
6
- * button. Rows sit on whitespace with no separators.
6
+ * Menu drawer. Without `placement` it is the mobile menu: a full-width panel
7
+ * under the header, opened from the menu button. With `placement` it is a
8
+ * panel fixed to the start, end or bottom edge, over a scrim. Rows sit on
9
+ * whitespace with no separators.
7
10
  *
8
- * Focus moves to the first row on open and returns to the trigger on close; the
9
- * page behind does not scroll while the drawer is open. The trigger is the
10
- * consumer's IconButton: flip its aria-expanded and swap its label between
11
- * "Menu openen" and "Menu sluiten". Pass that trigger element through
12
- * `triggerRef` so focus returns to it on close.
11
+ * While open, Tab and Shift+Tab stay inside the drawer, Escape closes it, and
12
+ * the rest of the page is inert and does not scroll. Focus moves to the first
13
+ * row on open. On close it returns to `triggerRef`, or else to whatever had
14
+ * focus before the drawer opened.
15
+ *
16
+ * Without `placement` the trigger sits outside the drawer and stays usable, so
17
+ * the menu button can close it again. It is part of the Tab cycle. The trigger
18
+ * is the consumer's IconButton: flip its aria-expanded and swap its label
19
+ * between "Menu openen" and "Menu sluiten".
20
+ *
21
+ * Pass `closeLabel` to show a close button at the top of the drawer. A drawer
22
+ * with a `placement` covers its trigger, so give it one.
23
+ *
24
+ * Uses only hooks and DOM calls that preact/compat supports.
13
25
  */
14
26
 
15
27
  export interface DrawerLink {
@@ -37,6 +49,32 @@ export interface DrawerProps {
37
49
  /** Sticky call to action at the bottom, usually the cart button. */
38
50
  cta?: ReactNode;
39
51
  label?: string;
52
+ /**
53
+ * Leave unset for the panel under the header. "start", "end" or "bottom"
54
+ * fix the drawer to that edge of the viewport, over a scrim.
55
+ */
56
+ placement?: "start" | "end" | "bottom";
57
+ /** Shows a close button with this accessible name, like "Menu sluiten". */
58
+ closeLabel?: string;
59
+ }
60
+
61
+ const FOCUSABLE =
62
+ 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
63
+
64
+ /** Sets `inert` on everything outside `root`, except branches holding `keep`. */
65
+ function inertOutside(root: HTMLElement, keep: HTMLElement | null) {
66
+ const changed: Element[] = [];
67
+ let node: HTMLElement = root;
68
+ while (node !== document.body && node.parentElement) {
69
+ for (const sibling of Array.from(node.parentElement.children)) {
70
+ if (sibling === node || sibling.hasAttribute("inert")) continue;
71
+ if (keep && sibling.contains(keep)) continue;
72
+ sibling.setAttribute("inert", "");
73
+ changed.push(sibling);
74
+ }
75
+ node = node.parentElement;
76
+ }
77
+ return () => changed.forEach((el) => el.removeAttribute("inert"));
40
78
  }
41
79
 
42
80
  export default function Drawer({
@@ -47,19 +85,34 @@ export default function Drawer({
47
85
  children,
48
86
  cta,
49
87
  label = "Menu",
88
+ placement,
89
+ closeLabel,
50
90
  }: DrawerProps) {
51
- const navRef = useRef<HTMLElement>(null);
91
+ const rootRef = useRef<HTMLElement>(null);
92
+ const scrimRef = useRef<HTMLDivElement>(null);
93
+ const onCloseRef = useRef(onClose);
94
+ onCloseRef.current = onClose;
52
95
 
53
- // Focus the first row on open, return to the trigger on close.
96
+ // Make the page inert, move focus in on open, and send it back on close.
54
97
  useEffect(() => {
55
98
  if (!open) return;
56
- const first = navRef.current?.querySelector<HTMLElement>(".ui-drawerlink");
57
- const raf = requestAnimationFrame(() => first?.focus());
99
+ const root = rootRef.current;
100
+ if (!root) return;
101
+ const previous = document.activeElement as HTMLElement | null;
102
+ const trigger = triggerRef?.current ?? null;
103
+ const outer = placement ? (scrimRef.current ?? root) : root;
104
+ const restore = inertOutside(outer, placement ? null : trigger);
105
+ const first =
106
+ root.querySelector<HTMLElement>(".ui-drawerlink") ??
107
+ root.querySelector<HTMLElement>(FOCUSABLE) ??
108
+ root;
109
+ const raf = requestAnimationFrame(() => first.focus());
58
110
  return () => {
59
111
  cancelAnimationFrame(raf);
60
- triggerRef?.current?.focus?.();
112
+ restore();
113
+ (trigger ?? previous)?.focus?.();
61
114
  };
62
- }, [open, triggerRef]);
115
+ }, [open, placement, triggerRef]);
63
116
 
64
117
  // Lock the page behind the drawer.
65
118
  useEffect(() => {
@@ -71,20 +124,78 @@ export default function Drawer({
71
124
  };
72
125
  }, [open]);
73
126
 
74
- // Escape closes.
127
+ // Escape closes. Tab and Shift+Tab cycle through the drawer, plus the
128
+ // trigger when the drawer sits under the header.
75
129
  useEffect(() => {
76
130
  if (!open) return;
77
131
  const onKey = (e: globalThis.KeyboardEvent) => {
78
- if (e.key === "Escape") onClose();
132
+ if (e.key === "Escape") {
133
+ e.preventDefault();
134
+ onCloseRef.current();
135
+ return;
136
+ }
137
+ if (e.key !== "Tab") return;
138
+ const root = rootRef.current;
139
+ if (!root) return;
140
+ const items = Array.from(
141
+ root.querySelectorAll<HTMLElement>(FOCUSABLE),
142
+ ).filter((el) => el.getClientRects().length > 0);
143
+ const trigger = placement ? null : triggerRef?.current;
144
+ if (trigger && trigger.isConnected) {
145
+ const triggerFirst =
146
+ trigger.compareDocumentPosition(root) &
147
+ Node.DOCUMENT_POSITION_FOLLOWING;
148
+ if (triggerFirst) items.unshift(trigger);
149
+ else items.push(trigger);
150
+ }
151
+ e.preventDefault();
152
+ if (items.length === 0) {
153
+ root.focus();
154
+ return;
155
+ }
156
+ const index = items.indexOf(document.activeElement as HTMLElement);
157
+ const next =
158
+ index === -1
159
+ ? e.shiftKey
160
+ ? items.length - 1
161
+ : 0
162
+ : (index + (e.shiftKey ? -1 : 1) + items.length) % items.length;
163
+ items[next].focus();
79
164
  };
80
165
  document.addEventListener("keydown", onKey);
81
166
  return () => document.removeEventListener("keydown", onKey);
82
- }, [open, onClose]);
167
+ }, [open, placement, triggerRef]);
83
168
 
84
169
  if (!open) return null;
85
170
 
86
- return (
87
- <nav className="ui-drawer" aria-label={label} ref={navRef}>
171
+ const panel = (
172
+ <nav
173
+ className={`ui-drawer${placement ? ` ui-drawer--${placement}` : ""}`}
174
+ aria-label={label}
175
+ ref={rootRef}
176
+ tabIndex={-1}
177
+ >
178
+ {closeLabel && (
179
+ <button
180
+ className="ui-iconbtn ui-drawer__close"
181
+ type="button"
182
+ aria-label={closeLabel}
183
+ onClick={onClose}
184
+ >
185
+ <svg
186
+ width="20"
187
+ height="20"
188
+ viewBox="0 0 24 24"
189
+ fill="none"
190
+ stroke="currentColor"
191
+ strokeWidth="1.8"
192
+ strokeLinecap="round"
193
+ aria-hidden="true"
194
+ >
195
+ <path d="M7 7l10 10M17 7L7 17" />
196
+ </svg>
197
+ </button>
198
+ )}
88
199
  {groups
89
200
  ? groups.map((group, gi) => (
90
201
  <div className="ui-drawer__group" key={gi}>
@@ -108,4 +219,21 @@ export default function Drawer({
108
219
  {cta && <div className="ui-drawer__cta">{cta}</div>}
109
220
  </nav>
110
221
  );
222
+
223
+ if (!placement) return panel;
224
+
225
+ return (
226
+ <div
227
+ ref={scrimRef}
228
+ className={`ui-drawer-scrim ui-drawer-scrim--${placement}`}
229
+ onMouseDown={(e) => {
230
+ if (e.target !== e.currentTarget) return;
231
+ // Keep the press from moving focus, so focus can go back on close.
232
+ e.preventDefault();
233
+ onClose();
234
+ }}
235
+ >
236
+ {panel}
237
+ </div>
238
+ );
111
239
  }
@@ -158,6 +158,9 @@
158
158
  border: 1px solid transparent;
159
159
  border-radius: var(--radius-md);
160
160
  color: var(--text);
161
+ /* IconButton renders a link when it has an href. Keep the count pill
162
+ free of the link underline. */
163
+ text-decoration: none;
161
164
  cursor: pointer;
162
165
  transition:
163
166
  background-color var(--duration-fast) var(--ease-standard),
@@ -0,0 +1,148 @@
1
+ /*
2
+ * Feedback styles as plain, importable CSS. This is the single copy of the
3
+ * .ui-alert, .ui-empty and .ui-skel rules. Alert.astro, EmptyState.astro and
4
+ * Skeleton.astro import it themselves, so a pure-Astro app needs nothing extra.
5
+ * Bundlers emit it once, however many components and pages pull it in.
6
+ *
7
+ * React and Preact islands cannot render Astro components. An island that
8
+ * shows an alert, an empty state or a loading skeleton writes the same markup
9
+ * as the Astro component and imports this sheet once:
10
+ *
11
+ * import "@kwtv/components/styles/feedback.css";
12
+ *
13
+ * Import `@kwtv/tokens/tokens.css` before this file.
14
+ */
15
+
16
+ @layer reset, tokens, base, components, motion, utilities;
17
+
18
+ @layer components {
19
+ /* ---- Alert ---------------------------------------------------------------- */
20
+ .ui-alert {
21
+ display: flex;
22
+ align-items: center;
23
+ gap: var(--space-2-5, 0.625rem);
24
+ padding: var(--space-3) var(--space-4);
25
+ border-left: 3px solid currentColor;
26
+ border-radius: var(--radius-md);
27
+ font-family: var(--font-sans);
28
+ font-size: var(--text-sm);
29
+ line-height: var(--leading-sm);
30
+ }
31
+
32
+ .ui-alert__icon {
33
+ display: inline-flex;
34
+ flex-shrink: 0;
35
+ align-self: flex-start;
36
+ margin-top: calc((var(--leading-sm, 1.375rem) - 16px) / 2);
37
+ }
38
+
39
+ /* Body keeps full strength; the left rule carries the tone. */
40
+ .ui-alert__body {
41
+ color: var(--text);
42
+ display: flex;
43
+ flex-direction: column;
44
+ gap: var(--space-1);
45
+ }
46
+
47
+ .ui-alert__title {
48
+ font-weight: var(--weight-semibold);
49
+ }
50
+
51
+ .ui-alert--info {
52
+ background: var(--surface-accent);
53
+ color: var(--surface-accent-fg, var(--primary));
54
+ }
55
+
56
+ .ui-alert--success {
57
+ background: var(--success-bg);
58
+ color: var(--success);
59
+ }
60
+
61
+ .ui-alert--warning {
62
+ background: var(--warning-bg);
63
+ color: var(--warning);
64
+ }
65
+
66
+ .ui-alert--danger {
67
+ background: var(--danger-bg);
68
+ color: var(--danger);
69
+ }
70
+
71
+ /* ---- Empty state ---------------------------------------------------------- */
72
+ .ui-empty {
73
+ display: flex;
74
+ flex-direction: column;
75
+ align-items: flex-start;
76
+ gap: var(--space-2);
77
+ padding: var(--space-8) var(--space-6);
78
+ border: 1px dashed var(--border-strong);
79
+ border-radius: var(--radius-lg);
80
+ background: var(--surface);
81
+ }
82
+
83
+ .ui-empty__title {
84
+ font-size: var(--text-lg);
85
+ font-weight: var(--weight-semibold);
86
+ color: var(--text);
87
+ }
88
+
89
+ .ui-empty__body {
90
+ font-size: var(--text-sm);
91
+ line-height: var(--leading-sm);
92
+ color: var(--text-muted);
93
+ }
94
+
95
+ /* The flex gap above spaces the action from the body. No extra margin. */
96
+
97
+ /* ---- Skeleton ------------------------------------------------------------- */
98
+ .ui-skel {
99
+ display: block;
100
+ background: var(--surface-hover);
101
+ background-image: linear-gradient(
102
+ 90deg,
103
+ var(--surface-hover) 0%,
104
+ var(--border) 50%,
105
+ var(--surface-hover) 100%
106
+ );
107
+ background-size: 200% 100%;
108
+ border-radius: var(--radius-md);
109
+ animation: ui-skel-shimmer 1400ms ease-in-out infinite;
110
+ }
111
+
112
+ .ui-skel--img {
113
+ width: 100%;
114
+ aspect-ratio: 4 / 3;
115
+ border-radius: 0;
116
+ }
117
+
118
+ .ui-skel--title {
119
+ height: 0.875rem;
120
+ border-radius: var(--radius-sm);
121
+ }
122
+
123
+ .ui-skel--line {
124
+ height: 0.625rem;
125
+ border-radius: var(--radius-sm);
126
+ }
127
+
128
+ .ui-skel--block {
129
+ height: 1rem;
130
+ }
131
+
132
+ @keyframes ui-skel-shimmer {
133
+ 0% {
134
+ background-position: 200% 0;
135
+ }
136
+ 100% {
137
+ background-position: -200% 0;
138
+ }
139
+ }
140
+
141
+ /* The shimmer stops for readers who ask for reduced motion. */
142
+ @media (prefers-reduced-motion: reduce) {
143
+ .ui-skel {
144
+ animation: none;
145
+ background-image: none;
146
+ }
147
+ }
148
+ }