@forumone/create-throughline 0.2.0 → 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 +26 -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,37 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { MediaBlock } from './MediaBlock.js'
3
+
4
+ const meta: Meta<typeof MediaBlock> = {
5
+ title: 'Media Block',
6
+ component: MediaBlock,
7
+ }
8
+
9
+ export default meta
10
+
11
+ type Story = StoryObj<typeof MediaBlock>
12
+
13
+ export const Image: Story = {
14
+ args: {
15
+ media: { type: 'image', url: 'https://placehold.co/1600x900', alt: 'Foundation staff at work' },
16
+ caption: 'Team members gathered for the annual retreat.',
17
+ },
18
+ }
19
+
20
+ export const Square: Story = {
21
+ args: {
22
+ aspect: 'square',
23
+ media: { type: 'image', url: 'https://placehold.co/800x800', alt: 'Portrait' },
24
+ },
25
+ }
26
+
27
+ export const Video: Story = {
28
+ args: {
29
+ media: {
30
+ type: 'video',
31
+ url: 'https://example.com/clip.mp4',
32
+ poster: 'https://placehold.co/1600x900',
33
+ ariaLabel: 'Introduction video',
34
+ },
35
+ caption: 'A three-minute introduction to the program.',
36
+ },
37
+ }
@@ -0,0 +1,35 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { MediaBlock } from './MediaBlock.js'
4
+
5
+ describe('MediaBlock', () => {
6
+ it('renders an image with alt text', () => {
7
+ render(
8
+ <MediaBlock media={{ type: 'image', url: '/pic.jpg', alt: 'A picture' }} />,
9
+ )
10
+ expect(screen.getByRole('img', { name: 'A picture' })).toHaveAttribute('src', '/pic.jpg')
11
+ })
12
+
13
+ it('renders a caption inside a figcaption when provided', () => {
14
+ render(
15
+ <MediaBlock
16
+ media={{ type: 'image', url: '/pic.jpg', alt: 'x' }}
17
+ caption="Explanatory caption"
18
+ />,
19
+ )
20
+ const caption = screen.getByText('Explanatory caption')
21
+ expect(caption.tagName.toLowerCase()).toBe('figcaption')
22
+ })
23
+
24
+ it('renders a video element with aria-label and poster', () => {
25
+ const { container } = render(
26
+ <MediaBlock
27
+ media={{ type: 'video', url: '/clip.mp4', poster: '/poster.jpg', ariaLabel: 'Intro' }}
28
+ />,
29
+ )
30
+ const video = container.querySelector('video')
31
+ expect(video).not.toBeNull()
32
+ expect(video).toHaveAttribute('aria-label', 'Intro')
33
+ expect(video).toHaveAttribute('poster', '/poster.jpg')
34
+ })
35
+ })
@@ -0,0 +1,49 @@
1
+ import { clsx } from 'clsx'
2
+ import styles from './MediaBlock.module.css'
3
+
4
+ export type MediaBlockAspect = '16:9' | '4:3' | 'square' | 'auto'
5
+
6
+ type ImageMedia = { type: 'image'; url: string; alt: string }
7
+ type VideoMedia = {
8
+ type: 'video'
9
+ url: string
10
+ poster?: string
11
+ /** Accessible label used when the video lacks audio/captions narrative context. */
12
+ ariaLabel?: string
13
+ }
14
+
15
+ export type MediaBlockMedia = ImageMedia | VideoMedia
16
+
17
+ export interface MediaBlockProps {
18
+ media: MediaBlockMedia
19
+ caption?: string
20
+ aspect?: MediaBlockAspect
21
+ className?: string
22
+ }
23
+
24
+ export function MediaBlock({
25
+ media,
26
+ caption,
27
+ aspect = '16:9',
28
+ className,
29
+ }: MediaBlockProps) {
30
+ const aspectKey = aspect === '16:9' ? 'aspect16x9' : aspect === '4:3' ? 'aspect4x3' : `aspect-${aspect}`
31
+ return (
32
+ <figure className={clsx(styles.figure, className)}>
33
+ <div className={clsx(styles.frame, styles[aspectKey])}>
34
+ {media.type === 'image' ? (
35
+ <img src={media.url} alt={media.alt} className={styles.media} />
36
+ ) : (
37
+ <video
38
+ src={media.url}
39
+ poster={media.poster}
40
+ controls
41
+ aria-label={media.ariaLabel}
42
+ className={styles.media}
43
+ />
44
+ )}
45
+ </div>
46
+ {caption ? <figcaption className={styles.caption}>{caption}</figcaption> : null}
47
+ </figure>
48
+ )
49
+ }
@@ -0,0 +1,7 @@
1
+ export {
2
+ MediaBlock,
3
+ type MediaBlockAspect,
4
+ type MediaBlockMedia,
5
+ type MediaBlockProps,
6
+ } from './MediaBlock.js'
7
+ export { contract as mediaBlockContract } from './MediaBlock.contract.js'
@@ -0,0 +1,107 @@
1
+ import type { ComponentContract } from '@forumone/throughline-design-contract'
2
+
3
+ export const contract: ComponentContract = {
4
+ name: 'Prose',
5
+ category: 'section',
6
+ description:
7
+ 'A typographic container for long-form content — articles, policies, documentation, any flowing prose with headings and lists.',
8
+ intent:
9
+ 'Wrap free-flowing editorial content with sensible typography defaults (heading hierarchy, paragraph rhythm, list styling, blockquote treatment). Use any time you have a block of content authored as rich text and you want it to read well without styling each element individually.',
10
+
11
+ composition: {
12
+ placement: ['section'],
13
+ maxPerPage: null,
14
+ requiredSiblings: [],
15
+ forbiddenAdjacent: [],
16
+ },
17
+
18
+ content: {
19
+ fields: [
20
+ {
21
+ name: 'children',
22
+ type: 'richtext',
23
+ required: true,
24
+ constraints:
25
+ 'Rich text content authored in the CMS; can include h2-h4, paragraphs, lists, blockquotes, links',
26
+ },
27
+ ],
28
+ variants: [
29
+ {
30
+ name: 'default',
31
+ description: 'Standard reading rhythm',
32
+ whenToUse: 'Most articles and editorial content',
33
+ },
34
+ {
35
+ name: 'compact',
36
+ description: 'Tighter spacing and line height',
37
+ whenToUse: 'Denser reference content, product docs, or reading-room pages',
38
+ },
39
+ {
40
+ name: 'spacious',
41
+ description: 'Looser spacing and line height',
42
+ whenToUse: 'Long-form essays where the reader benefits from breathing room',
43
+ },
44
+ ],
45
+ },
46
+
47
+ tokens: {
48
+ consumes: [
49
+ 'color.text.primary',
50
+ 'color.text.secondary',
51
+ 'color.border.strong',
52
+ 'color.brand.primary',
53
+ 'spacing.container',
54
+ 'spacing.10',
55
+ 'spacing.8',
56
+ 'spacing.6',
57
+ 'spacing.5',
58
+ 'spacing.4',
59
+ 'spacing.2',
60
+ 'font.size.3xl',
61
+ 'font.size.2xl',
62
+ 'font.size.xl',
63
+ 'font.size.lg',
64
+ 'font.weight.semibold',
65
+ 'line.height.snug',
66
+ 'line.height.normal',
67
+ 'line.height.relaxed',
68
+ 'line.height.loose',
69
+ ],
70
+ },
71
+
72
+ accessibility: {
73
+ keyboardSupport: [],
74
+ screenReaderBehavior:
75
+ 'Children render as their native semantic HTML (h2, h3, p, ul, ol, blockquote, a). Prose adds no ARIA beyond what the authored content provides.',
76
+ contentWarnings: [
77
+ 'Author h2 and below inside Prose; never h1 (the page already has one)',
78
+ 'Use real list markup, not paragraphs with bullets',
79
+ ],
80
+ },
81
+
82
+ examples: [
83
+ { label: 'Default', intent: 'Standard article body', storyId: 'prose--default' },
84
+ { label: 'Compact', intent: 'Reference or documentation page', storyId: 'prose--compact' },
85
+ { label: 'Spacious', intent: 'Long-form essay or manifesto', storyId: 'prose--spacious' },
86
+ ],
87
+
88
+ antiExamples: [
89
+ {
90
+ label: 'Nesting Prose inside Prose',
91
+ why: 'Creates duplicate typography scales and unpredictable spacing',
92
+ useInstead: 'Use a single Prose at the outermost level',
93
+ },
94
+ {
95
+ label: 'Putting interactive components inside Prose',
96
+ why: 'Prose is for flowing editorial content; components like Stats or CardGrid have their own layout',
97
+ useInstead: 'Close the Prose, render the component, open another Prose for continuing text',
98
+ },
99
+ ],
100
+
101
+ behavior: {
102
+ fetchesData: false,
103
+ hasClientState: false,
104
+ animates: false,
105
+ requiresAnalytics: false,
106
+ },
107
+ }
@@ -0,0 +1,72 @@
1
+ .prose {
2
+ max-width: 42rem;
3
+ margin: 0 auto;
4
+ padding: 0 var(--spacing-container);
5
+ color: var(--color-text-primary);
6
+ line-height: var(--line-height-relaxed);
7
+ }
8
+
9
+ .prose > * + * {
10
+ margin-top: var(--spacing-5);
11
+ }
12
+
13
+ .prose h2 {
14
+ font-size: var(--font-size-3xl);
15
+ font-weight: var(--font-weight-semibold);
16
+ line-height: var(--line-height-snug);
17
+ margin-top: var(--spacing-10);
18
+ }
19
+
20
+ .prose h3 {
21
+ font-size: var(--font-size-2xl);
22
+ font-weight: var(--font-weight-semibold);
23
+ line-height: var(--line-height-snug);
24
+ margin-top: var(--spacing-8);
25
+ }
26
+
27
+ .prose h4 {
28
+ font-size: var(--font-size-xl);
29
+ font-weight: var(--font-weight-semibold);
30
+ }
31
+
32
+ .prose p {
33
+ font-size: var(--font-size-lg);
34
+ }
35
+
36
+ .prose a {
37
+ color: var(--color-brand-primary);
38
+ text-decoration: underline;
39
+ text-underline-offset: 0.15em;
40
+ }
41
+
42
+ .prose ul,
43
+ .prose ol {
44
+ padding-inline-start: var(--spacing-6);
45
+ }
46
+
47
+ .prose li + li {
48
+ margin-top: var(--spacing-2);
49
+ }
50
+
51
+ .prose blockquote {
52
+ border-inline-start: 3px solid var(--color-border-strong);
53
+ padding-inline-start: var(--spacing-4);
54
+ color: var(--color-text-secondary);
55
+ font-style: italic;
56
+ }
57
+
58
+ .compact {
59
+ line-height: var(--line-height-normal);
60
+ }
61
+
62
+ .compact > * + * {
63
+ margin-top: var(--spacing-4);
64
+ }
65
+
66
+ .spacious {
67
+ line-height: var(--line-height-loose);
68
+ }
69
+
70
+ .spacious > * + * {
71
+ margin-top: var(--spacing-6);
72
+ }
@@ -0,0 +1,39 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { Prose } from './Prose.js'
3
+
4
+ const meta: Meta<typeof Prose> = {
5
+ title: 'Prose',
6
+ component: Prose,
7
+ }
8
+
9
+ export default meta
10
+
11
+ type Story = StoryObj<typeof Prose>
12
+
13
+ const sampleContent = (
14
+ <>
15
+ <h2>Why content-first CMSes matter</h2>
16
+ <p>
17
+ A content management system should help editorial teams describe the world accurately, not
18
+ force them into a grid of tiles and buckets. When the CMS models the world well, the rest of
19
+ the work — rendering, governance, publication — becomes tractable.
20
+ </p>
21
+ <h3>The three pillars</h3>
22
+ <ul>
23
+ <li>Content models that describe intent, not layout</li>
24
+ <li>Governance that encodes approval policy</li>
25
+ <li>Publication that&apos;s reversible and auditable</li>
26
+ </ul>
27
+ <blockquote>
28
+ &ldquo;The best CMS is the one your editors forget they are using.&rdquo;
29
+ </blockquote>
30
+ <p>
31
+ Everything else is detail. The reference design system exists to prove the framework can
32
+ ship a working, accessible, and accountable editorial experience with just a dozen components.
33
+ </p>
34
+ </>
35
+ )
36
+
37
+ export const Default: Story = { args: { children: sampleContent } }
38
+ export const Compact: Story = { args: { size: 'compact', children: sampleContent } }
39
+ export const Spacious: Story = { args: { size: 'spacious', children: sampleContent } }
@@ -0,0 +1,26 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { Prose } from './Prose.js'
4
+
5
+ describe('Prose', () => {
6
+ it('renders child headings with their native semantics', () => {
7
+ render(
8
+ <Prose>
9
+ <h2>Section</h2>
10
+ <p>Body</p>
11
+ </Prose>,
12
+ )
13
+ expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent('Section')
14
+ expect(screen.getByText('Body')).toBeInTheDocument()
15
+ })
16
+
17
+ it('applies size variants', () => {
18
+ const { container } = render(
19
+ <Prose size="compact">
20
+ <p>One</p>
21
+ </Prose>,
22
+ )
23
+ const root = container.firstChild as HTMLElement
24
+ expect(root.className).toContain('compact')
25
+ })
26
+ })
@@ -0,0 +1,15 @@
1
+ import { clsx } from 'clsx'
2
+ import type { ReactNode } from 'react'
3
+ import styles from './Prose.module.css'
4
+
5
+ export type ProseSize = 'default' | 'compact' | 'spacious'
6
+
7
+ export interface ProseProps {
8
+ children: ReactNode
9
+ size?: ProseSize
10
+ className?: string
11
+ }
12
+
13
+ export function Prose({ children, size = 'default', className }: ProseProps) {
14
+ return <div className={clsx(styles.prose, styles[size], className)}>{children}</div>
15
+ }
@@ -0,0 +1,2 @@
1
+ export { Prose, type ProseProps, type ProseSize } from './Prose.js'
2
+ export { contract as proseContract } from './Prose.contract.js'
@@ -0,0 +1,101 @@
1
+ import type { ComponentContract } from '@forumone/throughline-design-contract'
2
+
3
+ export const contract: ComponentContract = {
4
+ name: 'Quote',
5
+ category: 'media',
6
+ description:
7
+ 'A pullquote or testimonial rendered as a <figure> with a <blockquote> and optional figcaption attribution.',
8
+ intent:
9
+ 'Surface a single human-scale quotation — a testimonial, a pullquote, a foundational claim. Use sparingly; quotes lose weight when stacked. The attribution is optional because some quotes stand on their own without a named author.',
10
+
11
+ composition: {
12
+ placement: ['section'],
13
+ maxPerPage: null,
14
+ requiredSiblings: [],
15
+ forbiddenAdjacent: ['Quote'],
16
+ },
17
+
18
+ content: {
19
+ fields: [
20
+ {
21
+ name: 'quote',
22
+ type: 'text',
23
+ required: true,
24
+ maxLength: 400,
25
+ constraints: 'One or two sentences; longer belongs in Prose',
26
+ },
27
+ {
28
+ name: 'attribution',
29
+ type: 'group',
30
+ required: false,
31
+ of: [
32
+ { name: 'name', type: 'text', required: true, maxLength: 80 },
33
+ { name: 'role', type: 'text', required: false, maxLength: 100 },
34
+ { name: 'avatar', type: 'image', required: false },
35
+ ],
36
+ },
37
+ ],
38
+ variants: [
39
+ { name: 'default', description: 'Standard size', whenToUse: 'Most testimonials and pullquotes' },
40
+ { name: 'large', description: 'Oversized display treatment', whenToUse: 'Editorial openers or mission-page quotes that carry real weight' },
41
+ ],
42
+ },
43
+
44
+ tokens: {
45
+ consumes: [
46
+ 'color.text.primary',
47
+ 'color.text.secondary',
48
+ 'spacing.10',
49
+ 'spacing.6',
50
+ 'spacing.3',
51
+ 'spacing.container',
52
+ 'font.size.4xl',
53
+ 'font.size.2xl',
54
+ 'font.size.sm',
55
+ 'font.weight.semibold',
56
+ 'line.height.tight',
57
+ 'line.height.snug',
58
+ 'radius.full',
59
+ ],
60
+ configurable: [
61
+ { prop: 'size', tokenGroup: 'layout', allowedValues: ['default', 'large'] },
62
+ ],
63
+ },
64
+
65
+ accessibility: {
66
+ keyboardSupport: [],
67
+ screenReaderBehavior:
68
+ 'Rendered as <figure> containing a <blockquote>; attribution is read as the figcaption. Decorative quotation marks are generated via CSS and are not in the accessible name.',
69
+ contentWarnings: [
70
+ 'Do not quote someone without permission',
71
+ 'Use their actual words; a paraphrase inside quotation marks misrepresents them',
72
+ ],
73
+ },
74
+
75
+ examples: [
76
+ { label: 'Default', intent: 'Testimonial with role', storyId: 'quote--default' },
77
+ { label: 'With avatar', intent: 'Testimonial with portrait', storyId: 'quote--with-avatar' },
78
+ { label: 'Large', intent: 'Editorial mission statement', storyId: 'quote--large' },
79
+ { label: 'Without attribution', intent: 'Aphoristic pullquote', storyId: 'quote--without-attribution' },
80
+ ],
81
+
82
+ antiExamples: [
83
+ {
84
+ label: 'Four quotes in a row',
85
+ why: 'Quote is meant to punctuate content, not fill a page',
86
+ useInstead: 'Pick the strongest quote; move the rest into Prose or a CardGrid of Cards',
87
+ },
88
+ {
89
+ label: 'Using Quote for product blurbs',
90
+ why: 'Quote implies a real human author; marketing copy without a source undermines trust',
91
+ useInstead: 'CTASection or Prose',
92
+ },
93
+ ],
94
+
95
+ behavior: {
96
+ fetchesData: false,
97
+ hasClientState: false,
98
+ animates: false,
99
+ requiresAnalytics: false,
100
+ },
101
+ }
@@ -0,0 +1,59 @@
1
+ .figure {
2
+ max-width: 48rem;
3
+ margin: 0 auto;
4
+ padding: var(--spacing-10) var(--spacing-container);
5
+ text-align: center;
6
+ }
7
+
8
+ .quote {
9
+ margin: 0 0 var(--spacing-6);
10
+ font-style: italic;
11
+ }
12
+
13
+ .quote p {
14
+ font-size: var(--font-size-2xl);
15
+ line-height: var(--line-height-snug);
16
+ color: var(--color-text-primary);
17
+ margin: 0;
18
+ }
19
+
20
+ .large .quote p {
21
+ font-size: var(--font-size-4xl);
22
+ line-height: var(--line-height-tight);
23
+ }
24
+
25
+ .quote p::before {
26
+ content: '\201C';
27
+ margin-right: 0.1em;
28
+ }
29
+
30
+ .quote p::after {
31
+ content: '\201D';
32
+ margin-left: 0.1em;
33
+ }
34
+
35
+ .attribution {
36
+ display: inline-flex;
37
+ align-items: center;
38
+ gap: var(--spacing-3);
39
+ color: var(--color-text-secondary);
40
+ font-size: var(--font-size-sm);
41
+ font-style: normal;
42
+ }
43
+
44
+ .avatar {
45
+ width: 2.5rem;
46
+ height: 2.5rem;
47
+ border-radius: var(--radius-full);
48
+ object-fit: cover;
49
+ }
50
+
51
+ .name {
52
+ font-weight: var(--font-weight-semibold);
53
+ color: var(--color-text-primary);
54
+ }
55
+
56
+ .role::before {
57
+ content: '\00B7';
58
+ margin: 0 0.25em;
59
+ }
@@ -0,0 +1,44 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { Quote } from './Quote.js'
3
+
4
+ const meta: Meta<typeof Quote> = {
5
+ title: 'Quote',
6
+ component: Quote,
7
+ }
8
+
9
+ export default meta
10
+
11
+ type Story = StoryObj<typeof Quote>
12
+
13
+ export const Default: Story = {
14
+ args: {
15
+ quote:
16
+ 'The best CMS is the one your editorial team forgets they are using.',
17
+ attribution: { name: 'Jordan Rivers', role: 'Design director' },
18
+ },
19
+ }
20
+
21
+ export const WithAvatar: Story = {
22
+ args: {
23
+ quote:
24
+ 'After we moved to a content-first model, our publishing velocity nearly doubled without adding to the team.',
25
+ attribution: {
26
+ name: 'Maria Santos',
27
+ role: 'Head of content, Greenfield Foundation',
28
+ avatar: { url: 'https://placehold.co/80x80', alt: 'Portrait of Maria Santos' },
29
+ },
30
+ },
31
+ }
32
+
33
+ export const Large: Story = {
34
+ args: {
35
+ size: 'large',
36
+ quote: 'Editorial teams deserve tools that take their workflows seriously.',
37
+ },
38
+ }
39
+
40
+ export const WithoutAttribution: Story = {
41
+ args: {
42
+ quote: 'Content first. Everything else follows.',
43
+ },
44
+ }
@@ -0,0 +1,37 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { Quote } from './Quote.js'
4
+
5
+ describe('Quote', () => {
6
+ it('renders the quote inside a blockquote', () => {
7
+ const { container } = render(<Quote quote="Content first." />)
8
+ expect(container.querySelector('blockquote')).not.toBeNull()
9
+ expect(screen.getByText('Content first.')).toBeInTheDocument()
10
+ })
11
+
12
+ it('renders the attribution inside a figcaption when provided', () => {
13
+ const { container } = render(
14
+ <Quote
15
+ quote="Words."
16
+ attribution={{ name: 'Ada Lovelace', role: 'Mathematician' }}
17
+ />,
18
+ )
19
+ const caption = container.querySelector('figcaption')
20
+ expect(caption).not.toBeNull()
21
+ expect(caption).toHaveTextContent('Ada Lovelace')
22
+ expect(caption).toHaveTextContent('Mathematician')
23
+ })
24
+
25
+ it('renders an avatar when attribution includes one', () => {
26
+ render(
27
+ <Quote
28
+ quote="Words."
29
+ attribution={{
30
+ name: 'Grace Hopper',
31
+ avatar: { url: '/avatar.jpg', alt: 'Grace Hopper portrait' },
32
+ }}
33
+ />,
34
+ )
35
+ expect(screen.getByRole('img', { name: 'Grace Hopper portrait' })).toBeInTheDocument()
36
+ })
37
+ })
@@ -0,0 +1,40 @@
1
+ import { clsx } from 'clsx'
2
+ import styles from './Quote.module.css'
3
+
4
+ export type QuoteSize = 'default' | 'large'
5
+
6
+ export interface QuoteProps {
7
+ quote: string
8
+ attribution?: {
9
+ name: string
10
+ role?: string
11
+ avatar?: { url: string; alt: string }
12
+ }
13
+ size?: QuoteSize
14
+ className?: string
15
+ }
16
+
17
+ export function Quote({ quote, attribution, size = 'default', className }: QuoteProps) {
18
+ return (
19
+ <figure className={clsx(styles.figure, styles[size], className)}>
20
+ <blockquote className={styles.quote}>
21
+ <p>{quote}</p>
22
+ </blockquote>
23
+ {attribution ? (
24
+ <figcaption className={styles.attribution}>
25
+ {attribution.avatar ? (
26
+ <img
27
+ src={attribution.avatar.url}
28
+ alt={attribution.avatar.alt}
29
+ className={styles.avatar}
30
+ />
31
+ ) : null}
32
+ <span className={styles.name}>{attribution.name}</span>
33
+ {attribution.role ? (
34
+ <span className={styles.role}>{attribution.role}</span>
35
+ ) : null}
36
+ </figcaption>
37
+ ) : null}
38
+ </figure>
39
+ )
40
+ }
@@ -0,0 +1,2 @@
1
+ export { Quote, type QuoteProps, type QuoteSize } from './Quote.js'
2
+ export { contract as quoteContract } from './Quote.contract.js'