@getvitops/generator 5.0.0 → 7.0.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.
@@ -1,112 +1,418 @@
1
1
  /**
2
- * Carousel — CSS-only scroll-snap carousel migrated from old-css-lib. Uses
3
- * ::scroll-button(), ::scroll-marker-group, ::column, scroll-state() and
4
- * anchor positioning (all polyfilled). Fully operable as a scroll-snap strip
5
- * without JS; the Lit component adds autoplay + any marker fallback.
2
+ * Carousel — a scroll-snap strip that is fully operable, and *discoverable*, with
3
+ * no JavaScript in every engine, and that uses the native scroll-marker machinery
4
+ * (`::scroll-button()`, `::scroll-marker`, `::scroll-marker-group`, `::column`)
5
+ * wherever it exists.
6
+ *
7
+ * As of Aug 2026 that machinery is Chromium-135+ only — Safari is landing it,
8
+ * Firefox has it behind flags — and there is no usable polyfill: the only
9
+ * candidate (flackr/carousel's `polyfill/polyfill.js`) is a 2024 prototype on the
10
+ * superseded syntax that re-parses inline `<style>` elements and so never sees a
11
+ * linked stylesheet. So `WCCarousel` builds real `<button>`s and a real dot nav
12
+ * instead, and this file styles BOTH paths identically. See the `@supports` split
13
+ * below for why they can't be one selector list.
14
+ *
15
+ * ── Structure ────────────────────────────────────────────────────────────────
16
+ * .carousel shell. Not a scroller. The <wc-carousel> tag.
17
+ * ├ .carousel__track the scroll container (a <ul role="list"> from Astro).
18
+ * │ └ .carousel__slide a slide — rules apply to any direct child of the track.
19
+ * │ └ .carousel__media aspect-ratio box the image covers.
20
+ * ├ .carousel__hint "Scroll for more" — OUTSIDE the scroller, so it can't
21
+ * │ scroll away with the slides. That is the whole reason
22
+ * │ the shell exists.
23
+ * ├ .carousel__button JS-built prev/next (fallback path only).
24
+ * ├ .carousel__markers JS-built dot nav (fallback path only).
25
+ * └ .carousel__autoplay play/pause toggle, built by the element.
26
+ *
27
+ * The shell also replaces the previous `position: fixed` + `anchor-name` /
28
+ * `position-area` placement of the controls. That was wrong three ways: an engine
29
+ * without anchor positioning pinned the real autoplay button to a VIEWPORT corner
30
+ * (the oddbird polyfill supports neither `position-area` nor a pseudo-element
31
+ * anchor, so the postrender polyfill never rescued it); `anchor-scope: --carousel`
32
+ * was one global name doing per-instance work; and the shell is already outside
33
+ * the scrollport, so `position: absolute` against it neither scrolls nor clips
34
+ * without any of that machinery.
35
+ *
36
+ * ── Modifiers ────────────────────────────────────────────────────────────────
37
+ * carousel--scroll-buttons prev/next
38
+ * carousel--scroll-markers dot nav
39
+ * carousel--auto-pages CSS multicol paging (`::column`) — Chromium-only,
40
+ * degrades to a plain multicol strip elsewhere
41
+ * carousel--inert dim + inert the off-screen slides
42
+ * carousel--force-stop `scroll-snap-stop: always`
43
+ * carousel--no-scrollbar opt out of the visible scrollbar
44
+ * carousel--markers-below dots below the strip instead of overlaid
6
45
  */
7
46
 
47
+ /* An unregistered custom element is `display: inline`, and this one wraps a block
48
+ scroller — the KNOWN GAP recorded in js/elements.ts. The grid lives on
49
+ `.carousel`, which is what an author actually writes; the tag only needs to
50
+ stop being inline before the element upgrades. */
51
+ wc-carousel {
52
+ display: block;
53
+ }
54
+
55
+ /* ── Feature probe ───────────────────────────────────────────────────────────
56
+ ONE declaration of each condition. `WCCarousel` does NOT re-run these with
57
+ `CSS.supports`; it reads the resolved custom property back off the element, so
58
+ the stylesheet and the component cannot disagree about which branch is live.
59
+ Two hand-synced condition strings — one here, one in JS — is the drift this
60
+ avoids, and its failure mode is silent duplicate controls sitting under working
61
+ native ones.
62
+
63
+ It also answers the harder question correctly. The real question is not "does
64
+ this engine support ::scroll-button()" but "did MY stylesheet's native branch
65
+ apply". A page that loads elements.js without the framework CSS has no
66
+ pseudo-elements to collide with — the probe reads empty and the fallback is
67
+ built. `CSS.supports` would have said yes and built nothing. */
68
+ .carousel {
69
+ --carousel-native-nav: 0;
70
+ --carousel-scroll-timeline: 0;
71
+ }
72
+
73
+ /* `(scroll-marker-group: after)` is ANDed in because `selector()` support for a
74
+ functional pseudo-element is the weaker signal of the two. */
75
+ @supports selector(::scroll-marker) and selector(::scroll-button(inline-end)) and
76
+ (scroll-marker-group: after) {
77
+ .carousel {
78
+ --carousel-native-nav: 1;
79
+ }
80
+ }
81
+
82
+ @supports (animation-timeline: scroll()) {
83
+ .carousel {
84
+ --carousel-scroll-timeline: 1;
85
+ }
86
+ }
87
+
88
+ /* ── Shell ───────────────────────────────────────────────────────────────────*/
89
+
8
90
  .carousel {
91
+ position: relative; /* containing block for both control paths */
92
+ display: grid;
93
+ grid-template-columns: minmax(0, 1fr);
94
+ row-gap: var(--carousel-shell-gap, 0.5rem);
95
+
96
+ /* Hoist the track's scroll-progress timeline so the hint — a SIBLING of the
97
+ scroller, not a descendant — can read it. This is why the hint fade is not a
98
+ `scroll-state()` container query: a scroll-state query's rules apply to
99
+ descendants of the scroll container, and the hint deliberately isn't one.
100
+ `timeline-scope` also makes it order-independent, so the hint may precede the
101
+ track in source. */
102
+ timeline-scope: --carousel-scroll;
103
+ }
104
+
105
+ /* ── Track ───────────────────────────────────────────────────────────────────
106
+ The second selector is the deprecated 5.x single-element form, where
107
+ `.carousel` IS the scroller and the slides are its bare children. It has
108
+ nowhere to put the hint or the fallback controls, so it is kept only for one
109
+ major; migration is a single wrapper element. A selector list rather than a
110
+ `:is()` because `:has()` is Baseline and this stays readable. */
111
+ .carousel__track,
112
+ .carousel:not(:has(> .carousel__track)) {
113
+ margin: 0;
114
+ padding: var(--carousel-padding, 0);
115
+ list-style: none;
116
+ max-inline-size: 100%;
9
117
  overflow-x: auto;
10
- scroll-behavior: smooth;
11
118
  overscroll-behavior-x: contain;
12
- scrollbar-width: none;
119
+ scroll-behavior: smooth;
13
120
  scroll-snap-type: x mandatory;
14
- scroll-padding: var(--carousel-scroll-padding, 1rem);
15
- padding: var(--carousel-padding, 1rem);
16
- max-inline-size: 100%;
17
- anchor-name: --carousel;
18
- anchor-scope: --carousel;
121
+ scroll-padding-inline: var(--carousel-scroll-padding, 0px);
122
+
123
+ /* The timeline the hint and the scrollbar fade read, declared here and
124
+ consumed on the shell's other children through `timeline-scope`. */
125
+ scroll-timeline: --carousel-scroll inline;
126
+
127
+ /* For `carousel--inert`, whose targets genuinely ARE descendants of the
128
+ scroller — the one place a scroll-state query is applicable here. */
129
+ container-type: scroll-state;
130
+
131
+ /* A thin, styled scrollbar is the default affordance. `scrollbar-width: none`
132
+ removed the only no-JS signal that the strip scrolls at all, on the
133
+ assumption the ::scroll-button()s were present — the exact assumption that
134
+ fails in every engine but one. `.carousel--no-scrollbar` opts out. */
135
+ scrollbar-width: thin;
136
+ scrollbar-color: var(--carousel-scrollbar-thumb, var(--color-bg-surface-bold))
137
+ var(--carousel-scrollbar-track, transparent);
138
+ }
139
+
140
+ .carousel--no-scrollbar > .carousel__track,
141
+ .carousel--no-scrollbar:not(:has(> .carousel__track)) {
142
+ scrollbar-width: none;
143
+
144
+ &::-webkit-scrollbar {
145
+ display: none;
146
+ }
147
+ }
19
148
 
20
- /* Non-paged (default) — grid layout */
21
- &:not(.carousel--auto-pages) {
149
+ /* Non-paged (default) — one grid column per slide. */
150
+ .carousel:not(.carousel--auto-pages) > .carousel__track,
151
+ .carousel:not(.carousel--auto-pages):not(:has(> .carousel__track)) {
152
+ display: grid;
153
+ grid-auto-flow: column;
154
+ grid-auto-columns: var(--carousel-slide-size, 100%);
155
+ gap: var(--carousel-gap, 1rem);
156
+ /* Equal-height slides whatever the media. */
157
+ align-items: stretch;
158
+
159
+ > * {
160
+ scroll-snap-align: center;
161
+ min-inline-size: 0;
162
+ /* A slide is a stretched grid item, so its media box has a definite
163
+ block-size for `object-fit: cover` to fill. */
22
164
  display: grid;
23
- grid-auto-flow: column;
24
- grid-auto-columns: var(--carousel-slide-size, 100%);
25
- gap: var(--carousel-gap, 1rem);
165
+ grid-template-rows: auto;
166
+ align-content: start;
167
+ }
168
+ }
26
169
 
27
- > * {
28
- scroll-snap-align: center;
29
- }
170
+ /* Paged — CSS multicol. `::column` is left ungated: an engine without it renders
171
+ a plain multicol strip, which still scrolls and snaps. */
172
+ .carousel--auto-pages > .carousel__track,
173
+ .carousel--auto-pages:not(:has(> .carousel__track)) {
174
+ columns: var(--carousel-columns, 1);
175
+ text-align: center;
30
176
 
31
- > [part='autoplay'] {
32
- scroll-snap-align: unset;
33
- }
177
+ > * {
178
+ break-inside: avoid;
179
+ display: inline-grid;
34
180
  }
35
181
 
36
- /* Paged — CSS columns */
37
- &.carousel--auto-pages {
38
- columns: var(--carousel-columns, 1);
39
- text-align: center;
182
+ &::column {
183
+ scroll-snap-align: center;
184
+ }
185
+ }
40
186
 
41
- > * {
42
- break-inside: avoid;
43
- display: inline-grid;
44
- }
187
+ /* ── Slide media ─────────────────────────────────────────────────────────────
188
+ Equal-height slides regardless of image aspect ratio. This file previously had
189
+ no `img` rule at all and global.css no media reset, so a portrait photo in a
190
+ strip of landscapes set the row height for every slide and the rest floated in
191
+ it. The fix is the one `patterns/media.css` and `patterns/subgrid.css` already
192
+ use: give the media a box with a known ratio and let the image cover it. */
193
+ .carousel__media {
194
+ aspect-ratio: var(--carousel-slide-aspect, 16 / 9);
195
+ overflow: hidden;
196
+ margin: 0;
197
+ border-radius: var(--br-carousel-group);
198
+
199
+ > :is(img, picture, video, svg, canvas) {
200
+ display: block;
201
+ inline-size: 100%;
202
+ block-size: 100%;
203
+ object-fit: var(--carousel-media-fit, cover);
204
+ object-position: var(--carousel-media-position, center);
205
+ }
206
+ }
45
207
 
46
- &::column {
47
- scroll-snap-align: center;
48
- }
208
+ /* Belt and braces for a slide that doesn't use the media wrapper. */
209
+ .carousel__track img {
210
+ max-inline-size: 100%;
211
+ }
212
+
213
+ .carousel__caption {
214
+ margin-block-start: 0.5rem;
215
+ color: var(--color-text-surface-muted);
216
+ }
217
+
218
+ /* ── Scroll buttons ──────────────────────────────────────────────────────────
219
+ Two blocks declaring the same thing, and they cannot be merged into one
220
+ selector list: a selector list is NOT forgiving, so one unparseable complex
221
+ selector drops the WHOLE rule. `.carousel__button, …::scroll-button(*) { … }`
222
+ would take the fallback button's styling down with the pseudo-element Firefox
223
+ doesn't know. `:is()` is no rescue either — it cannot contain pseudo-elements.
224
+ `carousel-parity.test.ts` asserts the two blocks declare the same properties. */
225
+
226
+ /* Fallback path — real <button>s built by WCCarousel when the probe reads 0. */
227
+ .carousel__button {
228
+ position: absolute;
229
+ inset-block: 0;
230
+ block-size: fit-content;
231
+ margin-block: auto;
232
+ z-index: var(--z-tier-raised);
233
+ box-sizing: border-box;
234
+ padding: 0;
235
+ border: 0;
236
+ line-height: 1;
237
+ display: grid;
238
+ place-content: center;
239
+ place-items: center;
240
+ inline-size: var(--carousel-button-size, 2.75rem);
241
+ aspect-ratio: 1;
242
+ border-radius: 50%;
243
+ font-size: var(--carousel-button-glyph, 1.5rem);
244
+ cursor: pointer;
245
+ color: var(--carousel-button-color, var(--color-text-surface));
246
+ background-color: var(--carousel-button-bg, var(--color-bg-surface-muted));
247
+ box-shadow: var(--ds-carousel-group);
248
+ transition:
249
+ background-color 0.2s ease,
250
+ opacity 0.2s ease;
251
+
252
+ &:hover {
253
+ /* A REAL hover step. The old rule set the same `--color-bg-surface-muted` the
254
+ base rule sets, so hover was a no-op — the token migration flattened two
255
+ distinct oklch literals onto one variable. `muted` → `bold` is one ordinal
256
+ step on the surface ladder and reads in both schemes. */
257
+ background-color: var(--carousel-button-bg-hover, var(--color-bg-surface-bold));
258
+ }
259
+
260
+ &:focus-visible {
261
+ outline: 2px solid var(--color-border-focus);
262
+ outline-offset: 2px;
49
263
  }
50
264
 
51
- /* Scroll buttons — anchor-positioned */
52
- &.carousel--scroll-buttons {
265
+ &:disabled {
266
+ cursor: not-allowed;
267
+ opacity: 0.35;
268
+ }
269
+ }
270
+
271
+ .carousel__button--prev {
272
+ inset-inline-start: var(--carousel-button-inset, 0.5rem);
273
+ }
274
+
275
+ .carousel__button--next {
276
+ inset-inline-end: var(--carousel-button-inset, 0.5rem);
277
+ }
278
+
279
+ /* Native path. */
280
+ @supports selector(::scroll-button(inline-end)) {
281
+ .carousel--scroll-buttons > .carousel__track,
282
+ .carousel--scroll-buttons:not(:has(> .carousel__track)) {
53
283
  &::scroll-button(*) {
54
- position: fixed;
55
- position-anchor: --carousel;
56
- position-visibility: always;
57
- cursor: pointer;
58
- font-size: 1.5rem;
59
- inline-size: 44px;
60
- aspect-ratio: 1;
61
- display: flex;
62
- place-items: center;
284
+ position: absolute;
285
+ inset-block: 0;
286
+ block-size: fit-content;
287
+ margin-block: auto;
288
+ z-index: var(--z-tier-raised);
289
+ /* global.css's reset is `*, *::before, *::after` — it does NOT reach a
290
+ ::scroll-button(), so this pseudo is content-box carrying Chrome's UA
291
+ `padding: 1px 6px`. `inline-size: 44px` + `aspect-ratio: 1` therefore
292
+ sized the CONTENT box, giving a 56×46 border box: an ellipse with the
293
+ glyph off-centre. That, plus `line-height: normal` letting a 1.5rem
294
+ glyph ride its own half-leading, is the whole centring bug. `grid`
295
+ rather than `flex` because `place-items`' justify-items half is inert in
296
+ a flex container. */
297
+ box-sizing: border-box;
298
+ padding: 0;
299
+ border: 0;
300
+ line-height: 1;
301
+ display: grid;
63
302
  place-content: center;
64
- border: none;
303
+ place-items: center;
304
+ inline-size: var(--carousel-button-size, 2.75rem);
305
+ aspect-ratio: 1;
65
306
  border-radius: 50%;
66
- color: var(--carousel-button-color, currentColor);
67
- background: var(--color-bg-surface-muted);
68
- opacity: 0.9;
307
+ font-size: var(--carousel-button-glyph, 1.5rem);
308
+ cursor: pointer;
309
+ color: var(--carousel-button-color, var(--color-text-surface));
310
+ background-color: var(--carousel-button-bg, var(--color-bg-surface-muted));
311
+ box-shadow: var(--ds-carousel-group);
69
312
  transition:
70
- opacity 0.2s ease,
71
- background 0.2s ease;
313
+ background-color 0.2s ease,
314
+ opacity 0.2s ease;
72
315
  }
73
316
 
74
317
  &::scroll-button(*):hover {
75
- opacity: 1;
76
- background: var(--color-bg-surface-muted);
318
+ background-color: var(--carousel-button-bg-hover, var(--color-bg-surface-bold));
77
319
  }
78
320
 
79
321
  &::scroll-button(*):focus-visible {
80
- outline: 2px solid currentColor;
322
+ outline: 2px solid var(--color-border-focus);
81
323
  outline-offset: 2px;
82
324
  }
83
325
 
84
326
  &::scroll-button(*):disabled {
85
327
  cursor: not-allowed;
86
- opacity: 0.2;
328
+ opacity: 0.35;
329
+ }
330
+
331
+ /* Logical, not physical. `(left)`/`(right)` put "previous" on the wrong side
332
+ in an RTL document and pointed the arrow the wrong way. Single guillemets
333
+ centre far better in a circle than `\2190`/`\2192`, whose side bearings are
334
+ asymmetric. The alt text takes a var() so it can be localised without
335
+ re-declaring `content`. */
336
+ &::scroll-button(inline-start) {
337
+ inset-inline-start: var(--carousel-button-inset, 0.5rem);
338
+ content: '\2039' / var(--carousel-label-prev, 'Previous');
87
339
  }
88
340
 
89
- &::scroll-button(left) {
90
- position-area: inline-start center;
91
- content: '\2190' / 'Previous';
341
+ &::scroll-button(inline-end) {
342
+ inset-inline-end: var(--carousel-button-inset, 0.5rem);
343
+ content: '\203A' / var(--carousel-label-next, 'Next');
92
344
  }
93
345
 
94
- &::scroll-button(right) {
95
- position-area: inline-end center;
96
- content: '\2192' / 'Next';
346
+ &:dir(rtl)::scroll-button(inline-start) {
347
+ content: '\203A' / var(--carousel-label-prev, 'Previous');
348
+ }
349
+
350
+ &:dir(rtl)::scroll-button(inline-end) {
351
+ content: '\2039' / var(--carousel-label-next, 'Next');
97
352
  }
98
353
  }
354
+ }
355
+
356
+ /* ── Scroll markers ──────────────────────────────────────────────────────────
357
+ Same two-block split, same reason. With the shell in place
358
+ `scroll-marker-group: after` generates the group box after the scroller in the
359
+ shell's flow — so unlike the previous version it needs no anchor and no
360
+ `position: fixed`, just an absolutely positioned flex row against the shell.
361
+ `.carousel--markers-below` flows them under the strip instead. */
362
+
363
+ .carousel__markers {
364
+ position: absolute;
365
+ inset-inline: 0;
366
+ inset-block-end: var(--carousel-marker-offset, 1rem);
367
+ z-index: var(--z-tier-raised);
368
+ display: flex;
369
+ align-items: center;
370
+ justify-content: center;
371
+ gap: 0.5rem;
372
+ padding-block: 0.5rem;
373
+ }
374
+
375
+ .carousel--markers-below > .carousel__markers {
376
+ position: static;
377
+ inset: auto;
378
+ }
99
379
 
100
- /* Scroll markers — anchor-positioned group */
101
- &.carousel--scroll-markers {
380
+ .carousel__marker {
381
+ box-sizing: border-box;
382
+ padding: 0;
383
+ border: 0;
384
+ inline-size: var(--carousel-marker-size, 0.75rem);
385
+ aspect-ratio: 1;
386
+ border-radius: 50%;
387
+ background-color: var(--carousel-marker-color, currentColor);
388
+ opacity: 0.4;
389
+ cursor: pointer;
390
+ transition: opacity 0.2s ease;
391
+
392
+ &[aria-selected='true'] {
393
+ opacity: 1;
394
+ background-color: var(
395
+ --carousel-marker-active-color,
396
+ var(--carousel-marker-color, currentColor)
397
+ );
398
+ }
399
+
400
+ &:focus-visible {
401
+ outline: 2px solid var(--color-border-focus);
402
+ outline-offset: 2px;
403
+ }
404
+ }
405
+
406
+ @supports selector(::scroll-marker) {
407
+ .carousel--scroll-markers > .carousel__track,
408
+ .carousel--scroll-markers:not(:has(> .carousel__track)) {
102
409
  scroll-marker-group: after;
103
410
 
104
411
  &::scroll-marker-group {
105
- position: fixed;
106
- position-anchor: --carousel;
107
- position-area: block-end;
108
- position-visibility: always;
109
- margin-block-end: var(--carousel-marker-offset, 1rem);
412
+ position: absolute;
413
+ inset-inline: 0;
414
+ inset-block-end: var(--carousel-marker-offset, 1rem);
415
+ z-index: var(--z-tier-raised);
110
416
  display: flex;
111
417
  align-items: center;
112
418
  justify-content: center;
@@ -114,156 +420,273 @@
114
420
  padding-block: 0.5rem;
115
421
  }
116
422
 
117
- /* Non-paged: per-item markers */
118
- &:not(.carousel--auto-pages) {
119
- > *::scroll-marker {
120
- content: '';
121
- inline-size: 0.75rem;
122
- block-size: 0.75rem;
123
- border-radius: 50%;
124
- background-color: var(--carousel-marker-color, currentColor);
125
- opacity: 0.4;
126
- cursor: pointer;
127
- transition: opacity 0.2s ease;
128
- }
129
-
130
- > [data-carousel-clone]::scroll-marker,
131
- > [part='autoplay']::scroll-marker {
132
- content: none;
133
- }
134
-
135
- > *::scroll-marker:target-current {
136
- opacity: 1;
137
- background-color: var(
138
- --carousel-marker-active-color,
139
- var(--carousel-marker-color, currentColor)
140
- );
141
- }
142
-
143
- > *::scroll-marker:focus-visible {
144
- outline: 2px solid currentColor;
145
- outline-offset: 2px;
146
- }
423
+ /* Non-paged: one marker per slide. */
424
+ > *::scroll-marker {
425
+ content: '';
426
+ box-sizing: border-box;
427
+ padding: 0;
428
+ border: 0;
429
+ inline-size: var(--carousel-marker-size, 0.75rem);
430
+ aspect-ratio: 1;
431
+ border-radius: 50%;
432
+ background-color: var(--carousel-marker-color, currentColor);
433
+ opacity: 0.4;
434
+ cursor: pointer;
435
+ transition: opacity 0.2s ease;
147
436
  }
148
437
 
149
- /* Paged: per-column markers */
150
- &.carousel--auto-pages {
151
- &::column::scroll-marker {
152
- content: '';
153
- inline-size: 0.75rem;
154
- block-size: 0.75rem;
155
- border-radius: 50%;
156
- background-color: var(--carousel-marker-color, currentColor);
157
- opacity: 0.4;
158
- cursor: pointer;
159
- transition: opacity 0.2s ease;
160
- }
161
-
162
- &::column::scroll-marker:target-current {
163
- opacity: 1;
164
- background-color: var(
165
- --carousel-marker-active-color,
166
- var(--carousel-marker-color, currentColor)
167
- );
168
- }
169
-
170
- &::column::scroll-marker:focus-visible {
171
- outline: 2px solid currentColor;
172
- outline-offset: 2px;
173
- }
438
+ /* A clone is a duplicate of a slide the visitor can already reach, so it must
439
+ not add a dot. The autoplay button now lives on the shell, not in the
440
+ track, so it no longer needs suppressing here. */
441
+ > [data-carousel-clone]::scroll-marker {
442
+ content: none;
174
443
  }
175
- }
176
444
 
177
- /* Inert — dim/disable off-screen content */
178
- &.carousel--inert {
179
- &:not(.carousel--auto-pages) > * {
180
- container-type: scroll-state;
445
+ > *::scroll-marker:target-current {
446
+ opacity: 1;
447
+ background-color: var(
448
+ --carousel-marker-active-color,
449
+ var(--carousel-marker-color, currentColor)
450
+ );
451
+ }
181
452
 
182
- > * {
183
- transition: opacity 0.5s ease;
453
+ > *::scroll-marker:focus-visible {
454
+ outline: 2px solid var(--color-border-focus);
455
+ outline-offset: 2px;
456
+ }
457
+ }
184
458
 
185
- @container not scroll-state(snapped) {
186
- interactivity: inert;
187
- opacity: var(--carousel-inert-opacity, 0.25);
188
- }
189
- }
459
+ /* Paged: one marker per column. Separate block because `::column::scroll-marker`
460
+ is a different origin and would otherwise double up with the `> *` rule. */
461
+ .carousel--scroll-markers.carousel--auto-pages > .carousel__track,
462
+ .carousel--scroll-markers.carousel--auto-pages:not(:has(> .carousel__track)) {
463
+ > *::scroll-marker {
464
+ content: none;
190
465
  }
191
466
 
192
- &.carousel--auto-pages > * {
193
- animation: carousel-offscreen-inert linear both;
194
- animation-timeline: view(x);
467
+ &::column::scroll-marker {
468
+ content: '';
469
+ box-sizing: border-box;
470
+ inline-size: var(--carousel-marker-size, 0.75rem);
471
+ aspect-ratio: 1;
472
+ border-radius: 50%;
473
+ background-color: var(--carousel-marker-color, currentColor);
474
+ opacity: 0.4;
475
+ cursor: pointer;
476
+ transition: opacity 0.2s ease;
195
477
  }
196
- }
197
478
 
198
- /* Autoplay — play/pause toggle (enhanced by the Lit component) */
199
- & > [part='autoplay'] {
200
- position: fixed;
201
- position-anchor: --carousel;
202
- position-area: block-end inline-end;
203
- position-visibility: always;
204
- margin-block-end: var(--carousel-marker-offset, 1rem);
205
- margin-inline-end: 0.5rem;
206
- z-index: var(--z-tier-raised);
207
- display: flex;
208
- align-items: center;
209
- justify-content: center;
210
- inline-size: 2rem;
211
- block-size: 2rem;
212
- border: none;
213
- border-radius: 50%;
214
- background: var(--color-bg-surface-muted);
215
- color: var(--carousel-button-color, currentColor);
216
- opacity: 0.7;
217
- cursor: pointer;
218
- font-size: 0.75rem;
219
- transition: opacity 0.2s ease;
220
-
221
- &:hover {
479
+ &::column::scroll-marker:target-current {
222
480
  opacity: 1;
481
+ background-color: var(
482
+ --carousel-marker-active-color,
483
+ var(--carousel-marker-color, currentColor)
484
+ );
223
485
  }
224
486
 
225
- &:focus-visible {
226
- outline: 2px solid currentColor;
487
+ &::column::scroll-marker:focus-visible {
488
+ outline: 2px solid var(--color-border-focus);
227
489
  outline-offset: 2px;
228
490
  }
229
491
  }
230
492
 
231
- /* Force stop — always stop at each snap point */
232
- &.carousel--force-stop {
233
- &:not(.carousel--auto-pages) > * {
234
- scroll-snap-stop: always;
493
+ /* `--markers-below` has to reach the native group too, not just the fallback —
494
+ otherwise the modifier silently does nothing in the one browser that has the
495
+ pseudo-elements. */
496
+ .carousel--markers-below > .carousel__track::scroll-marker-group,
497
+ .carousel--markers-below:not(:has(> .carousel__track))::scroll-marker-group {
498
+ position: static;
499
+ inset: auto;
500
+ }
501
+
502
+ /* The native group replaces the JS one; if both somehow exist, the native one
503
+ wins and the fallback is hidden rather than stacked on top of it. */
504
+ .carousel--scroll-markers > .carousel__markers {
505
+ display: none;
506
+ }
507
+ }
508
+
509
+ .carousel--markers-below {
510
+ --carousel-marker-offset: 0px;
511
+ }
512
+
513
+ /* ── Scroll-for-more hint ────────────────────────────────────────────────────*/
514
+
515
+ /* Above the strip and right-aligned, not overlaid on it: "Scroll for more" printed
516
+ across the first slide's image is frequently unreadable, and the shell exists so
517
+ the hint has somewhere to go that isn't the scrollport.
518
+
519
+ The row assignment is deliberate rather than incidental. Grid places explicitly
520
+ positioned items before auto-placed ones, so claiming row 1 here pushes the
521
+ track — which claims no row — to row 2. Omit the hint and nothing claims row 1,
522
+ the track auto-places there, and no empty track is left behind to collect
523
+ `row-gap`. That is why the track is NOT given `grid-row: 2` to match. */
524
+ .carousel__hint {
525
+ grid-row: 1;
526
+ grid-column: 1;
527
+ justify-self: end;
528
+ align-self: start;
529
+ margin: var(--carousel-hint-inset, 0.5rem);
530
+ z-index: var(--z-tier-raised);
531
+ pointer-events: none;
532
+ color: var(--color-text-surface-x-muted);
533
+ transition: opacity 0.3s ease;
534
+ }
535
+
536
+ @keyframes carousel-hint-dim {
537
+ to {
538
+ opacity: var(--carousel-hint-dimmed, 0.2);
539
+ }
540
+ }
541
+
542
+ @keyframes carousel-scrollbar-dim {
543
+ to {
544
+ scrollbar-color: var(--carousel-scrollbar-thumb-dim, var(--color-bg-surface-muted)) transparent;
545
+ }
546
+ }
547
+
548
+ /* Pure CSS, no JS: both read the TRACK's scroll progress through the timeline the
549
+ shell hoisted with `timeline-scope`. */
550
+ @supports (animation-timeline: scroll()) {
551
+ .carousel__hint {
552
+ animation: carousel-hint-dim linear both;
553
+ animation-timeline: --carousel-scroll;
554
+ animation-range: 0 var(--carousel-hint-range, 12%);
555
+ }
556
+
557
+ .carousel__track {
558
+ animation: carousel-scrollbar-dim linear both;
559
+ animation-timeline: --carousel-scroll;
560
+ animation-range: 0 var(--carousel-hint-range, 12%);
561
+ }
562
+ }
563
+
564
+ /* Engines with no scroll timelines get the same effect from the attribute
565
+ WCCarousel sets on first scroll — mirroring how animation.css cancels under
566
+ `@supports not (animation-timeline: view())`. */
567
+ @supports not (animation-timeline: scroll()) {
568
+ .carousel[data-scrolled] .carousel__hint {
569
+ opacity: var(--carousel-hint-dimmed, 0.2);
570
+ }
571
+
572
+ .carousel[data-scrolled] > .carousel__track {
573
+ scrollbar-color: var(--carousel-scrollbar-thumb-dim, var(--color-bg-surface-muted)) transparent;
574
+ }
575
+ }
576
+
577
+ /* ── Autoplay toggle ─────────────────────────────────────────────────────────
578
+ A child of the SHELL, not the track. It used to be appended into the grid track
579
+ where it occupied a slide-width column, papered over with `scroll-snap-align:
580
+ unset` and a suppressed marker. `[part='autoplay']` is kept as a deprecated
581
+ alias for one release: `part` only crosses a shadow boundary and this element is
582
+ light DOM, so that was never a part — just an arbitrary attribute selector. */
583
+ .carousel__autoplay,
584
+ .carousel > [part='autoplay'] {
585
+ position: absolute;
586
+ inset-block-end: var(--carousel-marker-offset, 1rem);
587
+ inset-inline-end: 0.5rem;
588
+ z-index: var(--z-tier-raised);
589
+ box-sizing: border-box;
590
+ padding: 0;
591
+ border: 0;
592
+ line-height: 1;
593
+ display: grid;
594
+ place-content: center;
595
+ inline-size: 2rem;
596
+ aspect-ratio: 1;
597
+ border-radius: 50%;
598
+ background-color: var(--carousel-button-bg, var(--color-bg-surface-muted));
599
+ color: var(--carousel-button-color, var(--color-text-surface));
600
+ opacity: 0.7;
601
+ cursor: pointer;
602
+ font-size: 0.75rem;
603
+ transition: opacity 0.2s ease;
604
+
605
+ &:hover {
606
+ opacity: 1;
607
+ }
608
+
609
+ &:focus-visible {
610
+ outline: 2px solid var(--color-border-focus);
611
+ outline-offset: 2px;
612
+ }
613
+ }
614
+
615
+ /* ── Inert off-screen slides ─────────────────────────────────────────────────
616
+ `interactivity: inert` is gated separately from the dimming: an engine without
617
+ it should dim only, rather than dim AND leave the content silently focusable in
618
+ a way the author didn't ask for. */
619
+
620
+ .carousel--inert:not(.carousel--auto-pages) > .carousel__track > *,
621
+ .carousel--inert:not(.carousel--auto-pages):not(:has(> .carousel__track)) > * {
622
+ container-type: scroll-state;
623
+
624
+ > * {
625
+ transition: opacity 0.5s ease;
626
+
627
+ @container not scroll-state(snapped) {
628
+ opacity: var(--carousel-inert-opacity, 0.25);
235
629
  }
630
+ }
631
+ }
236
632
 
237
- &.carousel--auto-pages::column {
238
- scroll-snap-stop: always;
633
+ @supports (interactivity: inert) {
634
+ .carousel--inert:not(.carousel--auto-pages) > .carousel__track > * > *,
635
+ .carousel--inert:not(.carousel--auto-pages):not(:has(> .carousel__track)) > * > * {
636
+ @container not scroll-state(snapped) {
637
+ interactivity: inert;
239
638
  }
240
639
  }
241
640
  }
242
641
 
642
+ .carousel--inert.carousel--auto-pages > .carousel__track > *,
643
+ .carousel--inert.carousel--auto-pages:not(:has(> .carousel__track)) > * {
644
+ animation: carousel-offscreen-inert linear both;
645
+ animation-timeline: view(x);
646
+ }
647
+
243
648
  @keyframes carousel-offscreen-inert {
244
649
  entry 0%,
245
650
  exit 100% {
246
- interactivity: inert;
247
651
  opacity: var(--carousel-inert-opacity, 0.25);
248
652
  }
249
653
 
250
654
  entry 100%,
251
655
  exit 0% {
252
- interactivity: auto;
253
656
  opacity: 1;
254
657
  }
255
658
  }
256
659
 
660
+ /* ── Force stop ──────────────────────────────────────────────────────────────*/
661
+
662
+ .carousel--force-stop:not(.carousel--auto-pages) > .carousel__track > *,
663
+ .carousel--force-stop:not(.carousel--auto-pages):not(:has(> .carousel__track)) > * {
664
+ scroll-snap-stop: always;
665
+ }
666
+
667
+ .carousel--force-stop.carousel--auto-pages > .carousel__track::column,
668
+ .carousel--force-stop.carousel--auto-pages:not(:has(> .carousel__track))::column {
669
+ scroll-snap-stop: always;
670
+ }
671
+
257
672
  @media (prefers-reduced-motion: reduce) {
258
- .carousel {
673
+ .carousel__track,
674
+ .carousel:not(:has(> .carousel__track)) {
259
675
  scroll-behavior: auto;
676
+ }
260
677
 
261
- &.carousel--inert:not(.carousel--auto-pages) > * > * {
262
- transition: none;
263
- }
678
+ .carousel__hint,
679
+ .carousel__button,
680
+ .carousel__marker,
681
+ .carousel__autoplay {
682
+ transition: none;
683
+ }
264
684
 
265
- &.carousel--inert.carousel--auto-pages > * {
266
- animation: none;
267
- }
685
+ .carousel--inert > .carousel__track > * > * {
686
+ transition: none;
687
+ }
688
+
689
+ .carousel--inert.carousel--auto-pages > .carousel__track > * {
690
+ animation: none;
268
691
  }
269
692
  }