srcdev-nuxt-components 9.2.11 → 9.3.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 (73) hide show
  1. package/.claude/hooks/check-component-update.sh +66 -0
  2. package/.claude/settings.json +1 -1
  3. package/.claude/skills/component-local-style-override.md +45 -0
  4. package/.claude/skills/components/accordian-core.md +26 -5
  5. package/.claude/skills/components/expanding-panel-classic.md +221 -0
  6. package/.claude/skills/components/expanding-panel.md +8 -8
  7. package/.claude/skills/components/navigation-items.md +51 -0
  8. package/.claude/skills/components/responsive-header.md +161 -0
  9. package/.claude/skills/components/site-header.md +98 -0
  10. package/.claude/skills/index.md +6 -1
  11. package/.claude/skills/page-transitions.md +116 -0
  12. package/.claude/skills/theming-form-geometry-tokens.md +12 -0
  13. package/.claude/skills/theming-override-default.md +14 -0
  14. package/.claude/skills/theming-partial-override.md +17 -0
  15. package/.vscode/srcdev-component-accordian-core.code-snippets +74 -0
  16. package/.vscode/srcdev-component-expanding-panel-classic.code-snippets +121 -0
  17. package/.vscode/srcdev-component-expanding-panel.code-snippets +1 -3
  18. package/.vscode/srcdev-component-responsive-header.code-snippets +46 -0
  19. package/.vscode/srcdev-component-site-header.code-snippets +74 -0
  20. package/app/app.config.ts +1 -1
  21. package/app/assets/styles/setup/01.config/index.css +2 -2
  22. package/app/assets/styles/setup/02.colours/index.css +8 -8
  23. package/app/assets/styles/setup/03.theming/index.css +6 -6
  24. package/app/assets/styles/setup/04.elements/forms/index.css +2 -2
  25. package/app/assets/styles/setup/04.elements/index.css +1 -1
  26. package/app/assets/styles/setup/05.typography/01.tokens/index.css +3 -3
  27. package/app/assets/styles/setup/05.typography/02.utility-classes/index.css +6 -6
  28. package/app/assets/styles/setup/05.typography/index.css +2 -2
  29. package/app/assets/styles/setup/06.utility-classes/animations/index.css +4 -4
  30. package/app/assets/styles/setup/06.utility-classes/index.css +3 -3
  31. package/app/assets/styles/setup/06.utility-classes/spacing/index.css +3 -3
  32. package/app/assets/styles/setup/a11y/index.css +1 -1
  33. package/app/assets/styles/setup/index.css +7 -7
  34. package/app/components/01.atoms/content-wrappers/docs-pages/tests/ContentDocs.spec.ts +3 -1
  35. package/app/components/02.molecules/alert-masked-content/tests/AlertMaskedContent.spec.ts +9 -5
  36. package/app/components/02.molecules/expandable/accordian/AccordianCore.vue +19 -11
  37. package/app/components/02.molecules/expandable/accordian/CONSUMER-STYLING.md +93 -0
  38. package/app/components/02.molecules/expandable/accordian/stories/AccordianCore.stories.ts +31 -0
  39. package/app/components/02.molecules/expandable/accordian/tests/AccordianCore.spec.ts +31 -0
  40. package/app/components/02.molecules/expandable/accordian/tests/__snapshots__/AccordianCore.spec.ts.snap +7 -21
  41. package/app/components/02.molecules/expandable/expanding-panel/CONSUMER-STYLING.md +10 -10
  42. package/app/components/02.molecules/expandable/expanding-panel/ExpandingPanel.vue +35 -37
  43. package/app/components/02.molecules/expandable/expanding-panel/stories/ExpandingPanel.stories.ts +5 -6
  44. package/app/components/02.molecules/expandable/expanding-panel/tests/ExpandingPanel.spec.ts +6 -0
  45. package/app/components/02.molecules/expandable/expanding-panel/tests/__snapshots__/ExpandingPanel.spec.ts.snap +5 -13
  46. package/app/components/02.molecules/expandable/expanding-panel-classic/CONSUMER-STYLING.md +103 -0
  47. package/app/components/02.molecules/expandable/expanding-panel-classic/ExpandingPanelClassic.vue +191 -0
  48. package/app/components/02.molecules/expandable/expanding-panel-classic/stories/ExpandingPanelClassic.stories.ts +293 -0
  49. package/app/components/02.molecules/expandable/expanding-panel-classic/tests/ExpandingPanelClassic.spec.ts +514 -0
  50. package/app/components/02.molecules/expandable/expanding-panel-classic/tests/__snapshots__/ExpandingPanelClassic.spec.ts.snap +59 -0
  51. package/app/components/02.molecules/navigation/site-navigation/tests/SiteNavigation.spec.ts +9 -5
  52. package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +9 -5
  53. package/app/components/03.organisms/image-galleries/slider-gallery/tests/SliderGallery.spec.ts +5 -1
  54. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +66 -0
  55. package/app/components/{responsive-header → 03.organisms/responsive-header}/NavigationItems.vue +21 -28
  56. package/app/components/{responsive-header → 03.organisms/responsive-header}/ResponsiveHeader.vue +141 -43
  57. package/app/components/03.organisms/responsive-header/stories/NavigationItems.stories.ts +80 -0
  58. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +122 -0
  59. package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +155 -0
  60. package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +319 -0
  61. package/app/components/03.organisms/responsive-header/tests/__snapshots__/NavigationItems.spec.ts.snap +34 -0
  62. package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +72 -0
  63. package/app/components/03.organisms/site-header/CONSUMER-STYLING.md +60 -0
  64. package/app/components/03.organisms/site-header/SiteHeader.vue +96 -0
  65. package/app/components/03.organisms/site-header/stories/SiteHeader.stories.ts +135 -0
  66. package/app/components/03.organisms/site-header/tests/SiteHeader.spec.ts +103 -0
  67. package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +15 -0
  68. package/app/components/alert-mask/tests/AlertMaskCore.spec.ts +9 -5
  69. package/app/composables/tests/useContainerBreakpoints.spec.ts +27 -7
  70. package/app/composables/tests/useWhatsApp.spec.ts +10 -4
  71. package/app/layouts/default.vue +64 -67
  72. package/nuxt.config.ts +9 -0
  73. package/package.json +29 -21
@@ -0,0 +1,96 @@
1
+ <template>
2
+ <PageRow tag="div" :variant="pageRowVariant" :style-class-passthrough="styleClassPassthrough">
3
+ <template #default>
4
+ <header class="site-header">
5
+ <nav class="home-navigation" aria-label="Home Navigation">
6
+ <SkipLinks>
7
+ <template #homeLink>
8
+ <slot name="branding"></slot>
9
+ </template>
10
+ </SkipLinks>
11
+ </nav>
12
+ <ResponsiveHeader
13
+ :responsive-nav-links="responsiveNavLinks"
14
+ :gap-between-first-and-second-nav="gapBetweenFirstAndSecondNav"
15
+ :overflow-details-summary-icons="overflowDetailsSummaryIcons"
16
+ :collapse-breakpoint="collapseBreakpoint"
17
+ :collapse-at-main-nav-intersection="collapseAtMainNavIntersection"
18
+ :allow-expand-on-gesture="allowExpandOnGesture"
19
+ :style-class-passthrough="navStyleClassPassthrough"
20
+ >
21
+ <template v-if="slots.secondaryNavigation" #secondaryNavigation>
22
+ <slot name="secondaryNavigation"></slot>
23
+ </template>
24
+ </ResponsiveHeader>
25
+ </header>
26
+ </template>
27
+ </PageRow>
28
+ </template>
29
+
30
+ <script setup lang="ts">
31
+ import type { ResponsiveHeaderProp } from "../../../types/components";
32
+ import PageRow from "../../01.atoms/page-row/PageRow.vue";
33
+ import SkipLinks from "../../skip-links/SkipLinks.vue";
34
+ import ResponsiveHeader from "../responsive-header/ResponsiveHeader.vue";
35
+
36
+ interface Props {
37
+ responsiveNavLinks?: ResponsiveHeaderProp;
38
+ gapBetweenFirstAndSecondNav?: number;
39
+ overflowDetailsSummaryIcons?: Record<string, string>;
40
+ collapseBreakpoint?: number | null;
41
+ collapseAtMainNavIntersection?: boolean;
42
+ allowExpandOnGesture?: boolean;
43
+ pageRowVariant?: "full" | "popout" | "content" | "inset-content";
44
+ styleClassPassthrough?: string | string[];
45
+ navStyleClassPassthrough?: string | string[];
46
+ }
47
+
48
+ withDefaults(defineProps<Props>(), {
49
+ responsiveNavLinks: () => ({}),
50
+ gapBetweenFirstAndSecondNav: 12,
51
+ overflowDetailsSummaryIcons: () => ({
52
+ more: "gravity-ui:ellipsis",
53
+ burger: "gravity-ui:bars",
54
+ }),
55
+ collapseBreakpoint: null,
56
+ collapseAtMainNavIntersection: false,
57
+ allowExpandOnGesture: true,
58
+ pageRowVariant: "content",
59
+ styleClassPassthrough: () => [],
60
+ navStyleClassPassthrough: () => [],
61
+ });
62
+
63
+ const slots = useSlots();
64
+ </script>
65
+
66
+ <style lang="css">
67
+ @layer components {
68
+ /* ─── Public CSS tokens ─────────────────────────────────────────────────
69
+ Override on the consumer's scope class (via styleClassPassthrough).
70
+ Nested --responsive-header-* / --overflow-nav-* tokens still apply —
71
+ forward a scope class via navStyleClassPassthrough to reach them.
72
+
73
+ --site-header-gap (default: 2.4rem)
74
+ --site-header-padding-inline (default: 2.4rem)
75
+ --site-header-padding-block (default: 0px)
76
+ --site-header-bg (default: transparent)
77
+ --site-header-position (default: static)
78
+ --site-header-sticky-offset (default: 0px)
79
+ --site-header-z-index (default: 9)
80
+ ──────────────────────────────────────────────────────────────────────── */
81
+
82
+ .site-header {
83
+ display: grid;
84
+ grid-template-columns: auto 1fr;
85
+ align-items: center;
86
+ gap: var(--site-header-gap, 2.4rem);
87
+ padding-block: var(--site-header-padding-block, 0px);
88
+ padding-inline: var(--site-header-padding-inline, 2.4rem);
89
+ background-color: var(--site-header-bg, transparent);
90
+ position: var(--site-header-position, static);
91
+ top: var(--site-header-sticky-offset, 0px);
92
+ z-index: var(--site-header-z-index, 9);
93
+ width: 100%;
94
+ }
95
+ }
96
+ </style>
@@ -0,0 +1,135 @@
1
+ import SiteHeader from "../SiteHeader.vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+
4
+ const meta: Meta<typeof SiteHeader> = {
5
+ title: "Organisms/Site Header",
6
+ component: SiteHeader,
7
+ argTypes: {
8
+ responsiveNavLinks: {
9
+ control: "object",
10
+ description: "Nav groups keyed by name — each item is either a link (`path`) or a dropdown (`childLinks`)",
11
+ },
12
+ gapBetweenFirstAndSecondNav: {
13
+ control: { type: "number", min: 0, step: 4 },
14
+ description: "Gap in pixels between the first and second nav groups",
15
+ },
16
+ collapseAtMainNavIntersection: {
17
+ control: "boolean",
18
+ description: "Collapse the main nav into the overflow burger menu once it no longer fits",
19
+ },
20
+ allowExpandOnGesture: {
21
+ control: "boolean",
22
+ description: "Allow the collapsed nav to expand on touch/gesture",
23
+ },
24
+ pageRowVariant: {
25
+ control: { type: "radio" },
26
+ options: ["full", "popout", "content", "inset-content"],
27
+ description: "Variant forwarded to the root PageRow",
28
+ },
29
+ styleClassPassthrough: {
30
+ control: "object",
31
+ description: "Extra CSS classes applied to the root PageRow (targets --site-header-* tokens)",
32
+ },
33
+ navStyleClassPassthrough: {
34
+ control: "object",
35
+ description: "Extra CSS classes applied to the nested ResponsiveHeader (targets its own token API)",
36
+ },
37
+ },
38
+ args: {
39
+ pageRowVariant: "content",
40
+ gapBetweenFirstAndSecondNav: 12,
41
+ collapseAtMainNavIntersection: false,
42
+ allowExpandOnGesture: true,
43
+ styleClassPassthrough: [],
44
+ navStyleClassPassthrough: [],
45
+ },
46
+ };
47
+
48
+ export default meta;
49
+ type Story = StoryObj<typeof SiteHeader>;
50
+
51
+ export const Default: Story = {
52
+ args: {
53
+ responsiveNavLinks: {
54
+ firstNav: [
55
+ {
56
+ name: "Components",
57
+ childLinksTitle: "UI Components",
58
+ childLinks: [
59
+ { name: "Buttons", path: "/forms/examples/buttons" },
60
+ { name: "Tabs", path: "/ui/tabs" },
61
+ { name: "Carousels", path: "/ui/carousel-basic" },
62
+ ],
63
+ },
64
+ { name: "Typography", path: "/typography/page-heading" },
65
+ ],
66
+ secondNav: [{ name: "Contact", path: "#" }],
67
+ },
68
+ },
69
+ render: (args) => ({
70
+ components: { SiteHeader },
71
+ setup() {
72
+ return { args };
73
+ },
74
+ template: `
75
+ <SiteHeader v-bind="args">
76
+ <template #branding>
77
+ <a href="/" style="color:inherit;text-decoration:none;font-weight:600">Brand</a>
78
+ </template>
79
+ </SiteHeader>
80
+ `,
81
+ }),
82
+ };
83
+
84
+ /** A single flat nav group with no dropdowns — the shape a lighter, few-page site would use. */
85
+ export const SimpleFlatNav: Story = {
86
+ name: "Simple Flat Nav (no dropdowns)",
87
+ args: {
88
+ responsiveNavLinks: {
89
+ main: [
90
+ { name: "Home", path: "/" },
91
+ { name: "About", path: "/about" },
92
+ { name: "Services", path: "/services" },
93
+ { name: "Contact", path: "/contact" },
94
+ ],
95
+ },
96
+ },
97
+ render: (args) => ({
98
+ components: { SiteHeader },
99
+ setup() {
100
+ return { args };
101
+ },
102
+ template: `
103
+ <SiteHeader v-bind="args">
104
+ <template #branding>
105
+ <a href="/" style="color:inherit;text-decoration:none;font-weight:600">Brand</a>
106
+ </template>
107
+ </SiteHeader>
108
+ `,
109
+ }),
110
+ };
111
+
112
+ export const WithSecondaryNavigationSlot: Story = {
113
+ name: "With secondaryNavigation slot",
114
+ args: {
115
+ responsiveNavLinks: {
116
+ firstNav: [{ name: "Home", path: "/" }],
117
+ },
118
+ },
119
+ render: (args) => ({
120
+ components: { SiteHeader },
121
+ setup() {
122
+ return { args };
123
+ },
124
+ template: `
125
+ <SiteHeader v-bind="args">
126
+ <template #branding>
127
+ <a href="/" style="color:inherit;text-decoration:none;font-weight:600">Brand</a>
128
+ </template>
129
+ <template #secondaryNavigation>
130
+ <a href="/settings" aria-label="Settings">⚙</a>
131
+ </template>
132
+ </SiteHeader>
133
+ `,
134
+ }),
135
+ };
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import SiteHeader from "../SiteHeader.vue";
4
+ import type { ResponsiveHeaderProp } from "../../../../types/components";
5
+
6
+ const navLinks: ResponsiveHeaderProp = {
7
+ firstNav: [{ name: "Home", path: "/" }],
8
+ };
9
+
10
+ const responsiveHeaderStub = {
11
+ name: "ResponsiveHeader",
12
+ props: [
13
+ "responsiveNavLinks",
14
+ "gapBetweenFirstAndSecondNav",
15
+ "overflowDetailsSummaryIcons",
16
+ "collapseBreakpoint",
17
+ "collapseAtMainNavIntersection",
18
+ "allowExpandOnGesture",
19
+ "styleClassPassthrough",
20
+ ],
21
+ template: `<div class="responsive-header-stub"><slot name="secondaryNavigation"></slot></div>`,
22
+ };
23
+
24
+ describe("SiteHeader", () => {
25
+ it("mounts without error", async () => {
26
+ const wrapper = await mountSuspended(SiteHeader, {
27
+ global: { stubs: { ResponsiveHeader: responsiveHeaderStub } },
28
+ });
29
+ expect(wrapper.vm).toBeTruthy();
30
+ });
31
+
32
+ it("renders the branding slot inside SkipLinks' homeLink slot", async () => {
33
+ const wrapper = await mountSuspended(SiteHeader, {
34
+ slots: { branding: "<a href='/' class='brand-link'>Brand</a>" },
35
+ global: { stubs: { ResponsiveHeader: responsiveHeaderStub } },
36
+ });
37
+ expect(wrapper.find(".home-link .brand-link").exists()).toBe(true);
38
+ });
39
+
40
+ it("does not render the secondaryNavigation slot when not provided", async () => {
41
+ const wrapper = await mountSuspended(SiteHeader, {
42
+ global: { stubs: { ResponsiveHeader: responsiveHeaderStub } },
43
+ });
44
+ expect(wrapper.text()).not.toContain("Extra nav");
45
+ });
46
+
47
+ it("forwards the secondaryNavigation slot through to ResponsiveHeader", async () => {
48
+ const wrapper = await mountSuspended(SiteHeader, {
49
+ slots: { secondaryNavigation: "<span>Extra nav</span>" },
50
+ global: { stubs: { ResponsiveHeader: responsiveHeaderStub } },
51
+ });
52
+ expect(wrapper.text()).toContain("Extra nav");
53
+ });
54
+
55
+ it("forwards responsiveNavLinks and other nav props to ResponsiveHeader", async () => {
56
+ const wrapper = await mountSuspended(SiteHeader, {
57
+ props: {
58
+ responsiveNavLinks: navLinks,
59
+ gapBetweenFirstAndSecondNav: 24,
60
+ collapseAtMainNavIntersection: true,
61
+ allowExpandOnGesture: false,
62
+ navStyleClassPassthrough: ["site-header-nav"],
63
+ },
64
+ global: { stubs: { ResponsiveHeader: responsiveHeaderStub } },
65
+ });
66
+ const responsiveHeader = wrapper.findComponent(responsiveHeaderStub);
67
+ expect(responsiveHeader.props("responsiveNavLinks")).toEqual(navLinks);
68
+ expect(responsiveHeader.props("gapBetweenFirstAndSecondNav")).toBe(24);
69
+ expect(responsiveHeader.props("collapseAtMainNavIntersection")).toBe(true);
70
+ expect(responsiveHeader.props("allowExpandOnGesture")).toBe(false);
71
+ expect(responsiveHeader.props("styleClassPassthrough")).toEqual(["site-header-nav"]);
72
+ });
73
+
74
+ it("passes pageRowVariant through to the root PageRow", async () => {
75
+ const wrapper = await mountSuspended(SiteHeader, {
76
+ props: { pageRowVariant: "full" },
77
+ global: { stubs: { ResponsiveHeader: responsiveHeaderStub } },
78
+ });
79
+ expect(wrapper.find(".page-row.full").exists()).toBe(true);
80
+ });
81
+
82
+ it("defaults pageRowVariant to content", async () => {
83
+ const wrapper = await mountSuspended(SiteHeader, {
84
+ global: { stubs: { ResponsiveHeader: responsiveHeaderStub } },
85
+ });
86
+ expect(wrapper.find(".page-row.content").exists()).toBe(true);
87
+ });
88
+
89
+ it("applies styleClassPassthrough to the root PageRow", async () => {
90
+ const wrapper = await mountSuspended(SiteHeader, {
91
+ props: { styleClassPassthrough: ["header"] },
92
+ global: { stubs: { ResponsiveHeader: responsiveHeaderStub } },
93
+ });
94
+ expect(wrapper.find(".page-row.header").exists()).toBe(true);
95
+ });
96
+
97
+ it("renders correct HTML structure with default props", async () => {
98
+ const wrapper = await mountSuspended(SiteHeader, {
99
+ global: { stubs: { ResponsiveHeader: responsiveHeaderStub } },
100
+ });
101
+ expect(wrapper.html()).toMatchSnapshot();
102
+ });
103
+ });
@@ -0,0 +1,15 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`SiteHeader > renders correct HTML structure with default props 1`] = `
4
+ "<div class="page-row content">
5
+ <header class="site-header">
6
+ <nav class="home-navigation" aria-label="Home Navigation">
7
+ <div class="home-link-navigation">
8
+ <div class="home-link"></div>
9
+ <nav class="skip-links-nav" aria-label="Skip navigation"><a href="#main-content" class="skip-link">Skip to main content</a><a href="#footer-content" class="skip-link">Skip to footer</a></nav>
10
+ </div>
11
+ </nav>
12
+ <div class="responsive-header-stub"></div>
13
+ </header>
14
+ </div>"
15
+ `;
@@ -23,11 +23,15 @@ interface AlertMaskCoreInstance {
23
23
  }
24
24
 
25
25
  // Mock ResizeObserver
26
- const mockResizeObserver = vi.fn(() => ({
27
- observe: vi.fn(),
28
- unobserve: vi.fn(),
29
- disconnect: vi.fn(),
30
- }));
26
+ // Vitest 4: a mock's implementation must be a regular function (not an
27
+ // arrow function) to remain constructible via `new`.
28
+ const mockResizeObserver = vi.fn(function () {
29
+ return {
30
+ observe: vi.fn(),
31
+ unobserve: vi.fn(),
32
+ disconnect: vi.fn(),
33
+ };
34
+ });
31
35
 
32
36
  vi.stubGlobal("ResizeObserver", mockResizeObserver);
33
37
 
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import { nextTick, ref } from "vue";
3
3
  import { useContainerBreakpoints } from "../useContainerBreakpoints";
4
4
 
@@ -8,7 +8,9 @@ beforeEach(() => {
8
8
  resizeCallback = null;
9
9
  vi.stubGlobal(
10
10
  "ResizeObserver",
11
- vi.fn((callback: ResizeObserverCallback) => {
11
+ // Vitest 4: a mock's implementation must be a regular function (not an
12
+ // arrow function) to remain constructible via `new`.
13
+ vi.fn(function (callback: ResizeObserverCallback) {
12
14
  resizeCallback = callback;
13
15
  return {
14
16
  observe: vi.fn(),
@@ -19,6 +21,10 @@ beforeEach(() => {
19
21
  );
20
22
  });
21
23
 
24
+ afterEach(() => {
25
+ document.body.innerHTML = "";
26
+ });
27
+
22
28
  function triggerResize(el: HTMLElement, width: number) {
23
29
  Object.defineProperty(el, "offsetWidth", { value: width, configurable: true });
24
30
  resizeCallback?.(
@@ -27,15 +33,29 @@ function triggerResize(el: HTMLElement, width: number) {
27
33
  );
28
34
  }
29
35
 
36
+ // @vueuse/core's useElementSize reads the element's initial width in onMounted via
37
+ // `offsetWidth - computed padding/border`, ahead of any ResizeObserver callback. happy-dom's
38
+ // getComputedStyle() only resolves real "0px" values (rather than "") for padding/border on an
39
+ // element that's actually attached to the document — a detached `createElement` div keeps
40
+ // returning "", so parseFloat() turns the subtraction into NaN. Zero the box model AND attach
41
+ // the element so that initial-width read is a real, non-NaN value.
42
+ function createEl(): HTMLElement {
43
+ const el = document.createElement("div");
44
+ el.style.padding = "0px";
45
+ el.style.border = "0px";
46
+ document.body.appendChild(el);
47
+ return el;
48
+ }
49
+
30
50
  describe("useContainerBreakpoints", () => {
31
51
  it("defaults active to base when narrower than the smallest breakpoint", () => {
32
- const el = ref<HTMLElement | null>(document.createElement("div"));
52
+ const el = ref<HTMLElement | null>(createEl());
33
53
  const { active } = useContainerBreakpoints(undefined, el);
34
54
  expect(active.value).toBe("base");
35
55
  });
36
56
 
37
57
  it("updates active as the observed element's width crosses breakpoints", async () => {
38
- const el = ref<HTMLElement | null>(document.createElement("div"));
58
+ const el = ref<HTMLElement | null>(createEl());
39
59
  const { active } = useContainerBreakpoints(undefined, el);
40
60
 
41
61
  triggerResize(el.value as HTMLElement, 700);
@@ -48,7 +68,7 @@ describe("useContainerBreakpoints", () => {
48
68
  });
49
69
 
50
70
  it("greaterOrEqual/smaller reflect the current width against a named breakpoint", async () => {
51
- const el = ref<HTMLElement | null>(document.createElement("div"));
71
+ const el = ref<HTMLElement | null>(createEl());
52
72
  const { greaterOrEqual, smaller } = useContainerBreakpoints(undefined, el);
53
73
  const isLg = greaterOrEqual("lg");
54
74
  const isSmallerThanMd = smaller("md");
@@ -63,7 +83,7 @@ describe("useContainerBreakpoints", () => {
63
83
  });
64
84
 
65
85
  it("between returns true only within the [min, max) range", async () => {
66
- const el = ref<HTMLElement | null>(document.createElement("div"));
86
+ const el = ref<HTMLElement | null>(createEl());
67
87
  const { between } = useContainerBreakpoints(undefined, el);
68
88
  const isTablet = between("sm", "lg");
69
89
 
@@ -77,7 +97,7 @@ describe("useContainerBreakpoints", () => {
77
97
  });
78
98
 
79
99
  it("supports a custom breakpoints map", async () => {
80
- const el = ref<HTMLElement | null>(document.createElement("div"));
100
+ const el = ref<HTMLElement | null>(createEl());
81
101
  const { active } = useContainerBreakpoints({ narrow: 400, wide: 900 }, el);
82
102
 
83
103
  triggerResize(el.value as HTMLElement, 500);
@@ -6,8 +6,14 @@ const PHONE_NUMBER = "447700900000";
6
6
 
7
7
  // mockNuxtImport intercepts Nuxt's auto-import resolution — vi.stubGlobal cannot.
8
8
  // Use vi.hoisted so the mock function is available before module evaluation.
9
+ // Nuxt's own router plugin also calls useRuntimeConfig() during app init and
10
+ // reads `.app.baseURL` — the mock must include that shape too, or app
11
+ // initialization throws before the test body runs.
9
12
  const { useRuntimeConfigMock } = vi.hoisted(() => ({
10
- useRuntimeConfigMock: vi.fn(() => ({ public: { whatsappNumber: PHONE_NUMBER } })),
13
+ useRuntimeConfigMock: vi.fn(() => ({
14
+ public: { whatsappNumber: PHONE_NUMBER },
15
+ app: { baseURL: "/" },
16
+ })),
11
17
  }));
12
18
 
13
19
  mockNuxtImport("useRuntimeConfig", () => useRuntimeConfigMock);
@@ -17,7 +23,7 @@ describe("useWhatsApp", () => {
17
23
 
18
24
  beforeEach(() => {
19
25
  windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null);
20
- useRuntimeConfigMock.mockReturnValue({ public: { whatsappNumber: PHONE_NUMBER } });
26
+ useRuntimeConfigMock.mockReturnValue({ public: { whatsappNumber: PHONE_NUMBER }, app: { baseURL: "/" } });
21
27
  });
22
28
 
23
29
  afterEach(() => {
@@ -85,14 +91,14 @@ describe("useWhatsApp", () => {
85
91
  });
86
92
 
87
93
  it("does not call window.open when number is not configured", () => {
88
- useRuntimeConfigMock.mockReturnValue({ public: { whatsappNumber: "" } });
94
+ useRuntimeConfigMock.mockReturnValue({ public: { whatsappNumber: "" }, app: { baseURL: "/" } });
89
95
  const { openWhatsApp } = useWhatsApp();
90
96
  openWhatsApp([{ label: "Name", value: "Jane" }]);
91
97
  expect(windowOpenSpy).not.toHaveBeenCalled();
92
98
  });
93
99
 
94
100
  it("logs a warning when number is not configured", () => {
95
- useRuntimeConfigMock.mockReturnValue({ public: { whatsappNumber: "" } });
101
+ useRuntimeConfigMock.mockReturnValue({ public: { whatsappNumber: "" }, app: { baseURL: "/" } });
96
102
  const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
97
103
  const { openWhatsApp } = useWhatsApp();
98
104
  openWhatsApp([{ label: "Name", value: "Jane" }]);
@@ -1,43 +1,35 @@
1
1
  <template>
2
2
  <div class="page-layout">
3
- <PageRow tag="div" variant="full" :style-class-passthrough="['header']">
4
- <template #default>
5
- <header class="responsive-header">
6
- <nav class="home-navigation" aria-label="Home Navigation">
7
- <SkipLinks>
8
- <template #homeLink>
9
- <NuxtLink to="/" class="home-link">
10
- SRCDEV
11
- <span>components</span>
12
- </NuxtLink>
13
- </template>
14
- </SkipLinks>
15
- </nav>
16
- <ResponsiveHeader
17
- :responsive-nav-links
18
- :gap-between-first-and-second-nav="12"
19
- :style-class-passthrough="['site-header-nav']"
20
- :overflow-details-summary-icons="{
21
- more: 'gravity-ui:ellipsis',
22
- burger: 'gravity-ui:bars',
23
- }"
24
- :collapse-at-main-nav-intersection="false"
25
- :allow-expand-on-gesture="false"
26
- >
27
- <template #secondaryNavigation>
28
- <ul class="secondary-navigation-list">
29
- <li class="secondary-navigation-item">
30
- <NuxtLink class="secondary-navigation-link" to="/">
31
- <Icon name="material-symbols:settings-outline-rounded" class="icon" aria-hidden="true" />
32
- <span class="sr-only">Settings</span>
33
- </NuxtLink>
34
- </li>
35
- </ul>
36
- </template>
37
- </ResponsiveHeader>
38
- </header>
3
+ <SiteHeader
4
+ :responsive-nav-links
5
+ :gap-between-first-and-second-nav="12"
6
+ page-row-variant="full"
7
+ :style-class-passthrough="['header']"
8
+ :nav-style-class-passthrough="['site-header-nav']"
9
+ :overflow-details-summary-icons="{
10
+ more: 'gravity-ui:ellipsis',
11
+ burger: 'gravity-ui:bars',
12
+ }"
13
+ :collapse-at-main-nav-intersection="false"
14
+ :allow-expand-on-gesture="false"
15
+ >
16
+ <template #branding>
17
+ <NuxtLink to="/" class="home-link">
18
+ SRCDEV
19
+ <span>components</span>
20
+ </NuxtLink>
39
21
  </template>
40
- </PageRow>
22
+ <template #secondaryNavigation>
23
+ <ul class="secondary-navigation-list">
24
+ <li class="secondary-navigation-item">
25
+ <NuxtLink class="secondary-navigation-link" to="/">
26
+ <Icon name="material-symbols:settings-outline-rounded" class="icon" aria-hidden="true" />
27
+ <span class="sr-only">Settings</span>
28
+ </NuxtLink>
29
+ </li>
30
+ </ul>
31
+ </template>
32
+ </SiteHeader>
41
33
  <PageRow id="main-content" class="main-content" tag="main" variant="full" :is-landmark="true">
42
34
  <template #default>
43
35
  <slot name="layout-content">Page content goes here</slot>
@@ -231,39 +223,31 @@ onMounted(() => {
231
223
 
232
224
  width: 100%;
233
225
 
234
- .responsive-header {
235
- display: grid;
236
- grid-template-columns: auto 1fr;
237
- gap: 24px;
238
- align-items: center;
239
- padding-block: 0;
240
- padding-inline: 24px;
241
- background-color: #000;
226
+ /* SiteHeader token overrides — see .claude/skills/components/site-header.md */
227
+ --site-header-gap: 2.4rem;
228
+ --site-header-padding-inline: 2.4rem;
229
+ --site-header-bg: #000;
230
+ --site-header-position: relative;
231
+ --site-header-z-index: 9;
242
232
 
243
- position: relative;
244
- z-index: 9;
233
+ .home-link {
234
+ display: flex;
235
+ flex-direction: column;
236
+ text-wrap-mode: nowrap;
237
+ font-size: var(--step-5);
238
+ letter-spacing: 0.2em;
239
+ color: var(--slate-00);
240
+ text-decoration: none;
245
241
 
246
- width: 100%;
247
-
248
- .home-link {
249
- display: flex;
250
- flex-direction: column;
251
- text-wrap-mode: nowrap;
252
- font-size: var(--step-5);
253
- letter-spacing: 0.2em;
254
- color: var(--slate-00);
255
- text-decoration: none;
256
-
257
- span {
258
- font-size: var(--step-3);
259
- letter-spacing: initial;
260
- }
242
+ span {
243
+ font-size: var(--step-3);
244
+ letter-spacing: initial;
245
+ }
261
246
 
262
- &:hover {
263
- border-radius: 0.4rem;
264
- outline: 2px solid var(--green-08);
265
- outline-offset: 0.4rem;
266
- }
247
+ &:hover {
248
+ border-radius: 0.4rem;
249
+ outline: 2px solid var(--green-08);
250
+ outline-offset: 0.4rem;
267
251
  }
268
252
  }
269
253
  }
@@ -282,6 +266,19 @@ onMounted(() => {
282
266
  --responsive-header-color: var(--slate-00);
283
267
  --responsive-header-link-color: var(--slate-00);
284
268
 
269
+ /* Fixed (non-fluid) nav-link font-size — deliberately NOT var(--step-*).
270
+ A vw-based clamp() here drifts with the scrollbar (see
271
+ ResponsiveHeader.vue's token comment) without ever re-triggering the
272
+ overflow-collapse ResizeObserver, silently letting one extra item fit
273
+ that shouldn't. Values TBC after a visual pass. */
274
+ --responsive-header-link-font-size: 1.2rem;
275
+ @media (min-width: 768px) {
276
+ --responsive-header-link-font-size: 1.4rem;
277
+ }
278
+ @media (min-width: 1024px) {
279
+ --responsive-header-link-font-size: 1.5rem;
280
+ }
281
+
285
282
  --responsive-header-sub-nav-bg: #000;
286
283
  --responsive-header-sub-nav-border: 1px solid #efefef75;
287
284
 
package/nuxt.config.ts CHANGED
@@ -130,6 +130,15 @@ export default defineNuxtConfig({
130
130
  ...(isStandalone ? ["vue-qrcode-reader"] : []),
131
131
  ],
132
132
  },
133
+ // Vite 8 (pulled in by Nuxt 4.5) defaults build.cssMinify to "lightningcss" instead of
134
+ // "esbuild". lightningcss has an open bug where it drops the unprefixed `backdrop-filter`
135
+ // once autoprefixer adds `-webkit-backdrop-filter` alongside it, breaking glass/blur effects
136
+ // (see https://github.com/vitejs/vite/issues/22649) — this repo relies on backdrop-filter in
137
+ // several components (GlassPanel, SiteNavigation, TabNavigation, DisplayDialog, ColourFinder,
138
+ // MagneticNavigation). Pin back to esbuild until upstream fixes it.
139
+ build: {
140
+ cssMinify: "esbuild",
141
+ },
133
142
  },
134
143
  vue: {
135
144
  runtimeCompiler: true,