@forumone/create-throughline 0.2.1 → 0.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 (139) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/generator.d.ts +2 -0
  3. package/dist/generator.d.ts.map +1 -1
  4. package/dist/generator.js +23 -4
  5. package/dist/generator.js.map +1 -1
  6. package/dist/post-install.js +1 -1
  7. package/dist/post-install.js.map +1 -1
  8. package/dist/templates/base/README.md +8 -9
  9. package/dist/templates/base/apps/web/package.json.template +11 -10
  10. package/dist/templates/base/apps/web/src/app/api/inngest/route.ts.template +33 -4
  11. package/dist/templates/base/apps/web/src/payload.config.ts.template +21 -6
  12. package/dist/templates/base/apps/web/tsconfig.json +2 -1
  13. package/dist/templates/base/gitignore +15 -0
  14. package/dist/templates/base/package.json.template +4 -1
  15. package/dist/templates/base/pnpm-workspace.yaml +1 -0
  16. package/dist/templates/base/turbo.json +11 -0
  17. package/dist/templates/with-reference-ds/design-system/.storybook/main.ts +21 -0
  18. package/dist/templates/with-reference-ds/design-system/.storybook/preview.ts +19 -0
  19. package/dist/templates/with-reference-ds/design-system/README.md +33 -0
  20. package/dist/templates/with-reference-ds/design-system/gitignore +5 -0
  21. package/dist/templates/with-reference-ds/design-system/package.json.template +62 -0
  22. package/dist/templates/with-reference-ds/design-system/scripts/build-manifest.ts +89 -0
  23. package/dist/templates/with-reference-ds/design-system/scripts/build-styles.ts +28 -0
  24. package/dist/templates/with-reference-ds/design-system/scripts/build-tokens-css.ts +43 -0
  25. package/dist/templates/with-reference-ds/design-system/scripts/validate.ts +58 -0
  26. package/dist/templates/with-reference-ds/design-system/src/components/CTASection/CTASection.contract.ts +128 -0
  27. package/dist/templates/with-reference-ds/design-system/src/components/CTASection/CTASection.module.css +83 -0
  28. package/dist/templates/with-reference-ds/design-system/src/components/CTASection/CTASection.stories.tsx +39 -0
  29. package/dist/templates/with-reference-ds/design-system/src/components/CTASection/CTASection.test.tsx +27 -0
  30. package/dist/templates/with-reference-ds/design-system/src/components/CTASection/CTASection.tsx +41 -0
  31. package/dist/templates/with-reference-ds/design-system/src/components/CTASection/index.ts +2 -0
  32. package/dist/templates/with-reference-ds/design-system/src/components/Card/Card.contract.ts +122 -0
  33. package/dist/templates/with-reference-ds/design-system/src/components/Card/Card.module.css +64 -0
  34. package/dist/templates/with-reference-ds/design-system/src/components/Card/Card.stories.tsx +45 -0
  35. package/dist/templates/with-reference-ds/design-system/src/components/Card/Card.test.tsx +37 -0
  36. package/dist/templates/with-reference-ds/design-system/src/components/Card/Card.tsx +39 -0
  37. package/dist/templates/with-reference-ds/design-system/src/components/Card/index.ts +2 -0
  38. package/dist/templates/with-reference-ds/design-system/src/components/CardGrid/CardGrid.contract.ts +78 -0
  39. package/dist/templates/with-reference-ds/design-system/src/components/CardGrid/CardGrid.module.css +34 -0
  40. package/dist/templates/with-reference-ds/design-system/src/components/CardGrid/CardGrid.stories.tsx +36 -0
  41. package/dist/templates/with-reference-ds/design-system/src/components/CardGrid/CardGrid.test.tsx +25 -0
  42. package/dist/templates/with-reference-ds/design-system/src/components/CardGrid/CardGrid.tsx +19 -0
  43. package/dist/templates/with-reference-ds/design-system/src/components/CardGrid/index.ts +2 -0
  44. package/dist/templates/with-reference-ds/design-system/src/components/Divider/Divider.contract.ts +87 -0
  45. package/dist/templates/with-reference-ds/design-system/src/components/Divider/Divider.module.css +18 -0
  46. package/dist/templates/with-reference-ds/design-system/src/components/Divider/Divider.stories.tsx +35 -0
  47. package/dist/templates/with-reference-ds/design-system/src/components/Divider/Divider.test.tsx +20 -0
  48. package/dist/templates/with-reference-ds/design-system/src/components/Divider/Divider.tsx +17 -0
  49. package/dist/templates/with-reference-ds/design-system/src/components/Divider/index.ts +2 -0
  50. package/dist/templates/with-reference-ds/design-system/src/components/FAQ/FAQ.contract.ts +104 -0
  51. package/dist/templates/with-reference-ds/design-system/src/components/FAQ/FAQ.module.css +66 -0
  52. package/dist/templates/with-reference-ds/design-system/src/components/FAQ/FAQ.stories.tsx +61 -0
  53. package/dist/templates/with-reference-ds/design-system/src/components/FAQ/FAQ.test.tsx +30 -0
  54. package/dist/templates/with-reference-ds/design-system/src/components/FAQ/FAQ.tsx +41 -0
  55. package/dist/templates/with-reference-ds/design-system/src/components/FAQ/index.ts +2 -0
  56. package/dist/templates/with-reference-ds/design-system/src/components/Hero/Hero.contract.ts +182 -0
  57. package/dist/templates/with-reference-ds/design-system/src/components/Hero/Hero.module.css +124 -0
  58. package/dist/templates/with-reference-ds/design-system/src/components/Hero/Hero.stories.tsx +58 -0
  59. package/dist/templates/with-reference-ds/design-system/src/components/Hero/Hero.test.tsx +49 -0
  60. package/dist/templates/with-reference-ds/design-system/src/components/Hero/Hero.tsx +67 -0
  61. package/dist/templates/with-reference-ds/design-system/src/components/Hero/index.ts +2 -0
  62. package/dist/templates/with-reference-ds/design-system/src/components/MediaBlock/MediaBlock.contract.ts +94 -0
  63. package/dist/templates/with-reference-ds/design-system/src/components/MediaBlock/MediaBlock.module.css +49 -0
  64. package/dist/templates/with-reference-ds/design-system/src/components/MediaBlock/MediaBlock.stories.tsx +37 -0
  65. package/dist/templates/with-reference-ds/design-system/src/components/MediaBlock/MediaBlock.test.tsx +35 -0
  66. package/dist/templates/with-reference-ds/design-system/src/components/MediaBlock/MediaBlock.tsx +49 -0
  67. package/dist/templates/with-reference-ds/design-system/src/components/MediaBlock/index.ts +7 -0
  68. package/dist/templates/with-reference-ds/design-system/src/components/Prose/Prose.contract.ts +107 -0
  69. package/dist/templates/with-reference-ds/design-system/src/components/Prose/Prose.module.css +72 -0
  70. package/dist/templates/with-reference-ds/design-system/src/components/Prose/Prose.stories.tsx +39 -0
  71. package/dist/templates/with-reference-ds/design-system/src/components/Prose/Prose.test.tsx +26 -0
  72. package/dist/templates/with-reference-ds/design-system/src/components/Prose/Prose.tsx +15 -0
  73. package/dist/templates/with-reference-ds/design-system/src/components/Prose/index.ts +2 -0
  74. package/dist/templates/with-reference-ds/design-system/src/components/Quote/Quote.contract.ts +101 -0
  75. package/dist/templates/with-reference-ds/design-system/src/components/Quote/Quote.module.css +59 -0
  76. package/dist/templates/with-reference-ds/design-system/src/components/Quote/Quote.stories.tsx +44 -0
  77. package/dist/templates/with-reference-ds/design-system/src/components/Quote/Quote.test.tsx +37 -0
  78. package/dist/templates/with-reference-ds/design-system/src/components/Quote/Quote.tsx +40 -0
  79. package/dist/templates/with-reference-ds/design-system/src/components/Quote/index.ts +2 -0
  80. package/dist/templates/with-reference-ds/design-system/src/components/SectionIntro/SectionIntro.contract.ts +108 -0
  81. package/dist/templates/with-reference-ds/design-system/src/components/SectionIntro/SectionIntro.module.css +36 -0
  82. package/dist/templates/with-reference-ds/design-system/src/components/SectionIntro/SectionIntro.stories.tsx +33 -0
  83. package/dist/templates/with-reference-ds/design-system/src/components/SectionIntro/SectionIntro.test.tsx +28 -0
  84. package/dist/templates/with-reference-ds/design-system/src/components/SectionIntro/SectionIntro.tsx +28 -0
  85. package/dist/templates/with-reference-ds/design-system/src/components/SectionIntro/index.ts +2 -0
  86. package/dist/templates/with-reference-ds/design-system/src/components/Spacer/Spacer.contract.ts +76 -0
  87. package/dist/templates/with-reference-ds/design-system/src/components/Spacer/Spacer.module.css +23 -0
  88. package/dist/templates/with-reference-ds/design-system/src/components/Spacer/Spacer.stories.tsx +25 -0
  89. package/dist/templates/with-reference-ds/design-system/src/components/Spacer/Spacer.test.tsx +17 -0
  90. package/dist/templates/with-reference-ds/design-system/src/components/Spacer/Spacer.tsx +19 -0
  91. package/dist/templates/with-reference-ds/design-system/src/components/Spacer/index.ts +2 -0
  92. package/dist/templates/with-reference-ds/design-system/src/components/Stats/Stats.contract.ts +103 -0
  93. package/dist/templates/with-reference-ds/design-system/src/components/Stats/Stats.module.css +88 -0
  94. package/dist/templates/with-reference-ds/design-system/src/components/Stats/Stats.stories.tsx +44 -0
  95. package/dist/templates/with-reference-ds/design-system/src/components/Stats/Stats.test.tsx +44 -0
  96. package/dist/templates/with-reference-ds/design-system/src/components/Stats/Stats.tsx +39 -0
  97. package/dist/templates/with-reference-ds/design-system/src/components/Stats/index.ts +2 -0
  98. package/dist/templates/with-reference-ds/design-system/src/css-modules.d.ts +5 -0
  99. package/dist/templates/with-reference-ds/design-system/src/foundations/Colors.stories.tsx +46 -0
  100. package/dist/templates/with-reference-ds/design-system/src/foundations/Elevation.stories.tsx +37 -0
  101. package/dist/templates/with-reference-ds/design-system/src/foundations/LayoutContainers.stories.tsx +86 -0
  102. package/dist/templates/with-reference-ds/design-system/src/foundations/Radii.stories.tsx +38 -0
  103. package/dist/templates/with-reference-ds/design-system/src/foundations/Spacing.stories.tsx +33 -0
  104. package/dist/templates/with-reference-ds/design-system/src/foundations/Typography.stories.tsx +55 -0
  105. package/dist/templates/with-reference-ds/design-system/src/foundations/_helpers.tsx +82 -0
  106. package/dist/templates/with-reference-ds/design-system/src/index.ts +14 -0
  107. package/dist/templates/with-reference-ds/design-system/src/styles/base.css +55 -0
  108. package/dist/templates/with-reference-ds/design-system/src/styles/reset.css +48 -0
  109. package/dist/templates/with-reference-ds/design-system/src/styles/tokens.css +116 -0
  110. package/dist/templates/with-reference-ds/design-system/src/tokens/colors.ts +54 -0
  111. package/dist/templates/with-reference-ds/design-system/src/tokens/index.ts +32 -0
  112. package/dist/templates/with-reference-ds/design-system/src/tokens/layout.ts +26 -0
  113. package/dist/templates/with-reference-ds/design-system/src/tokens/radii.ts +11 -0
  114. package/dist/templates/with-reference-ds/design-system/src/tokens/spacing.ts +26 -0
  115. package/dist/templates/with-reference-ds/design-system/src/tokens/typography.ts +32 -0
  116. package/dist/templates/with-reference-ds/design-system/tsconfig.json +24 -0
  117. package/dist/templates/with-reference-ds/design-system/vitest.config.ts +15 -0
  118. package/dist/templates/with-reference-ds/design-system/vitest.setup.ts +7 -0
  119. package/dist/templates/without-reference-ds/design-system/.storybook/main.ts +21 -0
  120. package/dist/templates/without-reference-ds/design-system/.storybook/preview.ts +16 -0
  121. package/dist/templates/without-reference-ds/design-system/README.md +20 -0
  122. package/dist/templates/without-reference-ds/design-system/gitignore +5 -0
  123. package/dist/templates/without-reference-ds/design-system/package.json.template +33 -0
  124. package/dist/templates/without-reference-ds/design-system/src/foundations/Welcome.stories.tsx +24 -0
  125. package/dist/templates/without-reference-ds/design-system/src/index.ts +9 -0
  126. package/dist/templates/without-reference-ds/design-system/tsconfig.json +11 -0
  127. package/dist/utils/versions.d.ts +20 -0
  128. package/dist/utils/versions.d.ts.map +1 -0
  129. package/dist/utils/versions.js +59 -0
  130. package/dist/utils/versions.js.map +1 -0
  131. package/dist/versions.json +14 -0
  132. package/package.json +7 -5
  133. package/dist/templates/with-reference-ds/packages/design-system/package.json.template +0 -18
  134. package/dist/templates/with-reference-ds/packages/design-system/src/index.ts +0 -6
  135. package/dist/templates/with-reference-ds/packages/design-system/src/manifest.ts +0 -6
  136. package/dist/templates/with-reference-ds/packages/design-system/tsconfig.json +0 -9
  137. package/dist/templates/without-reference-ds/packages/design-system/README.md +0 -19
  138. package/dist/templates/without-reference-ds/packages/design-system/package.json.template +0 -14
  139. package/dist/templates/without-reference-ds/packages/design-system/src/index.ts +0 -8
@@ -0,0 +1,59 @@
1
+ .figure {
2
+ max-width: 48rem;
3
+ margin: 0 auto;
4
+ padding: var(--spacing-10) var(--spacing-container);
5
+ text-align: center;
6
+ }
7
+
8
+ .quote {
9
+ margin: 0 0 var(--spacing-6);
10
+ font-style: italic;
11
+ }
12
+
13
+ .quote p {
14
+ font-size: var(--font-size-2xl);
15
+ line-height: var(--line-height-snug);
16
+ color: var(--color-text-primary);
17
+ margin: 0;
18
+ }
19
+
20
+ .large .quote p {
21
+ font-size: var(--font-size-4xl);
22
+ line-height: var(--line-height-tight);
23
+ }
24
+
25
+ .quote p::before {
26
+ content: '\201C';
27
+ margin-right: 0.1em;
28
+ }
29
+
30
+ .quote p::after {
31
+ content: '\201D';
32
+ margin-left: 0.1em;
33
+ }
34
+
35
+ .attribution {
36
+ display: inline-flex;
37
+ align-items: center;
38
+ gap: var(--spacing-3);
39
+ color: var(--color-text-secondary);
40
+ font-size: var(--font-size-sm);
41
+ font-style: normal;
42
+ }
43
+
44
+ .avatar {
45
+ width: 2.5rem;
46
+ height: 2.5rem;
47
+ border-radius: var(--radius-full);
48
+ object-fit: cover;
49
+ }
50
+
51
+ .name {
52
+ font-weight: var(--font-weight-semibold);
53
+ color: var(--color-text-primary);
54
+ }
55
+
56
+ .role::before {
57
+ content: '\00B7';
58
+ margin: 0 0.25em;
59
+ }
@@ -0,0 +1,44 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { Quote } from './Quote.js'
3
+
4
+ const meta: Meta<typeof Quote> = {
5
+ title: 'Quote',
6
+ component: Quote,
7
+ }
8
+
9
+ export default meta
10
+
11
+ type Story = StoryObj<typeof Quote>
12
+
13
+ export const Default: Story = {
14
+ args: {
15
+ quote:
16
+ 'The best CMS is the one your editorial team forgets they are using.',
17
+ attribution: { name: 'Jordan Rivers', role: 'Design director' },
18
+ },
19
+ }
20
+
21
+ export const WithAvatar: Story = {
22
+ args: {
23
+ quote:
24
+ 'After we moved to a content-first model, our publishing velocity nearly doubled without adding to the team.',
25
+ attribution: {
26
+ name: 'Maria Santos',
27
+ role: 'Head of content, Greenfield Foundation',
28
+ avatar: { url: 'https://placehold.co/80x80', alt: 'Portrait of Maria Santos' },
29
+ },
30
+ },
31
+ }
32
+
33
+ export const Large: Story = {
34
+ args: {
35
+ size: 'large',
36
+ quote: 'Editorial teams deserve tools that take their workflows seriously.',
37
+ },
38
+ }
39
+
40
+ export const WithoutAttribution: Story = {
41
+ args: {
42
+ quote: 'Content first. Everything else follows.',
43
+ },
44
+ }
@@ -0,0 +1,37 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { Quote } from './Quote.js'
4
+
5
+ describe('Quote', () => {
6
+ it('renders the quote inside a blockquote', () => {
7
+ const { container } = render(<Quote quote="Content first." />)
8
+ expect(container.querySelector('blockquote')).not.toBeNull()
9
+ expect(screen.getByText('Content first.')).toBeInTheDocument()
10
+ })
11
+
12
+ it('renders the attribution inside a figcaption when provided', () => {
13
+ const { container } = render(
14
+ <Quote
15
+ quote="Words."
16
+ attribution={{ name: 'Ada Lovelace', role: 'Mathematician' }}
17
+ />,
18
+ )
19
+ const caption = container.querySelector('figcaption')
20
+ expect(caption).not.toBeNull()
21
+ expect(caption).toHaveTextContent('Ada Lovelace')
22
+ expect(caption).toHaveTextContent('Mathematician')
23
+ })
24
+
25
+ it('renders an avatar when attribution includes one', () => {
26
+ render(
27
+ <Quote
28
+ quote="Words."
29
+ attribution={{
30
+ name: 'Grace Hopper',
31
+ avatar: { url: '/avatar.jpg', alt: 'Grace Hopper portrait' },
32
+ }}
33
+ />,
34
+ )
35
+ expect(screen.getByRole('img', { name: 'Grace Hopper portrait' })).toBeInTheDocument()
36
+ })
37
+ })
@@ -0,0 +1,40 @@
1
+ import { clsx } from 'clsx'
2
+ import styles from './Quote.module.css'
3
+
4
+ export type QuoteSize = 'default' | 'large'
5
+
6
+ export interface QuoteProps {
7
+ quote: string
8
+ attribution?: {
9
+ name: string
10
+ role?: string
11
+ avatar?: { url: string; alt: string }
12
+ }
13
+ size?: QuoteSize
14
+ className?: string
15
+ }
16
+
17
+ export function Quote({ quote, attribution, size = 'default', className }: QuoteProps) {
18
+ return (
19
+ <figure className={clsx(styles.figure, styles[size], className)}>
20
+ <blockquote className={styles.quote}>
21
+ <p>{quote}</p>
22
+ </blockquote>
23
+ {attribution ? (
24
+ <figcaption className={styles.attribution}>
25
+ {attribution.avatar ? (
26
+ <img
27
+ src={attribution.avatar.url}
28
+ alt={attribution.avatar.alt}
29
+ className={styles.avatar}
30
+ />
31
+ ) : null}
32
+ <span className={styles.name}>{attribution.name}</span>
33
+ {attribution.role ? (
34
+ <span className={styles.role}>{attribution.role}</span>
35
+ ) : null}
36
+ </figcaption>
37
+ ) : null}
38
+ </figure>
39
+ )
40
+ }
@@ -0,0 +1,2 @@
1
+ export { Quote, type QuoteProps, type QuoteSize } from './Quote.js'
2
+ export { contract as quoteContract } from './Quote.contract.js'
@@ -0,0 +1,108 @@
1
+ import type { ComponentContract } from '@forumone/throughline-design-contract'
2
+
3
+ export const contract: ComponentContract = {
4
+ name: 'SectionIntro',
5
+ category: 'section',
6
+ description:
7
+ 'A section opener that establishes what the next block of content is about without competing with the page hero.',
8
+ intent:
9
+ 'Introduce a new section inside a page. Use when you need an h2-level heading with optional eyebrow and supporting body copy, such as "Our approach", "Recent work", or "What we believe". Pairs with any content block below it.',
10
+
11
+ composition: {
12
+ placement: ['section'],
13
+ maxPerPage: null,
14
+ requiredSiblings: [],
15
+ forbiddenAdjacent: ['SectionIntro'],
16
+ },
17
+
18
+ content: {
19
+ fields: [
20
+ {
21
+ name: 'eyebrow',
22
+ type: 'text',
23
+ required: false,
24
+ maxLength: 40,
25
+ constraints: 'Short kicker label above the headline',
26
+ },
27
+ {
28
+ name: 'headline',
29
+ type: 'text',
30
+ required: true,
31
+ maxLength: 100,
32
+ constraints: 'Sentence case; scannable; reads like a chapter title',
33
+ },
34
+ {
35
+ name: 'body',
36
+ type: 'text',
37
+ required: false,
38
+ maxLength: 240,
39
+ constraints: 'One or two sentences that set up the section',
40
+ },
41
+ ],
42
+ variants: [
43
+ {
44
+ name: 'start',
45
+ description: 'Left-aligned',
46
+ whenToUse: 'Default — pairs with left-aligned content blocks below',
47
+ },
48
+ {
49
+ name: 'center',
50
+ description: 'Center-aligned',
51
+ whenToUse: 'Landing pages and marketing sections where the intro anchors a centered layout',
52
+ },
53
+ ],
54
+ },
55
+
56
+ tokens: {
57
+ consumes: [
58
+ 'color.text.primary',
59
+ 'color.text.secondary',
60
+ 'spacing.container',
61
+ 'spacing.3',
62
+ 'spacing.2',
63
+ 'font.size.3xl',
64
+ 'font.size.lg',
65
+ 'font.size.sm',
66
+ 'font.weight.semibold',
67
+ 'line.height.snug',
68
+ 'line.height.relaxed',
69
+ 'letter.spacing.wide',
70
+ ],
71
+ },
72
+
73
+ accessibility: {
74
+ role: 'region',
75
+ keyboardSupport: [],
76
+ screenReaderBehavior:
77
+ 'The headline is announced as an h2 element. The eyebrow is supplementary text read before the headline. No interactive controls.',
78
+ contentWarnings: [
79
+ 'Do not use SectionIntro before Hero on the same page — the page must start with a single h1',
80
+ ],
81
+ },
82
+
83
+ examples: [
84
+ { label: 'Default', intent: 'Introduce a section inside a content page', storyId: 'section-intro--default' },
85
+ { label: 'Centered', intent: 'Anchor a centered marketing section', storyId: 'section-intro--centered' },
86
+ { label: 'Headline only', intent: 'Minimal section divider with just a heading', storyId: 'section-intro--headline-only' },
87
+ ],
88
+
89
+ antiExamples: [
90
+ {
91
+ label: 'Using SectionIntro as the page opener',
92
+ why: 'SectionIntro renders h2; a page should begin with an h1 for accessibility and SEO',
93
+ useInstead: 'Hero',
94
+ },
95
+ {
96
+ label: 'Stacking two SectionIntros with no content between them',
97
+ why: 'Creates a visual and semantic duplicate that signals nothing new',
98
+ useInstead: 'Merge into a single SectionIntro with a longer body',
99
+ },
100
+ ],
101
+
102
+ behavior: {
103
+ fetchesData: false,
104
+ hasClientState: false,
105
+ animates: false,
106
+ requiresAnalytics: false,
107
+ },
108
+ }
@@ -0,0 +1,36 @@
1
+ .intro {
2
+ max-width: 48rem;
3
+ padding: 0 var(--spacing-container);
4
+ margin: 0 auto;
5
+ }
6
+
7
+ .start {
8
+ text-align: start;
9
+ margin-inline-start: 0;
10
+ }
11
+
12
+ .center {
13
+ text-align: center;
14
+ }
15
+
16
+ .eyebrow {
17
+ font-size: var(--font-size-sm);
18
+ font-weight: var(--font-weight-semibold);
19
+ text-transform: uppercase;
20
+ letter-spacing: var(--letter-spacing-wide);
21
+ color: var(--color-text-secondary);
22
+ margin-bottom: var(--spacing-2);
23
+ }
24
+
25
+ .headline {
26
+ font-size: var(--font-size-3xl);
27
+ font-weight: var(--font-weight-semibold);
28
+ line-height: var(--line-height-snug);
29
+ margin-bottom: var(--spacing-3);
30
+ }
31
+
32
+ .body {
33
+ font-size: var(--font-size-lg);
34
+ line-height: var(--line-height-relaxed);
35
+ color: var(--color-text-secondary);
36
+ }
@@ -0,0 +1,33 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { SectionIntro } from './SectionIntro.js'
3
+
4
+ const meta: Meta<typeof SectionIntro> = {
5
+ title: 'Section Intro',
6
+ component: SectionIntro,
7
+ }
8
+
9
+ export default meta
10
+
11
+ type Story = StoryObj<typeof SectionIntro>
12
+
13
+ export const Default: Story = {
14
+ args: {
15
+ eyebrow: 'Our approach',
16
+ headline: 'How we work with partners',
17
+ body: 'We start with the content model, then the governance, then the visual design.',
18
+ },
19
+ }
20
+
21
+ export const Centered: Story = {
22
+ args: {
23
+ alignment: 'center',
24
+ headline: 'What we believe',
25
+ body: 'Editorial teams deserve tools that take their workflows seriously.',
26
+ },
27
+ }
28
+
29
+ export const HeadlineOnly: Story = {
30
+ args: {
31
+ headline: 'Recent work',
32
+ },
33
+ }
@@ -0,0 +1,28 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { SectionIntro } from './SectionIntro.js'
4
+
5
+ describe('SectionIntro', () => {
6
+ it('renders the headline as h2', () => {
7
+ render(<SectionIntro headline="Our approach" />)
8
+ const heading = screen.getByRole('heading', { level: 2 })
9
+ expect(heading).toHaveTextContent('Our approach')
10
+ })
11
+
12
+ it('renders eyebrow and body when provided', () => {
13
+ render(
14
+ <SectionIntro
15
+ eyebrow="About"
16
+ headline="How we work"
17
+ body="We start with content."
18
+ />,
19
+ )
20
+ expect(screen.getByText('About')).toBeInTheDocument()
21
+ expect(screen.getByText('We start with content.')).toBeInTheDocument()
22
+ })
23
+
24
+ it('omits the body when not provided', () => {
25
+ render(<SectionIntro headline="Plain" />)
26
+ expect(screen.queryByText(/we start/i)).not.toBeInTheDocument()
27
+ })
28
+ })
@@ -0,0 +1,28 @@
1
+ import { clsx } from 'clsx'
2
+ import styles from './SectionIntro.module.css'
3
+
4
+ export type SectionIntroAlignment = 'start' | 'center'
5
+
6
+ export interface SectionIntroProps {
7
+ eyebrow?: string
8
+ headline: string
9
+ body?: string
10
+ alignment?: SectionIntroAlignment
11
+ className?: string
12
+ }
13
+
14
+ export function SectionIntro({
15
+ eyebrow,
16
+ headline,
17
+ body,
18
+ alignment = 'start',
19
+ className,
20
+ }: SectionIntroProps) {
21
+ return (
22
+ <header className={clsx(styles.intro, styles[alignment], className)}>
23
+ {eyebrow ? <p className={styles.eyebrow}>{eyebrow}</p> : null}
24
+ <h2 className={styles.headline}>{headline}</h2>
25
+ {body ? <p className={styles.body}>{body}</p> : null}
26
+ </header>
27
+ )
28
+ }
@@ -0,0 +1,2 @@
1
+ export { SectionIntro, type SectionIntroProps, type SectionIntroAlignment } from './SectionIntro.js'
2
+ export { contract as sectionIntroContract } from './SectionIntro.contract.js'
@@ -0,0 +1,76 @@
1
+ import type { ComponentContract } from '@forumone/throughline-design-contract'
2
+
3
+ export const contract: ComponentContract = {
4
+ name: 'Spacer',
5
+ category: 'utility',
6
+ description: 'An explicit vertical spacing block that inserts a token-sized gap between adjacent components.',
7
+ intent:
8
+ 'Add breathing room between two components when neither component\'s own margins produce the right rhythm. Prefer component-owned spacing when possible; reach for Spacer only when layout composition requires it, such as between Stats and a following CardGrid on a dense page.',
9
+
10
+ composition: {
11
+ placement: ['page', 'section'],
12
+ maxPerPage: null,
13
+ requiredSiblings: [],
14
+ forbiddenAdjacent: ['Spacer'],
15
+ },
16
+
17
+ content: {
18
+ fields: [
19
+ {
20
+ name: 'size',
21
+ type: 'select',
22
+ required: false,
23
+ constraints: 'xs | sm | md | lg | xl',
24
+ },
25
+ ],
26
+ variants: [
27
+ { name: 'xs', description: 'Extra small', whenToUse: 'Fine-grained adjustment' },
28
+ { name: 'sm', description: 'Small', whenToUse: 'Between closely related elements' },
29
+ { name: 'md', description: 'Medium (default)', whenToUse: 'Most cases' },
30
+ { name: 'lg', description: 'Large', whenToUse: 'Between page sections that need visual separation' },
31
+ { name: 'xl', description: 'Extra large', whenToUse: 'Major visual pauses on long landing pages' },
32
+ ],
33
+ },
34
+
35
+ tokens: {
36
+ consumes: ['spacing.2', 'spacing.4', 'spacing.8', 'spacing.16', 'spacing.24'],
37
+ configurable: [
38
+ { prop: 'size', tokenGroup: 'spacing', allowedValues: ['xs', 'sm', 'md', 'lg', 'xl'] },
39
+ ],
40
+ },
41
+
42
+ accessibility: {
43
+ keyboardSupport: [],
44
+ screenReaderBehavior:
45
+ 'Spacer is a presentational div marked aria-hidden. Assistive technology skips it entirely.',
46
+ contentWarnings: [
47
+ 'Do not use Spacer to replace paragraph margins inside Prose — the typography already handles that',
48
+ ],
49
+ },
50
+
51
+ examples: [
52
+ { label: 'Small', intent: 'Subtle gap', storyId: 'spacer--small' },
53
+ { label: 'Medium', intent: 'Standard gap', storyId: 'spacer--medium' },
54
+ { label: 'Large', intent: 'Major section break', storyId: 'spacer--large' },
55
+ ],
56
+
57
+ antiExamples: [
58
+ {
59
+ label: 'Stacking multiple Spacers to achieve a custom size',
60
+ why: 'Nesting spacers bypasses the token scale and makes spacing unauditable',
61
+ useInstead: 'Pick the nearest size in the scale or adjust the neighboring components',
62
+ },
63
+ {
64
+ label: 'Using Spacer for semantic breaks',
65
+ why: 'Spacer has no semantic meaning; readers cannot tell one section has ended and another begun',
66
+ useInstead: 'Divider (decorative=false) or SectionIntro',
67
+ },
68
+ ],
69
+
70
+ behavior: {
71
+ fetchesData: false,
72
+ hasClientState: false,
73
+ animates: false,
74
+ requiresAnalytics: false,
75
+ },
76
+ }
@@ -0,0 +1,23 @@
1
+ .spacer {
2
+ width: 100%;
3
+ }
4
+
5
+ .xs {
6
+ height: var(--spacing-2);
7
+ }
8
+
9
+ .sm {
10
+ height: var(--spacing-4);
11
+ }
12
+
13
+ .md {
14
+ height: var(--spacing-8);
15
+ }
16
+
17
+ .lg {
18
+ height: var(--spacing-16);
19
+ }
20
+
21
+ .xl {
22
+ height: var(--spacing-24);
23
+ }
@@ -0,0 +1,25 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { Spacer, type SpacerSize } from './Spacer.js'
3
+
4
+ const meta: Meta<typeof Spacer> = {
5
+ title: 'Spacer',
6
+ component: Spacer,
7
+ }
8
+
9
+ export default meta
10
+
11
+ type Story = StoryObj<typeof Spacer>
12
+
13
+ function bracket(size: SpacerSize) {
14
+ return (
15
+ <div style={{ background: 'var(--color-bg-secondary)', padding: '0.5rem' }}>
16
+ <p>Content above (size: {size})</p>
17
+ <Spacer size={size} />
18
+ <p>Content below</p>
19
+ </div>
20
+ )
21
+ }
22
+
23
+ export const Small: Story = { render: () => bracket('sm'), args: { size: 'sm' } }
24
+ export const Medium: Story = { render: () => bracket('md'), args: { size: 'md' } }
25
+ export const Large: Story = { render: () => bracket('lg'), args: { size: 'lg' } }
@@ -0,0 +1,17 @@
1
+ import { render } from '@testing-library/react'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { Spacer } from './Spacer.js'
4
+
5
+ describe('Spacer', () => {
6
+ it('is aria-hidden and presentational', () => {
7
+ const { container } = render(<Spacer />)
8
+ const spacer = container.firstChild as HTMLElement
9
+ expect(spacer).toHaveAttribute('aria-hidden', 'true')
10
+ expect(spacer).toHaveAttribute('role', 'presentation')
11
+ })
12
+
13
+ it('applies the size modifier class', () => {
14
+ const { container } = render(<Spacer size="lg" />)
15
+ expect((container.firstChild as HTMLElement).className).toContain('lg')
16
+ })
17
+ })
@@ -0,0 +1,19 @@
1
+ import { clsx } from 'clsx'
2
+ import styles from './Spacer.module.css'
3
+
4
+ export type SpacerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
5
+
6
+ export interface SpacerProps {
7
+ size?: SpacerSize
8
+ className?: string
9
+ }
10
+
11
+ export function Spacer({ size = 'md', className }: SpacerProps) {
12
+ return (
13
+ <div
14
+ aria-hidden="true"
15
+ role="presentation"
16
+ className={clsx(styles.spacer, styles[size], className)}
17
+ />
18
+ )
19
+ }
@@ -0,0 +1,2 @@
1
+ export { Spacer, type SpacerProps, type SpacerSize } from './Spacer.js'
2
+ export { contract as spacerContract } from './Spacer.contract.js'