@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,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'
@@ -0,0 +1,78 @@
1
+ import type { ComponentContract } from '@forumone/throughline-design-contract'
2
+
3
+ export const contract: ComponentContract = {
4
+ name: 'CardGrid',
5
+ category: 'section',
6
+ description:
7
+ 'A responsive layout container for two, three, or four Cards per row. Reflows to fewer columns on narrow viewports.',
8
+ intent:
9
+ 'Lay out multiple Card components inside a section. Use for program lists, blog indexes, staff directories, and any collection of similarly-shaped content. CardGrid is purely layout; it does not fetch data or filter — authors list the Cards they want.',
10
+
11
+ composition: {
12
+ placement: ['section'],
13
+ maxPerPage: null,
14
+ requiredSiblings: ['Card'],
15
+ forbiddenAdjacent: [],
16
+ allowedSlots: { children: ['Card'] },
17
+ },
18
+
19
+ content: {
20
+ fields: [
21
+ {
22
+ name: 'children',
23
+ type: 'array',
24
+ required: true,
25
+ constraints: 'An array of Card components',
26
+ of: [{ name: 'card', type: 'group', required: true }],
27
+ },
28
+ ],
29
+ variants: [
30
+ { name: '2', description: 'Two columns', whenToUse: 'Lists of 2-4 prominent items' },
31
+ { name: '3', description: 'Three columns', whenToUse: 'Default — works for most collections' },
32
+ { name: '4', description: 'Four columns', whenToUse: 'Denser lists of 8 or more short cards' },
33
+ ],
34
+ },
35
+
36
+ tokens: {
37
+ consumes: ['spacing.container', 'spacing.6'],
38
+ configurable: [
39
+ { prop: 'columns', tokenGroup: 'layout', allowedValues: ['2', '3', '4'] },
40
+ ],
41
+ },
42
+
43
+ accessibility: {
44
+ keyboardSupport: [],
45
+ screenReaderBehavior:
46
+ 'Purely visual layout. Screen readers traverse children in source order; CardGrid itself adds no ARIA.',
47
+ contentWarnings: [
48
+ 'Children must be Cards; mixing other components breaks the visual rhythm',
49
+ 'Keep all cards roughly the same content length — dramatically unequal cards look broken',
50
+ ],
51
+ },
52
+
53
+ examples: [
54
+ { label: 'Two columns', intent: 'Featured program pair', storyId: 'card-grid--two-column' },
55
+ { label: 'Three columns', intent: 'Standard content index', storyId: 'card-grid--three-column' },
56
+ { label: 'Four columns', intent: 'Dense staff directory', storyId: 'card-grid--four-column' },
57
+ ],
58
+
59
+ antiExamples: [
60
+ {
61
+ label: 'Mixing Cards and non-Card children',
62
+ why: 'The grid assumes uniform children; mixing Stats or MediaBlocks produces broken columns',
63
+ useInstead: 'Group by type — one CardGrid, then the other components below',
64
+ },
65
+ {
66
+ label: 'Using CardGrid with a single Card',
67
+ why: 'A CardGrid wrapping one Card is overhead without benefit',
68
+ useInstead: 'Render the Card directly',
69
+ },
70
+ ],
71
+
72
+ behavior: {
73
+ fetchesData: false,
74
+ hasClientState: false,
75
+ animates: false,
76
+ requiresAnalytics: false,
77
+ },
78
+ }
@@ -0,0 +1,34 @@
1
+ .grid {
2
+ max-width: 72rem;
3
+ margin: 0 auto;
4
+ padding: 0 var(--spacing-container);
5
+ display: grid;
6
+ gap: var(--spacing-6);
7
+ }
8
+
9
+ .cols-2 {
10
+ grid-template-columns: repeat(2, minmax(0, 1fr));
11
+ }
12
+
13
+ .cols-3 {
14
+ grid-template-columns: repeat(3, minmax(0, 1fr));
15
+ }
16
+
17
+ .cols-4 {
18
+ grid-template-columns: repeat(4, minmax(0, 1fr));
19
+ }
20
+
21
+ @media (max-width: 960px) {
22
+ .cols-3,
23
+ .cols-4 {
24
+ grid-template-columns: repeat(2, minmax(0, 1fr));
25
+ }
26
+ }
27
+
28
+ @media (max-width: 600px) {
29
+ .cols-2,
30
+ .cols-3,
31
+ .cols-4 {
32
+ grid-template-columns: 1fr;
33
+ }
34
+ }
@@ -0,0 +1,36 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { Card } from '../Card/Card.js'
3
+ import { CardGrid } from './CardGrid.js'
4
+
5
+ const meta: Meta<typeof CardGrid> = {
6
+ title: 'Card Grid',
7
+ component: CardGrid,
8
+ }
9
+
10
+ export default meta
11
+
12
+ type Story = StoryObj<typeof CardGrid>
13
+
14
+ function sampleCards(count: number) {
15
+ return Array.from({ length: count }, (_, index) => (
16
+ <Card
17
+ key={index}
18
+ image={{ url: `https://placehold.co/640x360?text=${index + 1}`, alt: `Placeholder ${index + 1}` }}
19
+ title={`Program ${index + 1}`}
20
+ description="Short description of this program or piece of content."
21
+ link={{ label: 'Learn more', url: '#' }}
22
+ />
23
+ ))
24
+ }
25
+
26
+ export const TwoColumn: Story = {
27
+ args: { columns: 2, children: sampleCards(4) },
28
+ }
29
+
30
+ export const ThreeColumn: Story = {
31
+ args: { columns: 3, children: sampleCards(6) },
32
+ }
33
+
34
+ export const FourColumn: Story = {
35
+ args: { columns: 4, children: sampleCards(8) },
36
+ }
@@ -0,0 +1,25 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { CardGrid } from './CardGrid.js'
4
+
5
+ describe('CardGrid', () => {
6
+ it('renders children', () => {
7
+ render(
8
+ <CardGrid>
9
+ <div data-testid="a">A</div>
10
+ <div data-testid="b">B</div>
11
+ </CardGrid>,
12
+ )
13
+ expect(screen.getByTestId('a')).toBeInTheDocument()
14
+ expect(screen.getByTestId('b')).toBeInTheDocument()
15
+ })
16
+
17
+ it('applies the columns modifier class', () => {
18
+ const { container } = render(
19
+ <CardGrid columns={4}>
20
+ <div />
21
+ </CardGrid>,
22
+ )
23
+ expect((container.firstChild as HTMLElement).className).toContain('cols-4')
24
+ })
25
+ })
@@ -0,0 +1,19 @@
1
+ import { clsx } from 'clsx'
2
+ import type { ReactNode } from 'react'
3
+ import styles from './CardGrid.module.css'
4
+
5
+ export type CardGridColumns = 2 | 3 | 4
6
+
7
+ export interface CardGridProps {
8
+ children: ReactNode
9
+ columns?: CardGridColumns
10
+ className?: string
11
+ }
12
+
13
+ export function CardGrid({ children, columns = 3, className }: CardGridProps) {
14
+ return (
15
+ <div className={clsx(styles.grid, styles[`cols-${columns}`], className)}>
16
+ {children}
17
+ </div>
18
+ )
19
+ }
@@ -0,0 +1,2 @@
1
+ export { CardGrid, type CardGridProps, type CardGridColumns } from './CardGrid.js'
2
+ export { contract as cardGridContract } from './CardGrid.contract.js'
@@ -0,0 +1,87 @@
1
+ import type { ComponentContract } from '@forumone/throughline-design-contract'
2
+
3
+ export const contract: ComponentContract = {
4
+ name: 'Divider',
5
+ category: 'utility',
6
+ description: 'A horizontal rule for separating sections of content.',
7
+ intent:
8
+ 'Mark a visual pause between two blocks of content without introducing a new section header. Use sparingly — a SectionIntro is usually a better signal of a new topic. Divider is for rhythm, not hierarchy.',
9
+
10
+ composition: {
11
+ placement: ['page', 'section', 'inline'],
12
+ maxPerPage: null,
13
+ requiredSiblings: [],
14
+ forbiddenAdjacent: ['Divider'],
15
+ },
16
+
17
+ content: {
18
+ fields: [
19
+ {
20
+ name: 'spacing',
21
+ type: 'select',
22
+ required: false,
23
+ constraints: 'compact | default | spacious',
24
+ },
25
+ {
26
+ name: 'decorative',
27
+ type: 'boolean',
28
+ required: false,
29
+ constraints: 'When true (default), the divider is aria-hidden',
30
+ },
31
+ ],
32
+ variants: [
33
+ { name: 'compact', description: 'Small vertical margin', whenToUse: 'Between tight pieces of content inside a list' },
34
+ { name: 'default', description: 'Standard vertical margin', whenToUse: 'Most cases' },
35
+ { name: 'spacious', description: 'Large vertical margin', whenToUse: 'Between major sections of a page' },
36
+ ],
37
+ },
38
+
39
+ tokens: {
40
+ consumes: [
41
+ 'color.border.default',
42
+ 'spacing.24',
43
+ 'spacing.12',
44
+ 'spacing.6',
45
+ ],
46
+ configurable: [
47
+ { prop: 'spacing', tokenGroup: 'spacing', allowedValues: ['compact', 'default', 'spacious'] },
48
+ ],
49
+ },
50
+
51
+ accessibility: {
52
+ role: 'separator',
53
+ keyboardSupport: [],
54
+ screenReaderBehavior:
55
+ 'When decorative=true (default) the divider is marked aria-hidden and assistive tech skips it. When decorative=false it announces as a separator.',
56
+ contentWarnings: [
57
+ 'Do not stack two Dividers in a row',
58
+ ],
59
+ },
60
+
61
+ examples: [
62
+ { label: 'Default', intent: 'Standard section break', storyId: 'divider--default' },
63
+ { label: 'Compact', intent: 'Between related list items', storyId: 'divider--compact' },
64
+ { label: 'Spacious', intent: 'Between major page sections', storyId: 'divider--spacious' },
65
+ { label: 'Meaningful', intent: 'Semantic section break announced by screen readers', storyId: 'divider--meaningful' },
66
+ ],
67
+
68
+ antiExamples: [
69
+ {
70
+ label: 'Using Divider instead of SectionIntro',
71
+ why: 'Dividers carry no semantic meaning; readers cannot tell a new section has started without the accompanying headline',
72
+ useInstead: 'SectionIntro',
73
+ },
74
+ {
75
+ label: 'Decorative dividers announced to screen readers',
76
+ why: 'Adds noise without value',
77
+ useInstead: 'Leave decorative=true (the default)',
78
+ },
79
+ ],
80
+
81
+ behavior: {
82
+ fetchesData: false,
83
+ hasClientState: false,
84
+ animates: false,
85
+ requiresAnalytics: false,
86
+ },
87
+ }
@@ -0,0 +1,18 @@
1
+ .divider {
2
+ border: 0;
3
+ border-top: 1px solid var(--color-border-default);
4
+ max-width: 72rem;
5
+ margin-inline: auto;
6
+ }
7
+
8
+ .compact {
9
+ margin-block: var(--spacing-6);
10
+ }
11
+
12
+ .default {
13
+ margin-block: var(--spacing-12);
14
+ }
15
+
16
+ .spacious {
17
+ margin-block: var(--spacing-24);
18
+ }
@@ -0,0 +1,35 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { Divider } from './Divider.js'
3
+
4
+ const meta: Meta<typeof Divider> = {
5
+ title: 'Divider',
6
+ component: Divider,
7
+ }
8
+
9
+ export default meta
10
+
11
+ type Story = StoryObj<typeof Divider>
12
+
13
+ export const Default: Story = {
14
+ render: (args) => (
15
+ <div>
16
+ <p>Above the divider.</p>
17
+ <Divider {...args} />
18
+ <p>Below the divider.</p>
19
+ </div>
20
+ ),
21
+ }
22
+
23
+ export const Compact: Story = { ...Default, args: { spacing: 'compact' } }
24
+ export const Spacious: Story = { ...Default, args: { spacing: 'spacious' } }
25
+ export const Meaningful: Story = {
26
+ ...Default,
27
+ args: { decorative: false },
28
+ parameters: {
29
+ docs: {
30
+ description: {
31
+ story: 'Use decorative={false} when the divider marks a meaningful section break that assistive technology should announce.',
32
+ },
33
+ },
34
+ },
35
+ }
@@ -0,0 +1,20 @@
1
+ import { render } from '@testing-library/react'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { Divider } from './Divider.js'
4
+
5
+ describe('Divider', () => {
6
+ it('renders a hr element', () => {
7
+ const { container } = render(<Divider />)
8
+ expect(container.querySelector('hr')).not.toBeNull()
9
+ })
10
+
11
+ it('defaults to aria-hidden for decorative use', () => {
12
+ const { container } = render(<Divider />)
13
+ expect(container.querySelector('hr')).toHaveAttribute('aria-hidden', 'true')
14
+ })
15
+
16
+ it('removes aria-hidden when not decorative', () => {
17
+ const { container } = render(<Divider decorative={false} />)
18
+ expect(container.querySelector('hr')).not.toHaveAttribute('aria-hidden')
19
+ })
20
+ })
@@ -0,0 +1,17 @@
1
+ import { clsx } from 'clsx'
2
+ import styles from './Divider.module.css'
3
+
4
+ export type DividerSpacing = 'compact' | 'default' | 'spacious'
5
+
6
+ export interface DividerProps {
7
+ spacing?: DividerSpacing
8
+ decorative?: boolean
9
+ className?: string
10
+ }
11
+
12
+ export function Divider({ spacing = 'default', decorative = true, className }: DividerProps) {
13
+ const ariaProps = decorative
14
+ ? { role: 'presentation', 'aria-hidden': true as const }
15
+ : {}
16
+ return <hr {...ariaProps} className={clsx(styles.divider, styles[spacing], className)} />
17
+ }
@@ -0,0 +1,2 @@
1
+ export { Divider, type DividerProps, type DividerSpacing } from './Divider.js'
2
+ export { contract as dividerContract } from './Divider.contract.js'
@@ -0,0 +1,104 @@
1
+ import type { ComponentContract } from '@forumone/throughline-design-contract'
2
+
3
+ export const contract: ComponentContract = {
4
+ name: 'FAQ',
5
+ category: 'data',
6
+ description:
7
+ 'A disclosure-style question and answer list using native <details>/<summary> for keyboard accessibility with no JavaScript.',
8
+ intent:
9
+ 'Answer the predictable questions readers arrive with. Use for program applications, purchase funnels, onboarding pages — anywhere the same three to ten questions come up repeatedly. Not for searchable knowledge bases (those need their own search UI).',
10
+
11
+ composition: {
12
+ placement: ['section'],
13
+ maxPerPage: 2,
14
+ requiredSiblings: [],
15
+ forbiddenAdjacent: [],
16
+ },
17
+
18
+ content: {
19
+ fields: [
20
+ {
21
+ name: 'headline',
22
+ type: 'text',
23
+ required: false,
24
+ maxLength: 100,
25
+ },
26
+ {
27
+ name: 'items',
28
+ type: 'array',
29
+ required: true,
30
+ constraints: 'Three to ten items; more than that needs its own dedicated page',
31
+ of: [
32
+ { name: 'question', type: 'text', required: true, maxLength: 200 },
33
+ { name: 'answer', type: 'richtext', required: true },
34
+ ],
35
+ },
36
+ {
37
+ name: 'defaultOpenFirst',
38
+ type: 'boolean',
39
+ required: false,
40
+ constraints: 'Set true when the first question is one readers always ask',
41
+ },
42
+ ],
43
+ },
44
+
45
+ tokens: {
46
+ consumes: [
47
+ 'color.bg.primary',
48
+ 'color.text.primary',
49
+ 'color.text.secondary',
50
+ 'color.border.default',
51
+ 'color.border.strong',
52
+ 'spacing.16',
53
+ 'spacing.8',
54
+ 'spacing.5',
55
+ 'spacing.4',
56
+ 'spacing.2',
57
+ 'spacing.container',
58
+ 'font.size.3xl',
59
+ 'font.size.xl',
60
+ 'font.weight.semibold',
61
+ 'line.height.relaxed',
62
+ 'radius.md',
63
+ ],
64
+ },
65
+
66
+ accessibility: {
67
+ keyboardSupport: [
68
+ 'Tab to each question summary',
69
+ 'Enter or Space opens/closes the current item',
70
+ ],
71
+ screenReaderBehavior:
72
+ 'Each question is a <summary> inside <details>, so screen readers announce open/closed state natively. The answer becomes available to assistive tech when the item is expanded.',
73
+ contentWarnings: [
74
+ 'Keep answers short and scannable — a paragraph at most',
75
+ 'Do not nest FAQs inside FAQs',
76
+ ],
77
+ },
78
+
79
+ examples: [
80
+ { label: 'Default', intent: 'Program application FAQ', storyId: 'faq--default' },
81
+ { label: 'First item open', intent: 'Lead with the most common question already answered', storyId: 'faq--open-first' },
82
+ { label: 'Without headline', intent: 'Inline FAQ under a larger section', storyId: 'faq--no-headline' },
83
+ ],
84
+
85
+ antiExamples: [
86
+ {
87
+ label: 'Twenty-question FAQ',
88
+ why: 'Past about ten items the disclosure pattern breaks down; readers cannot scan the list',
89
+ useInstead: 'Split into topic-specific FAQs across multiple pages, or build a searchable knowledge base',
90
+ },
91
+ {
92
+ label: 'Using FAQ to hide primary content',
93
+ why: 'Important content should be visible by default, not hidden behind a click',
94
+ useInstead: 'Prose or a proper section with a SectionIntro',
95
+ },
96
+ ],
97
+
98
+ behavior: {
99
+ fetchesData: false,
100
+ hasClientState: true,
101
+ animates: false,
102
+ requiresAnalytics: false,
103
+ },
104
+ }