srcdev-nuxt-components 9.6.12 → 9.6.14

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 (111) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +2 -1
  2. package/.claude/rules/consumer/story-register-components.md +1 -1
  3. package/.claude/rules/consumer/vfor-keys-from-data.md +14 -0
  4. package/.claude/skills/component-compliance-checklist.md +24 -1
  5. package/.claude/skills/component-naming.md +1 -0
  6. package/.claude/skills/components/banner-video.md +24 -30
  7. package/.claude/skills/components/content-docs.md +18 -5
  8. package/.claude/skills/components/display-dialog.md +16 -6
  9. package/.claude/skills/components/display-qr-code.md +22 -4
  10. package/.claude/skills/components/{display-tooltip-defined.md → display-tooltip-rich.md} +40 -42
  11. package/.claude/skills/components/display-tooltip.md +17 -6
  12. package/.claude/skills/components/eyebrow-text.md +9 -5
  13. package/.claude/skills/components/header-block.md +13 -5
  14. package/.claude/skills/components/hero-text.md +13 -9
  15. package/.claude/skills/components/link-text.md +8 -5
  16. package/.claude/skills/components/pop-over.md +17 -3
  17. package/.claude/skills/components/skip-links.md +16 -0
  18. package/.claude/skills/composable-tooltips-guide.md +4 -4
  19. package/.claude/skills/index.md +6 -6
  20. package/.claude/skills/storybook-add-story.md +5 -2
  21. package/.claude/skills/testing-add-playwright.md +12 -7
  22. package/.claude/skills/testing-add-unit-test.md +8 -0
  23. package/.vscode/srcdev-component-banner-video.code-snippets +38 -25
  24. package/.vscode/srcdev-component-content-docs.code-snippets +69 -60
  25. package/.vscode/srcdev-component-display-dialog.code-snippets +7 -5
  26. package/.vscode/srcdev-component-display-qr-code.code-snippets +12 -1
  27. package/.vscode/srcdev-component-display-tooltip-rich.code-snippets +50 -0
  28. package/.vscode/srcdev-component-display-tooltip.code-snippets +31 -0
  29. package/.vscode/srcdev-component-hero-text.code-snippets +21 -5
  30. package/.vscode/srcdev-component-link-text.code-snippets +3 -3
  31. package/.vscode/srcdev-component-pop-over.code-snippets +28 -0
  32. package/.vscode/srcdev-component-skip-links.code-snippets +1 -1
  33. package/app/components/01.atoms/banner-video/BannerVideo.vue +51 -19
  34. package/app/components/01.atoms/banner-video/CONSUMER-STYLING.md +59 -40
  35. package/app/components/01.atoms/banner-video/stories/BannerVideo.stories.ts +88 -1
  36. package/app/components/01.atoms/banner-video/tests/BannerVideo.spec.ts +127 -78
  37. package/app/components/01.atoms/banner-video/tests/__snapshots__/BannerVideo.spec.ts.snap +12 -4
  38. package/app/components/01.atoms/content-wrappers/docs-pages/CONSUMER-STYLING.md +42 -14
  39. package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +86 -112
  40. package/app/components/01.atoms/content-wrappers/docs-pages/stories/ContentDocs.stories.ts +134 -8
  41. package/app/components/01.atoms/content-wrappers/docs-pages/tests/ContentDocs.spec.ts +131 -65
  42. package/app/components/01.atoms/display-dialog/CONSUMER-STYLING.md +28 -3
  43. package/app/components/01.atoms/display-dialog/DisplayDialog.vue +39 -31
  44. package/app/components/01.atoms/display-dialog/stories/DisplayDialog.stories.ts +92 -1
  45. package/app/components/01.atoms/display-dialog/tests/DisplayDialog.spec.ts +87 -41
  46. package/app/components/01.atoms/display-dialog/tests/__snapshots__/DisplayDialog.spec.ts.snap +5 -1
  47. package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +70 -24
  48. package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +24 -12
  49. package/app/components/01.atoms/display-tooltip/stories/DisplayTooltip.stories.ts +89 -2
  50. package/app/components/01.atoms/display-tooltip/tests/DisplayTooltip.spec.ts +65 -18
  51. package/app/components/01.atoms/display-tooltip/tests/__snapshots__/DisplayTooltip.spec.ts.snap +3 -3
  52. package/app/components/01.atoms/navigation/skip-links/CONSUMER-STYLING.md +65 -19
  53. package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +20 -14
  54. package/app/components/01.atoms/navigation/skip-links/stories/SkipLinks.stories.ts +89 -20
  55. package/app/components/01.atoms/navigation/skip-links/tests/SkipLinks.spec.ts +70 -10
  56. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +42 -9
  57. package/app/components/01.atoms/pop-over/PopOver.vue +28 -12
  58. package/app/components/01.atoms/pop-over/stories/PopOver.stories.ts +114 -2
  59. package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +35 -0
  60. package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +5 -1
  61. package/app/components/01.atoms/qr-code/CONSUMER-STYLING.md +16 -3
  62. package/app/components/01.atoms/qr-code/DisplayQrCode.vue +31 -0
  63. package/app/components/01.atoms/qr-code/stories/DisplayQrCode.stories.ts +190 -157
  64. package/app/components/01.atoms/qr-code/tests/DisplayQrCode.spec.ts +58 -0
  65. package/app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md +9 -1
  66. package/app/components/01.atoms/text-blocks/eyebrow-text/EyebrowText.vue +5 -4
  67. package/app/components/01.atoms/text-blocks/eyebrow-text/playwright/eyebrow-text.playwright.ts +1 -1
  68. package/app/components/01.atoms/text-blocks/eyebrow-text/stories/EyebrowText.stories.ts +104 -17
  69. package/app/components/01.atoms/text-blocks/eyebrow-text/tests/EyebrowText.spec.ts +68 -118
  70. package/app/components/01.atoms/text-blocks/eyebrow-text/tests/__snapshots__/EyebrowText.spec.ts.snap +8 -8
  71. package/app/components/01.atoms/text-blocks/header-block/CONSUMER-STYLING.md +49 -24
  72. package/app/components/01.atoms/text-blocks/header-block/HeaderBlock.vue +9 -1
  73. package/app/components/01.atoms/text-blocks/header-block/stories/HeaderBlock.stories.ts +96 -26
  74. package/app/components/01.atoms/text-blocks/header-block/tests/HeaderBlock.spec.ts +64 -48
  75. package/app/components/01.atoms/text-blocks/header-block/tests/__snapshots__/HeaderBlock.spec.ts.snap +2 -2
  76. package/app/components/01.atoms/text-blocks/hero-text/CONSUMER-STYLING.md +22 -6
  77. package/app/components/01.atoms/text-blocks/hero-text/HeroText.vue +32 -23
  78. package/app/components/01.atoms/text-blocks/hero-text/playwright/hero-text.playwright.ts +1 -1
  79. package/app/components/01.atoms/text-blocks/hero-text/stories/HeroText.stories.ts +176 -23
  80. package/app/components/01.atoms/text-blocks/hero-text/tests/HeroText.spec.ts +129 -123
  81. package/app/components/01.atoms/text-blocks/hero-text/tests/__snapshots__/HeroText.spec.ts.snap +2 -2
  82. package/app/components/01.atoms/text-blocks/link-text/CONSUMER-STYLING.md +17 -0
  83. package/app/components/01.atoms/text-blocks/link-text/LinkText.vue +9 -3
  84. package/app/components/01.atoms/text-blocks/link-text/stories/LinkText.stories.ts +96 -99
  85. package/app/components/01.atoms/text-blocks/link-text/tests/LinkText.spec.ts +84 -93
  86. package/app/components/02.molecules/display-tooltip-rich/CONSUMER-STYLING.md +110 -0
  87. package/app/components/02.molecules/display-tooltip-rich/DisplayTooltipRich.vue +148 -0
  88. package/app/components/02.molecules/display-tooltip-rich/stories/DisplayTooltipRich.stories.ts +254 -0
  89. package/app/components/02.molecules/display-tooltip-rich/tests/DisplayTooltipRich.spec.ts +156 -0
  90. package/app/components/02.molecules/display-tooltip-rich/tests/__snapshots__/DisplayTooltipRich.spec.ts.snap +22 -0
  91. package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +1 -0
  92. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +1 -1
  93. package/app/components/02.molecules/price-list/tests/__snapshots__/PriceList.spec.ts.snap +8 -8
  94. package/app/components/03.organisms/image-galleries/carousel-flip/CarouselFlip.vue +1 -0
  95. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +15 -15
  96. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +3 -3
  97. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +3 -3
  98. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +3 -3
  99. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +1 -1
  100. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +9 -9
  101. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +9 -9
  102. package/app/composables/tests/useTooltips.spec.ts +3 -3
  103. package/app/types/components/index.ts +1 -1
  104. package/package.json +1 -1
  105. package/.vscode/srcdev-component-display-tooltip-defined.code-snippets +0 -29
  106. package/app/components/02.molecules/display-tooltip-defined/CONSUMER-STYLING.md +0 -53
  107. package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +0 -117
  108. package/app/components/02.molecules/display-tooltip-defined/stories/DisplayTooltipDefined.stories.ts +0 -129
  109. package/app/components/02.molecules/display-tooltip-defined/tests/DisplayTooltipDefined.spec.ts +0 -85
  110. package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +0 -17
  111. /package/app/types/components/{display-tooltip-defined.d.ts → display-tooltip-rich.d.ts} +0 -0
@@ -8,42 +8,129 @@ const meta: Meta<typeof EyebrowText> = {
8
8
  tag: {
9
9
  control: { type: "select" },
10
10
  options: ["p", "div", "span"],
11
- description: "HTML tag for the text element",
12
- defaultValue: "div",
11
+ description: "HTML tag for the text element. Use `span` inside a line of text.",
12
+ table: { category: "Basic" },
13
13
  },
14
14
  fontSize: {
15
15
  control: { type: "select" },
16
16
  options: ["large", "medium", "small"],
17
- description: "Font size",
18
- defaultValue: "medium",
17
+ description: "Size step, set as `data-font-size` and sized by `--eyebrow-text-large/medium/small`.",
18
+ table: { category: "Basic" },
19
19
  },
20
20
  textContent: {
21
- control: "object",
22
- description: "Text content for the eyebrow text element",
23
- defaultValue: "Designing Artistry at Home",
21
+ control: { type: "text" },
22
+ description: "The label text. Pass it in normal case: the component uppercases it.",
23
+ table: { category: "Content" },
24
24
  },
25
- styleClassPassthrough: {
26
- control: "object",
27
- description: "Additional classes",
28
- defaultValue: [],
25
+ styleClassPassthrough: { table: { disable: true } },
26
+ },
27
+ args: {
28
+ tag: "div",
29
+ fontSize: "medium",
30
+ textContent: "Designing Artistry at Home",
31
+ },
32
+ parameters: {
33
+ docs: {
34
+ description: {
35
+ component:
36
+ "A short uppercase label above a heading, filled with the `--eyebrow-text-bg-img` gradient. `textContent` is the label, `fontSize` picks one of three size steps and `tag` the element. Rendered inside `ServicesCard`, `ServiceSummary` and `ServiceDetail`; `ProfileSection` restyles one placed in its slot.",
37
+ },
29
38
  },
30
39
  },
31
40
  };
32
41
 
33
42
  export default meta;
34
43
  type Story = StoryObj<typeof EyebrowText>;
44
+
45
+ // Links in the canvas open in the Storybook manager (target="_top"), not inside the preview iframe.
46
+ const storyNoteStyle = "margin: 0 0 1.6rem; font-size: 1.4rem;";
47
+ const parentNote = `
48
+ <p style="${storyNoteStyle}">
49
+ In use: the subtitle above the title in
50
+ <a href="/?path=/story/organisms-services-services-card--config-overrides" target="_top">ServicesCard</a>
51
+ (size and tag set through <code>eyebrowConfig</code>),
52
+ <a href="/?path=/story/organisms-services-service-summary--default" target="_top">ServiceSummary</a> and
53
+ <a href="/?path=/story/organisms-services-service-detail--default" target="_top">ServiceDetail</a>.
54
+ </p>
55
+ `;
56
+ const parentDocs =
57
+ "In use: the subtitle in [ServicesCard](?path=/story/organisms-services-services-card--config-overrides) (sized through `eyebrowConfig`), [ServiceSummary](?path=/story/organisms-services-service-summary--default) and [ServiceDetail](?path=/story/organisms-services-service-detail--default).";
58
+
35
59
  export const Default: Story = {
36
- args: {
37
- tag: "div",
38
- fontSize: "medium",
39
- textContent: "Designing Artistry at Home",
40
- styleClassPassthrough: [],
60
+ render: (args) => ({
61
+ components: { EyebrowText },
62
+ setup() {
63
+ return { args };
64
+ },
65
+ template: `<div>${parentNote}<EyebrowText v-bind="args" /></div>`,
66
+ }),
67
+ parameters: {
68
+ docs: {
69
+ description: {
70
+ story: `Default \`div\` at the \`medium\` size. Use the Controls panel to change the tag, size and text. ${parentDocs}`,
71
+ },
72
+ },
41
73
  },
74
+ };
75
+
76
+ export const Sizes: Story = {
42
77
  render: (args) => ({
43
78
  components: { EyebrowText },
44
79
  setup() {
45
80
  return { args };
46
81
  },
47
- template: `<EyebrowText v-bind="args" />`,
82
+ template: `
83
+ ${parentNote}
84
+ <div style="display: grid; gap: 1.2rem;">
85
+ <EyebrowText v-bind="args" font-size="large" />
86
+ <EyebrowText v-bind="args" font-size="medium" />
87
+ <EyebrowText v-bind="args" font-size="small" />
88
+ </div>
89
+ `,
48
90
  }),
91
+ parameters: {
92
+ docs: {
93
+ description: {
94
+ story: `The three \`fontSize\` steps, largest first. ${parentDocs}`,
95
+ },
96
+ },
97
+ },
98
+ };
99
+
100
+ export const StressTest: Story = {
101
+ name: "Stress Test (Worst-Case Data)",
102
+ render: () => ({
103
+ components: { EyebrowText },
104
+ template: `
105
+ <div style="display: grid; gap: 2.4rem; padding: 2.4rem; font-size: 1.4rem;">
106
+ <p style="margin: 0;">Long German copy and an unbroken string in an 18rem column.</p>
107
+ <div style="display: grid; gap: 1.2rem; inline-size: 18rem; outline: 0.1rem dashed currentColor;">
108
+ <EyebrowText text-content="Kostenlose Lieferung für alle Bestellungen über einem Mindestbestellwert" font-size="large" />
109
+ <EyebrowText text-content="Kostenloselieferungfürallebestellungenübereinemmindestbestellwert" />
110
+ <EyebrowText text-content="https://example.invalid/a/very/long/unbroken/url/with/no/break/opportunity" font-size="small" />
111
+ </div>
112
+
113
+ <p style="margin: 0;">Emoji first, HTML-like text (must show as text), right-to-left text, a single character and an empty string (renders nothing visible).</p>
114
+ <div style="display: grid; gap: 1.2rem;">
115
+ <EyebrowText text-content="😀 Emoji at the start" />
116
+ <EyebrowText text-content="<b>not bold</b>" />
117
+ <EyebrowText text-content="التوصيل المجاني" dir="rtl" />
118
+ <EyebrowText text-content="a" />
119
+ <EyebrowText text-content="" />
120
+ </div>
121
+
122
+ <p style="margin: 0;">Inline <code>span</code> inside a sentence: the italic overhang of the last letter should not be cut off.</p>
123
+ <p style="margin: 0;">Before <EyebrowText tag="span" text-content="Inline eyebrow WIDTH" font-size="large" /> after.</p>
124
+ </div>
125
+ `,
126
+ }),
127
+ parameters: {
128
+ controls: { disable: true },
129
+ docs: {
130
+ description: {
131
+ story:
132
+ "Worst-case text. Check: long copy and unbroken strings wrap inside the dashed column; emoji and right-to-left text keep the gradient fill; HTML-like text shows its tags as text; an empty string adds no visible box; the inline span sits on the line and its last italic letter is fully painted.",
133
+ },
134
+ },
135
+ },
49
136
  };
@@ -1,52 +1,55 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
2
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
3
  import EyebrowText from "../EyebrowText.vue";
4
4
 
5
- describe("EyebrowText", () => {
6
- // ─── Mount ───────────────────────────────────────────────────────────────
5
+ type EyebrowProps = InstanceType<typeof EyebrowText>["$props"];
6
+
7
+ const mounted: Awaited<ReturnType<typeof mountSuspended>>[] = [];
8
+
9
+ const mountEyebrow = async (props: EyebrowProps) => {
10
+ const wrapper = await mountSuspended(EyebrowText, { props });
11
+ mounted.push(wrapper);
12
+ return wrapper;
13
+ };
14
+
15
+ afterEach(() => {
16
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
17
+ vi.restoreAllMocks();
18
+ });
7
19
 
20
+ describe("EyebrowText", () => {
8
21
  it("mounts without error", async () => {
9
- const wrapper = await mountSuspended(EyebrowText, {
10
- props: { textContent: "Hello World" },
11
- });
22
+ const wrapper = await mountEyebrow({ textContent: "Hello World" });
12
23
  expect(wrapper.vm).toBeTruthy();
13
24
  });
14
25
 
15
26
  // ─── Snapshots ───────────────────────────────────────────────────────────
16
27
 
17
28
  it("renders correct HTML structure with default props", async () => {
18
- const wrapper = await mountSuspended(EyebrowText, {
19
- props: { textContent: "Featured Collection" },
20
- });
29
+ const wrapper = await mountEyebrow({ textContent: "Featured Collection" });
21
30
  expect(wrapper.html()).toMatchSnapshot();
22
31
  });
23
32
 
24
33
  it("renders correct HTML structure with all props set", async () => {
25
- const wrapper = await mountSuspended(EyebrowText, {
26
- props: {
27
- tag: "p",
28
- fontSize: "large",
29
- textContent: "New Arrivals",
30
- styleClassPassthrough: ["custom-class"],
31
- },
34
+ const wrapper = await mountEyebrow({
35
+ tag: "p",
36
+ fontSize: "large",
37
+ textContent: "New Arrivals",
38
+ styleClassPassthrough: ["custom-class"],
32
39
  });
33
40
  expect(wrapper.html()).toMatchSnapshot();
34
41
  });
35
42
 
36
43
  it("renders correct HTML structure for each tag variant", async () => {
37
44
  for (const tag of ["p", "div", "span"] as const) {
38
- const wrapper = await mountSuspended(EyebrowText, {
39
- props: { tag, textContent: "Snapshot text" },
40
- });
45
+ const wrapper = await mountEyebrow({ tag, textContent: "Snapshot text" });
41
46
  expect(wrapper.html()).toMatchSnapshot();
42
47
  }
43
48
  });
44
49
 
45
50
  it("renders correct HTML structure for each fontSize variant", async () => {
46
51
  for (const fontSize of ["large", "medium", "small"] as const) {
47
- const wrapper = await mountSuspended(EyebrowText, {
48
- props: { fontSize, textContent: "Snapshot text" },
49
- });
52
+ const wrapper = await mountEyebrow({ fontSize, textContent: "Snapshot text" });
50
53
  expect(wrapper.html()).toMatchSnapshot();
51
54
  }
52
55
  });
@@ -54,154 +57,101 @@ describe("EyebrowText", () => {
54
57
  // ─── Tag rendering ───────────────────────────────────────────────────────
55
58
 
56
59
  it("renders as <div> by default", async () => {
57
- const wrapper = await mountSuspended(EyebrowText, {
58
- props: { textContent: "Default tag" },
59
- });
60
+ const wrapper = await mountEyebrow({ textContent: "Default tag" });
60
61
  expect(wrapper.element.tagName).toBe("DIV");
61
62
  });
62
63
 
63
- it("renders as <p> when tag='p'", async () => {
64
- const wrapper = await mountSuspended(EyebrowText, {
65
- props: { tag: "p", textContent: "Paragraph tag" },
66
- });
67
- expect(wrapper.element.tagName).toBe("P");
68
- });
69
-
70
- it("renders as <span> when tag='span'", async () => {
71
- const wrapper = await mountSuspended(EyebrowText, {
72
- props: { tag: "span", textContent: "Span tag" },
73
- });
74
- expect(wrapper.element.tagName).toBe("SPAN");
75
- });
76
-
77
- it("renders as <div> when tag='div'", async () => {
78
- const wrapper = await mountSuspended(EyebrowText, {
79
- props: { tag: "div", textContent: "Div tag" },
80
- });
81
- expect(wrapper.element.tagName).toBe("DIV");
64
+ it.each([
65
+ ["p", "P"],
66
+ ["span", "SPAN"],
67
+ ["div", "DIV"],
68
+ ] as const)("renders as <%s> when tag='%s'", async (tag, tagName) => {
69
+ const wrapper = await mountEyebrow({ tag, textContent: "Tag" });
70
+ expect(wrapper.element.tagName).toBe(tagName);
82
71
  });
83
72
 
84
- // ─── Base class ──────────────────────────────────────────────────────────
85
-
86
73
  it("always has the eyebrow-text class", async () => {
87
- const wrapper = await mountSuspended(EyebrowText, {
88
- props: { textContent: "Base class check" },
89
- });
74
+ const wrapper = await mountEyebrow({ textContent: "Base class check" });
90
75
  expect(wrapper.classes()).toContain("eyebrow-text");
91
76
  });
92
77
 
93
- // ─── fontSize class ──────────────────────────────────────────────────────
78
+ // ─── fontSize ────────────────────────────────────────────────────────────
94
79
 
95
- it("applies the medium class by default", async () => {
96
- const wrapper = await mountSuspended(EyebrowText, {
97
- props: { textContent: "Default fontSize" },
98
- });
99
- expect(wrapper.classes()).toContain("medium");
80
+ it("sets data-font-size='medium' by default", async () => {
81
+ const wrapper = await mountEyebrow({ textContent: "Default fontSize" });
82
+ expect(wrapper.attributes("data-font-size")).toBe("medium");
100
83
  });
101
84
 
102
- it("applies the large class when fontSize='large'", async () => {
103
- const wrapper = await mountSuspended(EyebrowText, {
104
- props: { fontSize: "large", textContent: "Large text" },
105
- });
106
- expect(wrapper.classes()).toContain("large");
85
+ it.each(["large", "medium", "small"] as const)("sets data-font-size='%s'", async (fontSize) => {
86
+ const wrapper = await mountEyebrow({ fontSize, textContent: "Sized" });
87
+ expect(wrapper.attributes("data-font-size")).toBe(fontSize);
107
88
  });
108
89
 
109
- it("applies the small class when fontSize='small'", async () => {
110
- const wrapper = await mountSuspended(EyebrowText, {
111
- props: { fontSize: "small", textContent: "Small text" },
112
- });
113
- expect(wrapper.classes()).toContain("small");
114
- });
115
-
116
- it("applies only one fontSize class at a time", async () => {
117
- const fontSizes = ["large", "medium", "small"] as const;
118
- for (const fontSize of fontSizes) {
119
- const wrapper = await mountSuspended(EyebrowText, {
120
- props: { fontSize, textContent: "One class only" },
121
- });
122
- const otherSizes = fontSizes.filter((s) => s !== fontSize);
123
- otherSizes.forEach((size) => {
124
- expect(wrapper.classes()).not.toContain(size);
125
- });
126
- }
90
+ it("does not put the size on the element as a bare class", async () => {
91
+ const wrapper = await mountEyebrow({ fontSize: "small", textContent: "No bare class" });
92
+ expect(wrapper.classes()).toEqual(["eyebrow-text"]);
127
93
  });
128
94
 
129
95
  // ─── textContent ─────────────────────────────────────────────────────────
130
96
 
131
97
  it("renders the textContent string", async () => {
132
- const wrapper = await mountSuspended(EyebrowText, {
133
- props: { textContent: "New Arrivals" },
134
- });
98
+ const wrapper = await mountEyebrow({ textContent: "New Arrivals" });
135
99
  expect(wrapper.text()).toBe("New Arrivals");
136
100
  });
137
101
 
138
102
  it("renders an empty string without error", async () => {
139
- const wrapper = await mountSuspended(EyebrowText, {
140
- props: { textContent: "" },
141
- });
103
+ const wrapper = await mountEyebrow({ textContent: "" });
142
104
  expect(wrapper.text()).toBe("");
143
105
  });
144
106
 
145
107
  it("renders textContent with special characters correctly", async () => {
146
- const wrapper = await mountSuspended(EyebrowText, {
147
- props: { textContent: "Édition Limitée & Co." },
148
- });
108
+ const wrapper = await mountEyebrow({ textContent: "Édition Limitée & Co." });
149
109
  expect(wrapper.text()).toBe("Édition Limitée & Co.");
150
110
  });
151
111
 
112
+ it("renders HTML-like textContent as text", async () => {
113
+ const wrapper = await mountEyebrow({ textContent: "<b>not bold</b>" });
114
+ expect(wrapper.find("b").exists()).toBe(false);
115
+ expect(wrapper.text()).toBe("<b>not bold</b>");
116
+ });
117
+
152
118
  // ─── styleClassPassthrough ───────────────────────────────────────────────
153
119
 
154
120
  it("applies a styleClassPassthrough array", async () => {
155
- const wrapper = await mountSuspended(EyebrowText, {
156
- props: {
157
- textContent: "Styled",
158
- styleClassPassthrough: ["class-a", "class-b"],
159
- },
160
- });
121
+ const wrapper = await mountEyebrow({ textContent: "Styled", styleClassPassthrough: ["class-a", "class-b"] });
161
122
  expect(wrapper.classes()).toContain("class-a");
162
123
  expect(wrapper.classes()).toContain("class-b");
163
124
  });
164
125
 
165
126
  it("applies a single styleClassPassthrough string", async () => {
166
- const wrapper = await mountSuspended(EyebrowText, {
167
- props: { textContent: "Styled", styleClassPassthrough: "solo-class" },
168
- });
127
+ const wrapper = await mountEyebrow({ textContent: "Styled", styleClassPassthrough: "solo-class" });
169
128
  expect(wrapper.classes()).toContain("solo-class");
170
129
  });
171
130
 
172
- it("renders cleanly with no styleClassPassthrough prop", async () => {
173
- const wrapper = await mountSuspended(EyebrowText, {
174
- props: { textContent: "No passthrough" },
175
- });
176
- expect(wrapper.classes()).toContain("eyebrow-text");
177
- });
178
-
179
- // ─── Combined props ───────────────────────────────────────────────────────
180
-
181
131
  it("renders correctly with all props combined", async () => {
182
- const wrapper = await mountSuspended(EyebrowText, {
183
- props: {
184
- tag: "span",
185
- fontSize: "small",
186
- textContent: "Limited Edition",
187
- styleClassPassthrough: ["promo-label"],
188
- },
132
+ const wrapper = await mountEyebrow({
133
+ tag: "span",
134
+ fontSize: "small",
135
+ textContent: "Limited Edition",
136
+ styleClassPassthrough: ["promo-label"],
189
137
  });
190
138
  expect(wrapper.element.tagName).toBe("SPAN");
191
139
  expect(wrapper.classes()).toContain("eyebrow-text");
192
- // expect(wrapper.classes()).toContain("small");
140
+ expect(wrapper.attributes("data-font-size")).toBe("small");
193
141
  expect(wrapper.classes()).toContain("promo-label");
194
142
  expect(wrapper.text()).toBe("Limited Edition");
195
143
  });
196
- });
197
144
 
198
- describe("EyebrowText styleClassPassthrough reactivity", () => {
199
145
  it("updates classes when styleClassPassthrough changes after mount", async () => {
200
- const wrapper = await mountSuspended(EyebrowText, {
201
- props: { textContent: "Eyebrow", styleClassPassthrough: ["original"] },
202
- });
146
+ const wrapper = await mountEyebrow({ textContent: "Eyebrow", styleClassPassthrough: ["original"] });
203
147
  await wrapper.setProps({ styleClassPassthrough: ["updated"] });
204
148
  expect(wrapper.classes()).not.toContain("original");
205
149
  expect(wrapper.classes()).toContain("updated");
206
150
  });
151
+
152
+ it("updates data-font-size when fontSize changes after mount", async () => {
153
+ const wrapper = await mountEyebrow({ textContent: "Eyebrow", fontSize: "small" });
154
+ await wrapper.setProps({ fontSize: "large" });
155
+ expect(wrapper.attributes("data-font-size")).toBe("large");
156
+ });
207
157
  });
@@ -1,17 +1,17 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
- exports[`EyebrowText > renders correct HTML structure for each fontSize variant 1`] = `"<div class="eyebrow-text large">Snapshot text</div>"`;
3
+ exports[`EyebrowText > renders correct HTML structure for each fontSize variant 1`] = `"<div class="eyebrow-text" data-font-size="large">Snapshot text</div>"`;
4
4
 
5
- exports[`EyebrowText > renders correct HTML structure for each fontSize variant 2`] = `"<div class="eyebrow-text medium">Snapshot text</div>"`;
5
+ exports[`EyebrowText > renders correct HTML structure for each fontSize variant 2`] = `"<div class="eyebrow-text" data-font-size="medium">Snapshot text</div>"`;
6
6
 
7
- exports[`EyebrowText > renders correct HTML structure for each fontSize variant 3`] = `"<div class="eyebrow-text small">Snapshot text</div>"`;
7
+ exports[`EyebrowText > renders correct HTML structure for each fontSize variant 3`] = `"<div class="eyebrow-text" data-font-size="small">Snapshot text</div>"`;
8
8
 
9
- exports[`EyebrowText > renders correct HTML structure for each tag variant 1`] = `"<p class="eyebrow-text medium">Snapshot text</p>"`;
9
+ exports[`EyebrowText > renders correct HTML structure for each tag variant 1`] = `"<p class="eyebrow-text" data-font-size="medium">Snapshot text</p>"`;
10
10
 
11
- exports[`EyebrowText > renders correct HTML structure for each tag variant 2`] = `"<div class="eyebrow-text medium">Snapshot text</div>"`;
11
+ exports[`EyebrowText > renders correct HTML structure for each tag variant 2`] = `"<div class="eyebrow-text" data-font-size="medium">Snapshot text</div>"`;
12
12
 
13
- exports[`EyebrowText > renders correct HTML structure for each tag variant 3`] = `"<span class="eyebrow-text medium">Snapshot text</span>"`;
13
+ exports[`EyebrowText > renders correct HTML structure for each tag variant 3`] = `"<span class="eyebrow-text" data-font-size="medium">Snapshot text</span>"`;
14
14
 
15
- exports[`EyebrowText > renders correct HTML structure with all props set 1`] = `"<p class="eyebrow-text custom-class large">New Arrivals</p>"`;
15
+ exports[`EyebrowText > renders correct HTML structure with all props set 1`] = `"<p class="eyebrow-text custom-class" data-font-size="large">New Arrivals</p>"`;
16
16
 
17
- exports[`EyebrowText > renders correct HTML structure with default props 1`] = `"<div class="eyebrow-text medium">Featured Collection</div>"`;
17
+ exports[`EyebrowText > renders correct HTML structure with default props 1`] = `"<div class="eyebrow-text" data-font-size="medium">Featured Collection</div>"`;
@@ -1,25 +1,27 @@
1
1
  # HeaderBlock — Consumer Styling Guide
2
2
 
3
- ## No component-owned CSS tokens
3
+ ## Public token API
4
4
 
5
- `HeaderBlock` has no `<style>` block of its own. Visual size comes from applying one of six global
6
- `.page-heading-1`–`.page-heading-6` utility classes (`classLevel` prop), defined in
7
- `app/assets/styles/setup/05.typography/02.utility-classes/_font-classes-page-heading.css`:
5
+ `HeaderBlock` has no tokens of its own, public or private (`--_`). Its own style block only sets
6
+ `overflow-wrap: anywhere` on `.header-block`, so an unbroken word or URL wraps instead of
7
+ overflowing its column.
8
8
 
9
- ```css
10
- .page-heading-1 { font-size: var(--step-8); }
11
- .page-heading-2 { font-size: var(--step-7); }
12
- /* … down to .page-heading-6 { font-size: var(--step-3); } */
13
- ```
9
+ Visual size comes from one of six global `.page-heading-1`–`.page-heading-6` utility classes (the
10
+ `classLevel` prop), defined in
11
+ `app/assets/styles/setup/05.typography/02.utility-classes/_font-classes-page-heading.css`:
14
12
 
15
- Each size is a fluid type-scale token (`--step-3` through `--step-8`, defined globally, not by
16
- this component) — override those tokens at a page/section scope to change every `page-heading-*`
17
- element's size at once, not just this component's.
13
+ | Class | `font-size` |
14
+ |---|---|
15
+ | `.page-heading-1` | `var(--step-8)` |
16
+ | `.page-heading-2` | `var(--step-7)` |
17
+ | `.page-heading-3` | `var(--step-6)` |
18
+ | `.page-heading-4` | `var(--step-5)` |
19
+ | `.page-heading-5` | `var(--step-4)` |
20
+ | `.page-heading-6` | `var(--step-3)` |
18
21
 
19
- All six levels also reset `margin-block: 0rem` and `padding-block: 0rem` (instead of relying on the
20
- browser's own per-level heading margins, which vary by `<h1>`–`<h6>` and would otherwise fight
21
- `classLevel`/`tagLevel` being independent). This is a deliberate blank slate — add spacing in your
22
- own CSS rather than expecting a level to come with baked-in margin:
22
+ All six also set `font-weight: 600`, `line-height: 1`, and reset `margin-block` and `padding-block`
23
+ to `0rem` instead of relying on the browser's per-level heading margins, which would fight
24
+ `classLevel` and `tagLevel` being independent. Add spacing in your own CSS:
23
25
 
24
26
  ```css
25
27
  .my-page .page-heading-1 {
@@ -29,13 +31,27 @@ own CSS rather than expecting a level to come with baked-in margin:
29
31
 
30
32
  ---
31
33
 
34
+ ## State hooks
35
+
36
+ No state attributes. Classes on the root `<h1>`–`<h6>`:
37
+
38
+ | Class | Meaning |
39
+ |---|---|
40
+ | `.header-block` | Always present |
41
+ | `.page-heading-{1-6}` | The `classLevel` prop (out-of-range values fall back to `1`) |
42
+
43
+ > Changed 2026-10-19: `.header-block` is new. Before, the root carried only the
44
+ > `.page-heading-*` class and passthrough classes.
45
+
46
+ ---
47
+
32
48
  ## Local overrides
33
49
 
34
- There are no public tokens, so override the classes above with direct properties, scoped to an
35
- element you own (a page or section class, or a class added with `:style-class-passthrough`). Keep
36
- the block **unlayered** (no `@layer` wrapper) so it beats the library's `@layer components`. If your
37
- own file uses `<style scoped>`, selectors that reach inside the component need `:deep()`. Patterns
38
- and examples: `.claude/skills/component-local-style-override.md`.
50
+ There are no public tokens, so override with direct properties, scoped to an element you own (a
51
+ page or section class, or a class added with `:style-class-passthrough`). Keep the block
52
+ **unlayered** (no `@layer` wrapper) so it beats the library's `@layer components`. If your own file
53
+ uses `<style scoped>`, selectors that reach inside the component need `:deep()`. Patterns and
54
+ examples: `.claude/skills/component-local-style-override.md`.
39
55
 
40
56
  **Caveat:** size comes from the global `--step-*` type-scale tokens behind `.page-heading-*`.
41
57
  Overriding a `--step-*` token on your own element changes every `page-heading-*` inside it, not just
@@ -43,9 +59,6 @@ this component's.
43
59
 
44
60
  ### One instance
45
61
 
46
- Use `styleClassPassthrough` to add your own class and override `font-size`/`font-weight`/etc. for
47
- a single `HeaderBlock` instance without touching the shared `.page-heading-*` utility classes:
48
-
49
62
  ```vue
50
63
  <HeaderBlock :tag-level="2" :class-level="1" style-class-passthrough="hero-title">Big idea</HeaderBlock>
51
64
  ```
@@ -56,3 +69,15 @@ a single `HeaderBlock` instance without touching the shared `.page-heading-*` ut
56
69
  }
57
70
  ```
58
71
 
72
+ ---
73
+
74
+ ## Class passthrough
75
+
76
+ `style-class-passthrough` adds classes to the root heading element. Reactive after mount.
77
+
78
+ ---
79
+
80
+ ## Notes
81
+
82
+ - Text arrives through the default slot, so there's no line-clamp token: clamp it in your own
83
+ markup if needed.
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <component :is="tag" :id :class="[classLevelClass, elementClasses]">
2
+ <component :is="tag" :id class="header-block" :class="[classLevelClass, elementClasses]">
3
3
  <slot></slot>
4
4
  </component>
5
5
  </template>
@@ -48,3 +48,11 @@ watch(
48
48
  }
49
49
  );
50
50
  </script>
51
+
52
+ <style lang="css">
53
+ @layer components {
54
+ .header-block {
55
+ overflow-wrap: anywhere;
56
+ }
57
+ }
58
+ </style>