@undrr/undrr-mangrove 2.0.1 → 2.1.1

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 (98) hide show
  1. package/README.md +1 -1
  2. package/components/BookCard.js +1 -1
  3. package/components/Breadcrumbs.js +1 -1
  4. package/components/Checkbox.js +1 -1
  5. package/components/Chips.js +1 -1
  6. package/components/CopyButton.js +1 -1
  7. package/components/CtaButton.js +1 -1
  8. package/components/Drawer.js +2 -2
  9. package/components/EmbedContainer.js +1 -1
  10. package/components/Footer.js +1 -1
  11. package/components/FormAction.js +1 -1
  12. package/components/FormErrorSummary.js +1 -1
  13. package/components/FormGroup.js +1 -1
  14. package/components/FullWidth.js +1 -1
  15. package/components/Gallery.js +2 -2
  16. package/components/Hero.js +2 -2
  17. package/components/HighlightBox.js +1 -1
  18. package/components/HorizontalBookCard.js +1 -1
  19. package/components/HorizontalCard.js +1 -1
  20. package/components/IconCard.js +1 -1
  21. package/components/Legend.js +1 -1
  22. package/components/Loader.js +2 -2
  23. package/components/MegaMenu.js +2 -2
  24. package/components/Notice.js +1 -1
  25. package/components/PageHeader.js +1 -1
  26. package/components/Pager.js +1 -1
  27. package/components/ProgressBar.js +2 -2
  28. package/components/QuoteHighlight.js +1 -1
  29. package/components/Radio.js +1 -1
  30. package/components/Range.js +1 -1
  31. package/components/ScrollContainer.js +1 -1
  32. package/components/SegmentedControl.js +1 -1
  33. package/components/Select.js +1 -1
  34. package/components/ServiceNotice.js +1 -1
  35. package/components/ShareButtons.js +1 -1
  36. package/components/ShowMore.js +2 -2
  37. package/components/SkipLink.js +1 -1
  38. package/components/StatsCard.js +1 -1
  39. package/components/SyndicationSearchWidget.js +2 -2
  40. package/components/Tab.js +1 -1
  41. package/components/TextCta.js +1 -1
  42. package/components/TextInput.js +1 -1
  43. package/components/Textarea.js +1 -1
  44. package/components/Tree.js +1 -1
  45. package/components/UserFeedback.js +1 -1
  46. package/components/VerticalCard.js +1 -1
  47. package/components/hydrate.js +2 -2
  48. package/css/style-all.css +41 -42
  49. package/css/style-all.css.map +1 -1
  50. package/css/style-delta.css +41 -42
  51. package/css/style-delta.css.map +1 -1
  52. package/css/style-gutenberg.css +4 -4
  53. package/css/style-gutenberg.css.map +1 -1
  54. package/css/style-irp.css +41 -42
  55. package/css/style-irp.css.map +1 -1
  56. package/css/style-mcr.css +41 -42
  57. package/css/style-mcr.css.map +1 -1
  58. package/css/style-preventionweb.css +41 -42
  59. package/css/style-preventionweb.css.map +1 -1
  60. package/css/style.css +41 -42
  61. package/css/style.css.map +1 -1
  62. package/docs/EDITORIAL-MANUAL.md +1 -1
  63. package/docs/llms-motion.txt +125 -0
  64. package/error-pages/401.html +1 -1
  65. package/error-pages/403.html +1 -1
  66. package/error-pages/404.html +1 -1
  67. package/error-pages/429.html +1 -1
  68. package/error-pages/500.html +1 -1
  69. package/error-pages/502.html +1 -1
  70. package/error-pages/503.html +1 -1
  71. package/error-pages/504.html +1 -1
  72. package/error-pages/5xx.html +1 -1
  73. package/error-pages/challenge.html +1 -1
  74. package/error-pages/managed-challenge.html +1 -1
  75. package/js/cite-this.js +685 -43
  76. package/js/on-this-page-nav.js +80 -1
  77. package/js/show-more.js +149 -21
  78. package/package.json +1 -1
  79. package/scss/Atom/Images/AuthorImage/author-image.scss +9 -2
  80. package/scss/Atom/ReachElement/Details/details.scss +4 -2
  81. package/scss/Components/Cards/Card/card.scss +8 -2
  82. package/scss/Components/Cards/StatsCard/stats-card.scss +8 -1
  83. package/scss/Components/CiteThis/cite-this.scss +162 -9
  84. package/scss/Components/Gallery/gallery.scss +40 -15
  85. package/scss/Components/Hero/hero.scss +165 -0
  86. package/scss/Components/MegaMenu/mega-menu.scss +42 -121
  87. package/scss/Components/Navigation/Drawer/drawer.scss +10 -0
  88. package/scss/Components/OnThisPageNav/on-this-page-nav.scss +108 -3
  89. package/scss/Components/SyndicationSearchWidget/syndication-search-widget.scss +258 -127
  90. package/scss/Components/Tab/tab.scss +2 -1
  91. package/scss/Patterns/RichContent/rich-content.scss +177 -0
  92. package/scss/Patterns/_shared/pattern-demo.scss +5 -0
  93. package/scss/Utilities/Loader/loader.scss +89 -11
  94. package/scss/Utilities/ShowMore/show-more.scss +6 -0
  95. package/scss/assets/scss/_components.scss +3 -0
  96. package/scss/assets/scss/_foundational.scss +23 -8
  97. package/scss/assets/scss/_mixins.scss +5 -0
  98. package/scss/assets/scss/_variables.scss +1 -1
@@ -107,6 +107,7 @@ export function mgOnThisPageNav(scope) {
107
107
  container._mgOnThisPageNavAbort = ac;
108
108
 
109
109
  setupClickHandlers(container, ac.signal);
110
+ setupBlockSize(container, ac.signal);
110
111
  setupScrollSpy(container);
111
112
  setupFocusScrolling(container, ac.signal);
112
113
  setupScrollButtons(container, ac.signal);
@@ -132,13 +133,53 @@ export function mgOnThisPageNavDestroy(container) {
132
133
  container._mgOnThisPageNavResizeObserver.disconnect();
133
134
  delete container._mgOnThisPageNavResizeObserver;
134
135
  }
136
+ if (container._mgOnThisPageNavSizeObserver) {
137
+ container._mgOnThisPageNavSizeObserver.disconnect();
138
+ delete container._mgOnThisPageNavSizeObserver;
139
+ document.documentElement.style.removeProperty(BLOCK_SIZE);
140
+ }
135
141
  container
136
142
  .querySelectorAll('.mg-on-this-page-nav__scroll-btn')
137
143
  .forEach(btn => btn.remove());
138
- container.classList.remove('mg-on-this-page-nav--has-left-overflow');
144
+ container.classList.remove(
145
+ 'mg-on-this-page-nav--has-left-overflow',
146
+ 'mg-on-this-page-nav--has-right-overflow'
147
+ );
139
148
  delete container.dataset.mgOnThisPageNavInitialized;
140
149
  }
141
150
 
151
+ const BLOCK_SIZE = '--mg-on-this-page-nav-block-size';
152
+
153
+ /**
154
+ * Publishes the bar's height on the root element while it is sticky, and 0px
155
+ * when it is not (it un-sticks on short viewports). The stylesheet adds it to
156
+ * `scroll-padding-top`, so anything the browser scrolls into view (focus,
157
+ * anchors, scrollIntoView from other components) lands below the bar rather
158
+ * than under it.
159
+ *
160
+ * @param {HTMLElement} container - The nav element
161
+ * @param {AbortSignal} signal - Removes the resize listener on destroy
162
+ */
163
+ function setupBlockSize(container, signal) {
164
+ const root = document.documentElement;
165
+ const publish = () => {
166
+ const sticky = getComputedStyle(container).position === 'sticky';
167
+ root.style.setProperty(
168
+ BLOCK_SIZE,
169
+ `${sticky ? container.offsetHeight || 0 : 0}px`
170
+ );
171
+ };
172
+ publish();
173
+ window.addEventListener('resize', publish, { passive: true, signal });
174
+ if (typeof ResizeObserver === 'function') {
175
+ const observer = new ResizeObserver(publish);
176
+ observer.observe(container);
177
+ container._mgOnThisPageNavSizeObserver = observer;
178
+ } else {
179
+ container._mgOnThisPageNavSizeObserver = { disconnect() {} };
180
+ }
181
+ }
182
+
142
183
  /**
143
184
  * Auto-detect mode: scan headings from the page and generate nav links.
144
185
  * Preserves any existing CTA element in the container.
@@ -399,6 +440,13 @@ function updateScrollButtons(container, list, prevBtn, nextBtn) {
399
440
  'mg-on-this-page-nav--has-left-overflow',
400
441
  rtl ? canScrollFurther : scrolledFromStart
401
442
  );
443
+ // has-right-overflow is its mirror. Only the RTL fades read it: in RTL the
444
+ // hidden content can sit on either physical side, and a fade belongs only
445
+ // on a side with something hidden behind it.
446
+ container.classList.toggle(
447
+ 'mg-on-this-page-nav--has-right-overflow',
448
+ rtl ? scrolledFromStart : canScrollFurther
449
+ );
402
450
  }
403
451
 
404
452
  /**
@@ -543,6 +591,15 @@ function setupScrollSpy(container) {
543
591
  if (targets.length === 0) return;
544
592
 
545
593
  let activeId = null;
594
+
595
+ // At the foot of a page that scrolls a real distance. On a page that barely
596
+ // scrolls, the bottom is also the top, and the first section is right.
597
+ const atFoot = () => {
598
+ const doc = document.documentElement;
599
+ const scrollable = doc.scrollHeight - window.innerHeight;
600
+ const minimum = Math.max(120, window.innerHeight * 0.25);
601
+ return scrollable >= minimum && window.scrollY >= scrollable - 2;
602
+ };
546
603
  // Track which targets are currently intersecting (avoids DOM expando properties)
547
604
  const intersecting = new Set();
548
605
 
@@ -584,6 +641,13 @@ function setupScrollSpy(container) {
584
641
  }
585
642
  });
586
643
 
644
+ // At the foot of the page the last section wins (see onScroll). The
645
+ // observer reports after the scroll event, so without this check it
646
+ // would take the mark straight back.
647
+ if (atFoot()) {
648
+ setActive(targets[targets.length - 1].id);
649
+ return;
650
+ }
587
651
  // Pick the first intersecting target in DOM order
588
652
  const firstVisible = targets.find(t => intersecting.has(t));
589
653
  if (firstVisible) {
@@ -599,12 +663,27 @@ function setupScrollSpy(container) {
599
663
  targets.forEach(target => observer.observe(target));
600
664
  container._mgOnThisPageNavObserver = observer;
601
665
 
666
+ // A short last section can never scroll up into the observed band, so at
667
+ // the foot of the page the previous section stayed marked, even straight
668
+ // after following the last link. At the bottom, mark the last target. Only
669
+ // on pages that scroll a real distance: on one that barely scrolls, the
670
+ // bottom is also the top, and the first section is the right answer.
671
+ const onScroll = () => {
672
+ if (atFoot()) setActive(targets[targets.length - 1].id);
673
+ };
674
+ window.addEventListener('scroll', onScroll, {
675
+ passive: true,
676
+ signal: container._mgOnThisPageNavAbort?.signal,
677
+ });
678
+
602
679
  const hashId = window.location.hash ? window.location.hash.slice(1) : null;
603
680
  if (hashId && linkMap.has(hashId)) {
604
681
  setActive(hashId);
605
682
  } else if (targets.length > 0) {
606
683
  setActive(targets[0].id);
607
684
  }
685
+ // A page restored at its foot (reload, back button) is checked straight away.
686
+ onScroll();
608
687
  }
609
688
 
610
689
  // Auto-wrap so the browser Event object is not passed as scope
package/js/show-more.js CHANGED
@@ -61,6 +61,58 @@ function resolveTarget(toggle, selector) {
61
61
  return document.querySelector(selector);
62
62
  }
63
63
 
64
+ /**
65
+ * The height the content collapses to, read from CSS: the computed
66
+ * max-height of the collapsed state, so `--mg-show-more-height` may be set in
67
+ * any unit, including `lh`. Applying and removing the class in one task
68
+ * forces a style recalculation but never paints.
69
+ *
70
+ * @param {HTMLElement} target - The collapsible content.
71
+ * @returns {number} Pixels, or NaN when CSS gives no length.
72
+ */
73
+ function collapsedHeight(target) {
74
+ const collapsed = target.classList.contains('mg-show-more--collapsed');
75
+ if (!collapsed) target.classList.add('mg-show-more--collapsed');
76
+ const height = parseFloat(getComputedStyle(target).maxHeight);
77
+ if (!collapsed) target.classList.remove('mg-show-more--collapsed');
78
+ return height;
79
+ }
80
+
81
+ /**
82
+ * @param {HTMLElement} target - The collapsible content.
83
+ * @returns {number} Its line height in pixels.
84
+ */
85
+ function lineHeight(target) {
86
+ const style = getComputedStyle(target);
87
+ const line = parseFloat(style.lineHeight);
88
+ return Number.isFinite(line) ? line : parseFloat(style.fontSize) * 1.2;
89
+ }
90
+
91
+ /**
92
+ * After a reader collapses content, bring the start of it back into view if
93
+ * the toggle has left the viewport. Scrolls the heading just before the
94
+ * content when there is one, honours scroll-padding (so a sticky bar such as
95
+ * On this page is allowed for) and reduced motion.
96
+ *
97
+ * @param {HTMLElement} toggle - The toggle that was used.
98
+ * @param {HTMLElement} target - The content it collapsed.
99
+ */
100
+ function returnToContent(toggle, target) {
101
+ const box = toggle.getBoundingClientRect();
102
+ if (box.bottom > 0 && box.top < window.innerHeight) return;
103
+ const previous = target.previousElementSibling;
104
+ const anchor = previous?.matches('h1, h2, h3, h4, h5, h6')
105
+ ? previous
106
+ : target;
107
+ const reduce =
108
+ typeof window.matchMedia === 'function' &&
109
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches;
110
+ anchor.scrollIntoView?.({
111
+ block: 'start',
112
+ behavior: reduce ? 'auto' : 'smooth',
113
+ });
114
+ }
115
+
64
116
  /**
65
117
  * Initializes "show more" toggle buttons.
66
118
  *
@@ -102,7 +154,6 @@ export function mgShowMore(scope) {
102
154
  'mg-show-more--collapsed'
103
155
  );
104
156
  const wasOpen = item.classList.contains('mg-show-more--button--open');
105
- const initialText = item.textContent;
106
157
  const initialControls = item.getAttribute('aria-controls');
107
158
  const initialRole = item.getAttribute('role');
108
159
  const onKeydown = event => {
@@ -112,8 +163,27 @@ export function mgShowMore(scope) {
112
163
  }
113
164
  };
114
165
 
115
- item.dataset.dataVfGoogleAnalyticsLabel =
116
- 'Show more: ' + (item.dataset.mgShowMoreLabelCollapsed || 'Show more');
166
+ // The click tracker reads data-vf-analytics-label. Report the action the
167
+ // toggle is about to perform, in fixed English so reports do not split by
168
+ // language. A label the author set by hand is kept as it is, unless
169
+ // per-state labels are given.
170
+ const COLLAPSED = 'mg-show-more--collapsed';
171
+ const authorLabel = item.getAttribute('data-vf-analytics-label');
172
+ const { mgShowMoreAnalyticsCollapsed, mgShowMoreAnalyticsOpen } =
173
+ item.dataset;
174
+ const stateLabels =
175
+ mgShowMoreAnalyticsCollapsed || mgShowMoreAnalyticsOpen || !authorLabel
176
+ ? {
177
+ collapsed: mgShowMoreAnalyticsCollapsed || 'Show more',
178
+ open: mgShowMoreAnalyticsOpen || 'Show less',
179
+ }
180
+ : null;
181
+
182
+ // Visible text lives in a [data-mg-show-more-label] child when there is
183
+ // one, so visually hidden context beside it survives each toggle.
184
+ const labelNode = item.querySelector('[data-mg-show-more-label]') || item;
185
+ const initialText = labelNode.textContent;
186
+ const initialHidden = item.hidden;
117
187
 
118
188
  // Relate the toggle to the content it acts on. A <button> already
119
189
  // announces as a button; an anchor copied from an earlier version of the
@@ -131,24 +201,35 @@ export function mgShowMore(scope) {
131
201
  item.addEventListener('keydown', onKeydown);
132
202
  }
133
203
 
134
- const onClick = event => {
135
- // Also neutralises the dead `href="#"` on a legacy anchor toggle.
136
- event.preventDefault();
137
- mgShowMoreTarget.classList.toggle('mg-show-more--collapsed');
138
-
139
- // Which label to show?
140
- item.textContent = mgShowMoreTarget.classList.contains(
141
- 'mg-show-more--collapsed'
142
- )
204
+ // Initialisation and re-measures change state here rather than through
205
+ // item.click(), so they reach no click listener (analytics reporting, the
206
+ // Drupal wrapper). Focus entering, or a click on, collapsed content still
207
+ // clicks the toggle below: those stand for a reader's action.
208
+ const setCollapsed = collapsed => {
209
+ mgShowMoreTarget.classList.toggle(COLLAPSED, collapsed);
210
+ labelNode.textContent = collapsed
143
211
  ? item.dataset.mgShowMoreLabelCollapsed || 'Show more'
144
212
  : item.dataset.mgShowMoreLabelOpen || 'Show less';
145
-
146
- if (mgShowMoreTarget.classList.contains('mg-show-more--collapsed')) {
147
- item.classList.remove('mg-show-more--button--open');
148
- } else {
149
- item.classList.add('mg-show-more--button--open');
213
+ item.classList.toggle('mg-show-more--button--open', !collapsed);
214
+ if (stateLabels) {
215
+ item.setAttribute(
216
+ 'data-vf-analytics-label',
217
+ collapsed ? stateLabels.collapsed : stateLabels.open
218
+ );
150
219
  }
151
220
  };
221
+
222
+ let readerToggled = false;
223
+ const onClick = event => {
224
+ // Also neutralises the dead `href="#"` on a legacy anchor toggle.
225
+ event.preventDefault();
226
+ readerToggled = true;
227
+ const collapsing = !mgShowMoreTarget.classList.contains(COLLAPSED);
228
+ setCollapsed(collapsing);
229
+ // Collapsing a long block pulls the toggle up the page, often out of
230
+ // view. Bring the reader back to the start of what they collapsed.
231
+ if (collapsing) returnToContent(item, mgShowMoreTarget);
232
+ };
152
233
  item.addEventListener('click', onClick);
153
234
 
154
235
  // The collapse is `max-height` + `overflow: hidden`, which clips content
@@ -158,7 +239,7 @@ export function mgShowMore(scope) {
158
239
  // entry keeps the focused control visible.
159
240
  const onFocusin = event => {
160
241
  if (item === event.target || item.contains(event.target)) return;
161
- if (mgShowMoreTarget.classList.contains('mg-show-more--collapsed')) {
242
+ if (mgShowMoreTarget.classList.contains(COLLAPSED)) {
162
243
  item.click();
163
244
  }
164
245
  };
@@ -167,14 +248,61 @@ export function mgShowMore(scope) {
167
248
  // Allow items to be shown by clicking anywhere on the collapsed item
168
249
  // https://gitlab.com/undrr/web-backlog/-/issues/1612
169
250
  const onTargetClick = () => {
170
- if (mgShowMoreTarget.classList.contains('mg-show-more--collapsed')) {
251
+ if (mgShowMoreTarget.classList.contains(COLLAPSED)) {
171
252
  item.click();
172
253
  }
173
254
  };
174
255
  mgShowMoreTarget.addEventListener('click', onTargetClick);
175
256
 
176
- item.click();
257
+ // A template may ship the toggle `hidden` so it never shows without
258
+ // JavaScript; the content is then simply open.
259
+ item.hidden = false;
260
+ setCollapsed(!wasCollapsed);
261
+
262
+ // Opt-in: collapse only when enough would be hidden. Short content stays
263
+ // open and the toggle is hidden, so a template can wrap every body field
264
+ // in ShowMore. The value is a number of lines (empty means 1): collapse
265
+ // when the content runs at least that many lines past the collapsed
266
+ // height. The collapsed height is read from CSS (--mg-show-more-height),
267
+ // so the decision does not depend on the current state and cannot flip
268
+ // back and forth. Measured again when the content resizes, until the
269
+ // reader uses the toggle. Content with no layout yet (inside a closed
270
+ // tab, say) waits until it has one.
271
+ let observer = null;
272
+ let frame = 0;
273
+ if (item.hasAttribute('data-mg-show-more-min-hidden-lines')) {
274
+ const lines =
275
+ parseFloat(item.dataset.mgShowMoreMinHiddenLines) > 0
276
+ ? parseFloat(item.dataset.mgShowMoreMinHiddenLines)
277
+ : 1;
278
+ const measure = () => {
279
+ frame = 0;
280
+ if (readerToggled || mgShowMoreTarget.scrollHeight === 0) return;
281
+ const limit = collapsedHeight(mgShowMoreTarget);
282
+ const line = lineHeight(mgShowMoreTarget);
283
+ if (!Number.isFinite(limit) || !Number.isFinite(line)) return;
284
+ const collapse = mgShowMoreTarget.scrollHeight - limit >= lines * line;
285
+ setCollapsed(collapse);
286
+ item.hidden = !collapse;
287
+ };
288
+ measure();
289
+ if (typeof ResizeObserver === 'function') {
290
+ // Deferred to the next frame: changing the observed box inside the
291
+ // callback is what raises "ResizeObserver loop" errors.
292
+ observer = new ResizeObserver(() => {
293
+ if (frame) cancelAnimationFrame(frame);
294
+ frame = requestAnimationFrame(measure);
295
+ });
296
+ observer.observe(mgShowMoreTarget);
297
+ }
298
+ }
299
+
177
300
  cleanups.push(() => {
301
+ observer?.disconnect();
302
+ if (frame) cancelAnimationFrame(frame);
303
+ item.hidden = initialHidden;
304
+ if (authorLabel === null) item.removeAttribute('data-vf-analytics-label');
305
+ else item.setAttribute('data-vf-analytics-label', authorLabel);
178
306
  item.removeEventListener('click', onClick);
179
307
  item.removeEventListener('keydown', onKeydown);
180
308
  mgShowMoreTarget.removeEventListener('focusin', onFocusin);
@@ -184,7 +312,7 @@ export function mgShowMore(scope) {
184
312
  wasCollapsed
185
313
  );
186
314
  item.classList.toggle('mg-show-more--button--open', wasOpen);
187
- item.textContent = initialText;
315
+ labelNode.textContent = initialText;
188
316
  for (const [attribute, value] of [
189
317
  ['aria-controls', initialControls],
190
318
  ['role', initialRole],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@undrr/undrr-mangrove",
3
- "version": "2.0.1",
3
+ "version": "2.1.1",
4
4
  "description": "Mangrove design System for UNDRR",
5
5
  "files": [
6
6
  "components/**/*",
@@ -49,8 +49,8 @@
49
49
  position: absolute;
50
50
  z-index: 1;
51
51
  transition:
52
- opacity 0.2s ease,
53
- transform 0.2s ease;
52
+ opacity var(--mg-motion-duration-fast) var(--mg-motion-easing-standard),
53
+ transform var(--mg-motion-duration-fast) var(--mg-motion-easing-standard);
54
54
  transform: translateY(2px);
55
55
  }
56
56
 
@@ -224,3 +224,10 @@
224
224
  justify-items: center;
225
225
  text-align: center;
226
226
  }
227
+
228
+ @media (prefers-reduced-motion: reduce) {
229
+ .mg-author-image__avatar::before {
230
+ transition: none;
231
+ transform: none;
232
+ }
233
+ }
@@ -24,7 +24,8 @@ details.mg-details {
24
24
  border: 1px solid rgb(var(--mg-color-neutral-400)); // #808080 — 3.95:1 on white (passes WCAG 1.4.11)
25
25
  border-radius: mg-rem(4);
26
26
  padding: mg-rem(10);
27
- transition: box-shadow 0.2s ease-in-out;
27
+ transition: box-shadow var(--mg-motion-duration-fast)
28
+ var(--mg-motion-easing-standard);
28
29
 
29
30
  &:hover {
30
31
  box-shadow: 0 2px 4px rgb(0 0 0 / 0.1);
@@ -40,7 +41,8 @@ details.mg-details {
40
41
  padding-block: mg-rem(10);
41
42
  padding-inline: mg-rem(10) mg-rem(44);
42
43
  position: relative;
43
- transition: background-color 0.2s ease-in-out;
44
+ transition: background-color var(--mg-motion-duration-fast)
45
+ var(--mg-motion-easing-standard);
44
46
 
45
47
  &:hover {
46
48
  background-color: rgb(
@@ -242,7 +242,8 @@
242
242
  width: 0.35em;
243
243
 
244
244
  @media (prefers-reduced-motion: no-preference) {
245
- transition: translate 200ms ease;
245
+ transition: translate var(--mg-motion-duration-fast)
246
+ var(--mg-motion-easing-standard);
246
247
  }
247
248
  }
248
249
 
@@ -578,7 +579,12 @@ span.mg-card__label {
578
579
  // Visual link for logo cards (srOnlyTitle) - stretched link pattern
579
580
  .mg-card__visual-link {
580
581
  display: block;
581
- transition: opacity 200ms ease;
582
+
583
+ @media (prefers-reduced-motion: no-preference) {
584
+ transition: opacity var(--mg-motion-duration-fast)
585
+ var(--mg-motion-easing-standard);
586
+ }
587
+
582
588
  width: 100%;
583
589
 
584
590
  &::after {
@@ -120,7 +120,8 @@
120
120
  text-decoration: underline;
121
121
  position: relative;
122
122
  z-index: 2; // above stretched-link cover — local to stats-card stacking context
123
- transition: opacity 200ms ease;
123
+ transition: opacity var(--mg-motion-duration-fast)
124
+ var(--mg-motion-easing-standard);
124
125
 
125
126
  &:hover {
126
127
  opacity: 0.7;
@@ -228,3 +229,9 @@
228
229
  }
229
230
 
230
231
  /* RTL support is automatic: the highlighted accent uses `border-inline-start`. */
232
+
233
+ @media (prefers-reduced-motion: reduce) {
234
+ .mg-stats-card-item__summary a {
235
+ transition: none;
236
+ }
237
+ }
@@ -43,6 +43,104 @@
43
43
  @media (prefers-reduced-motion: reduce) {
44
44
  transition: none;
45
45
  }
46
+
47
+ // The filled trigger relies on its background, which forced colours remove.
48
+ @media (forced-colors: active) {
49
+ border: 1px solid ButtonText;
50
+ }
51
+ }
52
+
53
+ // Default enhanced layout, with the same structure and metrics as
54
+ // ShareButtons: a label, the citation as a copyable field, and a quiet
55
+ // dialog link. `--compact` shows the dialog trigger alone.
56
+ &__panel {
57
+ display: flex;
58
+ flex-direction: column;
59
+ align-items: flex-start;
60
+ gap: var(--mg-spacing-100);
61
+ max-inline-size: mg-rem(400);
62
+ }
63
+
64
+ &__header {
65
+ margin: 0;
66
+ color: rgb(var(--mg-color-text));
67
+ font-family: var(--mg-font-family-text);
68
+ font-size: var(--mg-font-size-300);
69
+ font-weight: 600;
70
+ }
71
+
72
+ &__preview {
73
+ box-sizing: border-box;
74
+ display: flex;
75
+ align-items: center;
76
+ gap: var(--mg-spacing-100);
77
+ inline-size: 100%;
78
+ min-block-size: mg-rem(40);
79
+ padding: var(--mg-spacing-100);
80
+ background-color: rgb(var(--mg-color-neutral-25));
81
+ border: 1px solid rgb(var(--mg-color-neutral-300) / 0.4);
82
+ border-radius: var(--mg-radius-button);
83
+ color: rgb(var(--mg-color-text));
84
+ cursor: pointer;
85
+ font-family: var(--mg-font-family-text);
86
+ text-align: start;
87
+
88
+ @media (prefers-reduced-motion: no-preference) {
89
+ transition: background-color var(--mg-motion-duration-fast)
90
+ var(--mg-motion-easing-standard);
91
+ }
92
+
93
+ .mg-icon {
94
+ flex: 0 0 mg-rem(24);
95
+ text-align: center;
96
+ }
97
+
98
+ &:hover {
99
+ background-color: rgb(var(--mg-color-neutral-50));
100
+
101
+ .mg-cite-this__preview-text {
102
+ text-decoration: underline;
103
+ text-underline-offset: 0.15em;
104
+ }
105
+ }
106
+
107
+ &:focus-visible {
108
+ @include mg-focus-ring;
109
+ }
110
+
111
+ @media (forced-colors: active) {
112
+ border-color: ButtonText;
113
+ }
114
+ }
115
+
116
+ &__preview-text {
117
+ display: -webkit-box;
118
+ flex: 1;
119
+ min-inline-size: 0;
120
+ overflow: hidden;
121
+ font-size: var(--mg-font-size-250);
122
+ line-height: 1.5;
123
+ overflow-wrap: anywhere;
124
+ -webkit-box-orient: vertical;
125
+ -webkit-line-clamp: 2;
126
+ line-clamp: 2;
127
+ }
128
+
129
+ // Doubled selector outranks the filled `__trigger` styles.
130
+ & &__more {
131
+ min-block-size: 44px;
132
+ padding: 0;
133
+ background: transparent;
134
+ color: rgb(var(--mg-color-interactive));
135
+ font-size: var(--mg-font-size-250);
136
+ font-weight: 400;
137
+ text-decoration: underline;
138
+ text-underline-offset: 0.15em;
139
+
140
+ &:hover {
141
+ filter: none;
142
+ text-decoration-thickness: 2px;
143
+ }
46
144
  }
47
145
 
48
146
  &__dialog {
@@ -72,7 +170,7 @@
72
170
  align-items: center;
73
171
  justify-content: space-between;
74
172
  gap: var(--mg-spacing-200);
75
- padding: var(--mg-spacing-150) var(--mg-spacing-200) 0;
173
+ padding: var(--mg-spacing-200) var(--mg-spacing-200) var(--mg-spacing-150);
76
174
  }
77
175
 
78
176
  &__title {
@@ -134,10 +232,32 @@
134
232
  padding: 0 var(--mg-spacing-200) var(--mg-spacing-200);
135
233
  }
136
234
 
137
- &__text {
235
+ // Doubled selector outranks `.mg-code`, which earlier markup put on the
236
+ // citation; a citation is prose to read, not code.
237
+ & &__text {
138
238
  margin: 0;
239
+ padding: var(--mg-spacing-150) var(--mg-spacing-200);
240
+ background: rgb(var(--mg-color-neutral-25));
241
+ border: 1px solid rgb(var(--mg-color-neutral-300) / 0.4);
242
+ border-radius: var(--mg-radius-form-input);
243
+ box-shadow: none;
244
+ color: rgb(var(--mg-color-neutral-800));
245
+ font-family: var(--mg-font-family-text);
246
+ font-size: var(--mg-font-size-300);
247
+ font-weight: 400;
248
+ line-height: 1.6;
139
249
  overflow-wrap: anywhere;
140
250
  text-wrap: pretty;
251
+
252
+ a {
253
+ color: rgb(var(--mg-color-interactive));
254
+ text-decoration: underline;
255
+ text-underline-offset: 0.15em;
256
+
257
+ &:focus-visible {
258
+ @include mg-focus-ring;
259
+ }
260
+ }
141
261
  }
142
262
 
143
263
  &__actions {
@@ -150,21 +270,31 @@
150
270
 
151
271
  &__copy {
152
272
  min-block-size: 44px;
153
- padding: var(--mg-spacing-100) var(--mg-spacing-200);
154
- background: rgb(var(--mg-color-interactive));
155
- border: 0;
273
+ padding: var(--mg-spacing-50) var(--mg-spacing-150);
274
+ background: transparent;
275
+ border: 2px solid rgb(var(--mg-color-interactive));
156
276
  border-radius: var(--mg-radius-button);
157
- color: rgb(var(--mg-color-white));
277
+ color: rgb(var(--mg-color-interactive));
158
278
  cursor: pointer;
159
279
  font-family: var(--mg-font-family-text);
160
- font-size: var(--mg-font-size-300);
280
+ font-size: var(--mg-font-size-250);
161
281
  font-weight: 600;
162
- transition-property: filter, scale;
282
+ transition-property: background-color, color, scale;
163
283
  transition-duration: var(--mg-motion-duration-fast);
164
284
  transition-timing-function: var(--mg-motion-easing-standard);
165
285
 
166
286
  &:hover {
167
- filter: brightness(0.92);
287
+ background: rgb(var(--mg-color-interactive));
288
+ color: rgb(var(--mg-color-white));
289
+ }
290
+
291
+ &:not(.mg-cite-this__copy-url) {
292
+ background: rgb(var(--mg-color-interactive));
293
+ color: rgb(var(--mg-color-white));
294
+
295
+ &:hover {
296
+ filter: brightness(0.92);
297
+ }
168
298
  }
169
299
 
170
300
  &:active {
@@ -180,6 +310,29 @@
180
310
  }
181
311
  }
182
312
 
313
+ &__exports {
314
+ display: flex;
315
+ flex-wrap: wrap;
316
+ gap: var(--mg-spacing-50) var(--mg-spacing-200);
317
+ margin-block-start: var(--mg-spacing-150);
318
+ padding-block-start: var(--mg-spacing-150);
319
+ border-block-start: 1px solid rgb(var(--mg-color-neutral-200));
320
+ font-size: var(--mg-font-size-250);
321
+ }
322
+
323
+ &__export {
324
+ display: inline-flex;
325
+ align-items: center;
326
+ min-block-size: 44px;
327
+ color: rgb(var(--mg-color-interactive));
328
+ text-decoration: underline;
329
+ text-underline-offset: 0.15em;
330
+
331
+ &:focus-visible {
332
+ @include mg-focus-ring;
333
+ }
334
+ }
335
+
183
336
  &__status {
184
337
  font-size: var(--mg-font-size-250);
185
338
  }