@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,128 @@
1
+ import type { ComponentContract } from '@forumone/throughline-design-contract'
2
+
3
+ export const contract: ComponentContract = {
4
+ name: 'CTASection',
5
+ category: 'cta',
6
+ description:
7
+ 'A page-bottom call to action with a headline, optional supporting copy, and one or two buttons.',
8
+ intent:
9
+ 'Close a page with a clear next step. Use at the end of program pages, campaigns, and marketing pages where the reader has enough context to act. Do not use at the top of a page (that is Hero\'s job) or as a section opener (that is SectionIntro\'s).',
10
+
11
+ composition: {
12
+ placement: ['page', 'section'],
13
+ maxPerPage: 2,
14
+ requiredSiblings: [],
15
+ forbiddenAdjacent: ['CTASection'],
16
+ },
17
+
18
+ content: {
19
+ fields: [
20
+ {
21
+ name: 'headline',
22
+ type: 'text',
23
+ required: true,
24
+ maxLength: 100,
25
+ constraints: 'Direct, imperative; reads like an invitation',
26
+ },
27
+ {
28
+ name: 'body',
29
+ type: 'text',
30
+ required: false,
31
+ maxLength: 200,
32
+ constraints: 'One sentence that reduces friction to clicking',
33
+ },
34
+ {
35
+ name: 'cta',
36
+ type: 'group',
37
+ required: true,
38
+ of: [
39
+ { name: 'label', type: 'text', required: true, maxLength: 30 },
40
+ { name: 'url', type: 'link', required: true },
41
+ ],
42
+ },
43
+ {
44
+ name: 'secondaryCta',
45
+ type: 'group',
46
+ required: false,
47
+ of: [
48
+ { name: 'label', type: 'text', required: true, maxLength: 30 },
49
+ { name: 'url', type: 'link', required: true },
50
+ ],
51
+ },
52
+ ],
53
+ variants: [
54
+ { name: 'primary', description: 'Standard background', whenToUse: 'Default' },
55
+ { name: 'secondary', description: 'Subtle grey background', whenToUse: 'Most page closes — less visual weight than Hero' },
56
+ { name: 'inverse', description: 'Dark background', whenToUse: 'Campaign pages or high-stakes calls to action' },
57
+ ],
58
+ },
59
+
60
+ tokens: {
61
+ consumes: [
62
+ 'color.bg.primary',
63
+ 'color.bg.secondary',
64
+ 'color.bg.inverse',
65
+ 'color.text.primary',
66
+ 'color.text.secondary',
67
+ 'color.text.inverse',
68
+ 'color.brand.primary',
69
+ 'color.brand.primaryHover',
70
+ 'color.border.strong',
71
+ 'spacing.section',
72
+ 'spacing.container',
73
+ 'spacing.8',
74
+ 'spacing.6',
75
+ 'spacing.4',
76
+ 'spacing.3',
77
+ 'font.size.4xl',
78
+ 'font.size.lg',
79
+ 'font.weight.bold',
80
+ 'font.weight.semibold',
81
+ 'line.height.tight',
82
+ 'line.height.relaxed',
83
+ 'radius.md',
84
+ ],
85
+ configurable: [
86
+ {
87
+ prop: 'background',
88
+ tokenGroup: 'color.bg',
89
+ allowedValues: ['primary', 'secondary', 'inverse'],
90
+ },
91
+ ],
92
+ },
93
+
94
+ accessibility: {
95
+ keyboardSupport: ['Tab to primary CTA', 'Tab to secondary CTA'],
96
+ screenReaderBehavior:
97
+ 'The headline is announced as an h2. CTAs are standard links; their label text is announced as the link name.',
98
+ contentWarnings: [
99
+ 'Avoid verb-only labels ("Click", "Go") — include the destination ("Start building", "Read the docs")',
100
+ ],
101
+ },
102
+
103
+ examples: [
104
+ { label: 'Default', intent: 'Two-button page close', storyId: 'cta-section--default' },
105
+ { label: 'Primary only', intent: 'Single-action CTA', storyId: 'cta-section--primary-only' },
106
+ { label: 'Inverse', intent: 'High-contrast campaign CTA', storyId: 'cta-section--inverse' },
107
+ ],
108
+
109
+ antiExamples: [
110
+ {
111
+ label: 'Three or more CTAs',
112
+ why: 'Too many options reduces conversion; pick the top one, put the rest on a detail page',
113
+ useInstead: 'One primary + optional secondary',
114
+ },
115
+ {
116
+ label: 'Using CTASection as a page opener',
117
+ why: 'Readers cannot convert without context; CTA belongs at the end',
118
+ useInstead: 'Hero',
119
+ },
120
+ ],
121
+
122
+ behavior: {
123
+ fetchesData: false,
124
+ hasClientState: false,
125
+ animates: false,
126
+ requiresAnalytics: true,
127
+ },
128
+ }
@@ -0,0 +1,83 @@
1
+ .cta {
2
+ padding: var(--spacing-section) 0;
3
+ text-align: center;
4
+ }
5
+
6
+ .bg-primary {
7
+ background: var(--color-bg-primary);
8
+ }
9
+
10
+ .bg-secondary {
11
+ background: var(--color-bg-secondary);
12
+ }
13
+
14
+ .bg-inverse {
15
+ background: var(--color-bg-inverse);
16
+ color: var(--color-text-inverse);
17
+ }
18
+
19
+ .container {
20
+ max-width: 48rem;
21
+ margin: 0 auto;
22
+ padding: 0 var(--spacing-container);
23
+ }
24
+
25
+ .headline {
26
+ font-size: var(--font-size-4xl);
27
+ font-weight: var(--font-weight-bold);
28
+ line-height: var(--line-height-tight);
29
+ margin-bottom: var(--spacing-4);
30
+ }
31
+
32
+ .body {
33
+ font-size: var(--font-size-lg);
34
+ color: var(--color-text-secondary);
35
+ line-height: var(--line-height-relaxed);
36
+ margin-bottom: var(--spacing-8);
37
+ }
38
+
39
+ .bg-inverse .body {
40
+ color: var(--color-text-inverse);
41
+ opacity: 0.85;
42
+ }
43
+
44
+ .actions {
45
+ display: flex;
46
+ gap: var(--spacing-4);
47
+ justify-content: center;
48
+ flex-wrap: wrap;
49
+ }
50
+
51
+ .primary,
52
+ .secondary {
53
+ display: inline-flex;
54
+ align-items: center;
55
+ padding: var(--spacing-3) var(--spacing-6);
56
+ border-radius: var(--radius-md);
57
+ font-weight: var(--font-weight-semibold);
58
+ text-decoration: none;
59
+ transition: background-color 0.15s ease, border-color 0.15s ease;
60
+ }
61
+
62
+ .primary {
63
+ background: var(--color-brand-primary);
64
+ color: var(--color-text-inverse);
65
+ }
66
+
67
+ .primary:hover {
68
+ background: var(--color-brand-primary-hover);
69
+ }
70
+
71
+ .secondary {
72
+ border: 1px solid var(--color-border-strong);
73
+ color: var(--color-text-primary);
74
+ }
75
+
76
+ .bg-inverse .secondary {
77
+ border-color: var(--color-text-inverse);
78
+ color: var(--color-text-inverse);
79
+ }
80
+
81
+ .secondary:hover {
82
+ background: var(--color-bg-secondary);
83
+ }
@@ -0,0 +1,39 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { CTASection } from './CTASection.js'
3
+
4
+ const meta: Meta<typeof CTASection> = {
5
+ title: 'CTA Section',
6
+ component: CTASection,
7
+ parameters: { layout: 'fullscreen' },
8
+ }
9
+
10
+ export default meta
11
+
12
+ type Story = StoryObj<typeof CTASection>
13
+
14
+ export const Default: Story = {
15
+ args: {
16
+ headline: 'Ready to get started?',
17
+ body: 'Create your first site in under an hour with a working content model.',
18
+ cta: { label: 'Start building', url: '#' },
19
+ secondaryCta: { label: 'Read the docs', url: '#' },
20
+ },
21
+ }
22
+
23
+ export const PrimaryOnly: Story = {
24
+ args: {
25
+ headline: 'Join the next cohort',
26
+ body: 'Applications close on October 15th.',
27
+ cta: { label: 'Apply now', url: '#' },
28
+ },
29
+ }
30
+
31
+ export const Inverse: Story = {
32
+ args: {
33
+ background: 'inverse',
34
+ headline: 'Support our work',
35
+ body: 'Your contribution makes this research possible.',
36
+ cta: { label: 'Donate', url: '#' },
37
+ secondaryCta: { label: 'Learn more', url: '#' },
38
+ },
39
+ }
@@ -0,0 +1,27 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { CTASection } from './CTASection.js'
4
+
5
+ describe('CTASection', () => {
6
+ it('renders headline as h2 and primary CTA', () => {
7
+ render(
8
+ <CTASection
9
+ headline="Act now"
10
+ cta={{ label: 'Start', url: '/start' }}
11
+ />,
12
+ )
13
+ expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent('Act now')
14
+ expect(screen.getByRole('link', { name: 'Start' })).toHaveAttribute('href', '/start')
15
+ })
16
+
17
+ it('renders secondary CTA when provided', () => {
18
+ render(
19
+ <CTASection
20
+ headline="Act now"
21
+ cta={{ label: 'Start', url: '/start' }}
22
+ secondaryCta={{ label: 'Learn more', url: '/learn' }}
23
+ />,
24
+ )
25
+ expect(screen.getByRole('link', { name: 'Learn more' })).toBeInTheDocument()
26
+ })
27
+ })
@@ -0,0 +1,41 @@
1
+ import { clsx } from 'clsx'
2
+ import styles from './CTASection.module.css'
3
+
4
+ export type CTASectionBackground = 'primary' | 'secondary' | 'inverse'
5
+
6
+ export interface CTASectionProps {
7
+ headline: string
8
+ body?: string
9
+ cta: { label: string; url: string }
10
+ secondaryCta?: { label: string; url: string }
11
+ background?: CTASectionBackground
12
+ className?: string
13
+ }
14
+
15
+ export function CTASection({
16
+ headline,
17
+ body,
18
+ cta,
19
+ secondaryCta,
20
+ background = 'secondary',
21
+ className,
22
+ }: CTASectionProps) {
23
+ return (
24
+ <section className={clsx(styles.cta, styles[`bg-${background}`], className)}>
25
+ <div className={styles.container}>
26
+ <h2 className={styles.headline}>{headline}</h2>
27
+ {body ? <p className={styles.body}>{body}</p> : null}
28
+ <div className={styles.actions}>
29
+ <a href={cta.url} className={styles.primary}>
30
+ {cta.label}
31
+ </a>
32
+ {secondaryCta ? (
33
+ <a href={secondaryCta.url} className={styles.secondary}>
34
+ {secondaryCta.label}
35
+ </a>
36
+ ) : null}
37
+ </div>
38
+ </div>
39
+ </section>
40
+ )
41
+ }
@@ -0,0 +1,2 @@
1
+ export { CTASection, type CTASectionProps, type CTASectionBackground } from './CTASection.js'
2
+ export { contract as ctaSectionContract } from './CTASection.contract.js'
@@ -0,0 +1,122 @@
1
+ import type { ComponentContract } from '@forumone/throughline-design-contract'
2
+
3
+ export const contract: ComponentContract = {
4
+ name: 'Card',
5
+ category: 'card',
6
+ description:
7
+ 'A single content card with optional image, eyebrow, title, description, and link. Renders as a link when a link target is provided.',
8
+ intent:
9
+ 'Surface a single piece of content in a scannable, consistent shape — a program page, a blog post, a case study, a staff bio. Cards are typically laid out in a CardGrid, but a single Card can appear alone inside a section when the content warrants singular emphasis.',
10
+
11
+ composition: {
12
+ placement: ['section', 'inline'],
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: 30,
25
+ constraints: 'Short label such as the content type ("Case study", "Program update")',
26
+ },
27
+ {
28
+ name: 'image',
29
+ type: 'image',
30
+ required: false,
31
+ constraints: 'Landscape orientation; framing works best for images that read at small sizes',
32
+ },
33
+ {
34
+ name: 'title',
35
+ type: 'text',
36
+ required: true,
37
+ maxLength: 100,
38
+ constraints: 'Sentence case; reads like a headline',
39
+ },
40
+ {
41
+ name: 'description',
42
+ type: 'text',
43
+ required: false,
44
+ maxLength: 200,
45
+ constraints: 'One to two sentences that give the reader a reason to click',
46
+ },
47
+ {
48
+ name: 'link',
49
+ type: 'group',
50
+ required: false,
51
+ of: [
52
+ { name: 'label', type: 'text', required: true, maxLength: 30 },
53
+ { name: 'url', type: 'link', required: true },
54
+ ],
55
+ },
56
+ ],
57
+ variants: [
58
+ { name: 'linked', description: 'Entire card is clickable', whenToUse: 'Teaser for a single piece of content' },
59
+ { name: 'static', description: 'No link; presentational only', whenToUse: 'Inline informational cards (team bios, program descriptions) that do not link elsewhere' },
60
+ ],
61
+ },
62
+
63
+ tokens: {
64
+ consumes: [
65
+ 'color.bg.primary',
66
+ 'color.bg.tertiary',
67
+ 'color.text.primary',
68
+ 'color.text.secondary',
69
+ 'color.border.default',
70
+ 'color.border.strong',
71
+ 'color.brand.primary',
72
+ 'spacing.6',
73
+ 'spacing.2',
74
+ 'font.size.xl',
75
+ 'font.size.base',
76
+ 'font.size.sm',
77
+ 'font.size.xs',
78
+ 'font.weight.semibold',
79
+ 'line.height.snug',
80
+ 'line.height.normal',
81
+ 'letter.spacing.wide',
82
+ 'radius.lg',
83
+ ],
84
+ },
85
+
86
+ accessibility: {
87
+ keyboardSupport: ['When linked, the whole card is a single tab stop'],
88
+ screenReaderBehavior:
89
+ 'Linked cards render as a single <a> element announcing the title + description + link label as one unit. Static cards render as <article> with an h3 title.',
90
+ contentWarnings: [
91
+ 'Avoid nested interactive elements inside a linked card (buttons, additional links)',
92
+ 'Always author alt text for card images or omit the image entirely',
93
+ ],
94
+ },
95
+
96
+ examples: [
97
+ { label: 'Default', intent: 'Content teaser with image and link', storyId: 'card--default' },
98
+ { label: 'With eyebrow', intent: 'Labeled teaser highlighting content type', storyId: 'card--with-eyebrow' },
99
+ { label: 'No image', intent: 'Text-only teaser for resources or downloads', storyId: 'card--no-image' },
100
+ { label: 'Static', intent: 'Presentational card without a link target', storyId: 'card--static' },
101
+ ],
102
+
103
+ antiExamples: [
104
+ {
105
+ label: 'Nesting interactive controls inside a linked card',
106
+ why: 'A linked card is a single tab stop; adding buttons or additional links creates nested interactive regions that confuse assistive technology',
107
+ useInstead: 'Use a static card with explicit nested controls, or move secondary actions to a detail page',
108
+ },
109
+ {
110
+ label: 'Using Card as a navigation menu item',
111
+ why: 'Cards are for content teasers; site navigation belongs in a Menu or Nav component',
112
+ useInstead: 'A dedicated navigation component',
113
+ },
114
+ ],
115
+
116
+ behavior: {
117
+ fetchesData: false,
118
+ hasClientState: false,
119
+ animates: false,
120
+ requiresAnalytics: false,
121
+ },
122
+ }
@@ -0,0 +1,64 @@
1
+ .card {
2
+ display: flex;
3
+ flex-direction: column;
4
+ background: var(--color-bg-primary);
5
+ border: 1px solid var(--color-border-default);
6
+ border-radius: var(--radius-lg);
7
+ overflow: hidden;
8
+ text-decoration: none;
9
+ color: inherit;
10
+ transition: box-shadow 0.15s ease, border-color 0.15s ease;
11
+ }
12
+
13
+ .linked:hover {
14
+ border-color: var(--color-border-strong);
15
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
16
+ }
17
+
18
+ .imageFrame {
19
+ aspect-ratio: 16 / 9;
20
+ background: var(--color-bg-tertiary);
21
+ overflow: hidden;
22
+ }
23
+
24
+ .image {
25
+ width: 100%;
26
+ height: 100%;
27
+ object-fit: cover;
28
+ }
29
+
30
+ .content {
31
+ padding: var(--spacing-6);
32
+ display: flex;
33
+ flex-direction: column;
34
+ gap: var(--spacing-2);
35
+ }
36
+
37
+ .eyebrow {
38
+ font-size: var(--font-size-xs);
39
+ font-weight: var(--font-weight-semibold);
40
+ text-transform: uppercase;
41
+ letter-spacing: var(--letter-spacing-wide);
42
+ color: var(--color-text-secondary);
43
+ }
44
+
45
+ .title {
46
+ font-size: var(--font-size-xl);
47
+ font-weight: var(--font-weight-semibold);
48
+ line-height: var(--line-height-snug);
49
+ margin: 0;
50
+ }
51
+
52
+ .description {
53
+ font-size: var(--font-size-base);
54
+ color: var(--color-text-secondary);
55
+ line-height: var(--line-height-normal);
56
+ margin: 0;
57
+ }
58
+
59
+ .linkLabel {
60
+ margin-top: var(--spacing-2);
61
+ color: var(--color-brand-primary);
62
+ font-weight: var(--font-weight-semibold);
63
+ font-size: var(--font-size-sm);
64
+ }
@@ -0,0 +1,45 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { Card } from './Card.js'
3
+
4
+ const meta: Meta<typeof Card> = {
5
+ title: 'Card',
6
+ component: Card,
7
+ }
8
+
9
+ export default meta
10
+
11
+ type Story = StoryObj<typeof Card>
12
+
13
+ export const Default: Story = {
14
+ args: {
15
+ image: { url: 'https://placehold.co/640x360', alt: 'Illustration of a forest' },
16
+ title: 'Reforesting urban lots',
17
+ description: 'Partners across three cities are turning vacant parcels into pollinator habitat.',
18
+ link: { label: 'Read the story', url: '#' },
19
+ },
20
+ }
21
+
22
+ export const WithEyebrow: Story = {
23
+ args: {
24
+ eyebrow: 'Program update',
25
+ image: { url: 'https://placehold.co/640x360', alt: 'Researchers reviewing findings' },
26
+ title: 'Year-one findings from the fellowship cohort',
27
+ description: 'Five researchers, twelve publications, and a clearer picture of where to go next.',
28
+ link: { label: 'View findings', url: '#' },
29
+ },
30
+ }
31
+
32
+ export const NoImage: Story = {
33
+ args: {
34
+ title: 'Policy brief: Community-led adaptation',
35
+ description: 'A short overview of our recommendations for local policymakers.',
36
+ link: { label: 'Download brief', url: '#' },
37
+ },
38
+ }
39
+
40
+ export const Static: Story = {
41
+ args: {
42
+ title: 'Mission statement',
43
+ description: 'Content that supports the work of mission-driven organizations.',
44
+ },
45
+ }
@@ -0,0 +1,37 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { Card } from './Card.js'
4
+
5
+ describe('Card', () => {
6
+ it('renders as a link when a link target is provided', () => {
7
+ render(
8
+ <Card
9
+ title="Linked"
10
+ description="desc"
11
+ link={{ label: 'Read', url: '/read' }}
12
+ />,
13
+ )
14
+ const link = screen.getByRole('link', { name: /linked/i })
15
+ expect(link).toHaveAttribute('href', '/read')
16
+ })
17
+
18
+ it('renders as an article without link', () => {
19
+ const { container } = render(<Card title="Static" description="plain" />)
20
+ const root = container.firstChild as HTMLElement
21
+ expect(root.tagName.toLowerCase()).toBe('article')
22
+ })
23
+
24
+ it('renders image, eyebrow, and description when provided', () => {
25
+ render(
26
+ <Card
27
+ eyebrow="Program"
28
+ image={{ url: '/img.jpg', alt: 'Illustration' }}
29
+ title="Hello"
30
+ description="Some text"
31
+ />,
32
+ )
33
+ expect(screen.getByText('Program')).toBeInTheDocument()
34
+ expect(screen.getByRole('img', { name: 'Illustration' })).toBeInTheDocument()
35
+ expect(screen.getByText('Some text')).toBeInTheDocument()
36
+ })
37
+ })
@@ -0,0 +1,39 @@
1
+ import { clsx } from 'clsx'
2
+ import styles from './Card.module.css'
3
+
4
+ export interface CardProps {
5
+ title: string
6
+ description?: string
7
+ image?: { url: string; alt: string }
8
+ link?: { label: string; url: string }
9
+ eyebrow?: string
10
+ className?: string
11
+ }
12
+
13
+ export function Card({ title, description, image, link, eyebrow, className }: CardProps) {
14
+ const body = (
15
+ <>
16
+ {image ? (
17
+ <div className={styles.imageFrame}>
18
+ <img src={image.url} alt={image.alt} className={styles.image} />
19
+ </div>
20
+ ) : null}
21
+ <div className={styles.content}>
22
+ {eyebrow ? <p className={styles.eyebrow}>{eyebrow}</p> : null}
23
+ <h3 className={styles.title}>{title}</h3>
24
+ {description ? <p className={styles.description}>{description}</p> : null}
25
+ {link ? <span className={styles.linkLabel}>{link.label}</span> : null}
26
+ </div>
27
+ </>
28
+ )
29
+
30
+ if (link) {
31
+ return (
32
+ <a href={link.url} className={clsx(styles.card, styles.linked, className)}>
33
+ {body}
34
+ </a>
35
+ )
36
+ }
37
+
38
+ return <article className={clsx(styles.card, className)}>{body}</article>
39
+ }
@@ -0,0 +1,2 @@
1
+ export { Card, type CardProps } from './Card.js'
2
+ export { contract as cardContract } from './Card.contract.js'