@forumone/create-throughline 0.2.1 → 0.3.0

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 (131) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/generator.d.ts +2 -0
  3. package/dist/generator.d.ts.map +1 -1
  4. package/dist/generator.js +21 -4
  5. package/dist/generator.js.map +1 -1
  6. package/dist/templates/base/apps/web/package.json.template +2 -1
  7. package/dist/templates/base/apps/web/src/app/api/inngest/route.ts.template +2 -2
  8. package/dist/templates/base/apps/web/src/payload.config.ts.template +7 -5
  9. package/dist/templates/base/apps/web/tsconfig.json +2 -1
  10. package/dist/templates/base/gitignore +15 -0
  11. package/dist/templates/base/package.json.template +4 -1
  12. package/dist/templates/base/pnpm-workspace.yaml +1 -0
  13. package/dist/templates/base/turbo.json +11 -0
  14. package/dist/templates/with-reference-ds/design-system/.storybook/main.ts +21 -0
  15. package/dist/templates/with-reference-ds/design-system/.storybook/preview.ts +19 -0
  16. package/dist/templates/with-reference-ds/design-system/README.md +33 -0
  17. package/dist/templates/with-reference-ds/design-system/gitignore +5 -0
  18. package/dist/templates/with-reference-ds/design-system/package.json.template +62 -0
  19. package/dist/templates/with-reference-ds/design-system/scripts/build-manifest.ts +89 -0
  20. package/dist/templates/with-reference-ds/design-system/scripts/build-styles.ts +28 -0
  21. package/dist/templates/with-reference-ds/design-system/scripts/build-tokens-css.ts +43 -0
  22. package/dist/templates/with-reference-ds/design-system/scripts/validate.ts +58 -0
  23. package/dist/templates/with-reference-ds/design-system/src/components/CTASection/CTASection.contract.ts +128 -0
  24. package/dist/templates/with-reference-ds/design-system/src/components/CTASection/CTASection.module.css +83 -0
  25. package/dist/templates/with-reference-ds/design-system/src/components/CTASection/CTASection.stories.tsx +39 -0
  26. package/dist/templates/with-reference-ds/design-system/src/components/CTASection/CTASection.test.tsx +27 -0
  27. package/dist/templates/with-reference-ds/design-system/src/components/CTASection/CTASection.tsx +41 -0
  28. package/dist/templates/with-reference-ds/design-system/src/components/CTASection/index.ts +2 -0
  29. package/dist/templates/with-reference-ds/design-system/src/components/Card/Card.contract.ts +122 -0
  30. package/dist/templates/with-reference-ds/design-system/src/components/Card/Card.module.css +64 -0
  31. package/dist/templates/with-reference-ds/design-system/src/components/Card/Card.stories.tsx +45 -0
  32. package/dist/templates/with-reference-ds/design-system/src/components/Card/Card.test.tsx +37 -0
  33. package/dist/templates/with-reference-ds/design-system/src/components/Card/Card.tsx +39 -0
  34. package/dist/templates/with-reference-ds/design-system/src/components/Card/index.ts +2 -0
  35. package/dist/templates/with-reference-ds/design-system/src/components/CardGrid/CardGrid.contract.ts +78 -0
  36. package/dist/templates/with-reference-ds/design-system/src/components/CardGrid/CardGrid.module.css +34 -0
  37. package/dist/templates/with-reference-ds/design-system/src/components/CardGrid/CardGrid.stories.tsx +36 -0
  38. package/dist/templates/with-reference-ds/design-system/src/components/CardGrid/CardGrid.test.tsx +25 -0
  39. package/dist/templates/with-reference-ds/design-system/src/components/CardGrid/CardGrid.tsx +19 -0
  40. package/dist/templates/with-reference-ds/design-system/src/components/CardGrid/index.ts +2 -0
  41. package/dist/templates/with-reference-ds/design-system/src/components/Divider/Divider.contract.ts +87 -0
  42. package/dist/templates/with-reference-ds/design-system/src/components/Divider/Divider.module.css +18 -0
  43. package/dist/templates/with-reference-ds/design-system/src/components/Divider/Divider.stories.tsx +35 -0
  44. package/dist/templates/with-reference-ds/design-system/src/components/Divider/Divider.test.tsx +20 -0
  45. package/dist/templates/with-reference-ds/design-system/src/components/Divider/Divider.tsx +17 -0
  46. package/dist/templates/with-reference-ds/design-system/src/components/Divider/index.ts +2 -0
  47. package/dist/templates/with-reference-ds/design-system/src/components/FAQ/FAQ.contract.ts +104 -0
  48. package/dist/templates/with-reference-ds/design-system/src/components/FAQ/FAQ.module.css +66 -0
  49. package/dist/templates/with-reference-ds/design-system/src/components/FAQ/FAQ.stories.tsx +61 -0
  50. package/dist/templates/with-reference-ds/design-system/src/components/FAQ/FAQ.test.tsx +30 -0
  51. package/dist/templates/with-reference-ds/design-system/src/components/FAQ/FAQ.tsx +41 -0
  52. package/dist/templates/with-reference-ds/design-system/src/components/FAQ/index.ts +2 -0
  53. package/dist/templates/with-reference-ds/design-system/src/components/Hero/Hero.contract.ts +182 -0
  54. package/dist/templates/with-reference-ds/design-system/src/components/Hero/Hero.module.css +124 -0
  55. package/dist/templates/with-reference-ds/design-system/src/components/Hero/Hero.stories.tsx +58 -0
  56. package/dist/templates/with-reference-ds/design-system/src/components/Hero/Hero.test.tsx +49 -0
  57. package/dist/templates/with-reference-ds/design-system/src/components/Hero/Hero.tsx +67 -0
  58. package/dist/templates/with-reference-ds/design-system/src/components/Hero/index.ts +2 -0
  59. package/dist/templates/with-reference-ds/design-system/src/components/MediaBlock/MediaBlock.contract.ts +94 -0
  60. package/dist/templates/with-reference-ds/design-system/src/components/MediaBlock/MediaBlock.module.css +49 -0
  61. package/dist/templates/with-reference-ds/design-system/src/components/MediaBlock/MediaBlock.stories.tsx +37 -0
  62. package/dist/templates/with-reference-ds/design-system/src/components/MediaBlock/MediaBlock.test.tsx +35 -0
  63. package/dist/templates/with-reference-ds/design-system/src/components/MediaBlock/MediaBlock.tsx +49 -0
  64. package/dist/templates/with-reference-ds/design-system/src/components/MediaBlock/index.ts +7 -0
  65. package/dist/templates/with-reference-ds/design-system/src/components/Prose/Prose.contract.ts +107 -0
  66. package/dist/templates/with-reference-ds/design-system/src/components/Prose/Prose.module.css +72 -0
  67. package/dist/templates/with-reference-ds/design-system/src/components/Prose/Prose.stories.tsx +39 -0
  68. package/dist/templates/with-reference-ds/design-system/src/components/Prose/Prose.test.tsx +26 -0
  69. package/dist/templates/with-reference-ds/design-system/src/components/Prose/Prose.tsx +15 -0
  70. package/dist/templates/with-reference-ds/design-system/src/components/Prose/index.ts +2 -0
  71. package/dist/templates/with-reference-ds/design-system/src/components/Quote/Quote.contract.ts +101 -0
  72. package/dist/templates/with-reference-ds/design-system/src/components/Quote/Quote.module.css +59 -0
  73. package/dist/templates/with-reference-ds/design-system/src/components/Quote/Quote.stories.tsx +44 -0
  74. package/dist/templates/with-reference-ds/design-system/src/components/Quote/Quote.test.tsx +37 -0
  75. package/dist/templates/with-reference-ds/design-system/src/components/Quote/Quote.tsx +40 -0
  76. package/dist/templates/with-reference-ds/design-system/src/components/Quote/index.ts +2 -0
  77. package/dist/templates/with-reference-ds/design-system/src/components/SectionIntro/SectionIntro.contract.ts +108 -0
  78. package/dist/templates/with-reference-ds/design-system/src/components/SectionIntro/SectionIntro.module.css +36 -0
  79. package/dist/templates/with-reference-ds/design-system/src/components/SectionIntro/SectionIntro.stories.tsx +33 -0
  80. package/dist/templates/with-reference-ds/design-system/src/components/SectionIntro/SectionIntro.test.tsx +28 -0
  81. package/dist/templates/with-reference-ds/design-system/src/components/SectionIntro/SectionIntro.tsx +28 -0
  82. package/dist/templates/with-reference-ds/design-system/src/components/SectionIntro/index.ts +2 -0
  83. package/dist/templates/with-reference-ds/design-system/src/components/Spacer/Spacer.contract.ts +76 -0
  84. package/dist/templates/with-reference-ds/design-system/src/components/Spacer/Spacer.module.css +23 -0
  85. package/dist/templates/with-reference-ds/design-system/src/components/Spacer/Spacer.stories.tsx +25 -0
  86. package/dist/templates/with-reference-ds/design-system/src/components/Spacer/Spacer.test.tsx +17 -0
  87. package/dist/templates/with-reference-ds/design-system/src/components/Spacer/Spacer.tsx +19 -0
  88. package/dist/templates/with-reference-ds/design-system/src/components/Spacer/index.ts +2 -0
  89. package/dist/templates/with-reference-ds/design-system/src/components/Stats/Stats.contract.ts +103 -0
  90. package/dist/templates/with-reference-ds/design-system/src/components/Stats/Stats.module.css +88 -0
  91. package/dist/templates/with-reference-ds/design-system/src/components/Stats/Stats.stories.tsx +44 -0
  92. package/dist/templates/with-reference-ds/design-system/src/components/Stats/Stats.test.tsx +44 -0
  93. package/dist/templates/with-reference-ds/design-system/src/components/Stats/Stats.tsx +39 -0
  94. package/dist/templates/with-reference-ds/design-system/src/components/Stats/index.ts +2 -0
  95. package/dist/templates/with-reference-ds/design-system/src/css-modules.d.ts +5 -0
  96. package/dist/templates/with-reference-ds/design-system/src/foundations/Colors.stories.tsx +46 -0
  97. package/dist/templates/with-reference-ds/design-system/src/foundations/Elevation.stories.tsx +37 -0
  98. package/dist/templates/with-reference-ds/design-system/src/foundations/LayoutContainers.stories.tsx +86 -0
  99. package/dist/templates/with-reference-ds/design-system/src/foundations/Radii.stories.tsx +38 -0
  100. package/dist/templates/with-reference-ds/design-system/src/foundations/Spacing.stories.tsx +33 -0
  101. package/dist/templates/with-reference-ds/design-system/src/foundations/Typography.stories.tsx +55 -0
  102. package/dist/templates/with-reference-ds/design-system/src/foundations/_helpers.tsx +82 -0
  103. package/dist/templates/with-reference-ds/design-system/src/index.ts +14 -0
  104. package/dist/templates/with-reference-ds/design-system/src/styles/base.css +55 -0
  105. package/dist/templates/with-reference-ds/design-system/src/styles/reset.css +48 -0
  106. package/dist/templates/with-reference-ds/design-system/src/styles/tokens.css +116 -0
  107. package/dist/templates/with-reference-ds/design-system/src/tokens/colors.ts +54 -0
  108. package/dist/templates/with-reference-ds/design-system/src/tokens/index.ts +32 -0
  109. package/dist/templates/with-reference-ds/design-system/src/tokens/layout.ts +26 -0
  110. package/dist/templates/with-reference-ds/design-system/src/tokens/radii.ts +11 -0
  111. package/dist/templates/with-reference-ds/design-system/src/tokens/spacing.ts +26 -0
  112. package/dist/templates/with-reference-ds/design-system/src/tokens/typography.ts +32 -0
  113. package/dist/templates/with-reference-ds/design-system/tsconfig.json +24 -0
  114. package/dist/templates/with-reference-ds/design-system/vitest.config.ts +15 -0
  115. package/dist/templates/with-reference-ds/design-system/vitest.setup.ts +7 -0
  116. package/dist/templates/without-reference-ds/design-system/.storybook/main.ts +21 -0
  117. package/dist/templates/without-reference-ds/design-system/.storybook/preview.ts +16 -0
  118. package/dist/templates/without-reference-ds/design-system/README.md +20 -0
  119. package/dist/templates/without-reference-ds/design-system/gitignore +5 -0
  120. package/dist/templates/without-reference-ds/design-system/package.json.template +33 -0
  121. package/dist/templates/without-reference-ds/design-system/src/foundations/Welcome.stories.tsx +24 -0
  122. package/dist/templates/without-reference-ds/design-system/src/index.ts +9 -0
  123. package/dist/templates/without-reference-ds/design-system/tsconfig.json +11 -0
  124. package/package.json +3 -1
  125. package/dist/templates/with-reference-ds/packages/design-system/package.json.template +0 -18
  126. package/dist/templates/with-reference-ds/packages/design-system/src/index.ts +0 -6
  127. package/dist/templates/with-reference-ds/packages/design-system/src/manifest.ts +0 -6
  128. package/dist/templates/with-reference-ds/packages/design-system/tsconfig.json +0 -9
  129. package/dist/templates/without-reference-ds/packages/design-system/README.md +0 -19
  130. package/dist/templates/without-reference-ds/packages/design-system/package.json.template +0 -14
  131. package/dist/templates/without-reference-ds/packages/design-system/src/index.ts +0 -8
@@ -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'
@@ -0,0 +1,103 @@
1
+ import type { ComponentContract } from '@forumone/throughline-design-contract'
2
+
3
+ export const contract: ComponentContract = {
4
+ name: 'Stats',
5
+ category: 'data',
6
+ description:
7
+ 'A numerical data display with two to four stat items, each with a value, a label, and an optional description.',
8
+ intent:
9
+ 'Surface a small set of headline metrics in a scannable row. Use for impact reports, program highlights, and annual summaries where numbers tell the story. Not appropriate for dense tabular data — that needs a real table component.',
10
+
11
+ composition: {
12
+ placement: ['section'],
13
+ maxPerPage: null,
14
+ requiredSiblings: [],
15
+ forbiddenAdjacent: [],
16
+ },
17
+
18
+ content: {
19
+ fields: [
20
+ {
21
+ name: 'eyebrow',
22
+ type: 'text',
23
+ required: false,
24
+ maxLength: 40,
25
+ },
26
+ {
27
+ name: 'headline',
28
+ type: 'text',
29
+ required: false,
30
+ maxLength: 100,
31
+ },
32
+ {
33
+ name: 'items',
34
+ type: 'array',
35
+ required: true,
36
+ constraints: 'Two to four items',
37
+ of: [
38
+ { name: 'value', type: 'text', required: true, maxLength: 20 },
39
+ { name: 'label', type: 'text', required: true, maxLength: 60 },
40
+ { name: 'description', type: 'text', required: false, maxLength: 80 },
41
+ ],
42
+ },
43
+ ],
44
+ },
45
+
46
+ tokens: {
47
+ consumes: [
48
+ 'color.text.primary',
49
+ 'color.text.secondary',
50
+ 'color.brand.primary',
51
+ 'spacing.16',
52
+ 'spacing.10',
53
+ 'spacing.8',
54
+ 'spacing.3',
55
+ 'spacing.2',
56
+ 'spacing.1',
57
+ 'spacing.container',
58
+ 'font.size.5xl',
59
+ 'font.size.3xl',
60
+ 'font.size.sm',
61
+ 'font.weight.bold',
62
+ 'font.weight.semibold',
63
+ 'line.height.tight',
64
+ 'letter.spacing.wide',
65
+ ],
66
+ },
67
+
68
+ accessibility: {
69
+ keyboardSupport: [],
70
+ screenReaderBehavior:
71
+ 'Statistics are rendered as a <dl> with <dt>/<dd> pairs. Screen readers traverse each value + label + description as a definition list entry.',
72
+ contentWarnings: [
73
+ 'Keep values concise — "4,200" instead of "4,200,000,000 dollars"',
74
+ 'Never invent numbers to fill the grid; use the real count even if unequal',
75
+ ],
76
+ },
77
+
78
+ examples: [
79
+ { label: 'Three stats', intent: 'Annual report highlights', storyId: 'stats--three-stats' },
80
+ { label: 'Two stats', intent: 'Minimal impact summary', storyId: 'stats--two-stats' },
81
+ { label: 'Four stats', intent: 'Dense overview', storyId: 'stats--four-stats' },
82
+ ],
83
+
84
+ antiExamples: [
85
+ {
86
+ label: 'Five or more stats',
87
+ why: 'Beyond four items the layout cramps on mobile and the individual numbers lose impact',
88
+ useInstead: 'Pick the top four; move the rest to a full report page',
89
+ },
90
+ {
91
+ label: 'Paragraph-length values',
92
+ why: 'Stats are designed for at-a-glance reading; long values overwhelm the layout',
93
+ useInstead: 'Use Prose for extended narrative',
94
+ },
95
+ ],
96
+
97
+ behavior: {
98
+ fetchesData: false,
99
+ hasClientState: false,
100
+ animates: false,
101
+ requiresAnalytics: false,
102
+ },
103
+ }
@@ -0,0 +1,88 @@
1
+ .stats {
2
+ padding: var(--spacing-16) 0;
3
+ }
4
+
5
+ .container {
6
+ max-width: 72rem;
7
+ margin: 0 auto;
8
+ padding: 0 var(--spacing-container);
9
+ }
10
+
11
+ .eyebrow {
12
+ font-size: var(--font-size-sm);
13
+ font-weight: var(--font-weight-semibold);
14
+ text-transform: uppercase;
15
+ letter-spacing: var(--letter-spacing-wide);
16
+ color: var(--color-text-secondary);
17
+ margin-bottom: var(--spacing-3);
18
+ text-align: center;
19
+ }
20
+
21
+ .headline {
22
+ font-size: var(--font-size-3xl);
23
+ font-weight: var(--font-weight-semibold);
24
+ margin-bottom: var(--spacing-10);
25
+ text-align: center;
26
+ }
27
+
28
+ .list {
29
+ display: grid;
30
+ gap: var(--spacing-8);
31
+ margin: 0;
32
+ }
33
+
34
+ .cols-2 {
35
+ grid-template-columns: repeat(2, 1fr);
36
+ }
37
+
38
+ .cols-3 {
39
+ grid-template-columns: repeat(3, 1fr);
40
+ }
41
+
42
+ .cols-4 {
43
+ grid-template-columns: repeat(4, 1fr);
44
+ }
45
+
46
+ @media (max-width: 720px) {
47
+ .cols-2,
48
+ .cols-3,
49
+ .cols-4 {
50
+ grid-template-columns: repeat(2, 1fr);
51
+ }
52
+ }
53
+
54
+ @media (max-width: 480px) {
55
+ .cols-2,
56
+ .cols-3,
57
+ .cols-4 {
58
+ grid-template-columns: 1fr;
59
+ }
60
+ }
61
+
62
+ .item {
63
+ text-align: center;
64
+ }
65
+
66
+ .value {
67
+ font-size: var(--font-size-5xl);
68
+ font-weight: var(--font-weight-bold);
69
+ line-height: var(--line-height-tight);
70
+ color: var(--color-brand-primary);
71
+ margin: 0;
72
+ }
73
+
74
+ .label {
75
+ margin: var(--spacing-2) 0 0;
76
+ display: flex;
77
+ flex-direction: column;
78
+ gap: var(--spacing-1);
79
+ }
80
+
81
+ .labelText {
82
+ font-weight: var(--font-weight-semibold);
83
+ }
84
+
85
+ .description {
86
+ font-size: var(--font-size-sm);
87
+ color: var(--color-text-secondary);
88
+ }
@@ -0,0 +1,44 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { Stats } from './Stats.js'
3
+
4
+ const meta: Meta<typeof Stats> = {
5
+ title: 'Stats',
6
+ component: Stats,
7
+ }
8
+
9
+ export default meta
10
+
11
+ type Story = StoryObj<typeof Stats>
12
+
13
+ export const ThreeStats: Story = {
14
+ args: {
15
+ eyebrow: 'Impact',
16
+ headline: 'Year in review',
17
+ items: [
18
+ { value: '128', label: 'Grants awarded', description: 'Across twelve programs' },
19
+ { value: '$4.2M', label: 'Total funded' },
20
+ { value: '87%', label: 'Completion rate', description: 'Measured over five years' },
21
+ ],
22
+ },
23
+ }
24
+
25
+ export const TwoStats: Story = {
26
+ args: {
27
+ items: [
28
+ { value: '30', label: 'Partner organizations' },
29
+ { value: '1,200', label: 'Researchers supported' },
30
+ ],
31
+ },
32
+ }
33
+
34
+ export const FourStats: Story = {
35
+ args: {
36
+ headline: 'By the numbers',
37
+ items: [
38
+ { value: '12', label: 'Countries' },
39
+ { value: '48', label: 'Programs' },
40
+ { value: '300+', label: 'Publications' },
41
+ { value: '5M', label: 'People reached' },
42
+ ],
43
+ },
44
+ }
@@ -0,0 +1,44 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { Stats } from './Stats.js'
4
+
5
+ describe('Stats', () => {
6
+ it('renders each item as a dt/dd pair inside a dl', () => {
7
+ const { container } = render(
8
+ <Stats
9
+ items={[
10
+ { value: '100', label: 'Projects' },
11
+ { value: '50', label: 'Partners' },
12
+ ]}
13
+ />,
14
+ )
15
+ expect(container.querySelector('dl')).not.toBeNull()
16
+ const dts = container.querySelectorAll('dt')
17
+ const dds = container.querySelectorAll('dd')
18
+ expect(dts).toHaveLength(2)
19
+ expect(dds).toHaveLength(2)
20
+ expect(dts[0]).toHaveTextContent('100')
21
+ expect(dds[0]).toHaveTextContent('Projects')
22
+ })
23
+
24
+ it('renders optional eyebrow + headline', () => {
25
+ render(
26
+ <Stats
27
+ eyebrow="Impact"
28
+ headline="Summary"
29
+ items={[{ value: '1', label: 'Thing' }]}
30
+ />,
31
+ )
32
+ expect(screen.getByText('Impact')).toBeInTheDocument()
33
+ expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent('Summary')
34
+ })
35
+
36
+ it('renders item description when present', () => {
37
+ render(
38
+ <Stats
39
+ items={[{ value: '1', label: 'Thing', description: 'Extra context' }]}
40
+ />,
41
+ )
42
+ expect(screen.getByText('Extra context')).toBeInTheDocument()
43
+ })
44
+ })