@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,89 @@
1
+ import { readFile, readdir, stat, writeFile, mkdir } from 'node:fs/promises'
2
+ import { dirname, join, resolve } from 'node:path'
3
+ import { pathToFileURL, fileURLToPath } from 'node:url'
4
+ import {
5
+ CONTRACT_VERSION,
6
+ ManifestSchema,
7
+ type ComponentContract,
8
+ } from '@forumone/throughline-design-contract'
9
+ import { getTokenList } from '../src/tokens/index.ts'
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url))
12
+ const packageRoot = resolve(__dirname, '..')
13
+ const componentsDir = resolve(packageRoot, 'src/components')
14
+ const outputDir = resolve(packageRoot, 'dist')
15
+ const outputFile = resolve(outputDir, 'manifest.json')
16
+
17
+ async function readPackageVersion(): Promise<string> {
18
+ const raw = await readFile(resolve(packageRoot, 'package.json'), 'utf8')
19
+ const parsed = JSON.parse(raw) as { version?: string }
20
+ return parsed.version ?? '0.0.0'
21
+ }
22
+
23
+ async function collectContracts(): Promise<Record<string, ComponentContract>> {
24
+ const components: Record<string, ComponentContract> = {}
25
+
26
+ let entries: string[]
27
+ try {
28
+ entries = await readdir(componentsDir)
29
+ } catch {
30
+ return components
31
+ }
32
+
33
+ for (const name of entries) {
34
+ const dir = resolve(componentsDir, name)
35
+ const dirStat = await stat(dir).catch(() => null)
36
+ if (!dirStat?.isDirectory()) continue
37
+
38
+ const contractPath = join(dir, `${name}.contract.ts`)
39
+ const contractStat = await stat(contractPath).catch(() => null)
40
+ if (!contractStat?.isFile()) continue
41
+
42
+ const module = (await import(pathToFileURL(contractPath).href)) as {
43
+ contract?: ComponentContract
44
+ }
45
+ if (!module.contract) {
46
+ throw new Error(`${name}.contract.ts does not export "contract"`)
47
+ }
48
+ components[name] = module.contract
49
+ }
50
+
51
+ return components
52
+ }
53
+
54
+ async function main() {
55
+ const components = await collectContracts()
56
+
57
+ const manifest = {
58
+ contractVersion: CONTRACT_VERSION,
59
+ designSystem: {
60
+ name: '@forumone/throughline-reference-ds',
61
+ version: await readPackageVersion(),
62
+ description:
63
+ 'A brand-neutral reference design system demonstrating contract compliance for Throughline.',
64
+ },
65
+ tokens: getTokenList(),
66
+ components,
67
+ build: {
68
+ timestamp: new Date().toISOString(),
69
+ source: 'scripts/build-manifest.ts',
70
+ },
71
+ }
72
+
73
+ const result = ManifestSchema.safeParse(manifest)
74
+ if (!result.success) {
75
+ console.error('Generated manifest failed validation:')
76
+ for (const issue of result.error.issues) {
77
+ console.error(` - ${issue.path.join('.') || '(root)'}: ${issue.message}`)
78
+ }
79
+ process.exit(1)
80
+ }
81
+
82
+ await mkdir(outputDir, { recursive: true })
83
+ await writeFile(outputFile, JSON.stringify(result.data, null, 2), 'utf8')
84
+
85
+ const componentCount = Object.keys(components).length
86
+ console.log(`Wrote ${outputFile} (${componentCount} components)`)
87
+ }
88
+
89
+ await main()
@@ -0,0 +1,28 @@
1
+ import { copyFile, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
2
+ import { basename, dirname, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url))
6
+ const stylesSrc = resolve(__dirname, '../src/styles')
7
+ const stylesDist = resolve(__dirname, '../dist/styles')
8
+
9
+ await mkdir(stylesDist, { recursive: true })
10
+
11
+ const entries = await readdir(stylesSrc)
12
+ const cssFiles = entries.filter((f) => f.endsWith('.css'))
13
+
14
+ for (const file of cssFiles) {
15
+ await copyFile(resolve(stylesSrc, file), resolve(stylesDist, file))
16
+ }
17
+
18
+ // Also emit a combined bundle so consumers can import `@forumone/throughline-reference-ds/styles.css`.
19
+ const bundleParts: string[] = []
20
+ for (const file of ['reset.css', 'tokens.css', 'base.css']) {
21
+ if (cssFiles.includes(file)) {
22
+ const content = await readFile(resolve(stylesSrc, file), 'utf8')
23
+ bundleParts.push(`/* ${basename(file)} */\n${content.trim()}\n`)
24
+ }
25
+ }
26
+ await writeFile(resolve(stylesDist, 'index.css'), bundleParts.join('\n'), 'utf8')
27
+
28
+ console.log(`Copied ${cssFiles.length} CSS files to dist/styles`)
@@ -0,0 +1,43 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises'
2
+ import { dirname, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { colors, darkOverrides } from '../src/tokens/colors.ts'
5
+ import { layout } from '../src/tokens/layout.ts'
6
+ import { radii } from '../src/tokens/radii.ts'
7
+ import { spacing } from '../src/tokens/spacing.ts'
8
+ import { typography } from '../src/tokens/typography.ts'
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url))
11
+ const outputPath = resolve(__dirname, '../src/styles/tokens.css')
12
+
13
+ /** Turns `color.brand.primaryHover` into `--color-brand-primary-hover`. */
14
+ function tokenToVariable(name: string): string {
15
+ const kebab = name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
16
+ return `--${kebab.replace(/\./g, '-')}`
17
+ }
18
+
19
+ function section(title: string, entries: Array<readonly [string, string]>): string {
20
+ const rules = entries.map(([name, value]) => ` ${tokenToVariable(name)}: ${value};`).join('\n')
21
+ return ` /* ${title} */\n${rules}`
22
+ }
23
+
24
+ const light = [
25
+ section('Colors', Object.entries(colors)),
26
+ section('Typography', Object.entries(typography)),
27
+ section('Spacing', Object.entries(spacing)),
28
+ section('Radii', Object.entries(radii)),
29
+ section('Layout & Containers', Object.entries(layout)),
30
+ ].join('\n\n')
31
+
32
+ const dark = Object.entries(darkOverrides)
33
+ .map(([name, value]) => ` ${tokenToVariable(name)}: ${value};`)
34
+ .join('\n')
35
+
36
+ const banner = '/* Auto-generated by scripts/build-tokens-css.ts. Do not edit by hand. */'
37
+
38
+ const output = `${banner}\n\n:root {\n${light}\n}\n\n@media (prefers-color-scheme: dark) {\n :root {\n${dark}\n }\n}\n`
39
+
40
+ await mkdir(dirname(outputPath), { recursive: true })
41
+ await writeFile(outputPath, output, 'utf8')
42
+
43
+ console.log(`Wrote ${outputPath}`)
@@ -0,0 +1,58 @@
1
+ import { readFile, stat } from 'node:fs/promises'
2
+ import { dirname, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { loadManifest } from '@forumone/throughline-design-contract'
5
+ import { formatLintIssues, lintManifest } from '@forumone/throughline-design-contract/lint'
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url))
8
+ const packageRoot = resolve(__dirname, '..')
9
+ const manifestPath = resolve(packageRoot, 'dist/manifest.json')
10
+ const storybookIndexPath = resolve(packageRoot, 'storybook-static/index.json')
11
+
12
+ async function readJson<T>(path: string): Promise<T> {
13
+ const raw = await readFile(path, 'utf8')
14
+ return JSON.parse(raw) as T
15
+ }
16
+
17
+ async function collectStoryIds(): Promise<Set<string> | undefined> {
18
+ const exists = await stat(storybookIndexPath).catch(() => null)
19
+ if (!exists) {
20
+ console.warn(
21
+ `No storybook-static/index.json found; skipping storyId lint. Run \`pnpm build-storybook\` first to enable it.`,
22
+ )
23
+ return undefined
24
+ }
25
+ const index = await readJson<{ entries: Record<string, { id: string; type: string }> }>(
26
+ storybookIndexPath,
27
+ )
28
+ const ids = new Set<string>()
29
+ for (const entry of Object.values(index.entries)) {
30
+ if (entry.type === 'story') ids.add(entry.id)
31
+ }
32
+ return ids
33
+ }
34
+
35
+ async function main() {
36
+ const raw = await readJson<unknown>(manifestPath)
37
+ const loaded = loadManifest(raw)
38
+
39
+ const availableStoryIds = await collectStoryIds()
40
+ const lintOptions = availableStoryIds ? { availableStoryIds } : {}
41
+
42
+ const issues = lintManifest(loaded.raw, lintOptions)
43
+ const errors = issues.filter((i) => i.severity === 'error')
44
+ const warnings = issues.filter((i) => i.severity === 'warning')
45
+
46
+ if (errors.length > 0) {
47
+ console.error(formatLintIssues(errors))
48
+ process.exit(1)
49
+ }
50
+
51
+ if (warnings.length > 0) {
52
+ console.warn(formatLintIssues(warnings))
53
+ } else {
54
+ console.log(`Manifest is clean (${loaded.listComponents().length} components).`)
55
+ }
56
+ }
57
+
58
+ await main()
@@ -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
+ }