@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,94 @@
1
+ import type { ComponentContract } from '@forumone/throughline-design-contract'
2
+
3
+ export const contract: ComponentContract = {
4
+ name: 'MediaBlock',
5
+ category: 'media',
6
+ description:
7
+ 'A full-width image or video block with optional caption and configurable aspect ratio.',
8
+ intent:
9
+ 'Insert a single piece of featured media inside a content flow. Use for editorial imagery that deserves its own moment — a photograph illustrating a story, a portrait accompanying a quote, or a video introducing a program. Not a gallery; for multiple items compose a custom layout.',
10
+
11
+ composition: {
12
+ placement: ['section'],
13
+ maxPerPage: null,
14
+ requiredSiblings: [],
15
+ forbiddenAdjacent: [],
16
+ },
17
+
18
+ content: {
19
+ fields: [
20
+ {
21
+ name: 'media',
22
+ type: 'group',
23
+ required: true,
24
+ of: [
25
+ { name: 'type', type: 'select', required: true },
26
+ { name: 'url', type: 'link', required: true },
27
+ { name: 'alt', type: 'text', required: false, maxLength: 200 },
28
+ { name: 'poster', type: 'image', required: false },
29
+ { name: 'ariaLabel', type: 'text', required: false, maxLength: 120 },
30
+ ],
31
+ },
32
+ {
33
+ name: 'caption',
34
+ type: 'text',
35
+ required: false,
36
+ maxLength: 200,
37
+ constraints: 'One sentence that adds context the image alone cannot carry',
38
+ },
39
+ ],
40
+ variants: [
41
+ { name: '16:9', description: 'Cinematic widescreen', whenToUse: 'Most photographs and video' },
42
+ { name: '4:3', description: 'Classic television ratio', whenToUse: 'Vintage or squared compositions' },
43
+ { name: 'square', description: '1:1', whenToUse: 'Portraits or product shots' },
44
+ { name: 'auto', description: 'Media\'s native ratio', whenToUse: 'Diagrams, infographics, or tall images' },
45
+ ],
46
+ },
47
+
48
+ tokens: {
49
+ consumes: [
50
+ 'color.bg.tertiary',
51
+ 'color.text.secondary',
52
+ 'spacing.container',
53
+ 'spacing.3',
54
+ 'font.size.sm',
55
+ 'radius.lg',
56
+ ],
57
+ },
58
+
59
+ accessibility: {
60
+ keyboardSupport: ['Video: native play/pause/seek controls'],
61
+ screenReaderBehavior:
62
+ 'Images announce their alt text. Videos announce their aria-label (or fall back to native labeling). Captions are associated with the figure semantically via <figcaption>.',
63
+ contentWarnings: [
64
+ 'Always author alt text for images that carry information; use empty alt ("") for purely decorative media',
65
+ 'Videos with spoken content must ship captions',
66
+ ],
67
+ },
68
+
69
+ examples: [
70
+ { label: 'Image with caption', intent: 'Editorial photograph', storyId: 'media-block--image' },
71
+ { label: 'Square crop', intent: 'Portrait or product shot', storyId: 'media-block--square' },
72
+ { label: 'Video', intent: 'Introductory video with poster', storyId: 'media-block--video' },
73
+ ],
74
+
75
+ antiExamples: [
76
+ {
77
+ label: 'Stacking three MediaBlocks for a gallery',
78
+ why: 'MediaBlock is a spotlight element; stacking them creates awkward repetition without true gallery ergonomics',
79
+ useInstead: 'Build a dedicated gallery component in your client DS',
80
+ },
81
+ {
82
+ label: 'Decorative image with invented alt text',
83
+ why: 'Screen readers read the alt text; inventing "people smiling" for decorative media wastes listener attention',
84
+ useInstead: 'Use alt="" for purely decorative media',
85
+ },
86
+ ],
87
+
88
+ behavior: {
89
+ fetchesData: false,
90
+ hasClientState: false,
91
+ animates: false,
92
+ requiresAnalytics: false,
93
+ },
94
+ }
@@ -0,0 +1,49 @@
1
+ .figure {
2
+ margin: 0 auto;
3
+ max-width: 72rem;
4
+ padding: 0 var(--spacing-container);
5
+ }
6
+
7
+ .frame {
8
+ position: relative;
9
+ width: 100%;
10
+ overflow: hidden;
11
+ border-radius: var(--radius-lg);
12
+ background: var(--color-bg-tertiary);
13
+ }
14
+
15
+ .aspect16x9 {
16
+ aspect-ratio: 16 / 9;
17
+ }
18
+
19
+ .aspect4x3 {
20
+ aspect-ratio: 4 / 3;
21
+ }
22
+
23
+ .aspect-square {
24
+ aspect-ratio: 1 / 1;
25
+ }
26
+
27
+ .aspect-auto {
28
+ aspect-ratio: auto;
29
+ }
30
+
31
+ .media {
32
+ position: absolute;
33
+ inset: 0;
34
+ width: 100%;
35
+ height: 100%;
36
+ object-fit: cover;
37
+ }
38
+
39
+ .aspect-auto .media {
40
+ position: static;
41
+ height: auto;
42
+ }
43
+
44
+ .caption {
45
+ margin-top: var(--spacing-3);
46
+ font-size: var(--font-size-sm);
47
+ color: var(--color-text-secondary);
48
+ text-align: center;
49
+ }
@@ -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
+ }