@kwtv/components 0.5.0 → 0.7.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,13 +7,14 @@ 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. |
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` | Button and icon-button styles for React islands that render the `ui-btn` / `ui-iconbtn` classes. |
16
- | `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. |
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. |
17
18
 
18
19
  ## Install
19
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kwtv/components",
3
- "version": "0.5.0",
3
+ "version": "0.7.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.",
@@ -15,6 +15,11 @@
15
15
  "files": [
16
16
  "src"
17
17
  ],
18
+ "sideEffects": [
19
+ "**/*.css",
20
+ "**/*.astro",
21
+ "./src/react/*.tsx"
22
+ ],
18
23
  "exports": {
19
24
  "./astro/*.astro": "./src/astro/*.astro",
20
25
  "./react/*.tsx": "./src/react/*.tsx",
@@ -34,6 +39,6 @@
34
39
  }
35
40
  },
36
41
  "dependencies": {
37
- "@kwtv/tokens": "^0.5.0"
42
+ "@kwtv/tokens": "^0.7.0"
38
43
  }
39
44
  }
@@ -60,7 +60,7 @@ const classes = ["ui-alert", `ui-alert--${tone}`, className]
60
60
  display: inline-flex;
61
61
  flex-shrink: 0;
62
62
  align-self: flex-start;
63
- margin-top: calc((var(--leading-sm, 1.375rem) - 1em) / 2);
63
+ margin-top: calc((var(--leading-sm, 1.375rem) - 16px) / 2);
64
64
  }
65
65
 
66
66
  /* Body keeps full strength; the left rule carries the tone. */
@@ -12,6 +12,9 @@ import Icon from "./Icon.astro";
12
12
  */
13
13
  interface Props {
14
14
  tone?: "neutral" | "success" | "warning" | "danger" | "outline";
15
+ /** Show the tone's default status icon. Defaults to true for status tones
16
+ * (success/warning/danger); pass false to suppress it for label-only use. */
17
+ icon?: boolean;
15
18
  removable?: boolean;
16
19
  /** aria-label for the × button when removable. Name the filter it clears. */
17
20
  removeLabel?: string;
@@ -20,6 +23,7 @@ interface Props {
20
23
 
21
24
  const {
22
25
  tone = "neutral",
26
+ icon: showIcon = true,
23
27
  removable = false,
24
28
  removeLabel = "Verwijderen",
25
29
  class: className,
@@ -31,7 +35,7 @@ const icons: Record<string, string> = {
31
35
  warning: "alert-triangle",
32
36
  danger: "close",
33
37
  };
34
- const icon = icons[tone];
38
+ const icon = showIcon ? icons[tone] : undefined;
35
39
 
36
40
  const classes = [
37
41
  "ui-badge",
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import Icon from "./Icon.astro";
3
+ import "../styles/controls.css";
3
4
 
4
5
  /**
5
6
  * Button. Four hierarchies, three sizes. One primary action per view.
@@ -69,155 +70,3 @@ const tagProps = href
69
70
  )}
70
71
  {loading && <span class="ui-btn__spinner" aria-hidden="true"></span>}
71
72
  </Tag>
72
-
73
- <style>
74
- @layer reset, tokens, base, components, motion, utilities;
75
-
76
- @layer components {
77
- .ui-btn {
78
- --_pad-x: var(--space-5);
79
- display: inline-flex;
80
- align-items: center;
81
- justify-content: center;
82
- gap: var(--space-2);
83
- position: relative;
84
- min-height: 40px;
85
- padding: var(--space-2) var(--_pad-x);
86
- border: 1px solid transparent;
87
- border-radius: var(--radius-md);
88
- font-family: var(--font-sans);
89
- font-size: var(--text-sm);
90
- font-weight: var(--weight-semibold);
91
- line-height: 1;
92
- text-decoration: none;
93
- cursor: pointer;
94
- transition:
95
- background-color var(--duration-fast) var(--ease-standard),
96
- border-color var(--duration-fast) var(--ease-standard),
97
- transform var(--duration-fast) var(--ease-standard);
98
- }
99
-
100
- /* Sizes: 32 / 40 / 48 px. */
101
- .ui-btn--sm {
102
- min-height: 32px;
103
- --_pad-x: var(--space-4);
104
- font-size: var(--text-xs);
105
- }
106
- .ui-btn--md {
107
- min-height: 40px;
108
- }
109
- .ui-btn--lg {
110
- min-height: 48px;
111
- --_pad-x: var(--space-6);
112
- font-size: var(--text-base);
113
- }
114
-
115
- .ui-btn--block {
116
- display: flex;
117
- width: 100%;
118
- }
119
-
120
- /* Primary. */
121
- .ui-btn--primary {
122
- background: var(--primary);
123
- color: var(--primary-fg);
124
- }
125
- .ui-btn--primary:hover,
126
- .ui-btn--primary.is-hover {
127
- background: var(--primary-hover);
128
- }
129
-
130
- /* Secondary. */
131
- .ui-btn--secondary {
132
- background: transparent;
133
- border-color: var(--border-strong);
134
- color: var(--text);
135
- }
136
- .ui-btn--secondary:hover,
137
- .ui-btn--secondary.is-hover {
138
- background: var(--surface-hover);
139
- }
140
-
141
- /* Accent. One call to action per page. */
142
- .ui-btn--accent {
143
- background: var(--accent);
144
- color: var(--accent-fg);
145
- }
146
- .ui-btn--accent:hover,
147
- .ui-btn--accent.is-hover {
148
- background: var(--accent-hover);
149
- }
150
-
151
- /* Ghost. Low-weight actions. */
152
- .ui-btn--ghost {
153
- background: transparent;
154
- color: var(--link);
155
- padding-inline: var(--space-3);
156
- }
157
- .ui-btn--ghost:hover,
158
- .ui-btn--ghost.is-hover {
159
- background: var(--surface-hover);
160
- color: var(--link-hover);
161
- }
162
-
163
- /* Focus: 2 px ring plus 4 px halo. */
164
- .ui-btn:focus-visible,
165
- .ui-btn.is-focus {
166
- outline: 2px solid var(--focus);
167
- outline-offset: 2px;
168
- box-shadow: 0 0 0 4px var(--focus-outer);
169
- }
170
-
171
- /* Pressed: 1 px downward shift. */
172
- .ui-btn:active,
173
- .ui-btn.is-active {
174
- transform: translateY(1px);
175
- }
176
-
177
- /* Disabled. */
178
- .ui-btn:disabled,
179
- .ui-btn[aria-disabled="true"] {
180
- opacity: 0.5;
181
- cursor: not-allowed;
182
- pointer-events: none;
183
- }
184
-
185
- /* Loading: label hidden, spinner shown. */
186
- .ui-btn.is-loading {
187
- cursor: progress;
188
- }
189
- .ui-btn.is-loading .ui-btn__label,
190
- .ui-btn.is-loading .ui-btn__icon {
191
- visibility: hidden;
192
- }
193
- .ui-btn__spinner {
194
- position: absolute;
195
- width: 16px;
196
- height: 16px;
197
- border: 2px solid currentColor;
198
- border-top-color: transparent;
199
- border-radius: var(--radius-full);
200
- animation: ui-btn-spin var(--duration-slower) linear infinite;
201
- }
202
-
203
- .ui-btn__icon {
204
- display: inline-flex;
205
- font-size: 1em;
206
- }
207
-
208
- @keyframes ui-btn-spin {
209
- to {
210
- transform: rotate(360deg);
211
- }
212
- }
213
-
214
- @media (prefers-reduced-motion: reduce) {
215
- .ui-btn {
216
- transition: none;
217
- }
218
- .ui-btn__spinner {
219
- animation-duration: 1200ms;
220
- }
221
- }
222
- }
223
- </style>
@@ -1,8 +1,9 @@
1
1
  ---
2
2
  /**
3
- * Checkbox. Native control tinted with accent-color, wrapped in a label so the
4
- * whole row is the hit area. 17 px box, 32 px row. Disabled rows keep their
5
- * text legible.
3
+ * Checkbox. Custom-styled native control: appearance:none box with a CSS
4
+ * checkmark SVG, so the checked state is consistent across surfaces and themes.
5
+ * Wrapped in a label so the whole row is the hit area. 17 px box, 32 px row.
6
+ * Disabled rows keep their text legible.
6
7
  *
7
8
  * When several checkboxes form a question, put them in a <fieldset> with a
8
9
  * <legend> in the consuming page so the question is announced first.
@@ -62,12 +63,28 @@ const classes = ["ui-check", disabled && "is-disabled", className]
62
63
  }
63
64
 
64
65
  .ui-check input {
66
+ appearance: none;
67
+ -webkit-appearance: none;
65
68
  width: 17px;
66
69
  height: 17px;
67
70
  flex-shrink: 0;
68
71
  margin-top: calc((var(--leading-sm, 1.375rem) - 17px) / 2);
69
- accent-color: var(--primary);
72
+ border: 1.5px solid var(--border-strong);
73
+ border-radius: var(--radius-xs, 3px);
74
+ background: var(--surface);
70
75
  cursor: pointer;
76
+ transition:
77
+ background-color var(--duration-fast, 100ms) var(--ease-standard, ease),
78
+ border-color var(--duration-fast, 100ms) var(--ease-standard, ease);
79
+ }
80
+
81
+ .ui-check input:checked {
82
+ background: var(--primary);
83
+ border-color: var(--primary);
84
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 10' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='1 5 4.5 8.5 11 1'/%3E%3C/svg%3E");
85
+ background-repeat: no-repeat;
86
+ background-position: center;
87
+ background-size: 10px 8px;
71
88
  }
72
89
 
73
90
  .ui-check input:focus-visible {
@@ -83,6 +100,7 @@ const classes = ["ui-check", disabled && "is-disabled", className]
83
100
 
84
101
  .ui-check.is-disabled input {
85
102
  cursor: not-allowed;
103
+ opacity: 0.45;
86
104
  }
87
105
 
88
106
  @media (max-width: 700px), (pointer: coarse) {
@@ -0,0 +1,128 @@
1
+ ---
2
+ /**
3
+ * CopyChip. Displays a short text value (typically an email or code string)
4
+ * in a monospace pill with a copy-to-clipboard button. Renders the text as
5
+ * static HTML; the button is injected client-side by the bundled script, so
6
+ * the component degrades cleanly without JavaScript.
7
+ *
8
+ * @prop {string} text The value to display and copy.
9
+ * @prop {string} [label] Accessible label for the copy button (default: "Copy").
10
+ * @prop {string} [class]
11
+ */
12
+ interface Props {
13
+ text: string;
14
+ label?: string;
15
+ class?: string;
16
+ }
17
+
18
+ const { text, label = "Copy", class: className, ...rest } = Astro.props;
19
+ const classes = ["ui-copy-chip", className].filter(Boolean).join(" ");
20
+ ---
21
+
22
+ <span class={classes} data-copy={text} {...rest}>
23
+ <span class="ui-copy-chip__text">{text}</span>
24
+ </span>
25
+
26
+ <script>
27
+ function initCopyChip(el: HTMLElement) {
28
+ if (el.querySelector(".ui-copy-chip__btn")) return;
29
+
30
+ const text = el.getAttribute("data-copy") ?? "";
31
+ const label = el.getAttribute("data-label") ?? "Copy";
32
+
33
+ const COPY_ICON = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="5" width="9" height="9" rx="1.5"/><path d="M3 11V3a1 1 0 0 1 1-1h8"/></svg>`;
34
+ const CHECK_ICON = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="2 8 6 12 14 4"/></svg>`;
35
+
36
+ const btn = document.createElement("button");
37
+ btn.className = "ui-copy-chip__btn";
38
+ btn.type = "button";
39
+ btn.setAttribute("aria-label", label);
40
+ btn.innerHTML = COPY_ICON;
41
+
42
+ btn.addEventListener("click", async () => {
43
+ try {
44
+ await navigator.clipboard.writeText(text);
45
+ btn.innerHTML = CHECK_ICON;
46
+ btn.setAttribute("aria-label", "Copied");
47
+ setTimeout(() => {
48
+ btn.innerHTML = COPY_ICON;
49
+ btn.setAttribute("aria-label", label);
50
+ }, 2000);
51
+ } catch (_e) {
52
+ // Clipboard API unavailable.
53
+ }
54
+ });
55
+
56
+ el.appendChild(btn);
57
+ }
58
+
59
+ function initAll(root: Document | HTMLElement) {
60
+ root
61
+ .querySelectorAll<HTMLElement>(".ui-copy-chip:not([data-copy-ready])")
62
+ .forEach((el) => {
63
+ el.setAttribute("data-copy-ready", "");
64
+ initCopyChip(el);
65
+ });
66
+ }
67
+
68
+ initAll(document);
69
+ document.addEventListener("astro:page-load", () => initAll(document));
70
+ </script>
71
+
72
+ <style>
73
+ @layer reset, tokens, base, components, motion, utilities;
74
+
75
+ @layer components {
76
+ .ui-copy-chip {
77
+ display: inline-flex;
78
+ align-items: center;
79
+ gap: var(--space-1);
80
+ padding: 1px var(--space-2);
81
+ border: 1px solid var(--border);
82
+ border-radius: var(--radius-sm);
83
+ background: var(--surface-hover);
84
+ font-family: var(--font-mono, monospace);
85
+ font-size: 0.875em;
86
+ color: var(--text);
87
+ white-space: nowrap;
88
+ vertical-align: middle;
89
+ }
90
+
91
+ .ui-copy-chip__text {
92
+ user-select: all;
93
+ }
94
+
95
+ .ui-copy-chip__btn {
96
+ display: inline-flex;
97
+ align-items: center;
98
+ justify-content: center;
99
+ width: 20px;
100
+ height: 20px;
101
+ padding: 0;
102
+ border: none;
103
+ border-radius: var(--radius-xs);
104
+ background: transparent;
105
+ color: var(--text-muted);
106
+ cursor: pointer;
107
+ flex-shrink: 0;
108
+ transition:
109
+ color var(--duration-fast, 100ms) var(--ease-standard, ease),
110
+ background-color var(--duration-fast, 100ms) var(--ease-standard, ease);
111
+ }
112
+
113
+ .ui-copy-chip__btn:hover {
114
+ color: var(--text);
115
+ background: var(--border);
116
+ }
117
+
118
+ .ui-copy-chip__btn:focus-visible {
119
+ outline: 2px solid var(--focus);
120
+ outline-offset: 2px;
121
+ }
122
+
123
+ .ui-copy-chip__btn svg {
124
+ width: 13px;
125
+ height: 13px;
126
+ }
127
+ }
128
+ </style>
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import Icon from "./Icon.astro";
3
+ import "../styles/controls.css";
3
4
 
4
5
  /**
5
6
  * Icon button. Square 44 px control for the header: cart and menu. Always
@@ -46,78 +47,3 @@ const classes = ["ui-iconbtn", className].filter(Boolean).join(" ");
46
47
  </span>
47
48
  )}
48
49
  </button>
49
-
50
- <style>
51
- @layer reset, tokens, base, components, motion, utilities;
52
-
53
- @layer components {
54
- .ui-iconbtn {
55
- position: relative;
56
- display: inline-flex;
57
- align-items: center;
58
- justify-content: center;
59
- width: 44px;
60
- height: 44px;
61
- padding: 0;
62
- background: transparent;
63
- border: 1px solid transparent;
64
- border-radius: var(--radius-md);
65
- color: var(--text);
66
- cursor: pointer;
67
- transition:
68
- background-color var(--duration-fast) var(--ease-standard),
69
- border-color var(--duration-fast) var(--ease-standard);
70
- }
71
-
72
- .ui-iconbtn:hover,
73
- .ui-iconbtn.is-hover {
74
- background: var(--surface-hover);
75
- border-color: var(--border-strong);
76
- }
77
-
78
- .ui-iconbtn:focus-visible,
79
- .ui-iconbtn.is-focus {
80
- outline: 2px solid var(--focus);
81
- outline-offset: 2px;
82
- box-shadow: 0 0 0 4px var(--focus-outer);
83
- }
84
-
85
- .ui-iconbtn:active,
86
- .ui-iconbtn.is-active {
87
- background: var(--surface-hover);
88
- }
89
-
90
- .ui-iconbtn:disabled,
91
- .ui-iconbtn.is-disabled {
92
- opacity: 0.5;
93
- cursor: not-allowed;
94
- pointer-events: none;
95
- }
96
-
97
- /* Count pill: 19 px, accent fill, top-right. */
98
- .ui-iconbtn__count {
99
- position: absolute;
100
- top: 3px;
101
- right: 3px;
102
- display: inline-flex;
103
- align-items: center;
104
- justify-content: center;
105
- min-width: 19px;
106
- height: 19px;
107
- padding: 0 var(--space-1);
108
- background: var(--accent);
109
- color: var(--accent-fg);
110
- border-radius: var(--radius-full);
111
- font-family: var(--font-sans);
112
- font-size: var(--text-2xs);
113
- font-weight: var(--weight-semibold);
114
- line-height: 1;
115
- }
116
-
117
- @media (prefers-reduced-motion: reduce) {
118
- .ui-iconbtn {
119
- transition: none;
120
- }
121
- }
122
- }
123
- </style>
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import Icon from "./Icon.astro";
3
+ import "../styles/navlink.css";
3
4
 
4
5
  /**
5
6
  * A header navigation group: a button trigger plus a panel of links. The panel
@@ -104,51 +105,8 @@ const classes = ["ui-navgroup", open && "is-open", className]
104
105
  display: inline-block;
105
106
  }
106
107
 
107
- /* The trigger carries .ui-navlink, but that class is styled in NavLink.astro */
108
- /* and Astro scopes styles per component: NavLink's rules only match NavLink's */
109
- /* own markup. Without this block the button falls back to the browser default */
110
- /* chrome, a grey fill and a border. Restated here so the trigger matches the */
111
- /* sibling links it sits next to. */
112
- .ui-navlink {
113
- position: relative;
114
- display: inline-flex;
115
- align-items: center;
116
- gap: var(--space-1-5);
117
- padding: 7px 10px;
118
- margin: -7px -10px;
119
- border-radius: var(--radius-sm);
120
- font-family: var(--font-sans);
121
- font-size: var(--text-sm);
122
- font-weight: var(--weight-medium);
123
- line-height: 1;
124
- color: var(--text);
125
- text-decoration: none;
126
- background: none;
127
- border: 0;
128
- cursor: pointer;
129
- transition: color var(--duration-fast) var(--ease-standard);
130
- }
131
-
132
- .ui-navlink::after {
133
- content: "";
134
- position: absolute;
135
- left: 10px;
136
- right: 10px;
137
- bottom: 2px;
138
- height: 2px;
139
- background: currentColor;
140
- opacity: 0;
141
- transition: opacity var(--duration-fast) var(--ease-standard);
142
- }
143
-
144
- .ui-navlink:hover,
145
- .ui-navlink.is-hover {
146
- color: var(--link-hover);
147
- }
148
- .ui-navlink:hover::after,
149
- .ui-navlink.is-hover::after {
150
- opacity: 1;
151
- }
108
+ /* The trigger is a .ui-navlink, styled in styles/navlink.css. Only the */
109
+ /* group's own states are here. */
152
110
 
153
111
  /* While the panel is open the trigger reads as active, the same way the */
154
112
  /* current page does. */
@@ -158,13 +116,6 @@ const classes = ["ui-navgroup", open && "is-open", className]
158
116
  opacity: 1;
159
117
  }
160
118
 
161
- .ui-navlink:focus-visible,
162
- .ui-navlink.is-focus {
163
- outline: 2px solid var(--focus);
164
- outline-offset: 2px;
165
- box-shadow: 0 0 0 4px var(--focus-outer);
166
- }
167
-
168
119
  /* The chevron turns over when the panel is open. */
169
120
  .ui-navlink > span {
170
121
  display: inline-flex;
@@ -221,8 +172,6 @@ const classes = ["ui-navgroup", open && "is-open", className]
221
172
 
222
173
  @media (prefers-reduced-motion: reduce) {
223
174
  .ui-navpanel :global(a),
224
- .ui-navlink,
225
- .ui-navlink::after,
226
175
  .ui-navlink > span {
227
176
  transition: none;
228
177
  }
@@ -1,4 +1,6 @@
1
1
  ---
2
+ import "../styles/navlink.css";
3
+
2
4
  /**
3
5
  * A navigation link for the header. Pads out to a 34 px row so the focus ring
4
6
  * never crowds the text, with an equal negative margin so the header rhythm is
@@ -26,72 +28,3 @@ const classes = ["ui-navlink", current && "is-current", className]
26
28
  >
27
29
  <slot />
28
30
  </a>
29
-
30
- <style>
31
- @layer reset, tokens, base, components, motion, utilities;
32
-
33
- @layer components {
34
- .ui-navlink {
35
- position: relative;
36
- display: inline-flex;
37
- align-items: center;
38
- gap: var(--space-1-5);
39
- /* 7 px block / 10 px inline padding with an equal negative margin, so the focus ring has room but the header rhythm does not shift. */
40
- padding: 7px 10px;
41
- margin: -7px -10px;
42
- border-radius: var(--radius-sm);
43
- font-family: var(--font-sans);
44
- font-size: var(--text-sm);
45
- font-weight: var(--weight-medium);
46
- line-height: 1;
47
- color: var(--text);
48
- text-decoration: none;
49
- background: none;
50
- border: 0;
51
- cursor: pointer;
52
- transition: color var(--duration-fast) var(--ease-standard);
53
- }
54
-
55
- .ui-navlink::after {
56
- content: "";
57
- position: absolute;
58
- left: 10px;
59
- right: 10px;
60
- bottom: 2px;
61
- height: 2px;
62
- background: currentColor;
63
- opacity: 0;
64
- transition: opacity var(--duration-fast) var(--ease-standard);
65
- }
66
-
67
- .ui-navlink:hover,
68
- .ui-navlink.is-hover {
69
- color: var(--link-hover);
70
- }
71
- .ui-navlink:hover::after,
72
- .ui-navlink.is-hover::after {
73
- opacity: 1;
74
- }
75
-
76
- .ui-navlink.is-current {
77
- font-weight: var(--weight-semibold);
78
- }
79
- .ui-navlink.is-current::after {
80
- opacity: 1;
81
- }
82
-
83
- .ui-navlink:focus-visible,
84
- .ui-navlink.is-focus {
85
- outline: 2px solid var(--focus);
86
- outline-offset: 2px;
87
- box-shadow: 0 0 0 4px var(--focus-outer);
88
- }
89
-
90
- @media (prefers-reduced-motion: reduce) {
91
- .ui-navlink,
92
- .ui-navlink::after {
93
- transition: none;
94
- }
95
- }
96
- }
97
- </style>
@@ -18,6 +18,12 @@ interface Props {
18
18
  ribbon?: "new" | "promo" | "last" | "fav";
19
19
  soldOut?: boolean;
20
20
  class?: string;
21
+ /** Responsive srcset for the product image (original format). */
22
+ srcset?: string;
23
+ /** Sizes descriptor matching the card's layout width. */
24
+ sizes?: string;
25
+ /** AVIF srcset — when provided, a `<picture>` with an AVIF `<source>` is rendered. */
26
+ avifSrcset?: string;
21
27
  }
22
28
 
23
29
  const {
@@ -31,6 +37,9 @@ const {
31
37
  ribbon,
32
38
  soldOut = false,
33
39
  class: className,
40
+ srcset,
41
+ sizes,
42
+ avifSrcset,
34
43
  ...rest
35
44
  } = Astro.props;
36
45
 
@@ -55,13 +64,30 @@ const classes = ["ui-product", soldOut && "is-disabled", className]
55
64
  <i>{soldOut ? "Uitverkocht" : ribbonLabel[ribbon!]}</i>
56
65
  </span>
57
66
  )}
58
- <img
59
- class="ui-product__img"
60
- loading="lazy"
61
- decoding="async"
62
- src={img}
63
- alt={alt}
64
- />
67
+ {avifSrcset ? (
68
+ <picture class="ui-product__img">
69
+ <source type="image/avif" srcset={avifSrcset} sizes={sizes} />
70
+ <img
71
+ src={img}
72
+ srcset={srcset}
73
+ sizes={sizes}
74
+ alt={alt}
75
+ loading="lazy"
76
+ decoding="async"
77
+ style="width:100%;height:100%;object-fit:inherit;"
78
+ />
79
+ </picture>
80
+ ) : (
81
+ <img
82
+ class="ui-product__img"
83
+ loading="lazy"
84
+ decoding="async"
85
+ src={img}
86
+ srcset={srcset}
87
+ sizes={sizes}
88
+ alt={alt}
89
+ />
90
+ )}
65
91
  <span class="ui-product__body">
66
92
  <span class="ui-product__title">{title}</span>
67
93
  {meta && <span class="ui-product__meta">{meta}</span>}
@@ -10,6 +10,12 @@
10
10
  @layer reset, tokens, base, components, motion, utilities;
11
11
 
12
12
  @layer base {
13
+ /* Text selection: sage fill instead of the browser default blue. */
14
+ ::selection {
15
+ background-color: var(--surface-accent);
16
+ color: var(--surface-accent-fg);
17
+ }
18
+
13
19
  /* Screen-reader-only, still announced. */
14
20
  .ui-sr-only {
15
21
  position: absolute;
@@ -1,14 +1,16 @@
1
1
  /*
2
- * Control styles as plain, importable CSS. The Astro components (Button.astro,
3
- * IconButton.astro) carry these same rules scoped to themselves, so a pure-Astro
4
- * app needs nothing here. React and Preact islands cannot see Astro's scoped
5
- * styles, so any React code that renders the .ui-btn / .ui-iconbtn classes
6
- * imports this sheet once:
2
+ * Control styles as plain, importable CSS. This is the single copy of the
3
+ * .ui-btn and .ui-iconbtn rules. Button.astro and IconButton.astro import it
4
+ * themselves, so a pure-Astro app needs nothing extra. Bundlers emit it once,
5
+ * however many components and pages pull it in.
6
+ *
7
+ * React and Preact islands cannot see Astro components, so any React code that
8
+ * renders the .ui-btn / .ui-iconbtn classes without an Astro Button on the
9
+ * page imports this sheet once:
7
10
  *
8
11
  * import "@kwtv/components/styles/controls.css";
9
12
  *
10
- * Values match the scoped versions exactly. Import `@kwtv/tokens/tokens.css`
11
- * before this file.
13
+ * Import `@kwtv/tokens/tokens.css` before this file.
12
14
  */
13
15
 
14
16
  @layer reset, tokens, base, components, motion, utilities;
@@ -0,0 +1,73 @@
1
+ /*
2
+ * Header navigation link. This is the single copy of the .ui-navlink rules.
3
+ * NavLink.astro and NavDropdown.astro import it, so a bundler emits it once.
4
+ * A plain sheet, not scoped, because NavDropdown's trigger is a <button> that
5
+ * carries the same class. Import `@kwtv/tokens/tokens.css` before this file.
6
+ */
7
+
8
+ @layer reset, tokens, base, components, motion, utilities;
9
+
10
+ @layer components {
11
+ .ui-navlink {
12
+ position: relative;
13
+ display: inline-flex;
14
+ align-items: center;
15
+ gap: var(--space-1-5);
16
+ /* 7 px block / 10 px inline padding with an equal negative margin, so the focus ring has room but the header rhythm does not shift. */
17
+ padding: 7px 10px;
18
+ margin: -7px -10px;
19
+ border-radius: var(--radius-sm);
20
+ font-family: var(--font-sans);
21
+ font-size: var(--text-sm);
22
+ font-weight: var(--weight-medium);
23
+ line-height: 1;
24
+ color: var(--text);
25
+ text-decoration: none;
26
+ background: none;
27
+ border: 0;
28
+ cursor: pointer;
29
+ transition: color var(--duration-fast) var(--ease-standard);
30
+ }
31
+
32
+ .ui-navlink::after {
33
+ content: "";
34
+ position: absolute;
35
+ left: 10px;
36
+ right: 10px;
37
+ bottom: 2px;
38
+ height: 2px;
39
+ background: currentColor;
40
+ opacity: 0;
41
+ transition: opacity var(--duration-fast) var(--ease-standard);
42
+ }
43
+
44
+ .ui-navlink:hover,
45
+ .ui-navlink.is-hover {
46
+ color: var(--link-hover);
47
+ }
48
+ .ui-navlink:hover::after,
49
+ .ui-navlink.is-hover::after {
50
+ opacity: 1;
51
+ }
52
+
53
+ .ui-navlink.is-current {
54
+ font-weight: var(--weight-semibold);
55
+ }
56
+ .ui-navlink.is-current::after {
57
+ opacity: 1;
58
+ }
59
+
60
+ .ui-navlink:focus-visible,
61
+ .ui-navlink.is-focus {
62
+ outline: 2px solid var(--focus);
63
+ outline-offset: 2px;
64
+ box-shadow: 0 0 0 4px var(--focus-outer);
65
+ }
66
+
67
+ @media (prefers-reduced-motion: reduce) {
68
+ .ui-navlink,
69
+ .ui-navlink::after {
70
+ transition: none;
71
+ }
72
+ }
73
+ }