srcdev-nuxt-components 9.1.50 → 9.1.52

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 (34) hide show
  1. package/.claude/settings.json +8 -27
  2. package/.claude/settings.local.json +24 -52
  3. package/.claude/skills/components/action-menu.md +141 -0
  4. package/.claude/skills/components/stepper-list.md +52 -18
  5. package/.claude/skills/css-nesting-conventions.md +121 -0
  6. package/.claude/skills/index.md +3 -0
  7. package/.claude/skills/pull-request-description.md +48 -0
  8. package/.claude/skills/testing-add-unit-test.md +40 -2
  9. package/README.md +26 -5
  10. package/app/components/02.molecules/action-menu/ActionMenu.vue +218 -0
  11. package/app/components/02.molecules/action-menu/ActionMenuItemCore.vue +123 -0
  12. package/app/components/02.molecules/action-menu/CONSUMER-STYLING.md +125 -0
  13. package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +326 -0
  14. package/app/components/02.molecules/action-menu/tests/ActionMenu.spec.ts +458 -0
  15. package/app/components/02.molecules/action-menu/tests/ActionMenuItemCore.spec.ts +199 -0
  16. package/app/components/02.molecules/action-menu/tests/__snapshots__/ActionMenu.spec.ts.snap +28 -0
  17. package/app/components/02.molecules/action-menu/tests/__snapshots__/ActionMenuItemCore.spec.ts.snap +27 -0
  18. package/app/components/02.molecules/display-chip/tests/DisplayChip.spec.ts +5 -1
  19. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +2 -1
  20. package/app/components/02.molecules/stepper-list/CONSUMER-STYLING.md +138 -0
  21. package/app/components/02.molecules/stepper-list/StepperList.vue +50 -24
  22. package/app/components/03.organisms/image-galleries/slider-gallery/tests/SliderGallery.spec.ts +5 -1
  23. package/app/components/05.forms/input-button/InputButtonCore.vue +1 -1
  24. package/app/components/carousels/tests/CarouselFlip.spec.ts +1 -0
  25. package/app/components/responsive-header/NavigationItems.vue +302 -81
  26. package/app/components/responsive-header/ResponsiveHeader.vue +360 -56
  27. package/app/layouts/default.vue +43 -352
  28. package/app/layouts/site-navigation-demo.vue +36 -34
  29. package/app/pages/page-hero-highlights.vue +3 -3
  30. package/app/pages/ui/scroll-reveal-image.vue +3 -3
  31. package/app/pages/ui/simple-grid.vue +9 -20
  32. package/modules/colour-scheme.ts +1 -1
  33. package/nuxt.config.ts +11 -2
  34. package/package.json +17 -16
@@ -1,6 +1,18 @@
1
1
  <template>
2
- <div ref="navigationWrapper" class="navigation" :class="[elementClasses, { loaded: navLoaded }]" role="banner">
3
- <nav ref="mainNav" class="main-navigation" aria-label="Main navigation">
2
+ <div
3
+ ref="navigationWrapper"
4
+ class="navigation"
5
+ :class="[elementClasses, { loaded: navLoaded, 'geometry-ready': isGeometryReady }]"
6
+ role="banner"
7
+ >
8
+ <nav
9
+ ref="mainNav"
10
+ class="main-navigation"
11
+ :class="{ 'is-animated': isAnimated }"
12
+ aria-label="Main navigation"
13
+ @mouseleave="hoveredItemKey = null"
14
+ @focusout="handleNavFocusout"
15
+ >
4
16
  <ul
5
17
  v-for="(navGroup, groupKey) in responsiveNavLinks"
6
18
  :key="groupKey"
@@ -16,6 +28,8 @@
16
28
  class="main-navigation-item"
17
29
  :class="{
18
30
  'visually-hidden': !mainNavigationState.clonedNavLinks?.[groupKey]?.[localIndex]?.config?.visible,
31
+ 'is-hovered': hoveredItemKey === `${String(groupKey)}-${localIndex}`,
32
+ 'is-active': isActiveNavItem(link),
19
33
  }"
20
34
  :style="{
21
35
  '--_main-navigation-item-width':
@@ -23,8 +37,8 @@
23
37
  }"
24
38
  :data-group-key="groupKey"
25
39
  :data-local-index="localIndex"
26
- @mouseenter="handleNavigationItemHover"
27
- @focusin="handleNavigationItemHover"
40
+ @mouseenter="handleNavigationItemHover(`${String(groupKey)}-${localIndex}`)"
41
+ @focusin="handleNavigationItemHover(`${String(groupKey)}-${localIndex}`)"
28
42
  >
29
43
  <NuxtLink
30
44
  v-if="link.path"
@@ -61,12 +75,14 @@
61
75
  </details>
62
76
  </li>
63
77
  </ul>
78
+ <div aria-hidden="true" class="nav-indicator-hovered"></div>
79
+ <div aria-hidden="true" class="nav-indicator-active"></div>
64
80
  </nav>
65
81
  <nav ref="secondaryNav" class="secondary-navigation" aria-label="Secondary navigation">
66
82
  <details
67
83
  ref="overflowDetails"
68
84
  class="overflow-details"
69
- :class="[{ 'visually-hidden': !navLoaded || !showOverflowDetails }]"
85
+ :class="overflowDetailsClass ? [overflowDetailsClass] : []"
70
86
  name="overflow-group"
71
87
  >
72
88
  <summary class="overflow-details-summary has-toggle-icon">
@@ -93,7 +109,12 @@
93
109
  </template>
94
110
 
95
111
  <script setup lang="ts">
96
- import type { ResponsiveHeaderProp, ResponsiveHeaderState, IFlooredRect } from "../../types/components";
112
+ import type {
113
+ ResponsiveHeaderProp,
114
+ ResponsiveHeaderState,
115
+ IFlooredRect,
116
+ ResponsiveHeaderNavItem,
117
+ } from "../../types/components";
97
118
  import { useResizeObserver, onClickOutside } from "@vueuse/core";
98
119
 
99
120
  interface Props {
@@ -176,19 +197,84 @@ const handleSummaryHover = (event: MouseEvent | FocusEvent) => {
176
197
  toggleDetailsElement(event);
177
198
  };
178
199
 
179
- const handleNavigationItemHover = () => {
200
+ const handleNavigationItemHover = (key: string) => {
201
+ hoveredItemKey.value = key;
180
202
  if (!props.allowExpandOnGesture) {
181
203
  return;
182
204
  }
183
-
184
- // Close all open navigation details when hovering over regular nav items
185
205
  closeAllNavigationDetails();
186
206
  };
187
207
 
208
+ const handleNavFocusout = (event: FocusEvent) => {
209
+ // Only clear when focus moves outside the nav entirely, not between nav items.
210
+ const nav = event.currentTarget as HTMLElement;
211
+ if (!event.relatedTarget || !nav.contains(event.relatedTarget as Node)) {
212
+ hoveredItemKey.value = null;
213
+ }
214
+ };
215
+
188
216
  const handleSummaryAction = (event: MouseEvent | KeyboardEvent) => {
189
217
  toggleDetailsElement(event);
190
218
  };
191
219
 
220
+ const hoveredItemKey = ref<string | null>(null);
221
+
222
+ const route = useRoute();
223
+
224
+ const isActiveNavItem = (link: ResponsiveHeaderNavItem): boolean => {
225
+ if (link.path) return route.path === link.path;
226
+ if (link.childLinks) return link.childLinks.some((child) => child.path && route.path === child.path);
227
+ return false;
228
+ };
229
+
230
+ const isAnimated = ref(true);
231
+
232
+ // Local (non-useState) flag — resets to false on every mount.
233
+ // Gates two things:
234
+ // 1. The `.geometry-ready` wrapper class that enables nav-item visibility transitions,
235
+ // preventing items from animating in on every route change.
236
+ // 2. Whether the overflow button is shown (via overflowDetailsHidden below).
237
+ const isGeometryReady = ref(false);
238
+
239
+ // Set to true only during Phase 2 of the initial geometry pass (see useResizeObserver).
240
+ // Allows the overflow button to be temporarily revealed at its natural size so that
241
+ // secondaryNavRects can be measured accurately before the final visibility pass runs.
242
+ const isOverflowVisibleForMeasurement = ref(false);
243
+
244
+ // Single source of truth for the overflow button's CSS class:
245
+ //
246
+ // 'visually-hidden' → width: 0 — removed from layout entirely (early boot, between
247
+ // the first and second measurement phases)
248
+ // 'is-measuring' → opacity:0 / visibility:hidden, but NATURAL width — button is
249
+ // present in layout so secondaryNavRects captures its real width,
250
+ // but invisible to the user (phase 2 of the geometry pass)
251
+ // null → fully visible (isGeometryReady + showOverflowDetails)
252
+ //
253
+ // Note: we never conditionally skip phase 2 with a peek check. A peek using
254
+ // determineNavigationItemVisibility() after phase 1 gives the wrong answer at the
255
+ // breakpoint because the CSS v-bind(mainNavigationMarginBlockEndStr) hasn't been
256
+ // flushed to the DOM yet — positions lag the reactive update by one tick.
257
+ const overflowDetailsClass = computed<string | null>(() => {
258
+ if (!navLoaded.value) return "visually-hidden";
259
+ if (!isGeometryReady.value) {
260
+ return isOverflowVisibleForMeasurement.value ? "is-measuring" : "visually-hidden";
261
+ }
262
+ return showOverflowDetails.value ? null : "visually-hidden";
263
+ });
264
+
265
+ watch(
266
+ () => route.path,
267
+ () => {
268
+ isAnimated.value = false;
269
+ requestAnimationFrame(() => {
270
+ requestAnimationFrame(() => {
271
+ isAnimated.value = true;
272
+ });
273
+ });
274
+ },
275
+ { flush: "pre" }
276
+ );
277
+
192
278
  // Initialize main navigation state
193
279
  const mainNavigationState = ref<ResponsiveHeaderState>({
194
280
  navListVisibility: {
@@ -348,6 +434,10 @@ const initMainNavigationState = () => {
348
434
  mainNavigationState.value.navListVisibility[groupKey] = true;
349
435
  }
350
436
  });
437
+
438
+ // Geometry pass is complete. The overflow button can now appear (if needed)
439
+ // and nav-item transitions can play from this point forward.
440
+ isGeometryReady.value = true;
351
441
  };
352
442
 
353
443
  // Function to set up click outside listeners
@@ -366,26 +456,95 @@ const setupClickOutsideListeners = () => {
366
456
  };
367
457
 
368
458
  onMounted(async () => {
369
- // If navigation is already initialized and loaded, skip the template refs setup
370
- if (navigationInitialized.value && navLoaded.value) {
371
- // But still set up click outside listeners as DOM elements may have changed
372
- await nextTick();
373
- setupClickOutsideListeners();
374
- return;
375
- }
459
+ // Always reset on every mount this is a local ref so it naturally resets,
460
+ // but being explicit here makes the intent clear: geometry must be re-verified
461
+ // on every route change before the overflow button appears or transitions play.
462
+ isGeometryReady.value = false;
463
+
464
+ // Always (re-)populate the local nav refs — firstNavRef / secondNavRef are local
465
+ // refs that reset on unmount, so they must be re-assigned on every mount even
466
+ // when the useState flags say we're already initialized.
467
+ await initTemplateRefs();
376
468
 
377
- await initTemplateRefs().then(() => {
469
+ if (!navigationInitialized.value || !navLoaded.value) {
378
470
  navLoaded.value = true;
379
471
  navigationInitialized.value = true;
380
- });
472
+ }
381
473
 
474
+ // Geometry recalculation is handled by useResizeObserver which fires when the
475
+ // element first becomes observed. isGeometryReady is set there after the pass.
382
476
  setupClickOutsideListeners();
383
477
  });
384
478
 
479
+ // ─── Re-entrancy guard for the ResizeObserver callback ────────────────────
480
+ // The observer fires an async callback that awaits multiple ticks and toggles
481
+ // layout-affecting state. If the observer fires again while the first pass is
482
+ // still in flight, two concurrent passes would race on isOverflowVisibleForMeasurement
483
+ // and mainNavigationState.
484
+ //
485
+ // Pattern: "run-latest" queue.
486
+ // • isMeasuring gates entry — any new observer call while a pass is running
487
+ // sets pendingMeasure = true and returns immediately.
488
+ // • The do/while re-runs once after the pass completes if a new resize arrived,
489
+ // so we never silently drop the final geometry update.
490
+ let isMeasuring = false;
491
+ let pendingMeasure = false;
492
+
385
493
  useResizeObserver(navigationWrapperRef, async () => {
386
- await updateNavigationConfig("useResizeObserver").then(() => {
387
- initMainNavigationState();
388
- });
494
+ if (isMeasuring) {
495
+ pendingMeasure = true;
496
+ return;
497
+ }
498
+
499
+ isMeasuring = true;
500
+ try {
501
+ do {
502
+ pendingMeasure = false;
503
+
504
+ if (!isGeometryReady.value) {
505
+ // ─── Two-pass measurement on initial mount / route change ──────────────
506
+ //
507
+ // We never use a "peek" check to decide whether phase 2 is needed.
508
+ // A peek after phase 1 gives the wrong answer at the breakpoint because:
509
+ // • updateNavigationConfig() sets secondaryNavRects reactively, but
510
+ // v-bind(mainNavigationMarginBlockEndStr) isn't flushed to the DOM until
511
+ // the next Vue render tick — so item rects are still measured against the
512
+ // OLD margin, producing a stale DOM/computed mismatch.
513
+ // • Even with correct timing, checking without the button reserved will
514
+ // always pass marginal items (those that fit only when button is absent).
515
+ //
516
+ // Phase 1: button is 'visually-hidden' (width:0).
517
+ // Measure the wrapper and secondary-nav rects without the button.
518
+ await updateNavigationConfig("phase1");
519
+
520
+ // Phase 2: switch button to 'is-measuring' — opacity:0 / visibility:hidden
521
+ // but natural width — so secondaryNavRects captures the button's real width.
522
+ // The button is invisible to the user; nav items are still visually-hidden.
523
+ isOverflowVisibleForMeasurement.value = true;
524
+ await nextTick(); // let Vue render the 'is-measuring' class (button at natural width)
525
+ await updateNavigationConfig("phase2"); // secondaryNavRects now includes button width
526
+
527
+ // Wait for Vue to flush the updated mainNavigationMarginBlockEndStr via v-bind
528
+ // before reading item positions — without this tick, getBoundingClientRect()
529
+ // in initMainNavigationState would see the pre-phase-2 margin-inline-end.
530
+ await nextTick();
531
+
532
+ // Final visibility pass — reads item rects against the correct margin.
533
+ // Also sets isGeometryReady = true.
534
+ initMainNavigationState();
535
+ // Hand control back to showOverflowDetails; measurement flag no longer needed.
536
+ isOverflowVisibleForMeasurement.value = false;
537
+ } else {
538
+ // ─── Normal resize after geometry is settled ───────────────────────────
539
+ // The button's state is already correct (driven by showOverflowDetails),
540
+ // so a single-pass measurement gives accurate secondaryNavRects.
541
+ await updateNavigationConfig("useResizeObserver");
542
+ initMainNavigationState();
543
+ }
544
+ } while (pendingMeasure);
545
+ } finally {
546
+ isMeasuring = false;
547
+ }
389
548
  });
390
549
 
391
550
  const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
@@ -415,7 +574,57 @@ watch(
415
574
  }
416
575
  }
417
576
 
577
+ /* ─── Public CSS tokens ─────────────────────────────────────────────────
578
+ Override these on the consumer's scope class to theme the nav.
579
+ Tokens are read via var(--token, default) — defaults are NOT declared
580
+ on this element to avoid source-order cascade conflicts with consumers.
581
+
582
+ --responsive-header-margin (default: 0)
583
+ --responsive-header-bg (default: transparent)
584
+ --responsive-header-border (default: none)
585
+ --responsive-header-border-radius (default: 0)
586
+ --responsive-header-padding-block (default: 0)
587
+ --responsive-header-padding-inline (default: 0)
588
+ --responsive-header-max-height (default: none)
589
+ --responsive-header-inline-size (default: 100%)
590
+
591
+ --responsive-header-color (default: inherit)
592
+ --responsive-header-link-color (default: inherit)
593
+
594
+ --responsive-header-sub-nav-bg (default: Canvas)
595
+ --responsive-header-sub-nav-border (default: 1px solid #efefef75)
596
+ --responsive-header-sub-nav-border-radius (default: 8px)
597
+ --responsive-header-sub-nav-padding (default: 12px)
598
+
599
+ --responsive-header-overflow-btn-bg (default: Canvas)
600
+ --responsive-header-overflow-btn-size (default: 20px)
601
+ --responsive-header-overflow-btn-border (default: 1px solid #ffffff90)
602
+ --responsive-header-overflow-btn-outline (default: 1px solid #ffffff10)
603
+ --responsive-header-overflow-btn-icon-color (default: inherit)
604
+ --responsive-header-overflow-btn-hover-outline (default: 1px solid #ffffff)
605
+
606
+ --responsive-header-overflow-nav-bg (default: Canvas)
607
+ --responsive-header-overflow-nav-border (default: 1px solid #ffffff90)
608
+ --responsive-header-overflow-nav-border-radius (default: 8px)
609
+ --responsive-header-overflow-nav-padding-block (default: 12px)
610
+
611
+ --responsive-nav-decorator-indicator-color (default: currentColor)
612
+ --responsive-nav-decorator-hovered-indicator-color (default: inherits --responsive-nav-decorator-indicator-color)
613
+ --responsive-nav-decorator-hovered-bg (default: oklch(100% 0 0 / 8%))
614
+ ──────────────────────────────────────────────────────────────────────── */
615
+
418
616
  --_link-visibility-transition: none;
617
+ position: relative;
618
+ color: var(--responsive-header-color, inherit);
619
+
620
+ margin: var(--responsive-header-margin, 0);
621
+ background-color: var(--responsive-header-bg, transparent);
622
+ border: var(--responsive-header-border, none);
623
+ border-radius: var(--responsive-header-border-radius, 0);
624
+ padding-block: var(--responsive-header-padding-block, 0);
625
+ padding-inline: var(--responsive-header-padding-inline, 0);
626
+ max-height: var(--responsive-header-max-height, none);
627
+ inline-size: var(--responsive-header-inline-size, 100%);
419
628
 
420
629
  &.loaded {
421
630
  --_link-visibility-transition: all 0.2s ease-in-out;
@@ -441,7 +650,6 @@ watch(
441
650
  flex-grow: 1;
442
651
  justify-content: space-between;
443
652
  gap: 60px;
444
-
445
653
  overflow-x: hidden;
446
654
  margin-inline-end: v-bind(mainNavigationMarginBlockEndStr);
447
655
 
@@ -464,9 +672,9 @@ watch(
464
672
  .main-navigation-item {
465
673
  /* width: var(--_main-navigation-item-width); */
466
674
  overflow: hidden;
467
- transition:
468
- opacity 0.2s ease-in-out,
469
- visibility 0.2s ease-in-out;
675
+ /* Transition is intentionally absent here — it is only enabled after
676
+ the first geometry pass via .navigation.geometry-ready below, so
677
+ items don't animate in on every route change. */
470
678
  padding-block: var(--_link-focus-visible-outline-width);
471
679
  padding-inline: var(--_link-focus-visible-outline-width);
472
680
 
@@ -474,21 +682,18 @@ watch(
474
682
  display: flex;
475
683
  gap: 6px;
476
684
  text-wrap-mode: nowrap;
477
- color: inherit;
685
+ color: var(--responsive-header-link-color, inherit);
478
686
  text-decoration: none;
687
+ cursor: pointer;
479
688
  margin-inline-start: 0;
480
- /* transition: var(--_link-visibility-transition); */
689
+ position: relative;
690
+ z-index: 4;
481
691
 
482
692
  padding-block: var(--_link-padding-block);
483
693
  padding-inline: var(--_link-padding-inline);
484
694
  margin-block: var(--_link-margin-block);
485
695
  margin-inline: var(--_link-margin-inline);
486
696
  border-bottom: var(--_link-border-default);
487
-
488
- &:hover {
489
- cursor: pointer;
490
- border-bottom-color: var(--_link-border-bottom-hover);
491
- }
492
697
  }
493
698
 
494
699
  .main-navigation-details {
@@ -499,10 +704,6 @@ watch(
499
704
 
500
705
  &[open] {
501
706
  --_icon-transform: scaleY(-1);
502
-
503
- .main-navigation-details-summary {
504
- border-bottom-color: var(--_link-border-bottom-hover);
505
- }
506
707
  }
507
708
 
508
709
  .has-toggle-icon {
@@ -524,17 +725,16 @@ watch(
524
725
  margin-inline: var(--_link-margin-inline);
525
726
  border-bottom: var(--_link-border-default);
526
727
  white-space: nowrap;
728
+ cursor: pointer;
729
+ position: relative;
730
+ z-index: 4;
731
+ color: var(--responsive-header-link-color, inherit);
527
732
 
528
733
  &::-webkit-details-marker,
529
734
  &::marker {
530
735
  display: none;
531
736
  }
532
737
 
533
- &:hover {
534
- cursor: pointer;
535
- border-bottom-color: var(--_link-border-bottom-hover);
536
- }
537
-
538
738
  .decorator-icon {
539
739
  margin-inline-start: 8px;
540
740
  }
@@ -542,10 +742,11 @@ watch(
542
742
 
543
743
  .main-navigation-sub-nav {
544
744
  position: absolute;
545
- padding: 12px;
546
- border: 1px solid #efefef75;
547
- border-radius: 8px;
548
- background-color: #000;
745
+
746
+ padding: var(--responsive-header-sub-nav-padding, 12px);
747
+ border: var(--responsive-header-sub-nav-border, 1px solid #efefef75);
748
+ border-radius: var(--responsive-header-sub-nav-border-radius, 8px);
749
+ background-color: var(--responsive-header-sub-nav-bg, Canvas);
549
750
  translate: 0 12px;
550
751
 
551
752
  min-width: var(--_main-navigation-item-width);
@@ -566,7 +767,7 @@ watch(
566
767
  display: block;
567
768
  text-wrap-mode: nowrap;
568
769
  text-decoration: none;
569
- color: inherit;
770
+ color: var(--responsive-header-link-color, inherit);
570
771
  }
571
772
  }
572
773
  }
@@ -576,6 +777,12 @@ watch(
576
777
  &.visually-hidden {
577
778
  visibility: hidden;
578
779
  opacity: 0;
780
+ /* Pin the outer <li> to its originally-measured width so re-measurements
781
+ while hidden don't pick up an inflated value from the shifted inner
782
+ content. Without this, each hide→measure cycle compounds the width
783
+ (feedback loop → 7995px → items never recover). */
784
+ inline-size: var(--_main-navigation-item-width);
785
+ overflow: hidden;
579
786
 
580
787
  .main-navigation-details,
581
788
  .main-navigation-link {
@@ -619,7 +826,7 @@ watch(
619
826
  display: flex;
620
827
  align-items: center;
621
828
  font: inherit;
622
- color: inherit;
829
+ color: var(--responsive-header-link-color, inherit);
623
830
 
624
831
  .icon {
625
832
  height: 1.35em;
@@ -652,9 +859,21 @@ watch(
652
859
  width: 0;
653
860
  }
654
861
 
862
+ /* Applied during phase 2 of the geometry pass only.
863
+ Button is invisible but at natural width so secondaryNavRects
864
+ captures its real footprint for margin-inline-end calculation.
865
+ transition:none prevents a width animation from 0→natural that
866
+ would make the mid-animation measurement inaccurate. */
867
+ &.is-measuring {
868
+ opacity: 0;
869
+ visibility: hidden;
870
+ pointer-events: none;
871
+ transition: none;
872
+ }
873
+
655
874
  .overflow-details-summary {
656
875
  --_icon-zoom: 1;
657
- --_icon-size: 20px;
876
+ --_icon-size: var(--responsive-header-overflow-btn-size, 20px);
658
877
  --_border-width: 1px;
659
878
  --_outline-width: 1px;
660
879
  --_transition-duration: 0.2s;
@@ -668,9 +887,9 @@ watch(
668
887
 
669
888
  aspect-ratio: 1;
670
889
  border-radius: 4px;
671
- border: var(--_border-width) solid #ffffff90;
672
- outline: var(--_outline-width) solid #ffffff10;
673
- background-color: Canvas;
890
+ border: var(--responsive-header-overflow-btn-border, 1px solid #ffffff90);
891
+ outline: var(--responsive-header-overflow-btn-outline, 1px solid #ffffff10);
892
+ background-color: var(--responsive-header-overflow-btn-bg, Canvas);
674
893
 
675
894
  width: var(--_icon-size);
676
895
  overflow: hidden;
@@ -686,12 +905,13 @@ watch(
686
905
  &:hover,
687
906
  &:focus-visible {
688
907
  --_icon-zoom: 1.2;
689
- outline: var(--_outline-width) solid #ffffff;
908
+ outline: var(--responsive-header-overflow-btn-hover-outline, 1px solid #ffffff);
690
909
  }
691
910
 
692
911
  .icon {
693
912
  grid-area: icon;
694
913
  scale: var(--_icon-zoom);
914
+ color: var(--responsive-header-overflow-btn-icon-color, inherit);
695
915
  transition: scale 0.2s ease-in-out;
696
916
  width: calc(var(--_icon-size) - var(--_border-width) * 2 - var(--_outline-width) * 2);
697
917
  height: calc(var(--_icon-size) - var(--_border-width) * 2 - var(--_outline-width) * 2);
@@ -711,10 +931,10 @@ watch(
711
931
  position: absolute;
712
932
  top: 135%;
713
933
  right: 0;
714
- background-color: #000;
715
- border: 1px solid #ffffff90;
716
- border-radius: 8px;
717
- padding-block: 12px;
934
+ background-color: var(--responsive-header-overflow-nav-bg, Canvas);
935
+ border: var(--responsive-header-overflow-nav-border, 1px solid #ffffff90);
936
+ border-radius: var(--responsive-header-overflow-nav-border-radius, 8px);
937
+ padding-block: var(--responsive-header-overflow-nav-padding-block, 12px);
718
938
  margin: 0;
719
939
  z-index: 999;
720
940
  min-width: var(--_overflow-drop-down-width, fit-content);
@@ -725,6 +945,90 @@ watch(
725
945
  }
726
946
  }
727
947
  }
948
+
949
+ /* Once geometry has been calculated for this mount, enable item transitions.
950
+ This prevents nav items from animating in on every route change while still
951
+ providing smooth hide/show transitions when the viewport is resized. */
952
+ &.geometry-ready {
953
+ .main-navigation-item {
954
+ transition:
955
+ opacity 0.2s ease-in-out,
956
+ visibility 0.2s ease-in-out;
957
+ }
958
+ }
959
+ }
960
+
961
+ /* ─── Anchor positioning for main-nav indicators ─────────────────────────
962
+ Single --responsive-main-nav-indicator anchor: sits on is-hovered when
963
+ something is hovered, falls back to is-active when nothing is hovered.
964
+ ──────────────────────────────────────────────────────────────────────── */
965
+
966
+ .main-navigation li.is-hovered {
967
+ anchor-name: --responsive-main-nav-indicator;
968
+ }
969
+
970
+ .main-navigation:not(:has(li.is-hovered)) li.is-active {
971
+ anchor-name: --responsive-main-nav-indicator;
972
+ }
973
+
974
+ .main-navigation .nav-indicator-hovered,
975
+ .main-navigation .nav-indicator-active {
976
+ display: none;
977
+ pointer-events: none;
978
+ }
979
+
980
+ .main-navigation {
981
+ .nav-indicator-hovered {
982
+ display: block;
983
+ position: absolute;
984
+ position-anchor: --responsive-main-nav-indicator;
985
+ left: anchor(left);
986
+ right: anchor(right);
987
+ top: anchor(top);
988
+ bottom: anchor(bottom);
989
+ background: var(--responsive-nav-decorator-hovered-bg, oklch(100% 0 0 / 8%));
990
+ border-radius: 4px;
991
+ z-index: 1;
992
+ opacity: 0;
993
+ transition:
994
+ left 200ms ease,
995
+ right 200ms ease,
996
+ opacity 150ms ease;
997
+ }
998
+
999
+ &:not(.is-animated) .nav-indicator-hovered {
1000
+ transition: none;
1001
+ }
1002
+
1003
+ &:has(li.is-hovered) .nav-indicator-hovered {
1004
+ opacity: 1;
1005
+ }
1006
+
1007
+ .nav-indicator-active {
1008
+ display: block;
1009
+ position: absolute;
1010
+ position-anchor: --responsive-main-nav-indicator;
1011
+ left: anchor(left);
1012
+ right: anchor(right);
1013
+ bottom: calc(anchor(bottom) - 2px);
1014
+ height: 2px;
1015
+ background: var(--responsive-nav-decorator-indicator-color, currentColor);
1016
+ z-index: 3;
1017
+ transition:
1018
+ left 200ms ease,
1019
+ right 200ms ease;
1020
+ }
1021
+
1022
+ &:not(.is-animated) .nav-indicator-active {
1023
+ transition: none;
1024
+ }
1025
+
1026
+ &:has(li.is-hovered) .nav-indicator-active {
1027
+ background: var(
1028
+ --responsive-nav-decorator-hovered-indicator-color,
1029
+ var(--responsive-nav-decorator-indicator-color, currentColor)
1030
+ );
1031
+ }
728
1032
  }
729
1033
  }
730
1034
  </style>