@fluid-theme/core 0.4.3 → 2.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.
Files changed (80) hide show
  1. package/AGENTS.md +6 -29
  2. package/CHANGELOG.md +543 -2
  3. package/CLAUDE.md +193 -14
  4. package/GEMINI.md +6 -45
  5. package/README.md +17 -8
  6. package/assets/js/compare.js +77 -0
  7. package/assets/js/fit-text.js +103 -0
  8. package/assets/js/lightbox.js +153 -0
  9. package/assets/js/scroll-reveal.js +72 -0
  10. package/assets/js/toc-build.mjs +86 -0
  11. package/assets/js/toc.js +384 -0
  12. package/dist/fluid-1x.min.css +4 -0
  13. package/dist/fluid-fonts.min.css +2 -0
  14. package/dist/fluid-legacy-1x.css +3 -0
  15. package/dist/fluid-nav.min.css +3 -0
  16. package/dist/fluid-toc.min.css +3 -0
  17. package/dist/fluid-zero.min.css +3 -0
  18. package/dist/fluid.min.css +3 -2
  19. package/dist/fonts/OFL-newsreader.txt +93 -0
  20. package/dist/fonts/OFL-schibsted-grotesk.txt +93 -0
  21. package/dist/fonts/newsreader-latin-opsz-italic.woff2 +0 -0
  22. package/dist/fonts/newsreader-latin-opsz-normal.woff2 +0 -0
  23. package/dist/fonts/schibsted-grotesk-latin-wght-italic.woff2 +0 -0
  24. package/dist/fonts/schibsted-grotesk-latin-wght-normal.woff2 +0 -0
  25. package/fluid-agent-kit/README.md +1 -0
  26. package/fluid-agent-kit/skills/accessibility.md +3 -1
  27. package/fluid-agent-kit/skills/css-conventions.md +22 -5
  28. package/fluid-agent-kit/skills/image-optimization.md +2 -0
  29. package/fluid-agent-kit/skills/preset-composition.md +108 -0
  30. package/package.json +29 -3
  31. package/src/animations/scroll-reveal.css +75 -193
  32. package/src/components/accordion.css +6 -47
  33. package/src/components/alert.css +47 -88
  34. package/src/components/avatar.css +4 -20
  35. package/src/components/badge.css +25 -48
  36. package/src/components/button.css +69 -175
  37. package/src/components/card.css +146 -481
  38. package/src/components/chip.css +46 -12
  39. package/src/components/compare.css +234 -0
  40. package/src/components/dropdown.css +10 -67
  41. package/src/components/empty-state.css +4 -0
  42. package/src/components/form.css +113 -81
  43. package/src/components/gallery.css +252 -0
  44. package/src/components/hero.css +203 -88
  45. package/src/components/indicator.css +23 -117
  46. package/src/components/loading.css +7 -29
  47. package/src/components/modal.css +12 -65
  48. package/src/components/nav-modes.css +261 -0
  49. package/src/components/nav.css +324 -392
  50. package/src/components/search-input.css +4 -2
  51. package/src/components/step-indicator.css +26 -27
  52. package/src/components/table.css +64 -171
  53. package/src/components/toc.css +254 -0
  54. package/src/components/tooltip.css +5 -46
  55. package/src/core/base.css +393 -547
  56. package/src/core/reset.css +17 -5
  57. package/src/core/tokens.css +426 -2276
  58. package/src/fluid.css +258 -17
  59. package/src/fonts/OFL-newsreader.txt +93 -0
  60. package/src/fonts/OFL-schibsted-grotesk.txt +93 -0
  61. package/src/fonts/fluid-fonts.css +55 -0
  62. package/src/fonts/newsreader-latin-opsz-italic.woff2 +0 -0
  63. package/src/fonts/newsreader-latin-opsz-normal.woff2 +0 -0
  64. package/src/fonts/schibsted-grotesk-latin-wght-italic.woff2 +0 -0
  65. package/src/fonts/schibsted-grotesk-latin-wght-normal.woff2 +0 -0
  66. package/src/layouts/positioning.css +6 -3
  67. package/src/legacy-1x.css +61 -0
  68. package/src/sections/cta.css +33 -101
  69. package/src/sections/features.css +42 -219
  70. package/src/sections/footer.css +53 -134
  71. package/src/sections/stats.css +40 -153
  72. package/src/sections/team.css +24 -113
  73. package/src/sections/testimonials.css +34 -148
  74. package/src/zero/base.css +166 -0
  75. package/src/zero/tokens.css +64 -0
  76. package/src/zero.css +20 -0
  77. package/src/components/scroll-progress.css +0 -104
  78. package/src/components/settings-panel.css +0 -603
  79. package/src/components/themed-images.css +0 -229
  80. package/src/components/version-badge.css +0 -70
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Fluid Framework - Form Components
3
- * @layer components
3
+ * @layer components (the honeypot and conditional fields: @layer utilities)
4
4
  *
5
5
  * Forms should feel welcoming, not like paperwork.
6
6
  * Clear labels, gentle feedback, graceful validation.
@@ -11,6 +11,7 @@
11
11
  * - Toggle switches
12
12
  * - Input groups and addons
13
13
  * - Form validation states
14
+ * - Honeypot and conditional fields
14
15
  */
15
16
 
16
17
  @layer components {
@@ -49,8 +50,8 @@
49
50
 
50
51
  /* Label styling */
51
52
  .form-label {
52
- font-size: var(--font-size-sm);
53
- font-weight: var(--weight-medium);
53
+ font-size: var(--step--1);
54
+ font-weight: calc(600 + var(--_dark-weight));
54
55
  color: var(--color-text);
55
56
  }
56
57
 
@@ -62,14 +63,14 @@
62
63
 
63
64
  /* Help text */
64
65
  .form-help {
65
- font-size: var(--font-size-sm);
66
+ font-size: var(--step--1);
66
67
  color: var(--color-text-muted);
67
68
  margin-block-start: var(--space-1);
68
69
  }
69
70
 
70
71
  /* Error message */
71
72
  .form-error {
72
- font-size: var(--font-size-sm);
73
+ font-size: var(--step--1);
73
74
  color: var(--color-error);
74
75
  margin-block-start: var(--space-1);
75
76
  }
@@ -94,14 +95,14 @@
94
95
 
95
96
  .input-group > input:first-child,
96
97
  .input-group > select:first-child {
97
- border-start-start-radius: var(--radius-md);
98
- border-end-start-radius: var(--radius-md);
98
+ border-start-start-radius: var(--radius-control);
99
+ border-end-start-radius: var(--radius-control);
99
100
  }
100
101
 
101
102
  .input-group > input:last-child,
102
103
  .input-group > select:last-child {
103
- border-start-end-radius: var(--radius-md);
104
- border-end-end-radius: var(--radius-md);
104
+ border-start-end-radius: var(--radius-control);
105
+ border-end-end-radius: var(--radius-control);
105
106
  }
106
107
 
107
108
  .input-group__addon {
@@ -111,19 +112,19 @@
111
112
  font-size: var(--font-size-sm);
112
113
  color: var(--color-text-muted);
113
114
  background-color: var(--color-surface);
114
- border: 1px solid var(--color-border);
115
+ border: var(--border-width) solid var(--color-border-strong);
115
116
  }
116
117
 
117
118
  .input-group__addon:first-child {
118
119
  border-inline-end: none;
119
- border-start-start-radius: var(--radius-md);
120
- border-end-start-radius: var(--radius-md);
120
+ border-start-start-radius: var(--radius-control);
121
+ border-end-start-radius: var(--radius-control);
121
122
  }
122
123
 
123
124
  .input-group__addon:last-child {
124
125
  border-inline-start: none;
125
- border-start-end-radius: var(--radius-md);
126
- border-end-end-radius: var(--radius-md);
126
+ border-start-end-radius: var(--radius-control);
127
+ border-end-end-radius: var(--radius-control);
127
128
  }
128
129
 
129
130
 
@@ -152,16 +153,15 @@
152
153
  flex-shrink: 0;
153
154
  inline-size: 1.25rem;
154
155
  block-size: 1.25rem;
155
- border: var(--border-width-thick) solid var(--color-border);
156
+ border: var(--border-width-thick) solid var(--color-border-strong);
156
157
  border-radius: var(--radius-sm);
157
158
  background-color: var(--color-background);
158
159
  display: flex;
159
160
  align-items: center;
160
161
  justify-content: center;
161
162
  transition:
162
- background-color var(--duration-normal) var(--ease-fluid),
163
- border-color var(--duration-normal) var(--ease-fluid),
164
- transform var(--duration-fast) var(--ease-settle);
163
+ background-color var(--duration-fast) var(--ease-fluid),
164
+ border-color var(--duration-fast) var(--ease-fluid);
165
165
  }
166
166
 
167
167
  /* Checkmark icon */
@@ -169,8 +169,8 @@
169
169
  content: "";
170
170
  inline-size: 0.5rem;
171
171
  block-size: 0.25rem;
172
- border-inline-start: 2px solid var(--white);
173
- border-block-end: 2px solid var(--white);
172
+ border-inline-start: var(--border-width-thick) solid var(--on-accent);
173
+ border-block-end: var(--border-width-thick) solid var(--on-accent);
174
174
  transform: rotate(-45deg) scale(0);
175
175
  transition: transform var(--duration-fast) var(--ease-settle);
176
176
  }
@@ -182,7 +182,8 @@
182
182
 
183
183
  /* Focus state */
184
184
  .checkbox__input:focus-visible + .checkbox__box {
185
- box-shadow: var(--shadow-glow);
185
+ outline: var(--focus-ring-width) solid var(--color-focus);
186
+ outline-offset: 2px;
186
187
  }
187
188
 
188
189
  /* Checked state */
@@ -244,7 +245,7 @@
244
245
  flex-shrink: 0;
245
246
  inline-size: 1.25rem;
246
247
  block-size: 1.25rem;
247
- border: var(--border-width-thick) solid var(--color-border);
248
+ border: var(--border-width-thick) solid var(--color-border-strong);
248
249
  border-radius: var(--radius-full);
249
250
  background-color: var(--color-background);
250
251
  display: flex;
@@ -269,7 +270,8 @@
269
270
  }
270
271
 
271
272
  .radio__input:focus-visible + .radio__circle {
272
- box-shadow: var(--shadow-glow);
273
+ outline: var(--focus-ring-width) solid var(--color-focus);
274
+ outline-offset: 2px;
273
275
  }
274
276
 
275
277
  .radio__input:checked + .radio__circle {
@@ -330,43 +332,45 @@
330
332
  block-size: 0;
331
333
  }
332
334
 
335
+ /* Square track and thumb (the radius knob); the thumb moves along the
336
+ inline axis with inset-inline-start, so RTL works. */
333
337
  .switch__track {
334
338
  position: relative;
339
+ flex-shrink: 0;
335
340
  inline-size: 2.75rem;
336
341
  block-size: 1.5rem;
337
- background-color: var(--color-border);
338
- border-radius: var(--radius-full);
339
- transition: background-color var(--duration-normal) var(--ease-fluid);
342
+ background-color: var(--color-background);
343
+ border: var(--border-width) solid var(--color-border-strong);
344
+ border-radius: var(--radius-control);
345
+ transition: background-color var(--duration-fast) var(--ease-fluid);
340
346
  }
341
347
 
342
348
  .switch__thumb {
343
349
  position: absolute;
344
350
  inset-block-start: 0.125rem;
345
351
  inset-inline-start: 0.125rem;
346
- inline-size: 1.25rem;
347
- block-size: 1.25rem;
348
- background-color: var(--white);
349
- border-radius: var(--radius-full);
350
- box-shadow: var(--shadow-sm);
352
+ inline-size: calc(1.25rem - 2 * var(--border-width));
353
+ block-size: calc(1.25rem - 2 * var(--border-width));
354
+ background-color: var(--color-text);
355
+ border-radius: var(--radius-control);
351
356
  transition:
352
- transform var(--duration-normal) var(--ease-settle),
353
- box-shadow var(--duration-fast) var(--ease-fluid);
354
- }
355
-
356
- .switch:hover .switch__track {
357
- background-color: var(--color-text-subtle);
357
+ inset-inline-start var(--duration-normal) var(--ease-settle),
358
+ background-color var(--duration-fast) var(--ease-fluid);
358
359
  }
359
360
 
360
361
  .switch__input:focus-visible + .switch__track {
361
- box-shadow: var(--shadow-glow);
362
+ outline: var(--focus-ring-width) solid var(--color-focus);
363
+ outline-offset: 2px;
362
364
  }
363
365
 
364
366
  .switch__input:checked + .switch__track {
365
367
  background-color: var(--accent);
368
+ border-color: var(--accent);
366
369
  }
367
370
 
368
371
  .switch__input:checked + .switch__track .switch__thumb {
369
- transform: translateX(1.25rem);
372
+ inset-inline-start: calc(100% - 1.25rem + 2 * var(--border-width) - 0.125rem);
373
+ background-color: var(--on-accent);
370
374
  }
371
375
 
372
376
  .switch__input:disabled + .switch__track {
@@ -395,7 +399,7 @@
395
399
  }
396
400
 
397
401
  .switch[data-size="sm"] .switch__input:checked + .switch__track .switch__thumb {
398
- transform: translateX(0.875rem);
402
+ inset-inline-start: calc(100% - 0.875rem - 0.125rem);
399
403
  }
400
404
 
401
405
 
@@ -414,12 +418,6 @@
414
418
  border-color: var(--color-success);
415
419
  }
416
420
 
417
- input[data-valid]:focus,
418
- textarea[data-valid]:focus,
419
- select[data-valid]:focus {
420
- box-shadow: 0 0 0 3px color-mix(in oklch, var(--color-success) 20%, transparent);
421
- }
422
-
423
421
  /* Invalid/Error state */
424
422
  input[data-invalid],
425
423
  textarea[data-invalid],
@@ -427,25 +425,28 @@
427
425
  input[aria-invalid="true"],
428
426
  textarea[aria-invalid="true"],
429
427
  select[aria-invalid="true"],
430
- input:invalid:not(:placeholder-shown),
431
- textarea:invalid:not(:placeholder-shown),
432
428
  .form-group[data-invalid] input,
433
429
  .form-group[data-invalid] textarea,
434
430
  .form-group[data-invalid] select {
435
431
  border-color: var(--color-error);
432
+ border-inline-start-width: calc(var(--border-width) * 4);
436
433
  }
437
434
 
438
- input[data-invalid]:focus,
439
- textarea[data-invalid]:focus,
440
- select[data-invalid]:focus,
441
- input[aria-invalid="true"]:focus,
442
- textarea[aria-invalid="true"]:focus,
443
- select[aria-invalid="true"]:focus,
444
- input:invalid:not(:placeholder-shown):focus,
445
- textarea:invalid:not(:placeholder-shown):focus {
446
- box-shadow: 0 0 0 3px color-mix(in oklch, var(--color-error) 20%, transparent);
435
+ /* Browser validation shows only after someone has edited or submitted the
436
+ field (:user-invalid). The old :invalid:not(:placeholder-shown) only
437
+ waited on fields with a placeholder, so an empty required field without
438
+ one was red on page load (the Fluid Stack's sign-up form, 2026-10-09).
439
+ Separate rules: a browser without :user-invalid drops only these. */
440
+ :is(input, textarea, select):user-invalid {
441
+ border-color: var(--color-error);
442
+ border-inline-start-width: calc(var(--border-width) * 4);
447
443
  }
448
444
 
445
+ /* An invalid field says so twice: the error colour and a heavy start
446
+ edge, so the state survives where the colour cannot be seen (on an
447
+ accent-strong band --color-error is --on-accent, the resting edge's
448
+ colour too). Focus adds the base outline (base.css) on top. */
449
+
449
450
 
450
451
  /* ========================================
451
452
  FORM LAYOUT
@@ -463,7 +464,7 @@
463
464
  gap: var(--space-3);
464
465
  margin-block-start: var(--space-6);
465
466
  padding-block-start: var(--space-6);
466
- border-block-start: 1px solid var(--color-border-subtle);
467
+ border-block-start: var(--border-width) solid var(--color-border);
467
468
  }
468
469
 
469
470
  .form-actions[data-align="end"] {
@@ -475,34 +476,65 @@
475
476
  }
476
477
 
477
478
 
479
+ /* FIELDSET: .form-fieldset is the base fieldset (no box, a top hairline,
480
+ the legend in the label voice; base.css). */
481
+
482
+
478
483
  /* ========================================
479
- FIELDSET & LEGEND
480
- Grouped form sections
484
+ REDUCED MOTION
481
485
  ======================================== */
482
486
 
483
- fieldset.form-fieldset {
484
- border: 1px solid var(--color-border-subtle);
485
- border-radius: var(--radius-lg);
486
- padding: var(--space-5);
487
- margin-block-end: var(--space-6);
488
- }
487
+ /* Accessibility: reduced motion */
488
+ /* Reduced motion: the reset shortens every transition. */
489
+ }
489
490
 
490
- fieldset.form-fieldset legend {
491
- font-size: var(--font-size-sm);
492
- font-weight: var(--weight-semibold);
493
- padding-inline: var(--space-2);
494
- color: var(--color-text);
495
- }
496
491
 
497
- /* Accessibility: reduced motion */
498
- @media (prefers-reduced-motion: reduce) {
499
- input,
500
- textarea,
501
- select,
502
- .checkbox__toggle,
503
- .checkbox__label::before,
504
- .radio__label::before {
505
- transition: none;
492
+ /* ========================================
493
+ HONEYPOT AND CONDITIONAL FIELDS
494
+ Functional rules the day-one forms depend on (the-fluid-stack
495
+ playbook forms.md D26c, security.md "Forms"). In @layer utilities,
496
+ like .sr-only, so they outrank a child theme in @layer themes (a
497
+ child theme outside a layer, or with !important, still wins).
498
+ ======================================== */
499
+ @layer utilities {
500
+ /* <div class="form-group hp" aria-hidden="true"> around
501
+ <input name="website" tabindex="-1" autocomplete="off">, last in the
502
+ form. Clipped to a 1px box where it stands, like .sr-only. A -10000px
503
+ offset scrolled the page sideways: a physical one on any RTL page, a
504
+ logical (inline-start) one whenever the form's direction differed
505
+ from the page's. display: block stops a theme's display: contents
506
+ from undoing it. */
507
+ .hp {
508
+ display: block;
509
+ position: absolute;
510
+ inline-size: 1px;
511
+ block-size: 1px;
512
+ margin: -1px;
513
+ padding: 0;
514
+ overflow: hidden;
515
+ clip-path: inset(50%);
516
+ white-space: nowrap;
517
+ border: 0;
518
+ }
519
+
520
+ /* A field with data-show-if="<reason>" (space-separated for several)
521
+ shows only while its form's select[name="reason"] has that value
522
+ (forms.md pattern 7). No script. revert-layer hands display back to
523
+ the component or child theme. Without :has() every field shows, which
524
+ is safe. A field this rule hides must never block the submit: never
525
+ required, and no constraint a hidden value can fail (no type=number,
526
+ email or url, pattern, min, max, step or minlength; use type="text"
527
+ inputmode="numeric" and check it on the server, forms.md pattern 2).
528
+ Option values must be these exact lowercase words. */
529
+ @supports selector(:has(*)) {
530
+ form:has(select[name="reason"]) [data-show-if] { display: none; }
531
+ form:has(select[name="reason"] option[value="advertise"]:checked) [data-show-if~="advertise"],
532
+ form:has(select[name="reason"] option[value="partner"]:checked) [data-show-if~="partner"],
533
+ form:has(select[name="reason"] option[value="buy-site"]:checked) [data-show-if~="buy-site"],
534
+ form:has(select[name="reason"] option[value="buy-domain"]:checked) [data-show-if~="buy-domain"],
535
+ form:has(select[name="reason"] option[value="hire"]:checked) [data-show-if~="hire"],
536
+ form:has(select[name="reason"] option[value="other"]:checked) [data-show-if~="other"] {
537
+ display: revert-layer;
506
538
  }
507
539
  }
508
540
  }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Fluid Framework - Gallery + Lightbox
3
+ * @layer components
4
+ *
5
+ * A grid of photos for portfolios (architects, interior designers, landscape
6
+ * architects, builders, photographers) and a full-screen viewer.
7
+ *
8
+ * Progressive enhancement: without JavaScript every thumbnail is a plain link
9
+ * to the full-size image. With assets/js/lightbox.js the links open in a
10
+ * native <dialog> with previous / next, arrow keys, Escape, swipe, a counter
11
+ * and the caption, and focus returns to the thumbnail on close.
12
+ *
13
+ * Usage:
14
+ * <ul class="gallery" role="list" data-lightbox>
15
+ * <li>
16
+ * <figure class="gallery__figure">
17
+ * <a class="gallery__link" href="photos/kitchen-2400.jpg">
18
+ * <img class="gallery__image" src="photos/kitchen-800.jpg" alt="Kitchen with walnut island"
19
+ * width="800" height="600" loading="lazy">
20
+ * </a>
21
+ * <figcaption class="gallery__caption">Kitchen · Photo: A. Smith</figcaption>
22
+ * </figure>
23
+ * </li>
24
+ * </ul>
25
+ * <script src="assets/js/lightbox.js" defer></script>
26
+ *
27
+ * Options on .gallery:
28
+ * data-aspect="square|landscape|portrait|video" crop of the thumbnails (default: natural ratio)
29
+ * data-size="sm|lg" thumbnail width (default 24rem: three across an
30
+ * 80rem column, a body of work, not a catalogue;
31
+ * --gallery-min on :root sets it, sm 11rem, lg 32rem)
32
+ * data-captions="overlay" captions over the image instead of below
33
+ * Per image: style="--focus-x: 30%; --focus-y: 60%" keeps the subject in the crop.
34
+ * Captions: write dimensions with no-break spaces (40&nbsp;×&nbsp;50&nbsp;cm), so
35
+ * they never break inside.
36
+ *
37
+ * The hang line: each item spans two rows of a subgrid with its image at
38
+ * the bottom of the first, so image bottoms and caption tops share one
39
+ * line across a row, whatever each image's ratio.
40
+ */
41
+
42
+ @layer components {
43
+ /* ========================================
44
+ GRID
45
+ ======================================== */
46
+
47
+ .gallery {
48
+ --_gallery-min: var(--gallery-min, 24rem);
49
+
50
+ display: grid;
51
+ grid-template-columns: repeat(auto-fill, minmax(min(var(--_gallery-min), 100%), 1fr));
52
+ gap: var(--space-m);
53
+ list-style: none;
54
+ padding: 0;
55
+ margin: 0;
56
+ }
57
+
58
+ .gallery[data-size="sm"] { --_gallery-min: 11rem; }
59
+ .gallery[data-size="lg"] { --_gallery-min: 32rem; }
60
+
61
+ .gallery[data-aspect="square"] { --_gallery-aspect: 1; }
62
+ .gallery[data-aspect="landscape"] { --_gallery-aspect: 4 / 3; }
63
+ .gallery[data-aspect="portrait"] { --_gallery-aspect: 3 / 4; }
64
+ .gallery[data-aspect="video"] { --_gallery-aspect: 16 / 9; }
65
+
66
+ .gallery > *,
67
+ .gallery > * > .gallery__figure {
68
+ display: grid;
69
+ grid-row: span 2;
70
+ grid-template-rows: subgrid;
71
+ row-gap: var(--space-xs);
72
+ min-inline-size: 0;
73
+ margin: 0;
74
+ }
75
+
76
+ /* The hang line: the image (or its link) sits at the bottom of row one. */
77
+ .gallery__figure > :is(.gallery__image, picture) { align-self: end; }
78
+
79
+ /* Firefox sizes the grid too tall when a bare <img> is a subgrid item
80
+ (each row +142px at 390px once the images load; Firefox 157). Those
81
+ figures leave the subgrid there; a link or <picture> wrapper keeps it. */
82
+ @supports (-moz-appearance: none) {
83
+ .gallery > :has(> .gallery__figure > img) { display: block; }
84
+ .gallery__figure:has(> img) { block-size: 100%; grid-template-rows: 1fr auto; }
85
+ }
86
+
87
+ .gallery__link {
88
+ display: block;
89
+ align-self: end;
90
+ background: var(--color-surface);
91
+ transition: opacity var(--duration-fast) var(--ease-fluid);
92
+ }
93
+
94
+ .gallery__link:hover { opacity: 0.9; }
95
+
96
+ .gallery__link:focus-visible {
97
+ outline: 3px solid var(--color-focus);
98
+ outline-offset: 2px;
99
+ }
100
+
101
+ .gallery__image {
102
+ display: block;
103
+ inline-size: 100%;
104
+ block-size: auto;
105
+ object-fit: cover;
106
+ object-position: var(--focus-x, 50%) var(--focus-y, 50%);
107
+ }
108
+
109
+ /* Only a crop sets the ratio: a bare `auto` here replaced the width and
110
+ height attributes' ratio, so a lazy image was only as tall as its alt
111
+ text until it loaded, and the page grew under the reader. */
112
+ .gallery[data-aspect] .gallery__image { aspect-ratio: var(--_gallery-aspect); }
113
+
114
+ .gallery__caption {
115
+ margin: 0;
116
+ overflow-wrap: anywhere;
117
+ }
118
+
119
+ /* Captions over the image */
120
+ .gallery[data-captions="overlay"] .gallery__figure { position: relative; }
121
+
122
+ .gallery[data-captions="overlay"] .gallery__caption {
123
+ position: absolute;
124
+ inset-inline: 0;
125
+ inset-block-end: 0;
126
+ padding: var(--space-6) var(--space-3) var(--space-3);
127
+ background: linear-gradient(to top, oklch(0% 0 0 / 0.75), transparent);
128
+ color: oklch(100% 0 0);
129
+ max-inline-size: none;
130
+ pointer-events: none;
131
+ }
132
+
133
+
134
+ /* ========================================
135
+ LIGHTBOX (built by assets/js/lightbox.js)
136
+ ======================================== */
137
+
138
+ .lightbox {
139
+ border: none;
140
+ padding: 0;
141
+ margin: 0;
142
+ max-inline-size: none;
143
+ max-block-size: none;
144
+ inline-size: 100%;
145
+ block-size: 100%;
146
+ background: oklch(12% 0 0 / 0.96);
147
+ color: oklch(96% 0 0);
148
+ overflow: hidden;
149
+ }
150
+
151
+ .lightbox::backdrop {
152
+ background: oklch(0% 0 0 / 0.6);
153
+ }
154
+
155
+ .lightbox[open] {
156
+ display: grid;
157
+ grid-template-rows: auto 1fr auto;
158
+ grid-template-areas: "bar" "stage" "caption";
159
+ }
160
+
161
+ .lightbox__bar {
162
+ grid-area: bar;
163
+ display: flex;
164
+ align-items: center;
165
+ justify-content: space-between;
166
+ gap: var(--space-3);
167
+ padding: var(--space-3) var(--space-4);
168
+ }
169
+
170
+ .lightbox__counter {
171
+ font-size: var(--font-size-sm);
172
+ font-variant-numeric: tabular-nums;
173
+ }
174
+
175
+ .lightbox__stage {
176
+ grid-area: stage;
177
+ position: relative;
178
+ display: flex;
179
+ align-items: center;
180
+ justify-content: center;
181
+ min-block-size: 0;
182
+ padding-inline: var(--space-3);
183
+ touch-action: pan-y;
184
+ }
185
+
186
+ .lightbox__image {
187
+ display: block;
188
+ max-inline-size: 100%;
189
+ max-block-size: 100%;
190
+ object-fit: contain;
191
+ border-radius: var(--radius-sm);
192
+ }
193
+
194
+ .lightbox__caption {
195
+ grid-area: caption;
196
+ padding: var(--space-3) var(--space-4) var(--space-5);
197
+ text-align: center;
198
+ font-size: var(--font-size-sm);
199
+ line-height: var(--leading-snug);
200
+ max-inline-size: 60ch;
201
+ margin-inline: auto;
202
+ overflow-wrap: anywhere;
203
+ }
204
+
205
+ .lightbox .lightbox__button {
206
+ display: inline-flex;
207
+ align-items: center;
208
+ justify-content: center;
209
+ min-inline-size: 2.75rem;
210
+ min-block-size: 2.75rem;
211
+ padding: var(--space-2);
212
+ border: var(--border-width) solid oklch(100% 0 0 / 0.35);
213
+ border-radius: var(--radius-full);
214
+ background: oklch(0% 0 0 / 0.55);
215
+ color: oklch(100% 0 0);
216
+ font-size: var(--font-size-lg);
217
+ line-height: 1;
218
+ cursor: pointer;
219
+ box-shadow: none;
220
+ }
221
+
222
+ .lightbox .lightbox__button:hover {
223
+ background: oklch(0% 0 0 / 0.8);
224
+ border-color: oklch(100% 0 0 / 0.7);
225
+ }
226
+
227
+ .lightbox .lightbox__button:focus-visible {
228
+ outline: 3px solid oklch(100% 0 0);
229
+ outline-offset: 2px;
230
+ }
231
+
232
+ .lightbox__prev,
233
+ .lightbox__next {
234
+ position: absolute;
235
+ inset-block-start: 50%;
236
+ translate: 0 -50%;
237
+ }
238
+
239
+ .lightbox__prev { inset-inline-start: var(--space-3); }
240
+ .lightbox__next { inset-inline-end: var(--space-3); }
241
+
242
+ .lightbox__prev[hidden],
243
+ .lightbox__next[hidden] {
244
+ display: none;
245
+ }
246
+
247
+ /* Keep the page still behind an open lightbox */
248
+ :root:has(.lightbox[open]) {
249
+ overflow: hidden;
250
+ }
251
+
252
+ }